Compare commits
21 Commits
a7f7423d7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e1ec041de | |||
| 0ea463f1fa | |||
| 40d30600a0 | |||
| 034513e95b | |||
| c16c9b2d25 | |||
| c5557ce9d6 | |||
| a264ffdeff | |||
| c1803ef106 | |||
| 44cfd4d5f1 | |||
| ffb4dc28c5 | |||
| 884bbb11fc | |||
| d16ac3d76e | |||
| ef31ed3564 | |||
| 2e61f00ce8 | |||
| 1035683b56 | |||
| eff740704b | |||
| 54e05173e0 | |||
| 38c081527b | |||
| 1ec6bd89c8 | |||
| 53dbb0ed97 | |||
| 01739a50cd |
13
Dockerfile
13
Dockerfile
@@ -19,11 +19,21 @@ ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ARG NEXT_PUBLIC_N8N_WEBHOOK_URL
|
||||
ARG NEXT_PUBLIC_GITEA_URL
|
||||
ARG GITEA_URL
|
||||
ARG GITEA_TOKEN
|
||||
ARG SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_N8N_WEBHOOK_URL=$NEXT_PUBLIC_N8N_WEBHOOK_URL
|
||||
ENV NEXT_PUBLIC_GITEA_URL=$NEXT_PUBLIC_GITEA_URL
|
||||
ENV GITEA_URL=$GITEA_URL
|
||||
ENV GITEA_TOKEN=$GITEA_TOKEN
|
||||
ENV SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -33,6 +43,9 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
|
||||
380
docs/cloudflare-setup.md
Normal file
380
docs/cloudflare-setup.md
Normal file
@@ -0,0 +1,380 @@
|
||||
# Cloudflare Setup for Mylder Platform
|
||||
|
||||
## Overview
|
||||
Cloudflare configuration for https://mylder.io - Next.js 16 frontend with self-hosted backend (Supabase, n8n) via Dokploy/Traefik.
|
||||
|
||||
**VPS:** 149.102.155.84
|
||||
**Stack:** Next.js 16 → Traefik → Cloudflare CDN
|
||||
|
||||
## DNS Configuration
|
||||
|
||||
### Step 1: Add Domain to Cloudflare
|
||||
1. Cloudflare Dashboard → Add Site → mylder.io
|
||||
2. Update nameservers at domain registrar to Cloudflare's nameservers
|
||||
|
||||
### Step 2: DNS Records
|
||||
|
||||
| Type | Name | Target | Proxy Status | Notes |
|
||||
|------|------|--------|--------------|-------|
|
||||
| A | @ | 149.102.155.84 | Proxied (orange cloud) | Main site |
|
||||
| A | www | 149.102.155.84 | Proxied | WWW redirect |
|
||||
| A | dokploy | 149.102.155.84 | DNS Only (grey cloud) | Dokploy dashboard - skip CDN |
|
||||
| A | supabase | 149.102.155.84 | DNS Only | Supabase API - skip CDN |
|
||||
| A | n8n | 149.102.155.84 | DNS Only | n8n webhooks - skip CDN |
|
||||
| A | gitea | 149.102.155.84 | DNS Only | Git push/pull - skip CDN |
|
||||
| A | saas | 149.102.155.84 | DNS Only | Zulip chat - skip CDN |
|
||||
|
||||
**Key Principle:**
|
||||
- **Proxied (CDN):** Frontend, static assets, public content
|
||||
- **DNS Only:** Admin tools, APIs with authentication, real-time services
|
||||
|
||||
### Step 3: Subdomain Wildcard (Optional)
|
||||
If you need dynamic subdomains (e.g., tenant.mylder.io):
|
||||
```
|
||||
CNAME * @ (Proxied)
|
||||
```
|
||||
|
||||
## SSL/TLS Settings
|
||||
|
||||
### Encryption Mode: Full (Strict)
|
||||
**Path:** SSL/TLS → Overview → Full (strict)
|
||||
|
||||
**Why:** Traefik already provides Let's Encrypt certificates. Full (strict) ensures end-to-end encryption:
|
||||
```
|
||||
Browser → Cloudflare (encrypted) → Traefik (encrypted) → App
|
||||
```
|
||||
|
||||
### Edge Certificates
|
||||
**Path:** SSL/TLS → Edge Certificates
|
||||
|
||||
- **Always Use HTTPS:** ON
|
||||
- **Minimum TLS Version:** 1.2
|
||||
- **Opportunistic Encryption:** ON
|
||||
- **TLS 1.3:** ON
|
||||
- **Automatic HTTPS Rewrites:** ON
|
||||
- **Certificate Transparency Monitoring:** ON
|
||||
|
||||
### Origin Server
|
||||
**Path:** SSL/TLS → Origin Server
|
||||
|
||||
No action needed - Traefik manages Let's Encrypt certificates automatically.
|
||||
|
||||
## Caching Rules
|
||||
|
||||
### Default Cache Levels
|
||||
**Path:** Caching → Configuration
|
||||
|
||||
- **Caching Level:** Standard
|
||||
- **Browser Cache TTL:** Respect Existing Headers
|
||||
- **Always Online:** ON (serves stale content if origin is down)
|
||||
|
||||
### Cache Rules for Next.js
|
||||
**Path:** Rules → Page Rules (legacy) or Cache Rules (new)**
|
||||
|
||||
#### Rule 1: Static Assets (Aggressive Caching)
|
||||
```
|
||||
URL Pattern: mylder.io/_next/static/*
|
||||
Settings:
|
||||
- Cache Level: Cache Everything
|
||||
- Edge Cache TTL: 1 year
|
||||
- Browser Cache TTL: 1 year
|
||||
```
|
||||
|
||||
#### Rule 2: Images (Smart Caching)
|
||||
```
|
||||
URL Pattern: mylder.io/_next/image/*
|
||||
Settings:
|
||||
- Cache Level: Cache Everything
|
||||
- Edge Cache TTL: 30 days
|
||||
- Browser Cache TTL: 7 days
|
||||
```
|
||||
|
||||
#### Rule 3: API Routes (No Cache)
|
||||
```
|
||||
URL Pattern: mylder.io/api/*
|
||||
Settings:
|
||||
- Cache Level: Bypass
|
||||
```
|
||||
|
||||
#### Rule 4: Dynamic Pages (Smart Cache)
|
||||
```
|
||||
URL Pattern: mylder.io/*
|
||||
Settings:
|
||||
- Cache Level: Standard
|
||||
- Edge Cache TTL: 2 hours
|
||||
- Browser Cache TTL: 30 minutes
|
||||
```
|
||||
|
||||
### Next.js Cache Headers
|
||||
Ensure your Next.js app sets proper headers:
|
||||
|
||||
```typescript
|
||||
// next.config.ts
|
||||
const nextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/_next/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/_next/image/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=2592000, stale-while-revalidate=86400',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Cache Purge Strategy
|
||||
**Path:** Caching → Configuration → Purge Cache
|
||||
|
||||
- **On Deploy:** Purge Everything (via API or Dashboard)
|
||||
- **On Content Update:** Purge by Tag/URL
|
||||
- **Emergency:** Purge Everything button
|
||||
|
||||
**API Purge (for CI/CD):**
|
||||
```bash
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
|
||||
-H "Authorization: Bearer {api_token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}'
|
||||
```
|
||||
|
||||
## Speed Optimization
|
||||
|
||||
### Auto Minify
|
||||
**Path:** Speed → Optimization
|
||||
|
||||
- **JavaScript:** ON
|
||||
- **CSS:** ON
|
||||
- **HTML:** ON
|
||||
|
||||
### Brotli Compression
|
||||
**Path:** Speed → Optimization
|
||||
|
||||
- **Brotli:** ON (better than gzip)
|
||||
|
||||
### Early Hints
|
||||
**Path:** Speed → Optimization
|
||||
|
||||
- **Early Hints:** ON (preload critical resources)
|
||||
|
||||
### Rocket Loader
|
||||
**Path:** Speed → Optimization
|
||||
|
||||
- **Rocket Loader:** OFF (can conflict with React hydration)
|
||||
|
||||
### Image Optimization
|
||||
**Path:** Speed → Optimization → Image Resizing
|
||||
|
||||
- **Polish:** Lossless (or Lossy for smaller files)
|
||||
- **Mirage:** ON (lazy loading for mobile)
|
||||
- **Image Resizing:** Available on Pro+ plan
|
||||
|
||||
**Next.js Image Optimization:**
|
||||
```typescript
|
||||
// next.config.ts
|
||||
const nextConfig = {
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
minimumCacheTTL: 2592000, // 30 days
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Security Settings
|
||||
|
||||
### SSL/TLS
|
||||
- **Always Use HTTPS:** ON
|
||||
- **HSTS:** Enable with max-age=31536000, includeSubDomains
|
||||
|
||||
### Firewall (WAF)
|
||||
**Path:** Security → WAF
|
||||
|
||||
#### Managed Rules (Free Tier)
|
||||
- **Cloudflare Managed Ruleset:** ON
|
||||
- **OWASP Core Ruleset:** ON
|
||||
|
||||
#### Rate Limiting (Pro+ Plan)
|
||||
```yaml
|
||||
Rule: API Protection
|
||||
If: Request URL contains /api/
|
||||
Then: Rate limit 100 requests per minute per IP
|
||||
```
|
||||
|
||||
#### Bot Fight Mode (Free)
|
||||
**Path:** Security → Bots
|
||||
|
||||
- **Bot Fight Mode:** ON
|
||||
- **Super Bot Fight Mode:** Pro+ plan
|
||||
|
||||
### Security Level
|
||||
**Path:** Security → Settings
|
||||
|
||||
- **Security Level:** Medium (balance between security and UX)
|
||||
|
||||
### Challenge Passage
|
||||
**Path:** Security → Settings
|
||||
|
||||
- **Challenge Passage:** 30 minutes
|
||||
|
||||
## Network Settings
|
||||
|
||||
### HTTP/2 & HTTP/3
|
||||
**Path:** Network
|
||||
|
||||
- **HTTP/2:** ON (default)
|
||||
- **HTTP/3 (QUIC):** ON (faster connections)
|
||||
- **0-RTT Connection Resumption:** ON
|
||||
|
||||
### WebSockets
|
||||
**Path:** Network
|
||||
|
||||
- **WebSockets:** ON (required for real-time features)
|
||||
|
||||
### gRPC
|
||||
**Path:** Network
|
||||
|
||||
- **gRPC:** OFF (not needed for standard Next.js)
|
||||
|
||||
## Analytics & Monitoring
|
||||
|
||||
### Web Analytics
|
||||
**Path:** Analytics & Logs → Web Analytics
|
||||
|
||||
- Enable Cloudflare Web Analytics (privacy-friendly, no cookie banner needed)
|
||||
|
||||
### Performance Monitoring
|
||||
- **Core Web Vitals:** Track LCP, FID, CLS
|
||||
- **Page Load Time:** Monitor at edge
|
||||
|
||||
### Real User Monitoring (RUM)
|
||||
Add Cloudflare Zaraz or custom analytics:
|
||||
```html
|
||||
<!-- Cloudflare Web Analytics -->
|
||||
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
|
||||
data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>
|
||||
```
|
||||
|
||||
## Subdomain Configuration
|
||||
|
||||
Since you have multiple services, ensure proper routing:
|
||||
|
||||
### Traefik Labels (Dokploy Docker Compose)
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.mylder.rule=Host(`mylder.io`) || Host(`www.mylder.io`)"
|
||||
- "traefik.http.routers.mylder.tls=true"
|
||||
- "traefik.http.routers.mylder.tls.certresolver=letsencrypt"
|
||||
```
|
||||
|
||||
### WWW Redirect (Cloudflare)
|
||||
**Path:** Rules → Redirect Rules
|
||||
|
||||
```
|
||||
If: Hostname equals www.mylder.io
|
||||
Then: Redirect to https://mylder.io$1 (301 permanent)
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### DNS Propagation
|
||||
```bash
|
||||
dig mylder.io
|
||||
nslookup mylder.io
|
||||
```
|
||||
|
||||
### SSL Certificate
|
||||
```bash
|
||||
curl -I https://mylder.io
|
||||
# Check for: HTTP/2 200, cf-cache-status header
|
||||
```
|
||||
|
||||
### Cache Verification
|
||||
```bash
|
||||
curl -I https://mylder.io/_next/static/[hash]/[file].js
|
||||
# Look for: cf-cache-status: HIT
|
||||
```
|
||||
|
||||
### Performance
|
||||
```bash
|
||||
# Lighthouse
|
||||
npx lighthouse https://mylder.io --view
|
||||
|
||||
# WebPageTest
|
||||
# Visit https://www.webpagetest.org
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 521 Error (Web Server Down)
|
||||
- Check Traefik is running: `docker ps | grep traefik`
|
||||
- Check Dokploy deployment status
|
||||
|
||||
### 522 Error (Connection Timed Out)
|
||||
- Verify firewall allows Cloudflare IPs
|
||||
- Check VPS security groups
|
||||
|
||||
### 525 Error (SSL Handshake Failed)
|
||||
- Ensure SSL/TLS mode is "Full (strict)"
|
||||
- Verify Traefik has valid Let's Encrypt certificate
|
||||
|
||||
### Cache Not Working
|
||||
- Check Cache-Control headers in response
|
||||
- Verify Page Rules are active
|
||||
- Clear Cloudflare cache and test
|
||||
|
||||
### Mixed Content Warnings
|
||||
- Enable "Automatic HTTPS Rewrites"
|
||||
- Update hardcoded http:// URLs to https://
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
### Free Plan Limits
|
||||
- 100k requests/day (~3M/month)
|
||||
- Unlimited bandwidth
|
||||
- Basic DDoS protection
|
||||
- Shared SSL certificate
|
||||
- 3 Page Rules
|
||||
|
||||
### When to Upgrade to Pro ($20/month)
|
||||
- Need more than 3 Page Rules
|
||||
- Want Image Optimization (Polish, Mirage)
|
||||
- Need custom SSL certificate
|
||||
- Require advanced WAF rules
|
||||
- Want priority support
|
||||
|
||||
### Enterprise Features (for future)
|
||||
- Cloudflare Workers (serverless at edge)
|
||||
- Load Balancing
|
||||
- Argo Smart Routing
|
||||
- Custom WAF rules
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate:** DNS + SSL/TLS setup
|
||||
2. **Week 1:** Caching rules + performance testing
|
||||
3. **Month 1:** Monitor analytics, tune cache rules
|
||||
4. **Quarter 1:** Consider Workers for edge logic (see cloudflare-workers-roadmap.md)
|
||||
|
||||
## References
|
||||
|
||||
- [Cloudflare DNS Docs](https://developers.cloudflare.com/dns/)
|
||||
- [Next.js Cloudflare Deployment](https://nextjs.org/docs/app/building-your-application/deploying)
|
||||
- [Cloudflare Cache API](https://developers.cloudflare.com/cache/)
|
||||
591
docs/cloudflare-workers-roadmap.md
Normal file
591
docs/cloudflare-workers-roadmap.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Cloudflare Workers Roadmap for Mylder
|
||||
|
||||
## Overview
|
||||
Edge computing strategy for Mylder platform using Cloudflare Workers, R2, D1, and KV.
|
||||
|
||||
**Philosophy:** Move compute closer to users, reduce VPS load, improve UX with sub-100ms responses.
|
||||
|
||||
## Current Architecture vs Edge-First
|
||||
|
||||
### Current (VPS-Centric)
|
||||
```
|
||||
User → Cloudflare CDN → VPS (149.102.155.84)
|
||||
├── Next.js (Frontend)
|
||||
├── Supabase (Auth, DB)
|
||||
├── n8n (Automation)
|
||||
└── Dokploy (Orchestration)
|
||||
```
|
||||
|
||||
### Future (Edge-First)
|
||||
```
|
||||
User → Cloudflare Workers (Edge Logic)
|
||||
├── Static Assets (CDN)
|
||||
├── Edge Auth (Workers + KV)
|
||||
├── Edge API (Workers + D1)
|
||||
└── Origin (VPS for heavy compute)
|
||||
├── Supabase (Primary DB)
|
||||
└── n8n (Automation)
|
||||
```
|
||||
|
||||
## Workers Use Cases
|
||||
|
||||
### 1. Edge Authentication (Priority: High)
|
||||
|
||||
**Problem:** Every auth check hits VPS → Supabase (100-300ms)
|
||||
**Solution:** Cache JWT validation at edge
|
||||
|
||||
```typescript
|
||||
// workers/auth-edge.ts
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const token = request.headers.get('Authorization')?.split('Bearer ')[1];
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
// Check KV cache for user session
|
||||
const cached = await env.SESSIONS.get(token);
|
||||
if (cached) {
|
||||
return new Response(cached, {
|
||||
headers: { 'X-Auth-Source': 'edge-cache' }
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to Supabase origin
|
||||
const response = await fetch('https://supabase.mylder.io/auth/v1/user', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.text();
|
||||
await env.SESSIONS.put(token, user, { expirationTtl: 3600 }); // 1 hour
|
||||
return new Response(user, {
|
||||
headers: { 'X-Auth-Source': 'origin' }
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Auth checks: 300ms → 10ms (30x faster)
|
||||
- Reduced Supabase load
|
||||
- Better UX for logged-in users
|
||||
|
||||
**Trade-offs:**
|
||||
- Session invalidation complexity
|
||||
- KV storage costs ($0.50/1M reads)
|
||||
|
||||
### 2. API Rate Limiting (Priority: High)
|
||||
|
||||
**Problem:** DDoS/abuse protection on VPS is resource-intensive
|
||||
**Solution:** Rate limit at edge before hitting origin
|
||||
|
||||
```typescript
|
||||
// workers/rate-limiter.ts
|
||||
import { RateLimiter } from '@cloudflare/workers-rate-limiter';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const limiter = new RateLimiter({
|
||||
namespace: env.RATE_LIMIT,
|
||||
// 100 requests per 60 seconds per IP
|
||||
limit: 100,
|
||||
period: 60,
|
||||
});
|
||||
|
||||
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||||
const { success } = await limiter.limit({ key: ip });
|
||||
|
||||
if (!success) {
|
||||
return new Response('Rate limit exceeded', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '60' }
|
||||
});
|
||||
}
|
||||
|
||||
// Forward to origin
|
||||
return fetch(request);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Block abuse before it hits VPS
|
||||
- Per-IP, per-user, per-endpoint limits
|
||||
- 0ms latency overhead
|
||||
|
||||
**Use Cases:**
|
||||
- `/api/*` endpoints
|
||||
- Login attempts
|
||||
- Form submissions
|
||||
- Search queries
|
||||
|
||||
### 3. A/B Testing at Edge (Priority: Medium)
|
||||
|
||||
**Problem:** Client-side A/B testing causes layout shift (poor CLS)
|
||||
**Solution:** Server-side rendering with edge variants
|
||||
|
||||
```typescript
|
||||
// workers/ab-test.ts
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Check existing variant
|
||||
let variant = request.headers.get('Cookie')?.match(/variant=([AB])/)?.[1];
|
||||
|
||||
if (!variant) {
|
||||
// Assign 50/50 split
|
||||
variant = Math.random() < 0.5 ? 'A' : 'B';
|
||||
}
|
||||
|
||||
// Rewrite request to origin with variant
|
||||
const originUrl = `${url.origin}${url.pathname}?variant=${variant}`;
|
||||
const response = await fetch(originUrl, request);
|
||||
|
||||
// Set variant cookie
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Set-Cookie', `variant=${variant}; Path=/; Max-Age=2592000`);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- No layout shift (CLS = 0)
|
||||
- Instant variant assignment
|
||||
- Analytics at edge
|
||||
|
||||
**Use Cases:**
|
||||
- Landing page variants
|
||||
- Pricing page tests
|
||||
- Feature flags
|
||||
|
||||
### 4. Edge Personalization (Priority: Medium)
|
||||
|
||||
**Problem:** Generic content for all users (low engagement)
|
||||
**Solution:** Geo/device-specific content at edge
|
||||
|
||||
```typescript
|
||||
// workers/personalize.ts
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const country = request.cf?.country as string;
|
||||
const device = request.headers.get('User-Agent')?.includes('Mobile')
|
||||
? 'mobile'
|
||||
: 'desktop';
|
||||
|
||||
// Fetch base HTML from origin
|
||||
const response = await fetch(request);
|
||||
const html = await response.text();
|
||||
|
||||
// Inject personalized content
|
||||
const personalized = html
|
||||
.replace('{{COUNTRY}}', country)
|
||||
.replace('{{DEVICE}}', device)
|
||||
.replace('{{CURRENCY}}', getCurrency(country));
|
||||
|
||||
return new Response(personalized, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
'Cache-Control': 'public, max-age=60, s-maxage=3600',
|
||||
'Vary': 'CF-IPCountry, User-Agent'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function getCurrency(country: string): string {
|
||||
const map: Record<string, string> = {
|
||||
US: 'USD', GB: 'GBP', EU: 'EUR', DK: 'DKK'
|
||||
};
|
||||
return map[country] || 'USD';
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Geo-specific content (currency, language)
|
||||
- Device-optimized markup
|
||||
- Sub-50ms personalization
|
||||
|
||||
**Use Cases:**
|
||||
- Pricing display
|
||||
- Content localization
|
||||
- Feature availability
|
||||
|
||||
### 5. Image Optimization & Resizing (Priority: Low)
|
||||
|
||||
**Problem:** Next.js Image Optimization on VPS is CPU-intensive
|
||||
**Solution:** Cloudflare Images or Workers + R2
|
||||
|
||||
```typescript
|
||||
// workers/image-optimize.ts
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const imagePath = url.pathname.replace('/img/', '');
|
||||
|
||||
// Parse resize params
|
||||
const width = url.searchParams.get('w') || '1920';
|
||||
const quality = url.searchParams.get('q') || '80';
|
||||
const format = url.searchParams.get('f') || 'webp';
|
||||
|
||||
// Fetch from R2
|
||||
const object = await env.IMAGES.get(imagePath);
|
||||
if (!object) return new Response('Not Found', { status: 404 });
|
||||
|
||||
// Resize at edge (requires paid Workers plan)
|
||||
const resized = await fetch(`https://mylder.io/cdn-cgi/image/width=${width},quality=${quality},format=${format}/${imagePath}`);
|
||||
|
||||
return new Response(resized.body, {
|
||||
headers: {
|
||||
'Content-Type': `image/${format}`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Offload VPS CPU
|
||||
- Auto WebP/AVIF conversion
|
||||
- Responsive images on-demand
|
||||
|
||||
**Trade-offs:**
|
||||
- Requires Cloudflare Images ($5/month + $1/100k)
|
||||
- Or R2 storage ($0.015/GB/month)
|
||||
|
||||
### 6. Edge API Caching (Priority: Medium)
|
||||
|
||||
**Problem:** Repeated API calls for same data
|
||||
**Solution:** Smart caching with stale-while-revalidate
|
||||
|
||||
```typescript
|
||||
// workers/api-cache.ts
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(request.url, request);
|
||||
|
||||
// Check cache
|
||||
let response = await cache.match(cacheKey);
|
||||
if (response) {
|
||||
const age = Date.now() - new Date(response.headers.get('Date')!).getTime();
|
||||
|
||||
// If stale (>60s), revalidate in background
|
||||
if (age > 60000) {
|
||||
ctx.waitUntil(revalidate(request, cache, cacheKey));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Fetch from origin
|
||||
response = await fetch(request);
|
||||
|
||||
// Cache for 5 minutes
|
||||
const cached = new Response(response.body, response);
|
||||
cached.headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=3600');
|
||||
ctx.waitUntil(cache.put(cacheKey, cached));
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
async function revalidate(request: Request, cache: Cache, key: Request) {
|
||||
const fresh = await fetch(request);
|
||||
await cache.put(key, fresh.clone());
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- API responses: 200ms → 10ms
|
||||
- Stale content served instantly
|
||||
- Background revalidation
|
||||
|
||||
**Use Cases:**
|
||||
- Public API endpoints
|
||||
- User profiles
|
||||
- Content feeds
|
||||
|
||||
## Cloudflare R2 (Object Storage)
|
||||
|
||||
### Use Cases for R2
|
||||
|
||||
**What:** S3-compatible object storage at edge (no egress fees)
|
||||
|
||||
#### 1. User Uploads
|
||||
```typescript
|
||||
// Store user avatars, documents
|
||||
await env.UPLOADS.put(`users/${userId}/avatar.jpg`, file);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- $0.015/GB/month (10x cheaper than S3)
|
||||
- No egress fees (free downloads)
|
||||
- Global CDN distribution
|
||||
|
||||
**When to Use:**
|
||||
- User-generated content (avatars, documents)
|
||||
- Static assets (fonts, icons)
|
||||
- Media files (videos, audio)
|
||||
|
||||
**When NOT to Use:**
|
||||
- Frequent small writes (use D1 instead)
|
||||
- Sub-10MB total storage (use KV instead)
|
||||
|
||||
#### 2. Static Asset Hosting
|
||||
```typescript
|
||||
// Move _next/static/* to R2
|
||||
const asset = await env.STATIC_ASSETS.get(path);
|
||||
return new Response(asset.body, {
|
||||
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Reduce VPS storage needs
|
||||
- Faster global distribution
|
||||
- Immutable content = perfect cache
|
||||
|
||||
#### 3. Backup Storage
|
||||
```typescript
|
||||
// Daily Supabase backups to R2
|
||||
await env.BACKUPS.put(`supabase-${date}.sql.gz`, backup);
|
||||
```
|
||||
|
||||
## Cloudflare D1 (Edge Database)
|
||||
|
||||
### Use Cases for D1
|
||||
|
||||
**What:** SQLite at edge (sub-10ms queries globally)
|
||||
|
||||
#### 1. Edge Metadata
|
||||
```typescript
|
||||
// Store user preferences, feature flags
|
||||
const db = env.DB;
|
||||
const result = await db.prepare(
|
||||
'SELECT theme, locale FROM user_prefs WHERE user_id = ?'
|
||||
).bind(userId).first();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Read latency: 100ms (Supabase) → 5ms (D1)
|
||||
- No VPS load for reads
|
||||
- SQL interface (familiar)
|
||||
|
||||
**When to Use:**
|
||||
- Read-heavy data (user settings, configs)
|
||||
- Small datasets (<1GB)
|
||||
- Geo-distributed reads
|
||||
|
||||
**When NOT to Use:**
|
||||
- Write-heavy workloads (D1 eventual consistency)
|
||||
- Complex joins (use Supabase instead)
|
||||
- Primary data storage (Supabase is source of truth)
|
||||
|
||||
#### 2. Analytics/Metrics
|
||||
```typescript
|
||||
// Store page views, click events
|
||||
await db.prepare(
|
||||
'INSERT INTO analytics (page, user_id, timestamp) VALUES (?, ?, ?)'
|
||||
).bind(page, userId, Date.now()).run();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- High-volume writes
|
||||
- Real-time aggregations
|
||||
- No Supabase quota impact
|
||||
|
||||
#### 3. Feature Flags
|
||||
```typescript
|
||||
// Enable/disable features per user
|
||||
const flags = await db.prepare(
|
||||
'SELECT * FROM feature_flags WHERE active = 1'
|
||||
).all();
|
||||
```
|
||||
|
||||
### D1 vs Supabase Decision Matrix
|
||||
|
||||
| Feature | D1 (Edge) | Supabase (VPS) |
|
||||
|---------|-----------|----------------|
|
||||
| Read Latency | 5-10ms | 100-300ms |
|
||||
| Write Latency | 50-100ms | 100-300ms |
|
||||
| Consistency | Eventual | Strong |
|
||||
| Storage Limit | 1GB | 8GB+ |
|
||||
| Cost | Free (5M reads/day) | Included in VPS |
|
||||
| Use Case | Cached metadata | Primary data |
|
||||
|
||||
**Strategy:** D1 = edge cache, Supabase = source of truth
|
||||
|
||||
## Cloudflare KV (Key-Value Store)
|
||||
|
||||
### Use Cases for KV
|
||||
|
||||
**What:** Global key-value store (sub-1ms reads)
|
||||
|
||||
#### 1. Session Storage
|
||||
```typescript
|
||||
await env.SESSIONS.put(sessionId, userData, { expirationTtl: 3600 });
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Fastest storage option (<1ms reads)
|
||||
- Auto-expiration (TTL)
|
||||
- Global replication
|
||||
|
||||
**Cost:** $0.50/1M reads, $5/1M writes (generous free tier)
|
||||
|
||||
#### 2. Configuration/Secrets
|
||||
```typescript
|
||||
const apiKey = await env.CONFIG.get('stripe_public_key');
|
||||
```
|
||||
|
||||
#### 3. Rate Limit Counters
|
||||
```typescript
|
||||
const count = await env.RATE_LIMIT.get(ip);
|
||||
await env.RATE_LIMIT.put(ip, count + 1, { expirationTtl: 60 });
|
||||
```
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Foundation (Month 1)
|
||||
**Goal:** Basic edge infrastructure
|
||||
|
||||
- [ ] Set up Cloudflare Workers project
|
||||
- [ ] Deploy edge auth validator (Worker + KV)
|
||||
- [ ] Implement rate limiting for `/api/*`
|
||||
- [ ] Migrate static assets to R2
|
||||
- [ ] Monitor performance impact
|
||||
|
||||
**Success Metrics:**
|
||||
- Auth latency: <20ms (p95)
|
||||
- Static asset cache hit rate: >95%
|
||||
- VPS CPU load: -20%
|
||||
|
||||
### Phase 2: Optimization (Month 2-3)
|
||||
**Goal:** Performance wins
|
||||
|
||||
- [ ] Deploy A/B testing worker
|
||||
- [ ] Implement edge personalization
|
||||
- [ ] Set up D1 for user preferences
|
||||
- [ ] Create stale-while-revalidate API cache
|
||||
- [ ] Image optimization via R2 + Workers
|
||||
|
||||
**Success Metrics:**
|
||||
- LCP: <1.5s (p75)
|
||||
- API latency: -50% for cached endpoints
|
||||
- Global availability: 99.9%
|
||||
|
||||
### Phase 3: Advanced (Month 4+)
|
||||
**Goal:** Edge-first architecture
|
||||
|
||||
- [ ] Move session management to edge (D1 + KV)
|
||||
- [ ] Implement edge analytics (D1)
|
||||
- [ ] Deploy feature flags system (D1)
|
||||
- [ ] Create edge API gateway
|
||||
- [ ] Multi-region failover
|
||||
|
||||
**Success Metrics:**
|
||||
- Edge request ratio: >70%
|
||||
- Origin load: -60%
|
||||
- Global latency: <100ms (p95)
|
||||
|
||||
## Cost Analysis
|
||||
|
||||
### Free Tier Limits
|
||||
- **Workers:** 100k requests/day
|
||||
- **KV:** 100k reads/day, 1k writes/day
|
||||
- **R2:** 10GB storage, 1M reads/month
|
||||
- **D1:** 5M reads/day, 100k writes/day
|
||||
|
||||
### Paid Costs (Estimated for 1M users/month)
|
||||
|
||||
| Service | Usage | Cost/Month |
|
||||
|---------|-------|------------|
|
||||
| Workers | 100M requests | $5 (bundled) |
|
||||
| KV | 50M reads | $25 |
|
||||
| R2 | 100GB storage + 500M reads | $1.50 |
|
||||
| D1 | Included in Workers | $0 |
|
||||
| **Total** | | **~$30/month** |
|
||||
|
||||
**ROI:** Potential to downgrade VPS ($30/month savings) → break even
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### 1. Over-Caching
|
||||
**Bad:** Cache everything at edge for 24 hours
|
||||
**Good:** Cache static assets long, dynamic content short
|
||||
|
||||
### 2. Edge as Primary DB
|
||||
**Bad:** Store all user data in D1
|
||||
**Good:** D1 = cache, Supabase = source of truth
|
||||
|
||||
### 3. Synchronous Edge-Origin Calls
|
||||
**Bad:** Worker → fetch origin → wait → respond
|
||||
**Good:** Serve stale, revalidate in background
|
||||
|
||||
### 4. Ignoring Cold Starts
|
||||
**Bad:** Assume Workers are instant
|
||||
**Good:** Optimize bundle size (<1MB), minimize dependencies
|
||||
|
||||
### 5. Complex Business Logic at Edge
|
||||
**Bad:** Move entire Next.js app to Workers
|
||||
**Good:** Keep heavy compute on VPS, edge for lightweight tasks
|
||||
|
||||
## Monitoring & Debugging
|
||||
|
||||
### Workers Analytics
|
||||
**Dashboard:** Workers & Pages → Analytics
|
||||
|
||||
- Request volume
|
||||
- Success rate (2xx/3xx/4xx/5xx)
|
||||
- CPU time (p50, p95, p99)
|
||||
- Error logs
|
||||
|
||||
### Tail Workers (Real-Time Logs)
|
||||
```bash
|
||||
wrangler tail mylder-auth-edge
|
||||
```
|
||||
|
||||
### Custom Metrics
|
||||
```typescript
|
||||
// workers/metrics.ts
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const start = Date.now();
|
||||
const response = await fetch(request);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Log to D1 analytics
|
||||
ctx.waitUntil(
|
||||
env.ANALYTICS.prepare(
|
||||
'INSERT INTO metrics (endpoint, duration, status) VALUES (?, ?, ?)'
|
||||
).bind(request.url, duration, response.status).run()
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Prototype:** Deploy simple edge auth worker (1 day)
|
||||
2. **Measure:** Compare edge vs origin performance (1 week)
|
||||
3. **Iterate:** Expand to rate limiting, A/B testing (1 month)
|
||||
4. **Optimize:** Move 70%+ traffic to edge (3 months)
|
||||
|
||||
## References
|
||||
|
||||
- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)
|
||||
- [R2 Storage](https://developers.cloudflare.com/r2/)
|
||||
- [D1 Database](https://developers.cloudflare.com/d1/)
|
||||
- [Workers Examples](https://developers.cloudflare.com/workers/examples/)
|
||||
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
|
||||
753
docs/performance-checklist.md
Normal file
753
docs/performance-checklist.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# Performance Checklist for Mylder Platform
|
||||
|
||||
## Core Web Vitals Targets
|
||||
|
||||
### Production Goals (75th Percentile)
|
||||
|
||||
| Metric | Target | Good | Needs Improvement | Poor |
|
||||
|--------|--------|------|-------------------|------|
|
||||
| **LCP** (Largest Contentful Paint) | <2.5s | <2.5s | 2.5s-4.0s | >4.0s |
|
||||
| **FID** (First Input Delay) | <100ms | <100ms | 100ms-300ms | >300ms |
|
||||
| **CLS** (Cumulative Layout Shift) | <0.1 | <0.1 | 0.1-0.25 | >0.25 |
|
||||
| **INP** (Interaction to Next Paint) | <200ms | <200ms | 200ms-500ms | >500ms |
|
||||
| **TTFB** (Time to First Byte) | <600ms | <800ms | 800ms-1800ms | >1800ms |
|
||||
| **FCP** (First Contentful Paint) | <1.8s | <1.8s | 1.8s-3.0s | >3.0s |
|
||||
|
||||
### Current vs Target Performance
|
||||
|
||||
**Baseline (before optimization):**
|
||||
```
|
||||
LCP: ~3.5s → Target: <2.5s (30% improvement needed)
|
||||
FID: ~150ms → Target: <100ms
|
||||
CLS: ~0.15 → Target: <0.1
|
||||
TTFB: ~800ms → Target: <600ms (edge caching)
|
||||
```
|
||||
|
||||
### Measurement Tools
|
||||
|
||||
#### 1. Real User Monitoring (RUM)
|
||||
```typescript
|
||||
// app/layout.tsx
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next';
|
||||
import { Analytics } from '@vercel/analytics/react';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{children}
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative:** Cloudflare Web Analytics (privacy-friendly, no cookies)
|
||||
```html
|
||||
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
|
||||
data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>
|
||||
```
|
||||
|
||||
#### 2. Lab Testing
|
||||
```bash
|
||||
# Lighthouse (local)
|
||||
npx lighthouse https://mylder.io --view
|
||||
|
||||
# WebPageTest (global)
|
||||
# https://www.webpagetest.org
|
||||
|
||||
# Chrome DevTools
|
||||
# DevTools → Performance → Record → Analyze
|
||||
```
|
||||
|
||||
#### 3. Continuous Monitoring
|
||||
```bash
|
||||
# Lighthouse CI (in CI/CD pipeline)
|
||||
npm install -g @lhci/cli
|
||||
lhci autorun --collect.url=https://mylder.io
|
||||
```
|
||||
|
||||
## Next.js Optimization Settings
|
||||
|
||||
### 1. Production Build Configuration
|
||||
|
||||
```typescript
|
||||
// next.config.ts
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Enable React strict mode for better error handling
|
||||
reactStrictMode: true,
|
||||
|
||||
// Optimize bundle size
|
||||
swcMinify: true,
|
||||
|
||||
// Compress output
|
||||
compress: true,
|
||||
|
||||
// Optimize images
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256],
|
||||
minimumCacheTTL: 2592000, // 30 days
|
||||
dangerouslyAllowSVG: false,
|
||||
contentDispositionType: 'attachment',
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'supabase.mylder.io',
|
||||
pathname: '/storage/v1/object/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Optimize fonts (automatic optimization)
|
||||
optimizeFonts: true,
|
||||
|
||||
// Experimental features
|
||||
experimental: {
|
||||
// Optimize CSS
|
||||
optimizeCss: true,
|
||||
// Tree shaking for smaller bundles
|
||||
optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react'],
|
||||
},
|
||||
|
||||
// Custom headers for caching
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-DNS-Prefetch-Control',
|
||||
value: 'on',
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/_next/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/_next/image/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=2592000, stale-while-revalidate=86400',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
### 2. Bundle Analyzer
|
||||
```bash
|
||||
# Install
|
||||
npm install -D @next/bundle-analyzer
|
||||
|
||||
# next.config.ts
|
||||
import bundleAnalyzer from '@next/bundle-analyzer';
|
||||
|
||||
const withBundleAnalyzer = bundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
export default withBundleAnalyzer(nextConfig);
|
||||
|
||||
# Run analysis
|
||||
ANALYZE=true npm run build
|
||||
```
|
||||
|
||||
**Target:** Total bundle size <500KB (gzipped)
|
||||
|
||||
### 3. Code Splitting Strategies
|
||||
|
||||
#### Dynamic Imports
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Lazy load heavy components
|
||||
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
|
||||
loading: () => <ChartSkeleton />,
|
||||
ssr: false, // Client-side only if needed
|
||||
});
|
||||
|
||||
export default function Dashboard() {
|
||||
return <HeavyChart data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Route-Based Splitting
|
||||
```typescript
|
||||
// Automatic with App Router
|
||||
app/
|
||||
dashboard/ # Separate chunk
|
||||
settings/ # Separate chunk
|
||||
(marketing)/ # Separate chunk (route group)
|
||||
```
|
||||
|
||||
#### Vendor Splitting
|
||||
```typescript
|
||||
// next.config.ts
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
'@radix-ui/react-icons',
|
||||
'lucide-react',
|
||||
'date-fns',
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
### 4. Server Components (Default)
|
||||
```typescript
|
||||
// app/page.tsx (Server Component by default)
|
||||
export default async function Home() {
|
||||
// Fetch at build time or request time
|
||||
const data = await fetch('https://api.mylder.io/data', {
|
||||
next: { revalidate: 3600 } // ISR: 1 hour cache
|
||||
});
|
||||
|
||||
return <div>{/* No client-side JS for this component */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- 0KB JavaScript sent to client
|
||||
- Faster initial page load
|
||||
- Better SEO
|
||||
|
||||
### 5. Client Components (Opt-In)
|
||||
```typescript
|
||||
// components/InteractiveButton.tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function InteractiveButton() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button onClick={() => setCount(count + 1)}>{count}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Rule:** Only use 'use client' when you need:
|
||||
- Interactivity (useState, useEffect)
|
||||
- Browser APIs (localStorage, window)
|
||||
- Event handlers (onClick, onSubmit)
|
||||
|
||||
## Image Optimization
|
||||
|
||||
### 1. Next.js Image Component
|
||||
```typescript
|
||||
import Image from 'next/image';
|
||||
|
||||
// Optimized image with automatic sizing
|
||||
<Image
|
||||
src="/hero.jpg"
|
||||
alt="Hero image"
|
||||
width={1920}
|
||||
height={1080}
|
||||
priority // For LCP images
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/..." // Generated at build
|
||||
/>
|
||||
|
||||
// Responsive image
|
||||
<Image
|
||||
src="/hero.jpg"
|
||||
alt="Hero image"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. Image Format Priority
|
||||
1. **AVIF** (best compression, 50% smaller than JPEG)
|
||||
2. **WebP** (wide support, 30% smaller than JPEG)
|
||||
3. **JPEG/PNG** (fallback)
|
||||
|
||||
### 3. Cloudflare Image Optimization
|
||||
```typescript
|
||||
// next.config.ts
|
||||
images: {
|
||||
loader: 'custom',
|
||||
loaderFile: './lib/cloudflare-image-loader.ts',
|
||||
}
|
||||
|
||||
// lib/cloudflare-image-loader.ts
|
||||
export default function cloudflareLoader({ src, width, quality }) {
|
||||
const params = [`width=${width}`, `quality=${quality || 75}`, 'format=auto'];
|
||||
return `https://mylder.io/cdn-cgi/image/${params.join(',')}/${src}`;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Lazy Loading Images
|
||||
```typescript
|
||||
// Non-critical images (below fold)
|
||||
<Image
|
||||
src="/feature.jpg"
|
||||
alt="Feature"
|
||||
width={800}
|
||||
height={600}
|
||||
loading="lazy" // Default, explicit here
|
||||
/>
|
||||
|
||||
// Critical images (above fold, LCP)
|
||||
<Image
|
||||
src="/hero.jpg"
|
||||
alt="Hero"
|
||||
width={1920}
|
||||
height={1080}
|
||||
priority // Preload immediately
|
||||
/>
|
||||
```
|
||||
|
||||
### 5. Responsive Images Checklist
|
||||
- [ ] Use Next.js Image component (automatic optimization)
|
||||
- [ ] Set explicit width/height (prevent CLS)
|
||||
- [ ] Use `priority` for LCP images
|
||||
- [ ] Use `loading="lazy"` for below-fold images
|
||||
- [ ] Provide blur placeholder for better UX
|
||||
- [ ] Compress images before upload (80-85% quality)
|
||||
- [ ] Use modern formats (AVIF, WebP)
|
||||
|
||||
## Font Optimization
|
||||
|
||||
### 1. Next.js Font Optimization (Built-In)
|
||||
```typescript
|
||||
// app/layout.tsx
|
||||
import { Inter, Roboto_Mono } from 'next/font/google';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap', // Prevent invisible text flash
|
||||
variable: '--font-inter',
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const robotoMono = Roboto_Mono({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-roboto-mono',
|
||||
weight: ['400', '700'], // Only load needed weights
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html className={`${inter.variable} ${robotoMono.variable}`}>
|
||||
<body className="font-sans">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Self-hosted fonts (no external requests)
|
||||
- Automatic subset optimization
|
||||
- Zero layout shift (font-display: swap)
|
||||
|
||||
### 2. Custom Fonts
|
||||
```typescript
|
||||
import localFont from 'next/font/local';
|
||||
|
||||
const myFont = localFont({
|
||||
src: './fonts/MyFont.woff2',
|
||||
display: 'swap',
|
||||
variable: '--font-my-font',
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Font Loading Best Practices
|
||||
- [ ] Use only 2-3 font families max
|
||||
- [ ] Load only needed weights (400, 700)
|
||||
- [ ] Use `font-display: swap`
|
||||
- [ ] Preload critical fonts
|
||||
- [ ] Self-host fonts (no Google Fonts CDN)
|
||||
|
||||
## CSS Optimization
|
||||
|
||||
### 1. Tailwind CSS Production Config
|
||||
```typescript
|
||||
// tailwind.config.ts
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
// Remove unused styles in production
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true, // Better mobile UX
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### 2. Critical CSS
|
||||
Next.js automatically inlines critical CSS for first paint.
|
||||
|
||||
### 3. CSS Module Best Practices
|
||||
```typescript
|
||||
// Use CSS Modules for component-specific styles
|
||||
import styles from './Button.module.css';
|
||||
|
||||
export function Button() {
|
||||
return <button className={styles.button}>Click me</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## JavaScript Optimization
|
||||
|
||||
### 1. Tree Shaking
|
||||
```typescript
|
||||
// Good: Import only what you need
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// Bad: Import entire library
|
||||
import * as dateFns from 'date-fns';
|
||||
```
|
||||
|
||||
### 2. Remove Unused Dependencies
|
||||
```bash
|
||||
# Analyze bundle
|
||||
npx depcheck
|
||||
|
||||
# Remove unused
|
||||
npm uninstall unused-package
|
||||
```
|
||||
|
||||
### 3. Use Smaller Alternatives
|
||||
|
||||
| Heavy Package | Lightweight Alternative | Savings |
|
||||
|---------------|------------------------|---------|
|
||||
| Moment.js (232KB) | date-fns (13KB) | 95% |
|
||||
| Lodash (70KB) | Lodash-es (24KB) | 66% |
|
||||
| Axios (30KB) | Fetch API (native) | 100% |
|
||||
| React Icons (full) | Lucide React (tree-shakeable) | 80% |
|
||||
|
||||
### 4. Debounce/Throttle Heavy Operations
|
||||
```typescript
|
||||
// components/Search.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { debounce } from 'lodash-es/debounce';
|
||||
|
||||
export function Search() {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleSearch = useCallback(
|
||||
debounce(async (value: string) => {
|
||||
const results = await fetch(`/api/search?q=${value}`);
|
||||
// Handle results
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
return <input onChange={(e) => handleSearch(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### 1. Static Generation (Fastest)
|
||||
```typescript
|
||||
// app/blog/page.tsx
|
||||
export const revalidate = false; // Static at build time
|
||||
|
||||
export default async function Blog() {
|
||||
const posts = await fetchPosts();
|
||||
return <PostList posts={posts} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Incremental Static Regeneration (ISR)
|
||||
```typescript
|
||||
// app/blog/[slug]/page.tsx
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await fetchPosts();
|
||||
return posts.map((post) => ({ slug: post.slug }));
|
||||
}
|
||||
|
||||
export default async function Post({ params }) {
|
||||
const post = await fetchPost(params.slug);
|
||||
return <Article post={post} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Server-Side Rendering (SSR)
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
export const dynamic = 'force-dynamic'; // Always server-render
|
||||
|
||||
export default async function Dashboard() {
|
||||
const data = await fetchUserData(); // Per-request
|
||||
return <DashboardContent data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Client-Side Caching
|
||||
```typescript
|
||||
// Use SWR or React Query for client-side data fetching
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
|
||||
export function Profile() {
|
||||
const { data, error } = useSWR('/api/user', fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 60000, // 1 minute
|
||||
});
|
||||
|
||||
if (error) return <div>Error</div>;
|
||||
if (!data) return <div>Loading...</div>;
|
||||
return <div>{data.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. HTTP Cache Headers
|
||||
```typescript
|
||||
// app/api/data/route.ts
|
||||
export async function GET() {
|
||||
const data = await fetchData();
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Cache Invalidation Strategy
|
||||
|
||||
### 1. On-Demand Revalidation
|
||||
```typescript
|
||||
// app/api/revalidate/route.ts
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { path, tag } = await request.json();
|
||||
|
||||
if (path) {
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
revalidateTag(tag);
|
||||
}
|
||||
|
||||
return Response.json({ revalidated: true });
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Webhook Triggers (n8n)
|
||||
```typescript
|
||||
// n8n workflow: CMS Update → POST /api/revalidate
|
||||
{
|
||||
"path": "/blog",
|
||||
"tag": "posts"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Cloudflare Cache Purge
|
||||
```bash
|
||||
# On deploy (CI/CD)
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
|
||||
-H "Authorization: Bearer {api_token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}'
|
||||
```
|
||||
|
||||
### 4. Selective Purge
|
||||
```bash
|
||||
# Purge specific URLs
|
||||
--data '{"files":["https://mylder.io/blog","https://mylder.io/about"]}'
|
||||
|
||||
# Purge by tag
|
||||
--data '{"tags":["blog-posts"]}'
|
||||
```
|
||||
|
||||
## Performance Testing Workflow
|
||||
|
||||
### 1. Pre-Deploy Checks
|
||||
```bash
|
||||
# Run before every deployment
|
||||
npm run build # Must succeed
|
||||
npm run lint # No errors
|
||||
npm run typecheck # No type errors
|
||||
|
||||
# Optional but recommended
|
||||
ANALYZE=true npm run build # Check bundle size
|
||||
npx lighthouse https://staging.mylder.io --view
|
||||
```
|
||||
|
||||
### 2. Post-Deploy Verification
|
||||
```bash
|
||||
# Production smoke tests
|
||||
curl -I https://mylder.io # Check TTFB
|
||||
curl -I https://mylder.io/_next/static/[hash]/[file].js # Check caching
|
||||
|
||||
# Full performance audit
|
||||
npx lighthouse https://mylder.io --view
|
||||
|
||||
# Real user monitoring
|
||||
# Check Cloudflare Analytics or Vercel Analytics dashboard
|
||||
```
|
||||
|
||||
### 3. Continuous Monitoring
|
||||
```bash
|
||||
# Set up alerts in Cloudflare/monitoring tool
|
||||
- LCP > 3s → Alert
|
||||
- Error rate > 1% → Alert
|
||||
- TTFB > 1s → Alert
|
||||
```
|
||||
|
||||
## Quick Wins Checklist
|
||||
|
||||
### Immediate (1 day)
|
||||
- [ ] Enable Cloudflare CDN (see cloudflare-setup.md)
|
||||
- [ ] Add proper cache headers to static assets
|
||||
- [ ] Use Next.js Image component everywhere
|
||||
- [ ] Enable `reactStrictMode` and `swcMinify`
|
||||
- [ ] Remove unused dependencies
|
||||
|
||||
### Short-term (1 week)
|
||||
- [ ] Implement ISR for blog/content pages
|
||||
- [ ] Lazy load heavy components
|
||||
- [ ] Optimize images (compress, convert to WebP/AVIF)
|
||||
- [ ] Set up bundle analyzer
|
||||
- [ ] Add loading states and skeletons (improve perceived performance)
|
||||
|
||||
### Medium-term (1 month)
|
||||
- [ ] Implement edge caching for auth (see cloudflare-workers-roadmap.md)
|
||||
- [ ] Set up real user monitoring (RUM)
|
||||
- [ ] Optimize third-party scripts (defer/async)
|
||||
- [ ] Implement stale-while-revalidate for APIs
|
||||
- [ ] Add Lighthouse CI to deployment pipeline
|
||||
|
||||
### Long-term (3 months)
|
||||
- [ ] Move 70% traffic to edge (Cloudflare Workers)
|
||||
- [ ] Implement edge personalization
|
||||
- [ ] Set up A/B testing at edge
|
||||
- [ ] Achieve Core Web Vitals targets consistently
|
||||
- [ ] Reduce VPS load by 60%+
|
||||
|
||||
## Common Performance Issues & Fixes
|
||||
|
||||
### Issue: High LCP (>4s)
|
||||
**Causes:**
|
||||
- Large hero image not optimized
|
||||
- Server response time (TTFB) too high
|
||||
- Render-blocking resources
|
||||
|
||||
**Fixes:**
|
||||
- Use Next.js Image with `priority` for LCP image
|
||||
- Enable Cloudflare CDN for faster TTFB
|
||||
- Inline critical CSS
|
||||
- Preload fonts
|
||||
|
||||
### Issue: High CLS (>0.25)
|
||||
**Causes:**
|
||||
- Images without width/height
|
||||
- Web fonts loading late
|
||||
- Dynamic content injections
|
||||
|
||||
**Fixes:**
|
||||
- Set explicit dimensions on images
|
||||
- Use `font-display: swap`
|
||||
- Reserve space for dynamic content
|
||||
- Avoid inserting content above existing content
|
||||
|
||||
### Issue: High FID/INP (>300ms)
|
||||
**Causes:**
|
||||
- Heavy JavaScript execution
|
||||
- Long tasks blocking main thread
|
||||
- Unoptimized event handlers
|
||||
|
||||
**Fixes:**
|
||||
- Code split large bundles
|
||||
- Debounce/throttle event handlers
|
||||
- Use Web Workers for heavy computation
|
||||
- Optimize third-party scripts
|
||||
|
||||
### Issue: Slow TTFB (>1s)
|
||||
**Causes:**
|
||||
- Slow server response
|
||||
- No CDN
|
||||
- Database queries during render
|
||||
|
||||
**Fixes:**
|
||||
- Enable Cloudflare CDN
|
||||
- Use ISR instead of SSR
|
||||
- Move database queries to edge (D1)
|
||||
- Implement caching layers
|
||||
|
||||
## Monitoring Dashboard Setup
|
||||
|
||||
### Cloudflare Web Analytics
|
||||
```html
|
||||
<!-- Add to app/layout.tsx <head> -->
|
||||
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
|
||||
data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>
|
||||
```
|
||||
|
||||
### Custom Performance Monitoring
|
||||
```typescript
|
||||
// app/layout.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function PerformanceMonitor() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
// Measure Web Vitals
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(console.log);
|
||||
getFID(console.log);
|
||||
getFCP(console.log);
|
||||
getLCP(console.log);
|
||||
getTTFB(console.log);
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Next.js Performance Docs](https://nextjs.org/docs/app/building-your-application/optimizing)
|
||||
- [Web.dev Core Web Vitals](https://web.dev/vitals/)
|
||||
- [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci)
|
||||
- [Cloudflare Performance](https://developers.cloudflare.com/fundamentals/speed/)
|
||||
475
package-lock.json
generated
475
package-lock.json
generated
@@ -8,24 +8,30 @@
|
||||
"name": "mylder-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.87.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"motion": "^12.23.26",
|
||||
"next": "16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"stripe": "^20.0.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1287,6 +1293,90 @@
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
@@ -2296,6 +2386,91 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
@@ -2382,6 +2557,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -2485,6 +2750,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
@@ -2521,6 +2801,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
@@ -3925,7 +4269,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3939,7 +4282,6 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -4156,6 +4498,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4250,7 +4602,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -4362,7 +4713,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4372,7 +4722,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4410,7 +4759,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -5054,11 +5402,37 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
|
||||
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -5119,7 +5493,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -5153,7 +5526,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@@ -5241,7 +5613,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5313,7 +5684,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5342,7 +5712,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -6343,7 +6712,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6396,6 +6764,47 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.26.tgz",
|
||||
"integrity": "sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.26",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -6555,7 +6964,6 @@
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6862,6 +7270,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -7305,7 +7728,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -7325,7 +7747,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -7342,7 +7763,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -7361,7 +7781,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -7553,6 +7972,26 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "20.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.0.0.tgz",
|
||||
"integrity": "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
|
||||
@@ -9,24 +9,30 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.87.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"motion": "^12.23.26",
|
||||
"next": "16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"stripe": "^20.0.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Bot, Loader2, Mail } from 'lucide-react'
|
||||
import { Bot, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSent, setIsSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
@@ -22,11 +24,9 @@ export default function LoginPage() {
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
@@ -35,46 +35,19 @@ export default function LoginPage() {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSent(true)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
if (isSent) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Mail className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Check your email</CardTitle>
|
||||
<CardDescription>
|
||||
We sent a magic link to <strong>{email}</strong>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-zinc-500">
|
||||
Click the link in your email to sign in. If you don't see it, check your spam folder.
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-center">
|
||||
<Button variant="ghost" onClick={() => setIsSent(false)}>
|
||||
Try a different email
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center gap-2 mb-4">
|
||||
<Bot className="h-8 w-8 text-primary" />
|
||||
<Bot className="h-8 w-8 text-brand" />
|
||||
<span className="text-xl font-bold">Mylder</span>
|
||||
</Link>
|
||||
<CardTitle>Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your account with a magic link</CardDescription>
|
||||
<CardDescription>Sign in to your account</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleLogin}>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -90,24 +63,36 @@ export default function LoginPage() {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
<Button type="submit" variant="brand" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending link...
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Send Magic Link'
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-sm text-center text-zinc-500">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary hover:underline">
|
||||
<Link href="/signup" className="text-brand hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -2,31 +2,45 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Bot, Loader2, Mail } from 'lucide-react'
|
||||
import { Bot, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSent, setIsSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
data: {
|
||||
full_name: fullName,
|
||||
},
|
||||
@@ -39,42 +53,15 @@ export default function SignupPage() {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSent(true)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
if (isSent) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Mail className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Check your email</CardTitle>
|
||||
<CardDescription>
|
||||
We sent a magic link to <strong>{email}</strong>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-zinc-500">
|
||||
Click the link in your email to complete your signup. If you don't see it, check your spam folder.
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-center">
|
||||
<Button variant="ghost" onClick={() => setIsSent(false)}>
|
||||
Try a different email
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center gap-2 mb-4">
|
||||
<Bot className="h-8 w-8 text-primary" />
|
||||
<Bot className="h-8 w-8 text-brand" />
|
||||
<span className="text-xl font-bold">Mylder</span>
|
||||
</Link>
|
||||
<CardTitle>Create your account</CardTitle>
|
||||
@@ -106,24 +93,48 @@ export default function SignupPage() {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
<Button type="submit" variant="brand" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Get Started Free'
|
||||
'Create Account'
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-sm text-center text-zinc-500">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
<Link href="/login" className="text-brand hover:underline">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -20,11 +20,11 @@ export default async function DashboardPage() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">Projects</h1>
|
||||
<p className="text-zinc-500">Manage your AI-powered development projects</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">Projects</h1>
|
||||
<p className="text-muted-foreground">Manage your AI-powered development projects</p>
|
||||
</div>
|
||||
<Link href="/projects/new">
|
||||
<Button className="gap-2">
|
||||
<Button variant="brand" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Project
|
||||
</Button>
|
||||
@@ -35,11 +35,11 @@ export default async function DashboardPage() {
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{projects.map((project) => (
|
||||
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<Card className="h-full hover:border-brand/50 hover:shadow-md transition-all cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{project.name}</CardTitle>
|
||||
<Badge variant={project.status === 'active' ? 'default' : 'secondary'}>
|
||||
<Badge variant={project.status === 'active' ? 'brand' : 'secondary'}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ export default async function DashboardPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-zinc-500">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{project.tech_stack && project.tech_stack.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{project.tech_stack.slice(0, 3).map((tech: string) => (
|
||||
@@ -59,7 +59,7 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-zinc-400 mt-4">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-4">
|
||||
<Clock className="h-3 w-3" />
|
||||
Updated {new Date(project.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
@@ -71,17 +71,17 @@ export default async function DashboardPage() {
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
|
||||
<FolderOpen className="h-8 w-8 text-zinc-400" />
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<FolderOpen className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white mb-2">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No projects yet
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-center mb-6 max-w-sm">
|
||||
<p className="text-muted-foreground text-center mb-6 max-w-sm">
|
||||
Create your first project to start building with AI assistance.
|
||||
</p>
|
||||
<Link href="/projects/new">
|
||||
<Button className="gap-2">
|
||||
<Button variant="brand" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Project
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { ProjectChat } from '@/components/chat/project-chat'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Settings } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { Database } from '@/types/database'
|
||||
import { ProjectView } from './project-view'
|
||||
import { ProjectSettingsMenu } from './project-settings-menu'
|
||||
|
||||
type Project = Database['public']['Tables']['projects']['Row']
|
||||
type Message = Database['public']['Tables']['messages']['Row']
|
||||
@@ -24,7 +24,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Get messages for this project
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
@@ -33,35 +32,38 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
||||
{/* Project Header */}
|
||||
<div className="border-b bg-white dark:bg-zinc-950 px-4 py-3">
|
||||
{/* Consolidated Project Header - compact single row */}
|
||||
<div className="border-b bg-card/80 backdrop-blur-sm px-4 py-2">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dashboard" className="text-zinc-500 hover:text-zinc-900 dark:hover:text-white">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">{project.name}</h1>
|
||||
<Badge variant={project.status === 'active' ? 'default' : 'secondary'}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="text-sm text-zinc-500">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<h1 className="text-sm font-medium">{project.name}</h1>
|
||||
<Badge
|
||||
variant={project.status === 'active' ? 'brand' : 'secondary'}
|
||||
className="text-xs h-5"
|
||||
>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
<ProjectSettingsMenu
|
||||
projectId={id}
|
||||
projectName={project.name}
|
||||
hasGiteaRepo={!!project.gitea_repo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Interface */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ProjectChat projectId={id} initialMessages={messages || []} />
|
||||
</div>
|
||||
<ProjectView
|
||||
projectId={id}
|
||||
project={project}
|
||||
initialMessages={messages || []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
184
src/app/(dashboard)/projects/[id]/project-settings-menu.tsx
Normal file
184
src/app/(dashboard)/projects/[id]/project-settings-menu.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { deleteProject } from '@/lib/supabase/projects'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Settings, Trash2, ExternalLink, Loader2, AlertTriangle, GitBranch } from 'lucide-react'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
interface ProjectSettingsMenuProps {
|
||||
projectId: string
|
||||
projectName: string
|
||||
hasGiteaRepo: boolean
|
||||
}
|
||||
|
||||
export function ProjectSettingsMenu({
|
||||
projectId,
|
||||
projectName,
|
||||
hasGiteaRepo,
|
||||
}: ProjectSettingsMenuProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
const canDelete = confirmText === projectName
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!canDelete) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
const result = await deleteProject(projectId)
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Project deleted',
|
||||
description: hasGiteaRepo
|
||||
? 'Project and associated repository have been deleted.'
|
||||
: 'Project has been deleted.',
|
||||
})
|
||||
router.push('/projects')
|
||||
router.refresh()
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to delete project',
|
||||
description: result.error || 'An error occurred',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete project',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeleteDialogOpen(false)
|
||||
setConfirmText('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{hasGiteaRepo && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`https://gitea.mylder.io/admin/${projectName.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
View Repository
|
||||
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
Delete Project
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
This action cannot be undone. This will permanently delete the project
|
||||
<strong className="text-foreground"> {projectName}</strong> and all
|
||||
associated data including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1 text-muted-foreground">
|
||||
<li>All messages and chat history</li>
|
||||
<li>All agent runs and proposed changes</li>
|
||||
<li>All backlog items and activities</li>
|
||||
<li>All phase tracking data</li>
|
||||
{hasGiteaRepo && (
|
||||
<li className="text-destructive font-medium">
|
||||
The Gitea repository and all its code
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="pt-2">
|
||||
<p className="text-sm mb-2">
|
||||
Type <strong className="text-foreground">{projectName}</strong> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting} onClick={() => setConfirmText('')}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={!canDelete || deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Project
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
52
src/app/(dashboard)/projects/[id]/project-tabs.tsx
Normal file
52
src/app/(dashboard)/projects/[id]/project-tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ProjectChat } from '@/components/chat/project-chat'
|
||||
import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard'
|
||||
import { MessageSquare, Compass } from 'lucide-react'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type Message = Database['public']['Tables']['messages']['Row']
|
||||
|
||||
interface ProjectTabsProps {
|
||||
projectId: string
|
||||
projectName: string
|
||||
initialMessages: Message[]
|
||||
}
|
||||
|
||||
export function ProjectTabs({ projectId, projectName, initialMessages }: ProjectTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="chat" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="border-b bg-card/50 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<TabsList className="h-10 bg-transparent p-0 gap-4">
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="phases"
|
||||
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
||||
>
|
||||
<Compass className="w-4 h-4 mr-2" />
|
||||
Phases
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chat" className="flex-1 overflow-hidden m-0 data-[state=inactive]:hidden">
|
||||
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="phases" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<DesignThinkingDashboard projectId={projectId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
137
src/app/(dashboard)/projects/[id]/project-view.tsx
Normal file
137
src/app/(dashboard)/projects/[id]/project-view.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ProjectChat } from '@/components/chat/project-chat'
|
||||
import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard'
|
||||
import { PhaseIndicatorCompact } from '@/components/project/phase-indicator-compact'
|
||||
import { ApprovalPanel } from '@/components/project/approval-panel'
|
||||
import { useProject } from '@/hooks/use-project'
|
||||
import { useAgentRuns } from '@/hooks/use-agent-runs'
|
||||
import { MessageSquare, Compass, PanelRightOpen, PanelRightClose, Bell } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Database } from '@/types/database'
|
||||
import type { DesignPhase } from '@/types/design-thinking'
|
||||
|
||||
type DBProject = Database['public']['Tables']['projects']['Row']
|
||||
type Message = Database['public']['Tables']['messages']['Row']
|
||||
|
||||
interface ProjectViewProps {
|
||||
projectId: string
|
||||
project: DBProject
|
||||
initialMessages: Message[]
|
||||
}
|
||||
|
||||
type SidebarView = 'phases' | 'approvals' | null
|
||||
|
||||
export function ProjectView({ projectId, initialMessages }: ProjectViewProps) {
|
||||
const [sidebarView, setSidebarView] = useState<SidebarView>(null)
|
||||
const { project: projectData, phaseStatuses, loading } = useProject(projectId)
|
||||
const { pendingRuns } = useAgentRuns(projectId)
|
||||
|
||||
const showPhases = sidebarView === 'phases'
|
||||
const showApprovals = sidebarView === 'approvals'
|
||||
const hasSidebar = sidebarView !== null
|
||||
|
||||
const handlePhaseClick = () => {
|
||||
setSidebarView('phases')
|
||||
}
|
||||
|
||||
// Default to 'empathize' if project not loaded yet
|
||||
const currentPhase = projectData?.current_phase || 'empathize'
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Main Chat Area */}
|
||||
<div className={cn(
|
||||
'flex-1 flex flex-col transition-all duration-300',
|
||||
hasSidebar ? 'lg:mr-80' : ''
|
||||
)}>
|
||||
{/* Inline toolbar with phase indicator */}
|
||||
<div className="border-b bg-muted/30 px-4 py-1.5">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Chat</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
{!loading && (
|
||||
<PhaseIndicatorCompact
|
||||
currentPhase={currentPhase}
|
||||
phaseStatuses={phaseStatuses}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Approvals button with badge */}
|
||||
<Button
|
||||
variant={showApprovals ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs relative"
|
||||
onClick={() => setSidebarView(showApprovals ? null : 'approvals')}
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Approvals</span>
|
||||
{pendingRuns.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-brand text-[10px] text-white flex items-center justify-center">
|
||||
{pendingRuns.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{/* Phases button */}
|
||||
<Button
|
||||
variant={showPhases ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => setSidebarView(showPhases ? null : 'phases')}
|
||||
>
|
||||
{hasSidebar ? (
|
||||
<PanelRightClose className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<PanelRightOpen className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Phases</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - slides in from right */}
|
||||
<div className={cn(
|
||||
'fixed right-0 top-[calc(4rem+2.5rem+2rem)] bottom-0 w-80 bg-card border-l transform transition-transform duration-300 overflow-y-auto z-40',
|
||||
hasSidebar ? 'translate-x-0' : 'translate-x-full'
|
||||
)}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{showPhases && <Compass className="w-4 h-4 text-muted-foreground" />}
|
||||
{showApprovals && <Bell className="w-4 h-4 text-muted-foreground" />}
|
||||
<h2 className="text-sm font-medium">
|
||||
{showPhases && 'Design Phases'}
|
||||
{showApprovals && 'Pending Approvals'}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setSidebarView(null)}
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{showPhases && <DesignThinkingDashboard projectId={projectId} />}
|
||||
{showApprovals && <ApprovalPanel projectId={projectId} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
src/app/(dashboard)/projects/new/actions.ts
Normal file
122
src/app/(dashboard)/projects/new/actions.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
'use server'
|
||||
|
||||
import type { GiteaRepository, GiteaWebhook } from '@/lib/gitea/types'
|
||||
|
||||
const GITEA_URL = process.env.GITEA_URL || 'https://gitea.mylder.io'
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
|
||||
const GITEA_OWNER = 'admin'
|
||||
const WWS_WEBHOOK_URL = 'https://wws.mylder.io/webhook/gitea'
|
||||
|
||||
interface CreateRepoResult {
|
||||
success: boolean
|
||||
repo?: GiteaRepository
|
||||
webhook?: GiteaWebhook
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function giteaRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${GITEA_URL}/api/v1${endpoint}`
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `token ${GITEA_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(error.message || `Gitea API error: ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function createGiteaRepo(slug: string, description?: string): Promise<CreateRepoResult> {
|
||||
try {
|
||||
// Check if repo already exists
|
||||
const existingCheck = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${slug}`, {
|
||||
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
|
||||
})
|
||||
|
||||
if (existingCheck.ok) {
|
||||
return { success: false, error: 'Repository already exists' }
|
||||
}
|
||||
|
||||
// Create the repository
|
||||
const repo = await giteaRequest<GiteaRepository>('/user/repos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
description: description || `Mylder project: ${slug}`,
|
||||
private: false,
|
||||
auto_init: true,
|
||||
default_branch: 'main',
|
||||
}),
|
||||
})
|
||||
|
||||
// Create webhook pointing to WWS (which routes to n8n)
|
||||
const webhook = await giteaRequest<GiteaWebhook>(`/repos/${GITEA_OWNER}/${slug}/hooks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'gitea',
|
||||
config: {
|
||||
url: WWS_WEBHOOK_URL,
|
||||
content_type: 'json',
|
||||
secret: '',
|
||||
},
|
||||
events: ['push', 'pull_request', 'issues', 'create', 'delete'],
|
||||
active: true,
|
||||
}),
|
||||
})
|
||||
|
||||
return { success: true, repo, webhook }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create repository',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGiteaRepoUrl(slug: string): Promise<string> {
|
||||
return `${GITEA_URL}/${GITEA_OWNER}/${slug}`
|
||||
}
|
||||
|
||||
interface DeleteRepoResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function deleteGiteaRepo(repoName: string): Promise<DeleteRepoResult> {
|
||||
try {
|
||||
// Check if repo exists first
|
||||
const checkResponse = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${repoName}`, {
|
||||
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
|
||||
})
|
||||
|
||||
if (!checkResponse.ok) {
|
||||
// Repo doesn't exist, consider it a success
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Delete the repository
|
||||
await giteaRequest<void>(`/repos/${GITEA_OWNER}/${repoName}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete repository',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, GitBranch, Check } from 'lucide-react'
|
||||
import { createGiteaRepo } from './actions'
|
||||
|
||||
type CreationStep = 'idle' | 'creating_project' | 'creating_repo' | 'complete'
|
||||
|
||||
export default function NewProjectPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [step, setStep] = useState<CreationStep>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -37,6 +41,9 @@ export default function NewProjectPage() {
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
|
||||
// Step 1: Create Supabase project
|
||||
setStep('creating_project')
|
||||
|
||||
// First, get or create the user's default team
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let { data: team } = await (supabase as any)
|
||||
@@ -60,12 +67,13 @@ export default function NewProjectPage() {
|
||||
if (teamError) {
|
||||
setError('Failed to create team')
|
||||
setIsLoading(false)
|
||||
setStep('idle')
|
||||
return
|
||||
}
|
||||
team = newTeam
|
||||
}
|
||||
|
||||
// Create the project
|
||||
// Create the project (without gitea_repo initially)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: project, error: projectError } = await (supabase as any)
|
||||
.from('projects')
|
||||
@@ -81,15 +89,40 @@ export default function NewProjectPage() {
|
||||
if (projectError) {
|
||||
setError(projectError.message)
|
||||
setIsLoading(false)
|
||||
setStep('idle')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Create Gitea repository
|
||||
setStep('creating_repo')
|
||||
const repoResult = await createGiteaRepo(slug, description || name)
|
||||
|
||||
if (repoResult.success && repoResult.repo) {
|
||||
// Update project with gitea_repo URL
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (supabase as any)
|
||||
.from('projects')
|
||||
.update({
|
||||
gitea_repo: repoResult.repo.html_url,
|
||||
gitea_webhook_id: repoResult.webhook?.id,
|
||||
})
|
||||
.eq('id', project.id)
|
||||
} else {
|
||||
// Log warning but don't fail - project still created
|
||||
console.warn('Gitea repo creation failed:', repoResult.error)
|
||||
}
|
||||
|
||||
setStep('complete')
|
||||
|
||||
// Small delay to show completion state
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
router.push(`/projects/${project.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Link href="/dashboard" className="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-white mb-6">
|
||||
<Link href="/dashboard" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
@@ -125,13 +158,44 @@ export default function NewProjectPage() {
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={isLoading || !name}>
|
||||
{isLoading && (
|
||||
<div className="space-y-2 py-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{step === 'creating_project' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand" />
|
||||
) : step === 'creating_repo' || step === 'complete' ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-full border-2 border-muted" />
|
||||
)}
|
||||
<span className={step === 'creating_project' ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
Creating project
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{step === 'creating_repo' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand" />
|
||||
) : step === 'complete' ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-full border-2 border-muted" />
|
||||
)}
|
||||
<span className={step === 'creating_repo' ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
<GitBranch className="inline h-3 w-3 mr-1" />
|
||||
Creating Gitea repository
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" variant="brand" className="w-full" disabled={isLoading || !name}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
{step === 'creating_project' && 'Creating project...'}
|
||||
{step === 'creating_repo' && 'Setting up repository...'}
|
||||
{step === 'complete' && 'Done!'}
|
||||
</>
|
||||
) : (
|
||||
'Create Project'
|
||||
|
||||
384
src/app/(dashboard)/settings/page.tsx
Normal file
384
src/app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { User, CreditCard, Bell, Shield, Loader2, Check, ExternalLink } from 'lucide-react'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type Profile = Database['public']['Tables']['profiles']['Row']
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row']
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null)
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
full_name: '',
|
||||
email: '',
|
||||
})
|
||||
const [preferences, setPreferences] = useState({
|
||||
emailNotifications: true,
|
||||
marketingEmails: false,
|
||||
weeklyDigest: true,
|
||||
})
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [profileRes, subRes] = await Promise.all([
|
||||
(supabase as any).from('profiles').select('*').eq('id', user.id).single(),
|
||||
(supabase as any).from('subscriptions').select('*').eq('user_id', user.id).single(),
|
||||
])
|
||||
|
||||
if (profileRes.data) {
|
||||
setProfile(profileRes.data)
|
||||
setFormData({
|
||||
full_name: profileRes.data.full_name || '',
|
||||
email: profileRes.data.email || '',
|
||||
})
|
||||
}
|
||||
|
||||
if (subRes.data) {
|
||||
setSubscription(subRes.data)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [supabase])
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!profile) return
|
||||
|
||||
setSaving(true)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error } = await (supabase as any)
|
||||
.from('profiles')
|
||||
.update({ full_name: formData.full_name })
|
||||
.eq('id', profile.id)
|
||||
|
||||
setSaving(false)
|
||||
if (!error) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManageBilling = async () => {
|
||||
const response = await fetch('/api/stripe/portal', {
|
||||
method: 'POST',
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
||||
<p className="text-muted-foreground">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="profile" className="gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Profile</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="billing" className="gap-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Billing</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Notifications</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Security</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
<CardDescription>Update your personal information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={formData.email}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Contact support to change your email
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Account Role</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your current account permissions
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={profile?.role === 'admin' ? 'brand' : 'secondary'}>
|
||||
{profile?.role || 'user'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveProfile} disabled={saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : saved ? (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
) : null}
|
||||
{saved ? 'Saved!' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="billing">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subscription & Billing</CardTitle>
|
||||
<CardDescription>Manage your subscription and payment methods</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">Current Plan</p>
|
||||
<Badge variant="brand">{subscription?.plan || 'Free'}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{subscription?.status === 'active' ? (
|
||||
<>
|
||||
Renews on{' '}
|
||||
{subscription.current_period_end
|
||||
? new Date(subscription.current_period_end).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</>
|
||||
) : (
|
||||
'Upgrade to unlock more features'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{subscription?.plan === 'free' ? (
|
||||
<Button variant="brand" asChild>
|
||||
<a href="/pricing">Upgrade Plan</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handleManageBilling}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Manage Billing
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-4">Plan Features</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{subscription?.plan === 'free' ? (
|
||||
<>
|
||||
<PlanFeature label="Projects" value="1" />
|
||||
<PlanFeature label="AI Messages" value="50/month" />
|
||||
<PlanFeature label="Templates" value="Basic" />
|
||||
<PlanFeature label="Support" value="Community" />
|
||||
</>
|
||||
) : subscription?.plan === 'pro' ? (
|
||||
<>
|
||||
<PlanFeature label="Projects" value="Unlimited" />
|
||||
<PlanFeature label="AI Messages" value="2,000/month" />
|
||||
<PlanFeature label="Templates" value="All" />
|
||||
<PlanFeature label="Support" value="Priority" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlanFeature label="Projects" value="Unlimited" />
|
||||
<PlanFeature label="AI Messages" value="10,000/month" />
|
||||
<PlanFeature label="Team Members" value="Unlimited" />
|
||||
<PlanFeature label="Support" value="Dedicated" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>Choose how you want to be notified</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<NotificationToggle
|
||||
label="Email Notifications"
|
||||
description="Receive email updates about your projects"
|
||||
checked={preferences.emailNotifications}
|
||||
onCheckedChange={(checked) =>
|
||||
setPreferences({ ...preferences, emailNotifications: checked })
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<NotificationToggle
|
||||
label="Weekly Digest"
|
||||
description="Get a weekly summary of your project activity"
|
||||
checked={preferences.weeklyDigest}
|
||||
onCheckedChange={(checked) =>
|
||||
setPreferences({ ...preferences, weeklyDigest: checked })
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<NotificationToggle
|
||||
label="Marketing Emails"
|
||||
description="Receive updates about new features and offers"
|
||||
checked={preferences.marketingEmails}
|
||||
onCheckedChange={(checked) =>
|
||||
setPreferences({ ...preferences, marketingEmails: checked })
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Settings</CardTitle>
|
||||
<CardDescription>Manage your account security</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Password</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last changed: Never
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline">Change Password</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Two-Factor Authentication</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add an extra layer of security
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">Coming Soon</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Active Sessions</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
1 active session
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline">View Sessions</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-medium text-destructive">Danger Zone</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Permanently delete your account and all data
|
||||
</p>
|
||||
<Button variant="destructive">Delete Account</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanFeature({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/app/(marketing)/layout.tsx
Normal file
37
src/app/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b bg-white/80 backdrop-blur-sm dark:bg-zinc-950/80 sticky top-0 z-50">
|
||||
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<Link href="/" className="text-xl font-bold tracking-tight">
|
||||
Mylder
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/pricing">
|
||||
<Button variant="ghost" size="sm">Pricing</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" size="sm">Log in</Button>
|
||||
</Link>
|
||||
<Link href="/signup">
|
||||
<Button size="sm">Get Started</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="border-t py-8 text-center text-sm text-muted-foreground">
|
||||
<p>© {new Date().getFullYear()} Mylder. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
src/app/(marketing)/pricing/page.tsx
Normal file
119
src/app/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { PLANS, type PlanKey } from '@/lib/stripe/config'
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState<PlanKey | null>(null)
|
||||
|
||||
const handleSubscribe = async (plan: PlanKey) => {
|
||||
if (plan === 'free') {
|
||||
router.push('/auth/signup')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(plan)
|
||||
try {
|
||||
const res = await fetch('/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error === 'Unauthorized') {
|
||||
router.push('/auth/login?redirect=/pricing')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-zinc-50 to-white dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-4">
|
||||
Simple, transparent pricing
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-600 dark:text-zinc-400 max-w-2xl mx-auto">
|
||||
Start free, upgrade when you need more. No hidden fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{(Object.entries(PLANS) as [PlanKey, typeof PLANS[PlanKey]][]).map(([key, plan]) => (
|
||||
<Card
|
||||
key={key}
|
||||
className={`relative ${
|
||||
key === 'pro' ? 'border-brand shadow-lg scale-105' : ''
|
||||
}`}
|
||||
>
|
||||
{key === 'pro' && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2" variant="brand">
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
${plan.price}
|
||||
</span>
|
||||
{plan.price > 0 && (
|
||||
<span className="text-muted-foreground">/month</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-brand shrink-0 mt-0.5" />
|
||||
<span className="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={key === 'pro' ? 'brand' : 'outline'}
|
||||
onClick={() => handleSubscribe(key)}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === key ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : key === 'free' ? (
|
||||
'Get Started'
|
||||
) : (
|
||||
'Subscribe'
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||
<p>All plans include SSL encryption and 99.9% uptime SLA.</p>
|
||||
<p className="mt-2">Questions? Contact support@mylder.io</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/app/api/stripe/checkout/route.ts
Normal file
66
src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { stripe, PLANS, type PlanKey } from '@/lib/stripe/config'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row']
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { plan } = await request.json() as { plan: PlanKey }
|
||||
const planConfig = PLANS[plan]
|
||||
|
||||
if (!planConfig || !planConfig.priceId) {
|
||||
return NextResponse.json({ error: 'Invalid plan' }, { status: 400 })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: subscription } = await (supabase as any)
|
||||
.from('subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
|
||||
|
||||
let customerId = subscription?.stripe_customer_id
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: { userId: user.id },
|
||||
})
|
||||
customerId = customer.id
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (supabase as any)
|
||||
.from('subscriptions')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
stripe_customer_id: customerId,
|
||||
plan: 'free',
|
||||
status: 'active',
|
||||
})
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{ price: planConfig.priceId, quantity: 1 }],
|
||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?checkout=success`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?checkout=cancelled`,
|
||||
metadata: { userId: user.id, plan },
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: session.url })
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
38
src/app/api/stripe/portal/route.ts
Normal file
38
src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { stripe } from '@/lib/stripe/config'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row']
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: subscription } = await (supabase as any)
|
||||
.from('subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
|
||||
|
||||
if (!subscription?.stripe_customer_id) {
|
||||
return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: subscription.stripe_customer_id,
|
||||
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/settings`,
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: session.url })
|
||||
} catch (error) {
|
||||
console.error('Portal error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create portal session' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
103
src/app/api/stripe/webhook/route.ts
Normal file
103
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { headers } from 'next/headers'
|
||||
import { stripe } from '@/lib/stripe/config'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text()
|
||||
const headersList = await headers()
|
||||
const signature = headersList.get('stripe-signature')!
|
||||
|
||||
let event: Stripe.Event
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
const userId = session.metadata?.userId
|
||||
const plan = session.metadata?.plan
|
||||
|
||||
if (userId && session.subscription) {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
)
|
||||
|
||||
await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
stripe_customer_id: session.customer as string,
|
||||
stripe_subscription_id: subscription.id,
|
||||
plan: plan || 'pro',
|
||||
status: subscription.status,
|
||||
current_period_start: new Date((subscription as unknown as { current_period_start: number }).current_period_start * 1000).toISOString(),
|
||||
current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(),
|
||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
const customerId = subscription.customer as string
|
||||
|
||||
const { data: existingSub } = await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.select('user_id')
|
||||
.eq('stripe_customer_id', customerId)
|
||||
.single()
|
||||
|
||||
if (existingSub) {
|
||||
const sub = subscription as unknown as { current_period_start: number; current_period_end: number }
|
||||
await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: subscription.status,
|
||||
plan: subscription.status === 'canceled' ? 'free' : undefined,
|
||||
current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
|
||||
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
|
||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('stripe_customer_id', customerId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
const customerId = invoice.customer as string
|
||||
|
||||
await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.update({ status: 'past_due', updated_at: new Date().toISOString() })
|
||||
.eq('stripe_customer_id', customerId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (error) {
|
||||
console.error('Webhook processing error:', error)
|
||||
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,120 +3,381 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ============================================
|
||||
MYLDER DESIGN SYSTEM
|
||||
Warm neutrals + Amber accent
|
||||
============================================ */
|
||||
|
||||
@theme inline {
|
||||
/* Color mappings */
|
||||
--color-background: var(--background);
|
||||
--color-background-subtle: var(--background-subtle);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
/* Brand colors */
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-brand-muted: var(--brand-muted);
|
||||
|
||||
/* Semantic colors */
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
|
||||
/* UI colors */
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar colors */
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIGHT MODE (Default)
|
||||
Warm stone neutrals + amber accent
|
||||
============================================ */
|
||||
:root {
|
||||
/* Base radius */
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Plus Jakarta Sans', var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', var(--font-geist-mono), monospace;
|
||||
|
||||
/* Brand - Amber */
|
||||
--brand: oklch(0.75 0.16 75);
|
||||
--brand-foreground: oklch(0.20 0.02 75);
|
||||
--brand-muted: oklch(0.92 0.06 75);
|
||||
|
||||
/* Backgrounds - Warm white */
|
||||
--background: oklch(0.99 0.002 75);
|
||||
--background-subtle: oklch(0.975 0.004 75);
|
||||
--foreground: oklch(0.15 0.01 75);
|
||||
|
||||
/* Cards & Surfaces */
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.15 0.01 75);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--popover-foreground: oklch(0.15 0.01 75);
|
||||
|
||||
/* Primary - Dark (for contrast) */
|
||||
--primary: oklch(0.20 0.01 75);
|
||||
--primary-foreground: oklch(0.98 0.002 75);
|
||||
|
||||
/* Secondary - Light gray */
|
||||
--secondary: oklch(0.955 0.006 75);
|
||||
--secondary-foreground: oklch(0.25 0.01 75);
|
||||
|
||||
/* Muted - Subtle */
|
||||
--muted: oklch(0.955 0.006 75);
|
||||
--muted-foreground: oklch(0.50 0.01 75);
|
||||
|
||||
/* Accent - Warm amber tint */
|
||||
--accent: oklch(0.96 0.04 75);
|
||||
--accent-foreground: oklch(0.25 0.02 75);
|
||||
|
||||
/* Semantic states */
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--success: oklch(0.65 0.18 145);
|
||||
--success-foreground: oklch(0.98 0 0);
|
||||
--warning: oklch(0.80 0.15 85);
|
||||
--warning-foreground: oklch(0.25 0.02 85);
|
||||
--info: oklch(0.65 0.15 240);
|
||||
--info-foreground: oklch(0.98 0 0);
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: oklch(0.90 0.006 75);
|
||||
--input: oklch(0.90 0.006 75);
|
||||
--ring: oklch(0.75 0.16 75);
|
||||
|
||||
/* Charts - Vibrant palette */
|
||||
--chart-1: oklch(0.75 0.16 75);
|
||||
--chart-2: oklch(0.60 0.15 200);
|
||||
--chart-3: oklch(0.65 0.18 280);
|
||||
--chart-4: oklch(0.70 0.16 145);
|
||||
--chart-5: oklch(0.65 0.20 25);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.985 0.003 75);
|
||||
--sidebar-foreground: oklch(0.15 0.01 75);
|
||||
--sidebar-primary: oklch(0.75 0.16 75);
|
||||
--sidebar-primary-foreground: oklch(0.20 0.02 75);
|
||||
--sidebar-accent: oklch(0.96 0.04 75);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.02 75);
|
||||
--sidebar-border: oklch(0.90 0.006 75);
|
||||
--sidebar-ring: oklch(0.75 0.16 75);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DARK MODE
|
||||
Warm dark + amber accent
|
||||
============================================ */
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
/* Brand - Slightly brighter for dark bg */
|
||||
--brand: oklch(0.78 0.15 75);
|
||||
--brand-foreground: oklch(0.15 0.02 75);
|
||||
--brand-muted: oklch(0.30 0.06 75);
|
||||
|
||||
/* Backgrounds - Warm dark */
|
||||
--background: oklch(0.14 0.008 75);
|
||||
--background-subtle: oklch(0.18 0.01 75);
|
||||
--foreground: oklch(0.96 0.004 75);
|
||||
|
||||
/* Cards & Surfaces */
|
||||
--card: oklch(0.20 0.01 75);
|
||||
--card-foreground: oklch(0.96 0.004 75);
|
||||
--popover: oklch(0.20 0.01 75);
|
||||
--popover-foreground: oklch(0.96 0.004 75);
|
||||
|
||||
/* Primary - Light (for contrast) */
|
||||
--primary: oklch(0.94 0.004 75);
|
||||
--primary-foreground: oklch(0.18 0.01 75);
|
||||
|
||||
/* Secondary */
|
||||
--secondary: oklch(0.26 0.012 75);
|
||||
--secondary-foreground: oklch(0.94 0.004 75);
|
||||
|
||||
/* Muted */
|
||||
--muted: oklch(0.26 0.012 75);
|
||||
--muted-foreground: oklch(0.65 0.01 75);
|
||||
|
||||
/* Accent */
|
||||
--accent: oklch(0.30 0.04 75);
|
||||
--accent-foreground: oklch(0.94 0.004 75);
|
||||
|
||||
/* Semantic states - Adjusted for dark */
|
||||
--destructive: oklch(0.65 0.20 22);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--success: oklch(0.70 0.16 145);
|
||||
--success-foreground: oklch(0.15 0 0);
|
||||
--warning: oklch(0.82 0.14 85);
|
||||
--warning-foreground: oklch(0.20 0.02 85);
|
||||
--info: oklch(0.70 0.14 240);
|
||||
--info-foreground: oklch(0.15 0 0);
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--ring: oklch(0.78 0.15 75);
|
||||
|
||||
/* Charts - Adjusted for dark */
|
||||
--chart-1: oklch(0.78 0.15 75);
|
||||
--chart-2: oklch(0.68 0.14 200);
|
||||
--chart-3: oklch(0.72 0.16 280);
|
||||
--chart-4: oklch(0.75 0.15 145);
|
||||
--chart-5: oklch(0.72 0.18 25);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.18 0.01 75);
|
||||
--sidebar-foreground: oklch(0.96 0.004 75);
|
||||
--sidebar-primary: oklch(0.78 0.15 75);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 75);
|
||||
--sidebar-accent: oklch(0.26 0.03 75);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.004 75);
|
||||
--sidebar-border: oklch(1 0 0 / 12%);
|
||||
--sidebar-ring: oklch(0.78 0.15 75);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BASE STYLES
|
||||
============================================ */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Focus visible styles */
|
||||
:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-ring;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: oklch(0.75 0.16 75 / 0.3);
|
||||
}
|
||||
|
||||
.dark ::selection {
|
||||
background: oklch(0.78 0.15 75 / 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
@layer utilities {
|
||||
/* Brand text gradient */
|
||||
.text-gradient-brand {
|
||||
background: linear-gradient(90deg, oklch(0.70 0.18 75), oklch(0.65 0.16 85));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Brand background gradient */
|
||||
.bg-gradient-brand {
|
||||
background: linear-gradient(135deg, oklch(0.75 0.16 75), oklch(0.70 0.18 65));
|
||||
}
|
||||
|
||||
/* Subtle brand tint */
|
||||
.bg-brand-subtle {
|
||||
background: linear-gradient(135deg,
|
||||
oklch(0.75 0.16 75 / 0.05),
|
||||
oklch(0.75 0.16 75 / 0.1));
|
||||
}
|
||||
|
||||
/* Brand glow shadow */
|
||||
.shadow-brand {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0.75 0.16 75 / 0.1),
|
||||
0 4px 16px oklch(0.75 0.16 75 / 0.15);
|
||||
}
|
||||
|
||||
.shadow-brand-lg {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0.75 0.16 75 / 0.15),
|
||||
0 8px 32px oklch(0.75 0.16 75 / 0.2);
|
||||
}
|
||||
|
||||
.dark .shadow-brand {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0.78 0.15 75 / 0.2),
|
||||
0 4px 16px oklch(0.78 0.15 75 / 0.2);
|
||||
}
|
||||
|
||||
.dark .shadow-brand-lg {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0.78 0.15 75 / 0.25),
|
||||
0 8px 32px oklch(0.78 0.15 75 / 0.25);
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass {
|
||||
background: oklch(1 0 0 / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background: oklch(0.14 0.008 75 / 0.8);
|
||||
}
|
||||
|
||||
/* Shimmer loading animation */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
oklch(0.95 0 0) 0%,
|
||||
oklch(0.90 0 0) 50%,
|
||||
oklch(0.95 0 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.dark .shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
oklch(0.25 0 0) 0%,
|
||||
oklch(0.30 0 0) 50%,
|
||||
oklch(0.25 0 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ANIMATIONS
|
||||
============================================ */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-brand {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 oklch(0.75 0.16 75 / 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px oklch(0.75 0.16 75 / 0);
|
||||
}
|
||||
}
|
||||
|
||||
5
src/app/health/route.ts
Normal file
5
src/app/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok', timestamp: Date.now() })
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700", "800"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -26,7 +28,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@@ -4,13 +4,13 @@ import { ArrowRight, Layers, MessageSquare, Zap, Clock, Shield, Users } from 'lu
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-zinc-50 to-white dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-background-subtle">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 border-b bg-white/80 backdrop-blur-sm dark:bg-zinc-950/80 dark:border-zinc-800">
|
||||
<nav className="fixed top-0 w-full z-50 border-b glass">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-8 w-8 text-primary" />
|
||||
<Layers className="h-8 w-8 text-brand" />
|
||||
<span className="text-xl font-bold">Mylder</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -18,7 +18,7 @@ export default function LandingPage() {
|
||||
<Button variant="ghost">Log in</Button>
|
||||
</Link>
|
||||
<Link href="/signup">
|
||||
<Button>Start a Project</Button>
|
||||
<Button variant="brand">Start a Project</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,21 +28,21 @@ export default function LandingPage() {
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-8">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand/10 text-brand text-sm font-medium mb-8">
|
||||
<Zap className="h-4 w-4" />
|
||||
Professional Software Development
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight text-zinc-900 dark:text-white mb-6">
|
||||
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight text-foreground mb-6">
|
||||
Your vision,
|
||||
<span className="text-primary"> professionally built</span>
|
||||
<span className="text-gradient-brand"> professionally built</span>
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-600 dark:text-zinc-400 mb-8 max-w-2xl mx-auto">
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
|
||||
A lean, professional process to bring your software ideas to life.
|
||||
Chat with us, get real-time updates, and launch with confidence.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Button variant="brand" size="lg" className="gap-2">
|
||||
Describe Your Project <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -56,13 +56,13 @@ export default function LandingPage() {
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section id="how-it-works" className="py-20 px-4 sm:px-6 lg:px-8 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<section id="how-it-works" className="py-20 px-4 sm:px-6 lg:px-8 bg-muted/50">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white mb-4">
|
||||
<h2 className="text-3xl font-bold text-foreground mb-4">
|
||||
A streamlined process from idea to launch
|
||||
</h2>
|
||||
<p className="text-lg text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
No complexity. No surprises. Just professional software development.
|
||||
</p>
|
||||
</div>
|
||||
@@ -90,10 +90,10 @@ export default function LandingPage() {
|
||||
<section id="features" className="py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white mb-4">
|
||||
<h2 className="text-3xl font-bold text-foreground mb-4">
|
||||
Why clients choose Mylder
|
||||
</h2>
|
||||
<p className="text-lg text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
A modern approach to software development that puts you in control.
|
||||
</p>
|
||||
</div>
|
||||
@@ -133,47 +133,47 @@ export default function LandingPage() {
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section id="pricing" className="py-20 px-4 sm:px-6 lg:px-8 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<section id="pricing" className="py-20 px-4 sm:px-6 lg:px-8 bg-muted/50">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white mb-4">
|
||||
<h2 className="text-3xl font-bold text-foreground mb-4">
|
||||
Transparent, project-based pricing
|
||||
</h2>
|
||||
<p className="text-lg text-zinc-600 dark:text-zinc-400">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Every project is unique. Get a custom quote based on your specific needs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border dark:border-zinc-800 p-8 md:p-12">
|
||||
<div className="bg-card rounded-2xl border p-8 md:p-12 shadow-sm">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-bold text-zinc-900 dark:text-white mb-4">
|
||||
<h3 className="text-2xl font-bold text-foreground mb-4">
|
||||
Start with a free consultation
|
||||
</h3>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-8 max-w-xl mx-auto">
|
||||
<p className="text-muted-foreground mb-8 max-w-xl mx-auto">
|
||||
Describe your project idea and we'll get back to you with a clear scope,
|
||||
timeline, and quote. No obligations, no pressure.
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-3 gap-6 mb-10 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-5 w-5 text-brand mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">Fixed-price projects with clear deliverables</span>
|
||||
<span className="text-sm text-muted-foreground">Fixed-price projects with clear deliverables</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-5 w-5 text-brand mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">Milestone-based payments for larger projects</span>
|
||||
<span className="text-sm text-muted-foreground">Milestone-based payments for larger projects</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-5 w-5 text-brand mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">Retainer options for ongoing development</span>
|
||||
<span className="text-sm text-muted-foreground">Retainer options for ongoing development</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Button variant="brand" size="lg" className="gap-2">
|
||||
Describe Your Project <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -183,7 +183,7 @@ export default function LandingPage() {
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8 bg-primary text-primary-foreground">
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8 bg-gradient-brand text-brand-foreground">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">
|
||||
Ready to bring your idea to life?
|
||||
@@ -200,13 +200,13 @@ export default function LandingPage() {
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-4 sm:px-6 lg:px-8 border-t dark:border-zinc-800">
|
||||
<footer className="py-12 px-4 sm:px-6 lg:px-8 border-t">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-6 w-6 text-primary" />
|
||||
<Layers className="h-6 w-6 text-brand" />
|
||||
<span className="font-semibold">Mylder</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} Mylder. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
@@ -218,23 +218,23 @@ export default function LandingPage() {
|
||||
function StepCard({ step, title, description }: { step: string; title: string; description: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xl font-bold mx-auto mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-brand text-brand-foreground flex items-center justify-center text-xl font-bold mx-auto mb-4">
|
||||
{step}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white mb-2">{title}</h3>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">{description}</p>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
||||
return (
|
||||
<div className="p-6 rounded-xl border bg-white dark:bg-zinc-900 dark:border-zinc-800">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center text-primary mb-4">
|
||||
<div className="p-6 rounded-xl border bg-card shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="w-12 h-12 rounded-lg bg-brand/10 flex items-center justify-center text-brand mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white mb-2">{title}</h3>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">{description}</p>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,12 +43,15 @@ export function ProjectChat({ projectId, initialMessages }: ProjectChatProps) {
|
||||
})
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
.subscribe((status) => {
|
||||
console.log('Realtime subscription status:', status)
|
||||
})
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [projectId, supabase])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId])
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
|
||||
298
src/components/dashboard/agentic-dashboard.tsx
Normal file
298
src/components/dashboard/agentic-dashboard.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ProgressTimeline, PHASES, type Phase } from '@/components/ui/progress-timeline'
|
||||
import { PhaseIndicator } from '@/components/ui/phase-indicator'
|
||||
import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { ActivityFeed, type Activity } from '@/components/ui/activity-feed'
|
||||
import { MetricCard } from '@/components/ui/metric-card'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Brain,
|
||||
Search,
|
||||
Lightbulb,
|
||||
Map,
|
||||
Hammer,
|
||||
Rocket,
|
||||
BarChart3,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Zap,
|
||||
Clock,
|
||||
Target,
|
||||
TrendingUp
|
||||
} from 'lucide-react'
|
||||
|
||||
const phaseIcons: Record<Phase, React.ReactNode> = {
|
||||
think: <Brain className="w-5 h-5" />,
|
||||
evaluate: <Search className="w-5 h-5" />,
|
||||
ideate: <Lightbulb className="w-5 h-5" />,
|
||||
plan: <Map className="w-5 h-5" />,
|
||||
create: <Hammer className="w-5 h-5" />,
|
||||
deploy: <Rocket className="w-5 h-5" />,
|
||||
analyze: <BarChart3 className="w-5 h-5" />,
|
||||
enhance: <Sparkles className="w-5 h-5" />
|
||||
}
|
||||
|
||||
const phaseDescriptions: Record<Phase, string> = {
|
||||
think: 'Define the problem space and user needs',
|
||||
evaluate: 'Assess technical feasibility and risks',
|
||||
ideate: 'Generate solution options through divergent thinking',
|
||||
plan: 'Select best approach and break into tasks',
|
||||
create: 'Build the MVP with iterative development',
|
||||
deploy: 'Ship to production with monitoring',
|
||||
analyze: 'Collect metrics and user feedback',
|
||||
enhance: 'Iterate based on data insights'
|
||||
}
|
||||
|
||||
interface AgenticDashboardProps {
|
||||
projectId: string
|
||||
projectName: string
|
||||
initialPhase?: Phase
|
||||
initialCompleted?: Phase[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AgenticDashboard({
|
||||
projectName,
|
||||
initialPhase = 'think',
|
||||
initialCompleted = [],
|
||||
className
|
||||
}: AgenticDashboardProps) {
|
||||
const [currentPhase, setCurrentPhase] = useState<Phase>(initialPhase)
|
||||
const [completedPhases, setCompletedPhases] = useState<Phase[]>(initialCompleted)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [selectedPhase, setSelectedPhase] = useState<Phase | null>(null)
|
||||
|
||||
const [activities] = useState<Activity[]>([
|
||||
{ id: '1', type: 'success', message: 'Project initialized successfully', timestamp: new Date(Date.now() - 3600000) },
|
||||
{ id: '2', type: 'phase_change', message: 'Started Think phase', timestamp: new Date(Date.now() - 1800000) },
|
||||
{ id: '3', type: 'action', message: 'Analyzing user requirements...', timestamp: new Date(Date.now() - 900000) },
|
||||
])
|
||||
|
||||
const progress = (completedPhases.length / PHASES.length) * 100
|
||||
const currentIndex = PHASES.indexOf(currentPhase)
|
||||
|
||||
const handleAdvancePhase = () => {
|
||||
if (currentIndex < PHASES.length - 1) {
|
||||
setCompletedPhases([...completedPhases, currentPhase])
|
||||
setCurrentPhase(PHASES[currentIndex + 1])
|
||||
} else {
|
||||
setCompletedPhases([...completedPhases, currentPhase])
|
||||
setCurrentPhase('think')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setCurrentPhase('think')
|
||||
setCompletedPhases([])
|
||||
setIsRunning(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{projectName}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<StatusBadge variant={isRunning ? 'info' : 'default'} pulse={isRunning}>
|
||||
{isRunning ? 'Running' : 'Paused'}
|
||||
</StatusBadge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Iteration #{Math.floor(completedPhases.length / PHASES.length) + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsRunning(!isRunning)}
|
||||
>
|
||||
{isRunning ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={handleAdvancePhase} className="gap-2">
|
||||
Advance <ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Timeline */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ProgressTimeline
|
||||
currentPhase={currentPhase}
|
||||
completedPhases={completedPhases}
|
||||
/>
|
||||
<div className="flex items-center justify-center gap-2 mt-4 text-sm text-muted-foreground">
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
<span>complete</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Phase Grid + Details */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Phase Cards */}
|
||||
<div className="lg:col-span-2 grid sm:grid-cols-2 gap-3">
|
||||
{PHASES.map((phase) => {
|
||||
const isCompleted = completedPhases.includes(phase)
|
||||
const isCurrent = phase === currentPhase
|
||||
const status = isCompleted ? 'completed' : isCurrent ? 'in_progress' : 'not_started'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={phase}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setSelectedPhase(phase)}
|
||||
>
|
||||
<Card className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
isCurrent && 'border-blue-500/50 bg-blue-500/5',
|
||||
isCompleted && 'border-emerald-500/50 bg-emerald-500/5',
|
||||
selectedPhase === phase && 'ring-2 ring-foreground/10'
|
||||
)}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
isCurrent && 'bg-blue-500/10 text-blue-500',
|
||||
isCompleted && 'bg-emerald-500/10 text-emerald-500',
|
||||
!isCurrent && !isCompleted && 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
{phaseIcons[phase]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium capitalize">{phase}</span>
|
||||
<PhaseIndicator phase={phase} status={status} showLabel={false} size="sm" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{phaseDescriptions[phase]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<div>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityFeed activities={activities} maxItems={5} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="Progress"
|
||||
value={progress}
|
||||
format="percent"
|
||||
icon={<Target className="w-4 h-4" />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Phases Complete"
|
||||
value={completedPhases.length}
|
||||
trend="up"
|
||||
trendValue={`of ${PHASES.length}`}
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Time in Phase"
|
||||
value={2.5}
|
||||
format="duration"
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Iterations"
|
||||
value={Math.floor(completedPhases.length / PHASES.length) + 1}
|
||||
icon={<RotateCcw className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phase Detail Panel */}
|
||||
<AnimatePresence>
|
||||
{selectedPhase && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
{phaseIcons[selectedPhase]}
|
||||
</div>
|
||||
<div>
|
||||
<span className="capitalize">{selectedPhase}</span>
|
||||
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||
{phaseDescriptions[selectedPhase]}
|
||||
</p>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Entry Criteria</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Previous phase completed</li>
|
||||
<li>• Required inputs available</li>
|
||||
<li>• Team capacity confirmed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Key Activities</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Research & discovery</li>
|
||||
<li>• Documentation</li>
|
||||
<li>• Stakeholder alignment</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Exit Criteria</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Deliverables complete</li>
|
||||
<li>• Quality checks passed</li>
|
||||
<li>• Ready for next phase</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPhase(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,12 +31,12 @@ export function DashboardNav({ user }: { user: SupabaseUser }) {
|
||||
.toUpperCase() || user.email?.[0].toUpperCase() || '?'
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 w-full z-50 border-b bg-white/80 backdrop-blur-sm dark:bg-zinc-950/80 dark:border-zinc-800">
|
||||
<nav className="fixed top-0 w-full z-50 border-b glass">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<Bot className="h-8 w-8 text-primary" />
|
||||
<Bot className="h-8 w-8 text-brand" />
|
||||
<span className="text-xl font-bold">Mylder</span>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-4">
|
||||
@@ -61,7 +61,7 @@ export function DashboardNav({ user }: { user: SupabaseUser }) {
|
||||
{user.user_metadata?.full_name && (
|
||||
<p className="font-medium">{user.user_metadata.full_name}</p>
|
||||
)}
|
||||
<p className="text-sm text-zinc-500 truncate">{user.email}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
247
src/components/project/approval-panel.tsx
Normal file
247
src/components/project/approval-panel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAgentRuns } from '@/hooks/use-agent-runs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
FileCode,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type AgentRun = Database['public']['Tables']['agent_runs']['Row']
|
||||
|
||||
interface ApprovalPanelProps {
|
||||
projectId: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ApprovalPanel({ projectId, className }: ApprovalPanelProps) {
|
||||
const { pendingRuns, loading, approve, reject } = useAgentRuns(projectId)
|
||||
const [expandedRun, setExpandedRun] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState<string | null>(null)
|
||||
|
||||
const handleApprove = async (runId: string) => {
|
||||
setProcessing(runId)
|
||||
try {
|
||||
await approve(runId)
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (runId: string) => {
|
||||
setProcessing(runId)
|
||||
try {
|
||||
await reject(runId)
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingRuns.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-center py-8', className)}>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No pending approvals
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
AI-proposed changes will appear here for review
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{pendingRuns.length} pending
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-20rem)] space-y-3 pr-1">
|
||||
{pendingRuns.map((run) => (
|
||||
<ApprovalCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
expanded={expandedRun === run.id}
|
||||
onToggle={() => setExpandedRun(expandedRun === run.id ? null : run.id)}
|
||||
onApprove={() => handleApprove(run.id)}
|
||||
onReject={() => handleReject(run.id)}
|
||||
processing={processing === run.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApprovalCardProps {
|
||||
run: AgentRun
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onApprove: () => void
|
||||
onReject: () => void
|
||||
processing: boolean
|
||||
}
|
||||
|
||||
function ApprovalCard({
|
||||
run,
|
||||
expanded,
|
||||
onToggle,
|
||||
onApprove,
|
||||
onReject,
|
||||
processing,
|
||||
}: ApprovalCardProps) {
|
||||
const changes = run.proposed_changes
|
||||
|
||||
if (!changes) return null
|
||||
|
||||
const fileCount = changes.files?.length || 0
|
||||
const operations = changes.files?.reduce(
|
||||
(acc, file) => {
|
||||
acc[file.operation] = (acc[file.operation] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
) || {}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm font-medium truncate">
|
||||
{run.command}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5 line-clamp-2">
|
||||
{changes.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<FileCode className="w-3 h-3" />
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{operations.create && (
|
||||
<Badge variant="outline" className="text-xs gap-1 text-emerald-600 border-emerald-200">
|
||||
<Plus className="w-3 h-3" />
|
||||
{operations.create}
|
||||
</Badge>
|
||||
)}
|
||||
{operations.update && (
|
||||
<Badge variant="outline" className="text-xs gap-1 text-blue-600 border-blue-200">
|
||||
<Pencil className="w-3 h-3" />
|
||||
{operations.update}
|
||||
</Badge>
|
||||
)}
|
||||
{operations.delete && (
|
||||
<Badge variant="outline" className="text-xs gap-1 text-red-600 border-red-200">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{operations.delete}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{expanded && changes.files && (
|
||||
<CardContent className="p-3 pt-0">
|
||||
<div className="border rounded-md overflow-hidden mt-2">
|
||||
{changes.files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-mono border-b last:border-b-0',
|
||||
file.operation === 'create' && 'bg-emerald-50 dark:bg-emerald-950/20',
|
||||
file.operation === 'update' && 'bg-blue-50 dark:bg-blue-950/20',
|
||||
file.operation === 'delete' && 'bg-red-50 dark:bg-red-950/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{file.operation === 'create' && <Plus className="w-3 h-3 text-emerald-600" />}
|
||||
{file.operation === 'update' && <Pencil className="w-3 h-3 text-blue-600" />}
|
||||
{file.operation === 'delete' && <Trash2 className="w-3 h-3 text-red-600" />}
|
||||
<span className="truncate">{file.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{changes.estimated_impact && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Impact: {changes.estimated_impact}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 p-3 pt-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={onReject}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Reject
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="brand"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={onApprove}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
249
src/components/project/backlog-board.tsx
Normal file
249
src/components/project/backlog-board.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Clock,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
MoreVertical,
|
||||
GripVertical
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { designPhases, type BacklogItem, type DesignPhase } from '@/types/design-thinking'
|
||||
|
||||
interface BacklogBoardProps {
|
||||
items: BacklogItem[]
|
||||
onItemClick?: (item: BacklogItem) => void
|
||||
onStatusChange?: (itemId: string, newStatus: BacklogItem['status']) => void
|
||||
onPriorityChange?: (itemId: string, direction: 'up' | 'down') => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const statusColors: Record<BacklogItem['status'], string> = {
|
||||
backlog: 'bg-muted text-muted-foreground',
|
||||
ready: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
in_progress: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||
done: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
blocked: 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
}
|
||||
|
||||
function PriorityScore({ score }: { score: number }) {
|
||||
const getColor = (s: number) => {
|
||||
if (s >= 7) return 'text-emerald-500'
|
||||
if (s >= 4) return 'text-amber-500'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', getColor(score))}>
|
||||
<Zap className="w-3 h-3" />
|
||||
<span className="text-xs font-medium">{score.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BacklogCard({
|
||||
item,
|
||||
onItemClick,
|
||||
onStatusChange,
|
||||
onPriorityChange
|
||||
}: {
|
||||
item: BacklogItem
|
||||
onItemClick?: (item: BacklogItem) => void
|
||||
onStatusChange?: (itemId: string, newStatus: BacklogItem['status']) => void
|
||||
onPriorityChange?: (itemId: string, direction: 'up' | 'down') => void
|
||||
}) {
|
||||
const phase = designPhases.find(p => p.id === item.phase)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="group"
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-md',
|
||||
item.status === 'blocked' && 'border-red-500/30'
|
||||
)}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/50 mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-sm font-medium truncate">{item.title}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'ready') }}>
|
||||
Mark Ready
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'in_progress') }}>
|
||||
Start Work
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'done') }}>
|
||||
Mark Done
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onStatusChange?.(item.id, 'blocked') }}>
|
||||
Mark Blocked
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColors[item.status]}>
|
||||
{item.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
{phase && (
|
||||
<span className="text-xs">{phase.icon}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PriorityScore score={item.priority_score} />
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => { e.stopPropagation(); onPriorityChange?.(item.id, 'up') }}
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => { e.stopPropagation(); onPriorityChange?.(item.id, 'down') }}
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WSJF breakdown on hover */}
|
||||
<div className="mt-2 pt-2 border-t border-dashed opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="grid grid-cols-4 gap-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Target className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-muted-foreground">{item.user_value}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-amber-500" />
|
||||
<span className="text-muted-foreground">{item.time_criticality}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-muted-foreground">{item.risk_reduction}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3 text-purple-500" />
|
||||
<span className="text-muted-foreground">E:{item.effort}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklogBoard({
|
||||
items,
|
||||
onItemClick,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
className
|
||||
}: BacklogBoardProps) {
|
||||
const [filter, setFilter] = useState<BacklogItem['status'] | 'all'>('all')
|
||||
|
||||
const filteredItems = filter === 'all'
|
||||
? items
|
||||
: items.filter(item => item.status === filter)
|
||||
|
||||
const sortedItems = [...filteredItems].sort((a, b) => b.priority_score - a.priority_score)
|
||||
|
||||
const statusCounts = {
|
||||
backlog: items.filter(i => i.status === 'backlog').length,
|
||||
ready: items.filter(i => i.status === 'ready').length,
|
||||
in_progress: items.filter(i => i.status === 'in_progress').length,
|
||||
done: items.filter(i => i.status === 'done').length,
|
||||
blocked: items.filter(i => i.status === 'blocked').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">Backlog</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
{(['all', 'backlog', 'ready', 'in_progress', 'done', 'blocked'] as const).map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={filter === status ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilter(status)}
|
||||
>
|
||||
{status === 'all' ? 'All' : status.replace('_', ' ')}
|
||||
{status !== 'all' && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({statusCounts[status]})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedItems.map((item) => (
|
||||
<BacklogCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onItemClick={onItemClick}
|
||||
onStatusChange={onStatusChange}
|
||||
onPriorityChange={onPriorityChange}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{sortedItems.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
No items in {filter === 'all' ? 'backlog' : filter}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
143
src/components/project/delete-project-dialog.tsx
Normal file
143
src/components/project/delete-project-dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { deleteProject } from '@/lib/supabase/projects'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Trash2, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
interface DeleteProjectDialogProps {
|
||||
projectId: string
|
||||
projectName: string
|
||||
hasGiteaRepo: boolean
|
||||
}
|
||||
|
||||
export function DeleteProjectDialog({
|
||||
projectId,
|
||||
projectName,
|
||||
hasGiteaRepo,
|
||||
}: DeleteProjectDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
const canDelete = confirmText === projectName
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!canDelete) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
const result = await deleteProject(projectId)
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Project deleted',
|
||||
description: hasGiteaRepo
|
||||
? 'Project and associated repository have been deleted.'
|
||||
: 'Project has been deleted.',
|
||||
})
|
||||
router.push('/projects')
|
||||
router.refresh()
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to delete project',
|
||||
description: result.error || 'An error occurred',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete project',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" className="gap-2">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Project
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
Delete Project
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-3">
|
||||
<p>
|
||||
This action cannot be undone. This will permanently delete the project
|
||||
<strong className="text-foreground"> {projectName}</strong> and all
|
||||
associated data including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1 text-muted-foreground">
|
||||
<li>All messages and chat history</li>
|
||||
<li>All agent runs and proposed changes</li>
|
||||
<li>All backlog items and activities</li>
|
||||
<li>All phase tracking data</li>
|
||||
{hasGiteaRepo && (
|
||||
<li className="text-destructive font-medium">
|
||||
The Gitea repository and all its code
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="pt-2">
|
||||
<p className="text-sm mb-2">
|
||||
Type <strong className="text-foreground">{projectName}</strong> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={!canDelete || deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Project
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
166
src/components/project/design-thinking-dashboard.tsx
Normal file
166
src/components/project/design-thinking-dashboard.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { useProject } from '@/hooks/use-project'
|
||||
import { PhaseNavigator } from './phase-navigator'
|
||||
import { HealthWidget } from './health-widget'
|
||||
import { BacklogBoard } from './backlog-board'
|
||||
import { RecommendationsWidget } from './recommendations-widget'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertCircle, RefreshCw, ChevronRight } from 'lucide-react'
|
||||
import type { DesignPhase, BacklogItem, AIRecommendation } from '@/types/design-thinking'
|
||||
|
||||
interface DesignThinkingDashboardProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<Skeleton className="h-48 lg:col-span-2" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-64" />
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||
return (
|
||||
<Card className="border-red-500/20 bg-red-500/5">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to load project</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{error.message}</p>
|
||||
<Button onClick={onRetry} variant="outline" className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function DesignThinkingDashboard({ projectId }: DesignThinkingDashboardProps) {
|
||||
const {
|
||||
project,
|
||||
phaseStatuses,
|
||||
backlogItems,
|
||||
health,
|
||||
recommendations,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
advancePhase,
|
||||
loopBack,
|
||||
updateBacklog,
|
||||
dismissRec
|
||||
} = useProject(projectId)
|
||||
|
||||
if (loading) {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorState error={error} onRetry={refresh} />
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handlePhaseClick = (phase: DesignPhase) => {
|
||||
// Could open a modal with phase details
|
||||
console.log('Phase clicked:', phase)
|
||||
}
|
||||
|
||||
const handleLoopBack = (fromPhase: DesignPhase, toPhase: DesignPhase) => {
|
||||
loopBack(fromPhase, toPhase)
|
||||
}
|
||||
|
||||
const handleBacklogItemClick = (item: BacklogItem) => {
|
||||
// Could open item detail modal
|
||||
console.log('Backlog item clicked:', item)
|
||||
}
|
||||
|
||||
const handleStatusChange = (itemId: string, newStatus: BacklogItem['status']) => {
|
||||
updateBacklog(itemId, { status: newStatus })
|
||||
}
|
||||
|
||||
const handlePriorityChange = (itemId: string, direction: 'up' | 'down') => {
|
||||
const item = backlogItems.find(i => i.id === itemId)
|
||||
if (!item) return
|
||||
|
||||
// Adjust user_value to change priority
|
||||
const adjustment = direction === 'up' ? 1 : -1
|
||||
const newValue = Math.max(1, Math.min(10, item.user_value + adjustment))
|
||||
updateBacklog(itemId, { user_value: newValue })
|
||||
}
|
||||
|
||||
const handleRecommendationAction = (rec: AIRecommendation) => {
|
||||
if (rec.action_command) {
|
||||
// Execute the command - this would integrate with the chat
|
||||
console.log('Execute command:', rec.action_command)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissRecommendation = (recId: string) => {
|
||||
dismissRec(recId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with project info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{project.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current phase: <span className="capitalize font-medium">{project.current_phase}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => advancePhase(project.current_phase)} className="gap-2">
|
||||
Complete Phase
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Phase Navigator */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<PhaseNavigator
|
||||
currentPhase={project.current_phase}
|
||||
phaseStatuses={phaseStatuses}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
onLoopBack={handleLoopBack}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Health + Recommendations row */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<HealthWidget health={health} />
|
||||
</div>
|
||||
<RecommendationsWidget
|
||||
recommendations={recommendations}
|
||||
onAction={handleRecommendationAction}
|
||||
onDismiss={handleDismissRecommendation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Backlog Board - full width */}
|
||||
<BacklogBoard
|
||||
items={backlogItems}
|
||||
onItemClick={handleBacklogItemClick}
|
||||
onStatusChange={handleStatusChange}
|
||||
onPriorityChange={handlePriorityChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
src/components/project/health-widget.tsx
Normal file
122
src/components/project/health-widget.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Activity, AlertTriangle, CheckCircle2, Clock, TrendingUp } from 'lucide-react'
|
||||
import type { ProjectHealth } from '@/types/design-thinking'
|
||||
|
||||
interface HealthWidgetProps {
|
||||
health: ProjectHealth
|
||||
className?: string
|
||||
}
|
||||
|
||||
function HealthRing({ value, size = 120, strokeWidth = 8 }: { value: number; size?: number; strokeWidth?: number }) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (value / 100) * circumference
|
||||
|
||||
const getColor = (v: number) => {
|
||||
if (v >= 80) return 'stroke-emerald-500'
|
||||
if (v >= 60) return 'stroke-amber-500'
|
||||
return 'stroke-red-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
className="stroke-muted fill-none"
|
||||
/>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
className={cn('fill-none', getColor(value))}
|
||||
strokeLinecap="round"
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1, ease: 'easeOut' }}
|
||||
style={{ strokeDasharray: circumference }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<motion.span
|
||||
className="text-3xl font-bold"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
{value}
|
||||
</motion.span>
|
||||
<span className="text-xs text-muted-foreground">Health Score</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HealthWidget({ health, className }: HealthWidgetProps) {
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Velocity',
|
||||
value: health.velocity,
|
||||
icon: TrendingUp,
|
||||
color: health.velocity >= 80 ? 'text-emerald-500' : health.velocity >= 60 ? 'text-amber-500' : 'text-red-500'
|
||||
},
|
||||
{
|
||||
label: 'Completion',
|
||||
value: health.completion_rate,
|
||||
icon: CheckCircle2,
|
||||
color: health.completion_rate >= 80 ? 'text-emerald-500' : health.completion_rate >= 60 ? 'text-amber-500' : 'text-red-500',
|
||||
suffix: '%'
|
||||
},
|
||||
{
|
||||
label: 'Blockers',
|
||||
value: health.blockers,
|
||||
icon: AlertTriangle,
|
||||
color: health.blockers === 0 ? 'text-emerald-500' : health.blockers <= 2 ? 'text-amber-500' : 'text-red-500',
|
||||
inverse: true
|
||||
},
|
||||
{
|
||||
label: 'Overdue',
|
||||
value: health.overdue,
|
||||
icon: Clock,
|
||||
color: health.overdue === 0 ? 'text-emerald-500' : health.overdue <= 2 ? 'text-amber-500' : 'text-red-500',
|
||||
inverse: true
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Activity className="w-4 h-4" />
|
||||
Project Health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-6">
|
||||
<HealthRing value={health.overall} />
|
||||
<div className="grid grid-cols-2 gap-4 flex-1">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<metric.icon className={cn('w-3.5 h-3.5', metric.color)} />
|
||||
<span className="text-xs text-muted-foreground">{metric.label}</span>
|
||||
</div>
|
||||
<p className={cn('text-lg font-semibold', metric.color)}>
|
||||
{metric.value}{metric.suffix || ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
5
src/components/project/index.ts
Normal file
5
src/components/project/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { PhaseNavigator } from './phase-navigator'
|
||||
export { HealthWidget } from './health-widget'
|
||||
export { BacklogBoard } from './backlog-board'
|
||||
export { RecommendationsWidget } from './recommendations-widget'
|
||||
export { DesignThinkingDashboard } from './design-thinking-dashboard'
|
||||
78
src/components/project/phase-indicator-compact.tsx
Normal file
78
src/components/project/phase-indicator-compact.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { DESIGN_PHASES, designPhases, type DesignPhase, type PhaseStatus } from '@/types/design-thinking'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface PhaseIndicatorCompactProps {
|
||||
currentPhase: DesignPhase
|
||||
phaseStatuses?: Record<DesignPhase, PhaseStatus>
|
||||
onPhaseClick?: (phase: DesignPhase) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PhaseIndicatorCompact({
|
||||
currentPhase,
|
||||
phaseStatuses,
|
||||
onPhaseClick,
|
||||
className
|
||||
}: PhaseIndicatorCompactProps) {
|
||||
const currentIndex = DESIGN_PHASES.indexOf(currentPhase)
|
||||
const currentPhaseData = designPhases.find(p => p.id === currentPhase)
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{/* Mini progress dots */}
|
||||
<div className="flex items-center gap-0.5 mr-2">
|
||||
{DESIGN_PHASES.map((phase, index) => {
|
||||
const status = phaseStatuses?.[phase]
|
||||
const isCurrent = index === currentIndex
|
||||
const isCompleted = status === 'completed' || index < currentIndex
|
||||
|
||||
return (
|
||||
<Tooltip key={phase}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onPhaseClick?.(phase)}
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-all',
|
||||
isCurrent && 'w-3 h-3 bg-brand animate-pulse',
|
||||
isCompleted && !isCurrent && 'bg-emerald-500',
|
||||
!isCompleted && !isCurrent && 'bg-muted-foreground/30'
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{designPhases.find(p => p.id === phase)?.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Current phase badge */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onPhaseClick?.(currentPhase)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand/10 text-brand text-sm font-medium hover:bg-brand/20 transition-colors"
|
||||
>
|
||||
<span>{currentPhaseData?.icon}</span>
|
||||
<span className="hidden sm:inline">{currentPhaseData?.label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p className="font-medium">{currentPhaseData?.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{currentPhaseData?.shortDescription}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
167
src/components/project/phase-navigator.tsx
Normal file
167
src/components/project/phase-navigator.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Loader2, AlertCircle, RotateCcw } from 'lucide-react'
|
||||
import { DESIGN_PHASES, designPhases, type DesignPhase, type PhaseStatus } from '@/types/design-thinking'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface PhaseNavigatorProps {
|
||||
currentPhase: DesignPhase
|
||||
phaseStatuses: Record<DesignPhase, PhaseStatus>
|
||||
onPhaseClick?: (phase: DesignPhase) => void
|
||||
onLoopBack?: (fromPhase: DesignPhase, toPhase: DesignPhase) => void
|
||||
className?: string
|
||||
variant?: 'horizontal' | 'compact'
|
||||
}
|
||||
|
||||
const colorMap: Record<string, { bg: string; border: string; text: string; ring: string }> = {
|
||||
purple: { bg: 'bg-purple-500/10', border: 'border-purple-500/50', text: 'text-purple-500', ring: 'ring-purple-500/30' },
|
||||
blue: { bg: 'bg-blue-500/10', border: 'border-blue-500/50', text: 'text-blue-500', ring: 'ring-blue-500/30' },
|
||||
amber: { bg: 'bg-amber-500/10', border: 'border-amber-500/50', text: 'text-amber-500', ring: 'ring-amber-500/30' },
|
||||
orange: { bg: 'bg-orange-500/10', border: 'border-orange-500/50', text: 'text-orange-500', ring: 'ring-orange-500/30' },
|
||||
emerald: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/50', text: 'text-emerald-500', ring: 'ring-emerald-500/30' },
|
||||
}
|
||||
|
||||
export function PhaseNavigator({
|
||||
currentPhase,
|
||||
phaseStatuses,
|
||||
onPhaseClick,
|
||||
onLoopBack,
|
||||
className,
|
||||
variant = 'horizontal'
|
||||
}: PhaseNavigatorProps) {
|
||||
const currentIndex = DESIGN_PHASES.indexOf(currentPhase)
|
||||
const progress = ((currentIndex + 1) / DESIGN_PHASES.length) * 100
|
||||
|
||||
const getStatusIcon = (status: PhaseStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Check className="w-4 h-4" />
|
||||
case 'in_progress':
|
||||
return <Loader2 className="w-4 h-4 animate-spin" />
|
||||
case 'blocked':
|
||||
case 'needs_review':
|
||||
return <AlertCircle className="w-4 h-4" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* Progress bar */}
|
||||
<div className="relative h-1.5 bg-muted rounded-full overflow-hidden mb-6">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-purple-500 via-blue-500 via-amber-500 via-orange-500 to-emerald-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phase nodes */}
|
||||
<div className="flex justify-between items-start">
|
||||
{designPhases.map((phase, index) => {
|
||||
const status = phaseStatuses[phase.id]
|
||||
const isCurrent = phase.id === currentPhase
|
||||
const isCompleted = status === 'completed'
|
||||
const isBlocked = status === 'blocked' || status === 'needs_review'
|
||||
const colors = colorMap[phase.color]
|
||||
|
||||
return (
|
||||
<div key={phase.id} className="flex flex-col items-center flex-1">
|
||||
{/* Connector line */}
|
||||
{index > 0 && (
|
||||
<div className="absolute" style={{ left: `${((index - 0.5) / DESIGN_PHASES.length) * 100}%`, top: '0.75rem', width: `${(1 / DESIGN_PHASES.length) * 100}%` }}>
|
||||
<div className={cn(
|
||||
'h-0.5 w-full transition-colors duration-300',
|
||||
index <= currentIndex ? 'bg-foreground/20' : 'bg-muted'
|
||||
)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase node */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.button
|
||||
onClick={() => onPhaseClick?.(phase.id)}
|
||||
className={cn(
|
||||
'relative w-12 h-12 rounded-full flex items-center justify-center text-xl transition-all duration-300 border-2',
|
||||
isCurrent && `${colors.bg} ${colors.border} ring-4 ${colors.ring}`,
|
||||
isCompleted && 'bg-emerald-500/10 border-emerald-500/50',
|
||||
isBlocked && 'bg-red-500/10 border-red-500/50',
|
||||
!isCurrent && !isCompleted && !isBlocked && 'bg-muted border-transparent'
|
||||
)}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
animate={isCurrent ? { scale: [1, 1.05, 1] } : {}}
|
||||
transition={{ repeat: isCurrent ? Infinity : 0, duration: 2 }}
|
||||
>
|
||||
<span>{phase.icon}</span>
|
||||
{/* Status indicator */}
|
||||
{(isCompleted || status === 'in_progress' || isBlocked) && (
|
||||
<div className={cn(
|
||||
'absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center',
|
||||
isCompleted && 'bg-emerald-500 text-white',
|
||||
status === 'in_progress' && 'bg-blue-500 text-white',
|
||||
isBlocked && 'bg-red-500 text-white'
|
||||
)}>
|
||||
{getStatusIcon(status)}
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{phase.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{phase.fullDescription}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{phase.commands.map(cmd => (
|
||||
<code key={cmd} className="text-xs bg-muted px-1 rounded">{cmd}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Labels */}
|
||||
<span className={cn(
|
||||
'text-sm font-medium mt-2 transition-colors',
|
||||
isCurrent ? colors.text : 'text-muted-foreground'
|
||||
)}>
|
||||
{phase.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||
{phase.shortDescription}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Loop back indicator */}
|
||||
{onLoopBack && currentIndex > 0 && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onLoopBack(currentPhase, DESIGN_PHASES[currentIndex - 1])}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Loop back to {designPhases[currentIndex - 1].label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
155
src/components/project/recommendations-widget.tsx
Normal file
155
src/components/project/recommendations-widget.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Sparkles,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
ChevronRight,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import type { AIRecommendation } from '@/types/design-thinking'
|
||||
|
||||
interface RecommendationsWidgetProps {
|
||||
recommendations: AIRecommendation[]
|
||||
onAction?: (recommendation: AIRecommendation) => void
|
||||
onDismiss?: (recommendationId: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const typeConfig: Record<AIRecommendation['type'], { icon: typeof Sparkles; color: string; bg: string }> = {
|
||||
action: { icon: Zap, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
warning: { icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10' },
|
||||
insight: { icon: Lightbulb, color: 'text-purple-500', bg: 'bg-purple-500/10' },
|
||||
optimization: { icon: TrendingUp, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||
}
|
||||
|
||||
const priorityColors: Record<AIRecommendation['priority'], string> = {
|
||||
low: 'bg-muted text-muted-foreground',
|
||||
medium: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
|
||||
high: 'bg-red-500/10 text-red-600 border-red-500/20',
|
||||
}
|
||||
|
||||
function RecommendationCard({
|
||||
recommendation,
|
||||
onAction,
|
||||
onDismiss
|
||||
}: {
|
||||
recommendation: AIRecommendation
|
||||
onAction?: (recommendation: AIRecommendation) => void
|
||||
onDismiss?: (recommendationId: string) => void
|
||||
}) {
|
||||
const config = typeConfig[recommendation.type]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20, height: 0 }}
|
||||
className="group"
|
||||
>
|
||||
<div className={cn(
|
||||
'relative p-3 rounded-lg border transition-all hover:shadow-sm',
|
||||
config.bg,
|
||||
'border-transparent hover:border-foreground/5'
|
||||
)}>
|
||||
{/* Dismiss button */}
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={() => onDismiss(recommendation.id)}
|
||||
className="absolute top-2 right-2 p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-foreground/10"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn('p-1.5 rounded-lg', config.bg)}>
|
||||
<Icon className={cn('w-4 h-4', config.color)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">{recommendation.title}</span>
|
||||
<Badge variant="outline" className={cn('text-[10px] px-1.5', priorityColors[recommendation.priority])}>
|
||||
{recommendation.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{recommendation.description}
|
||||
</p>
|
||||
{recommendation.action_command && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 -ml-2"
|
||||
onClick={() => onAction?.(recommendation)}
|
||||
>
|
||||
<code className="bg-muted px-1 rounded">{recommendation.action_command}</code>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RecommendationsWidget({
|
||||
recommendations,
|
||||
onAction,
|
||||
onDismiss,
|
||||
className
|
||||
}: RecommendationsWidgetProps) {
|
||||
const sortedRecommendations = [...recommendations].sort((a, b) => {
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 }
|
||||
return priorityOrder[a.priority] - priorityOrder[b.priority]
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||
AI Recommendations
|
||||
{recommendations.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{recommendations.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedRecommendations.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.id}
|
||||
recommendation={rec}
|
||||
onAction={onAction}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{recommendations.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Sparkles className="w-8 h-8 text-muted-foreground/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No recommendations yet
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
AI insights will appear as you work
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
80
src/components/ui/activity-feed.tsx
Normal file
80
src/components/ui/activity-feed.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Zap,
|
||||
ArrowRight,
|
||||
Clock
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface Activity {
|
||||
id: string
|
||||
type: 'phase_change' | 'action' | 'notification' | 'error' | 'success'
|
||||
message: string
|
||||
timestamp: Date
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: Activity[]
|
||||
maxItems?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const typeConfig = {
|
||||
phase_change: { icon: ArrowRight, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
action: { icon: Zap, color: 'text-violet-500', bg: 'bg-violet-500/10' },
|
||||
notification: { icon: Info, color: 'text-muted-foreground', bg: 'bg-muted' },
|
||||
error: { icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-500/10' },
|
||||
success: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||
}
|
||||
|
||||
export function ActivityFeed({ activities, maxItems = 10, className }: ActivityFeedProps) {
|
||||
const displayActivities = activities.slice(0, maxItems)
|
||||
|
||||
if (displayActivities.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-8 text-muted-foreground', className)}>
|
||||
<Clock className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">No activity yet</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{displayActivities.map((activity, index) => {
|
||||
const config = typeConfig[activity.type]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={activity.id}
|
||||
initial={{ opacity: 0, x: -20, height: 0 }}
|
||||
animate={{ opacity: 1, x: 0, height: 'auto' }}
|
||||
exit={{ opacity: 0, x: 20, height: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-card border hover:border-foreground/10 transition-colors"
|
||||
>
|
||||
<div className={cn('p-1.5 rounded-full shrink-0', config.bg)}>
|
||||
<Icon className={cn('w-3.5 h-3.5', config.color)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">{activity.message}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -11,10 +11,26 @@ const badgeVariants = cva(
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
brand:
|
||||
"border-transparent bg-brand text-brand-foreground [a&]:hover:bg-brand/90",
|
||||
"brand-outline":
|
||||
"border-brand/30 bg-brand/10 text-brand [a&]:hover:bg-brand/20",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
success:
|
||||
"border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90",
|
||||
"success-outline":
|
||||
"border-success/30 bg-success/10 text-success [a&]:hover:bg-success/20",
|
||||
warning:
|
||||
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90",
|
||||
"warning-outline":
|
||||
"border-warning/30 bg-warning/10 text-warning [a&]:hover:bg-warning/20",
|
||||
info:
|
||||
"border-transparent bg-info text-info-foreground [a&]:hover:bg-info/90",
|
||||
"info-outline":
|
||||
"border-info/30 bg-info/10 text-info [a&]:hover:bg-info/20",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ const buttonVariants = cva(
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
brand:
|
||||
"bg-brand text-white hover:bg-brand/90 focus-visible:ring-brand/20",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
@@ -38,8 +40,8 @@ const buttonVariants = cva(
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
@@ -51,6 +53,8 @@ function Button({
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
76
src/components/ui/metric-card.tsx
Normal file
76
src/components/ui/metric-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useSpring, useTransform } from 'motion/react'
|
||||
import { useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string
|
||||
value: number
|
||||
format?: 'number' | 'percent' | 'duration'
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
trendValue?: string
|
||||
icon?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value, format = 'number' }: { value: number; format?: string }) {
|
||||
const spring = useSpring(0, { stiffness: 100, damping: 30 })
|
||||
const display = useTransform(spring, (current) => {
|
||||
if (format === 'percent') return `${Math.round(current)}%`
|
||||
if (format === 'duration') return `${current.toFixed(1)}s`
|
||||
return Math.round(current).toLocaleString()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
spring.set(value)
|
||||
}, [spring, value])
|
||||
|
||||
return <motion.span>{display}</motion.span>
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
format = 'number',
|
||||
trend,
|
||||
trendValue,
|
||||
icon,
|
||||
className
|
||||
}: MetricCardProps) {
|
||||
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -2, transition: { duration: 0.2 } }}
|
||||
className={cn(
|
||||
'p-4 rounded-xl border bg-card hover:border-foreground/10 transition-colors',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
{icon && <span className="text-muted-foreground">{icon}</span>}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-2xl font-bold">
|
||||
<AnimatedNumber value={value} format={format} />
|
||||
</span>
|
||||
{trend && trendValue && (
|
||||
<span className={cn(
|
||||
'flex items-center gap-0.5 text-xs font-medium pb-1',
|
||||
trend === 'up' && 'text-emerald-500',
|
||||
trend === 'down' && 'text-red-500',
|
||||
trend === 'neutral' && 'text-muted-foreground'
|
||||
)}>
|
||||
<TrendIcon className="w-3 h-3" />
|
||||
{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
58
src/components/ui/phase-indicator.tsx
Normal file
58
src/components/ui/phase-indicator.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Circle, Loader2, AlertTriangle, X, SkipForward } from 'lucide-react'
|
||||
|
||||
export type PhaseStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'failed' | 'skipped'
|
||||
|
||||
interface PhaseIndicatorProps {
|
||||
phase: string
|
||||
status: PhaseStatus
|
||||
label?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const statusConfig: Record<PhaseStatus, { icon: typeof Circle; color: string; bg: string; ring: string; animate?: boolean }> = {
|
||||
not_started: { icon: Circle, color: 'text-muted-foreground', bg: 'bg-muted', ring: '' },
|
||||
in_progress: { icon: Loader2, color: 'text-blue-500', bg: 'bg-blue-500/10', ring: 'ring-2 ring-blue-500/30', animate: true },
|
||||
completed: { icon: Check, color: 'text-emerald-500', bg: 'bg-emerald-500/10', ring: '' },
|
||||
blocked: { icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10', ring: 'ring-2 ring-amber-500/30' },
|
||||
failed: { icon: X, color: 'text-red-500', bg: 'bg-red-500/10', ring: '' },
|
||||
skipped: { icon: SkipForward, color: 'text-muted-foreground/50', bg: 'bg-muted/50', ring: '' },
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: { wrapper: 'p-1.5', icon: 'w-3 h-3', text: 'text-xs' },
|
||||
md: { wrapper: 'p-2', icon: 'w-4 h-4', text: 'text-sm' },
|
||||
lg: { wrapper: 'p-3', icon: 'w-5 h-5', text: 'text-base' },
|
||||
}
|
||||
|
||||
export function PhaseIndicator({ phase, status, label, size = 'md', showLabel = true }: PhaseIndicatorProps) {
|
||||
const config = statusConfig[status]
|
||||
const sizes = sizeConfig[size]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<motion.div
|
||||
className={cn('rounded-full transition-all duration-300', sizes.wrapper, config.bg, config.ring)}
|
||||
animate={status === 'in_progress' ? { scale: [1, 1.05, 1] } : {}}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
>
|
||||
<Icon className={cn(sizes.icon, config.color, config.animate && 'animate-spin')} />
|
||||
</motion.div>
|
||||
{showLabel && (
|
||||
<span className={cn('font-medium capitalize', sizes.text, status === 'in_progress' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{label || phase}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
141
src/components/ui/progress-timeline.tsx
Normal file
141
src/components/ui/progress-timeline.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Circle, Loader2 } from 'lucide-react'
|
||||
|
||||
export const PHASES = ['think', 'evaluate', 'ideate', 'plan', 'create', 'deploy', 'analyze', 'enhance'] as const
|
||||
export type Phase = typeof PHASES[number]
|
||||
|
||||
interface ProgressTimelineProps {
|
||||
currentPhase: Phase
|
||||
completedPhases: Phase[]
|
||||
className?: string
|
||||
variant?: 'horizontal' | 'vertical'
|
||||
showLabels?: boolean
|
||||
}
|
||||
|
||||
export function ProgressTimeline({
|
||||
currentPhase,
|
||||
completedPhases,
|
||||
className,
|
||||
variant = 'horizontal',
|
||||
showLabels = true
|
||||
}: ProgressTimelineProps) {
|
||||
const progress = (completedPhases.length / PHASES.length) * 100
|
||||
|
||||
if (variant === 'vertical') {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', className)}>
|
||||
{PHASES.map((phase, index) => {
|
||||
const isCompleted = completedPhases.includes(phase)
|
||||
const isCurrent = phase === currentPhase
|
||||
const isPast = PHASES.indexOf(phase) < PHASES.indexOf(currentPhase)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={phase}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<motion.div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center transition-all duration-300',
|
||||
isCompleted && 'bg-emerald-500 text-white',
|
||||
isCurrent && 'bg-blue-500 text-white ring-4 ring-blue-500/20',
|
||||
!isCompleted && !isCurrent && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
animate={isCurrent ? { scale: [1, 1.1, 1] } : {}}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : isCurrent ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4" />
|
||||
)}
|
||||
</motion.div>
|
||||
{index < PHASES.length - 1 && (
|
||||
<div className={cn(
|
||||
'w-0.5 h-6 transition-colors duration-300',
|
||||
isPast || isCompleted ? 'bg-emerald-500' : 'bg-muted'
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
{showLabels && (
|
||||
<span className={cn(
|
||||
'text-sm capitalize transition-colors',
|
||||
isCurrent && 'font-semibold text-foreground',
|
||||
isCompleted && 'text-emerald-600',
|
||||
!isCurrent && !isCompleted && 'text-muted-foreground'
|
||||
)}>
|
||||
{phase}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div className="relative h-2 bg-muted rounded-full overflow-hidden mb-4">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-violet-500 to-emerald-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500/50 via-violet-500/50 to-emerald-500/50 blur-sm"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
{PHASES.map((phase, index) => {
|
||||
const isCompleted = completedPhases.includes(phase)
|
||||
const isCurrent = phase === currentPhase
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={phase}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'w-3 h-3 rounded-full transition-all duration-300 mb-1',
|
||||
isCompleted && 'bg-emerald-500',
|
||||
isCurrent && 'bg-blue-500 ring-4 ring-blue-500/20',
|
||||
!isCompleted && !isCurrent && 'bg-muted-foreground/30'
|
||||
)}
|
||||
animate={isCurrent ? { scale: [1, 1.3, 1] } : {}}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
/>
|
||||
{showLabels && (
|
||||
<span className={cn(
|
||||
'text-xs capitalize hidden sm:block',
|
||||
isCurrent && 'font-semibold text-foreground',
|
||||
!isCurrent && 'text-muted-foreground'
|
||||
)}>
|
||||
{phase}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
50
src/components/ui/status-badge.tsx
Normal file
50
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const variants = {
|
||||
default: 'bg-muted text-muted-foreground border-muted',
|
||||
success: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
||||
warning: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
|
||||
error: 'bg-red-500/10 text-red-600 border-red-500/20',
|
||||
info: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
|
||||
purple: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant?: keyof typeof variants
|
||||
children: React.ReactNode
|
||||
pulse?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant = 'default', children, pulse, className }: StatusBadgeProps) {
|
||||
const pulseColor = {
|
||||
default: 'bg-muted-foreground',
|
||||
success: 'bg-emerald-500',
|
||||
warning: 'bg-amber-500',
|
||||
error: 'bg-red-500',
|
||||
info: 'bg-blue-500',
|
||||
purple: 'bg-violet-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||
variants[variant],
|
||||
className
|
||||
)}>
|
||||
{pulse && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className={cn(
|
||||
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
|
||||
pulseColor[variant]
|
||||
)} />
|
||||
<span className={cn(
|
||||
'relative inline-flex rounded-full h-2 w-2',
|
||||
pulseColor[variant]
|
||||
)} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
31
src/components/ui/tooltip.tsx
Normal file
31
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
143
src/hooks/use-agent-runs.ts
Normal file
143
src/hooks/use-agent-runs.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type AgentRun = Database['public']['Tables']['agent_runs']['Row']
|
||||
type AgentRunInsert = Database['public']['Tables']['agent_runs']['Insert']
|
||||
|
||||
interface UseAgentRunsReturn {
|
||||
runs: AgentRun[]
|
||||
pendingRuns: AgentRun[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
refresh: () => Promise<void>
|
||||
approve: (runId: string) => Promise<void>
|
||||
reject: (runId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useAgentRuns(projectId: string): UseAgentRunsReturn {
|
||||
const [runs, setRuns] = useState<AgentRun[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const supabase = createClient()
|
||||
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('agent_runs')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (fetchError) throw fetchError
|
||||
setRuns((data || []) as unknown as AgentRun[])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to load agent runs'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRuns()
|
||||
|
||||
// Set up real-time subscription
|
||||
const supabase = createClient()
|
||||
const channel = supabase
|
||||
.channel(`agent_runs:${projectId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'agent_runs',
|
||||
filter: `project_id=eq.${projectId}`,
|
||||
},
|
||||
(payload) => {
|
||||
if (payload.eventType === 'INSERT') {
|
||||
setRuns((prev) => [payload.new as AgentRun, ...prev])
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
setRuns((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === (payload.new as AgentRun).id
|
||||
? (payload.new as AgentRun)
|
||||
: run
|
||||
)
|
||||
)
|
||||
} else if (payload.eventType === 'DELETE') {
|
||||
setRuns((prev) =>
|
||||
prev.filter((run) => run.id !== (payload.old as AgentRun).id)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [projectId, fetchRuns])
|
||||
|
||||
const approve = useCallback(async (runId: string) => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('agent_runs')
|
||||
.update({
|
||||
approval_status: 'approved',
|
||||
approved_by: user.id,
|
||||
approved_at: new Date().toISOString(),
|
||||
} as never)
|
||||
.eq('id', runId)
|
||||
|
||||
if (updateError) throw updateError
|
||||
|
||||
// Trigger n8n webhook to execute the approved action
|
||||
await fetch(`${process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL}/agent-approved`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ run_id: runId, project_id: projectId }),
|
||||
})
|
||||
}, [projectId])
|
||||
|
||||
const reject = useCallback(async (runId: string) => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('agent_runs')
|
||||
.update({
|
||||
approval_status: 'rejected',
|
||||
approved_by: user.id,
|
||||
approved_at: new Date().toISOString(),
|
||||
status: 'cancelled',
|
||||
} as never)
|
||||
.eq('id', runId)
|
||||
|
||||
if (updateError) throw updateError
|
||||
}, [])
|
||||
|
||||
const pendingRuns = runs.filter(
|
||||
(run) => run.approval_status === 'pending' && run.proposed_changes
|
||||
)
|
||||
|
||||
return {
|
||||
runs,
|
||||
pendingRuns,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchRuns,
|
||||
approve,
|
||||
reject,
|
||||
}
|
||||
}
|
||||
132
src/hooks/use-project.ts
Normal file
132
src/hooks/use-project.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
getProject,
|
||||
getBacklogItems,
|
||||
getProjectHealth,
|
||||
getRecommendations,
|
||||
getProjectActivities,
|
||||
updateProjectPhase,
|
||||
loopBackToPhase,
|
||||
updateBacklogItem,
|
||||
dismissRecommendation,
|
||||
type Project,
|
||||
type ProjectPhase
|
||||
} from '@/lib/supabase/projects'
|
||||
import type {
|
||||
DesignPhase,
|
||||
PhaseStatus,
|
||||
BacklogItem,
|
||||
ProjectHealth,
|
||||
AIRecommendation
|
||||
} from '@/types/design-thinking'
|
||||
import { DESIGN_PHASES } from '@/types/design-thinking'
|
||||
|
||||
interface UseProjectReturn {
|
||||
project: Project | null
|
||||
phases: ProjectPhase[]
|
||||
phaseStatuses: Record<DesignPhase, PhaseStatus>
|
||||
backlogItems: BacklogItem[]
|
||||
health: ProjectHealth
|
||||
recommendations: AIRecommendation[]
|
||||
activities: Array<{ id: string; activity_type: string; message: string; created_at: string }>
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
refresh: () => Promise<void>
|
||||
advancePhase: (phase: DesignPhase) => Promise<void>
|
||||
loopBack: (fromPhase: DesignPhase, toPhase: DesignPhase) => Promise<void>
|
||||
updateBacklog: (itemId: string, updates: Partial<BacklogItem>) => Promise<void>
|
||||
dismissRec: (recId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useProject(projectId: string): UseProjectReturn {
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [phases, setPhases] = useState<ProjectPhase[]>([])
|
||||
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
|
||||
const [health, setHealth] = useState<ProjectHealth>({
|
||||
overall: 100,
|
||||
velocity: 0,
|
||||
blockers: 0,
|
||||
overdue: 0,
|
||||
completion_rate: 0
|
||||
})
|
||||
const [recommendations, setRecommendations] = useState<AIRecommendation[]>([])
|
||||
const [activities, setActivities] = useState<Array<{ id: string; activity_type: string; message: string; created_at: string }>>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
// Derive phase statuses from phases array
|
||||
const phaseStatuses: Record<DesignPhase, PhaseStatus> = DESIGN_PHASES.reduce((acc, phase) => {
|
||||
const latestPhase = phases.find(p => p.phase === phase)
|
||||
acc[phase] = latestPhase?.status || 'not_started'
|
||||
return acc
|
||||
}, {} as Record<DesignPhase, PhaseStatus>)
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [projectData, backlog, healthData, recs, acts] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getBacklogItems(projectId),
|
||||
getProjectHealth(projectId),
|
||||
getRecommendations(projectId),
|
||||
getProjectActivities(projectId)
|
||||
])
|
||||
|
||||
setProject(projectData.project)
|
||||
setPhases(projectData.phases)
|
||||
setBacklogItems(backlog)
|
||||
setHealth(healthData)
|
||||
setRecommendations(recs)
|
||||
setActivities(acts)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to load project'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
}, [fetchAll])
|
||||
|
||||
const advancePhase = useCallback(async (phase: DesignPhase) => {
|
||||
await updateProjectPhase(projectId, phase, 'completed')
|
||||
await fetchAll()
|
||||
}, [projectId, fetchAll])
|
||||
|
||||
const loopBack = useCallback(async (fromPhase: DesignPhase, toPhase: DesignPhase) => {
|
||||
await loopBackToPhase(projectId, fromPhase, toPhase)
|
||||
await fetchAll()
|
||||
}, [projectId, fetchAll])
|
||||
|
||||
const updateBacklog = useCallback(async (itemId: string, updates: Partial<BacklogItem>) => {
|
||||
await updateBacklogItem(itemId, updates)
|
||||
await fetchAll()
|
||||
}, [fetchAll])
|
||||
|
||||
const dismissRec = useCallback(async (recId: string) => {
|
||||
await dismissRecommendation(recId)
|
||||
setRecommendations(prev => prev.filter(r => r.id !== recId))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
project,
|
||||
phases,
|
||||
phaseStatuses,
|
||||
backlogItems,
|
||||
health,
|
||||
recommendations,
|
||||
activities,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchAll,
|
||||
advancePhase,
|
||||
loopBack,
|
||||
updateBacklog,
|
||||
dismissRec
|
||||
}
|
||||
}
|
||||
23
src/hooks/use-toast.ts
Normal file
23
src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { toast as sonnerToast } from 'sonner'
|
||||
|
||||
interface ToastProps {
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const toast = ({ title, description, variant }: ToastProps) => {
|
||||
if (variant === 'destructive') {
|
||||
sonnerToast.error(title, {
|
||||
description,
|
||||
})
|
||||
} else {
|
||||
sonnerToast.success(title, {
|
||||
description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { toast }
|
||||
}
|
||||
187
src/hooks/useProjectPhase.ts
Normal file
187
src/hooks/useProjectPhase.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { Phase } from '@/components/ui/progress-timeline'
|
||||
import type { Activity } from '@/components/ui/activity-feed'
|
||||
|
||||
export interface ProjectPhaseState {
|
||||
projectId: string
|
||||
currentPhase: Phase
|
||||
completedPhases: Phase[]
|
||||
phaseStatus: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'failed'
|
||||
activities: Activity[]
|
||||
metrics: {
|
||||
totalDuration: number
|
||||
phaseDurations: Record<Phase, number>
|
||||
iterationCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export function useProjectPhase(projectId: string) {
|
||||
const [state, setState] = useState<ProjectPhaseState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const fetchProjectState = useCallback(async () => {
|
||||
const supabase = createClient()
|
||||
|
||||
try {
|
||||
const { data: project, error: projectError } = await supabase
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (projectError) throw projectError
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const metadata = (project as any)?.phase_metadata as ProjectPhaseState | undefined
|
||||
|
||||
setState({
|
||||
projectId,
|
||||
currentPhase: metadata?.currentPhase || 'think',
|
||||
completedPhases: metadata?.completedPhases || [],
|
||||
phaseStatus: metadata?.phaseStatus || 'not_started',
|
||||
activities: metadata?.activities || [],
|
||||
metrics: metadata?.metrics || {
|
||||
totalDuration: 0,
|
||||
phaseDurations: {} as Record<Phase, number>,
|
||||
iterationCount: 0
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch project'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectState()
|
||||
|
||||
const supabase = createClient()
|
||||
const channel = supabase
|
||||
.channel(`project:${projectId}`)
|
||||
.on('postgres_changes', {
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'projects',
|
||||
filter: `id=eq.${projectId}`
|
||||
}, (payload) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const metadata = (payload.new as any)?.phase_metadata as ProjectPhaseState | undefined
|
||||
if (metadata) {
|
||||
setState(prev => prev ? { ...prev, ...metadata, projectId } : null)
|
||||
}
|
||||
})
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [projectId, fetchProjectState])
|
||||
|
||||
const transitionPhase = useCallback(async (newPhase: Phase) => {
|
||||
if (!state) return
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const newActivity: Activity = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'phase_change',
|
||||
message: `Transitioned to ${newPhase} phase`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
const updatedState: ProjectPhaseState = {
|
||||
...state,
|
||||
currentPhase: newPhase,
|
||||
completedPhases: state.currentPhase !== newPhase
|
||||
? [...state.completedPhases, state.currentPhase]
|
||||
: state.completedPhases,
|
||||
phaseStatus: 'in_progress',
|
||||
activities: [newActivity, ...state.activities].slice(0, 50)
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
setState(updatedState)
|
||||
|
||||
// Persist to database
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error: updateError } = await (supabase as any)
|
||||
.from('projects')
|
||||
.update({ phase_metadata: updatedState })
|
||||
.eq('id', projectId)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to persist phase:', updateError)
|
||||
// Revert on error
|
||||
setState(state)
|
||||
}
|
||||
}, [state, projectId])
|
||||
|
||||
const setPhaseStatus = useCallback(async (status: ProjectPhaseState['phaseStatus']) => {
|
||||
if (!state) return
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const updatedState: ProjectPhaseState = {
|
||||
...state,
|
||||
phaseStatus: status
|
||||
}
|
||||
|
||||
setState(updatedState)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error: updateError } = await (supabase as any)
|
||||
.from('projects')
|
||||
.update({ phase_metadata: updatedState })
|
||||
.eq('id', projectId)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update phase status:', updateError)
|
||||
setState(state)
|
||||
}
|
||||
}, [state, projectId])
|
||||
|
||||
const addActivity = useCallback(async (activity: Omit<Activity, 'id' | 'timestamp'>) => {
|
||||
if (!state) return
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const newActivity: Activity = {
|
||||
...activity,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
const updatedState: ProjectPhaseState = {
|
||||
...state,
|
||||
activities: [newActivity, ...state.activities].slice(0, 50)
|
||||
}
|
||||
|
||||
setState(updatedState)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error: updateError } = await (supabase as any)
|
||||
.from('projects')
|
||||
.update({ phase_metadata: updatedState })
|
||||
.eq('id', projectId)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to add activity:', updateError)
|
||||
setState(state)
|
||||
}
|
||||
}, [state, projectId])
|
||||
|
||||
return {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
transitionPhase,
|
||||
setPhaseStatus,
|
||||
addActivity,
|
||||
refresh: fetchProjectState
|
||||
}
|
||||
}
|
||||
197
src/lib/gitea/client.ts
Normal file
197
src/lib/gitea/client.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
GiteaRepository,
|
||||
GiteaWebhook,
|
||||
GiteaPullRequest,
|
||||
GiteaBranch,
|
||||
CreateRepoOptions,
|
||||
CreateWebhookOptions,
|
||||
CreateCommitOptions,
|
||||
CreatePROptions,
|
||||
GiteaError,
|
||||
} from './types'
|
||||
|
||||
const GITEA_URL = process.env.NEXT_PUBLIC_GITEA_URL || 'https://gitea.mylder.io'
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
|
||||
const GITEA_OWNER = 'admin'
|
||||
|
||||
class GiteaClient {
|
||||
private baseUrl: string
|
||||
private token: string
|
||||
private owner: string
|
||||
|
||||
constructor(baseUrl: string = GITEA_URL, token: string = GITEA_TOKEN, owner: string = GITEA_OWNER) {
|
||||
this.baseUrl = baseUrl
|
||||
this.token = token
|
||||
this.owner = owner
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}/api/v1${endpoint}`
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: GiteaError = await response.json().catch(() => ({
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(error.message || `Gitea API error: ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async createRepo(options: CreateRepoOptions): Promise<GiteaRepository> {
|
||||
return this.request<GiteaRepository>('/user/repos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: options.name,
|
||||
description: options.description || '',
|
||||
private: options.private ?? false,
|
||||
auto_init: options.auto_init ?? true,
|
||||
default_branch: options.default_branch || 'main',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async getRepo(name: string): Promise<GiteaRepository | null> {
|
||||
try {
|
||||
return await this.request<GiteaRepository>(`/repos/${this.owner}/${name}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRepo(name: string): Promise<void> {
|
||||
await this.request(`/repos/${this.owner}/${name}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async createWebhook(options: CreateWebhookOptions): Promise<GiteaWebhook> {
|
||||
return this.request<GiteaWebhook>(`/repos/${this.owner}/${options.repo}/hooks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'gitea',
|
||||
config: {
|
||||
url: options.url,
|
||||
content_type: 'json',
|
||||
secret: options.secret || '',
|
||||
},
|
||||
events: options.events,
|
||||
active: options.active ?? true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async listWebhooks(repo: string): Promise<GiteaWebhook[]> {
|
||||
return this.request<GiteaWebhook[]>(`/repos/${this.owner}/${repo}/hooks`)
|
||||
}
|
||||
|
||||
async deleteWebhook(repo: string, hookId: number): Promise<void> {
|
||||
await this.request(`/repos/${this.owner}/${repo}/hooks/${hookId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async getBranch(repo: string, branch: string): Promise<GiteaBranch | null> {
|
||||
try {
|
||||
return await this.request<GiteaBranch>(`/repos/${this.owner}/${repo}/branches/${branch}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async createBranch(repo: string, branchName: string, baseBranch: string = 'main'): Promise<GiteaBranch> {
|
||||
const base = await this.getBranch(repo, baseBranch)
|
||||
if (!base) {
|
||||
throw new Error(`Base branch ${baseBranch} not found`)
|
||||
}
|
||||
|
||||
return this.request<GiteaBranch>(`/repos/${this.owner}/${repo}/branches`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
new_branch_name: branchName,
|
||||
old_ref_name: baseBranch,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async createCommit(options: CreateCommitOptions): Promise<{ commit: { sha: string } }> {
|
||||
const results = await Promise.all(
|
||||
options.files.map(async (file) => {
|
||||
const existing = await this.getFileContent(options.repo, file.path, options.branch)
|
||||
const method = existing ? 'PUT' : 'POST'
|
||||
const endpoint = `/repos/${this.owner}/${options.repo}/contents/${file.path}`
|
||||
|
||||
return this.request(endpoint, {
|
||||
method,
|
||||
body: JSON.stringify({
|
||||
message: options.message,
|
||||
content: Buffer.from(file.content).toString('base64'),
|
||||
branch: options.branch,
|
||||
sha: existing?.sha,
|
||||
author: options.author,
|
||||
}),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return results[0] as { commit: { sha: string } }
|
||||
}
|
||||
|
||||
async getFileContent(repo: string, path: string, ref?: string): Promise<{ sha: string; content: string } | null> {
|
||||
try {
|
||||
const endpoint = `/repos/${this.owner}/${repo}/contents/${path}${ref ? `?ref=${ref}` : ''}`
|
||||
return await this.request(endpoint)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async createPullRequest(options: CreatePROptions): Promise<GiteaPullRequest> {
|
||||
return this.request<GiteaPullRequest>(`/repos/${this.owner}/${options.repo}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: options.title,
|
||||
body: options.body || '',
|
||||
head: options.head,
|
||||
base: options.base || 'main',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async getPullRequest(repo: string, number: number): Promise<GiteaPullRequest> {
|
||||
return this.request<GiteaPullRequest>(`/repos/${this.owner}/${repo}/pulls/${number}`)
|
||||
}
|
||||
|
||||
async mergePullRequest(repo: string, number: number, strategy: 'merge' | 'rebase' | 'squash' = 'squash'): Promise<void> {
|
||||
await this.request(`/repos/${this.owner}/${repo}/pulls/${number}/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ do: strategy }),
|
||||
})
|
||||
}
|
||||
|
||||
async listPullRequests(repo: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<GiteaPullRequest[]> {
|
||||
return this.request<GiteaPullRequest[]>(`/repos/${this.owner}/${repo}/pulls?state=${state}`)
|
||||
}
|
||||
|
||||
getRepoUrl(name: string): string {
|
||||
return `${this.baseUrl}/${this.owner}/${name}`
|
||||
}
|
||||
|
||||
getCloneUrl(name: string): string {
|
||||
return `${this.baseUrl}/${this.owner}/${name}.git`
|
||||
}
|
||||
}
|
||||
|
||||
export const gitea = new GiteaClient()
|
||||
export { GiteaClient }
|
||||
82
src/lib/gitea/types.ts
Normal file
82
src/lib/gitea/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export interface GiteaRepository {
|
||||
id: number
|
||||
name: string
|
||||
full_name: string
|
||||
description: string
|
||||
html_url: string
|
||||
clone_url: string
|
||||
ssh_url: string
|
||||
default_branch: string
|
||||
empty: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GiteaWebhook {
|
||||
id: number
|
||||
type: string
|
||||
url: string
|
||||
active: boolean
|
||||
events: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GiteaCommitFile {
|
||||
path: string
|
||||
content: string
|
||||
operation?: 'create' | 'update' | 'delete'
|
||||
}
|
||||
|
||||
export interface GiteaPullRequest {
|
||||
id: number
|
||||
number: number
|
||||
title: string
|
||||
body: string
|
||||
html_url: string
|
||||
state: 'open' | 'closed' | 'merged'
|
||||
head: { ref: string }
|
||||
base: { ref: string }
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GiteaBranch {
|
||||
name: string
|
||||
commit: { id: string; message: string }
|
||||
}
|
||||
|
||||
export interface CreateRepoOptions {
|
||||
name: string
|
||||
description?: string
|
||||
private?: boolean
|
||||
auto_init?: boolean
|
||||
default_branch?: string
|
||||
}
|
||||
|
||||
export interface CreateWebhookOptions {
|
||||
repo: string
|
||||
url: string
|
||||
events: string[]
|
||||
secret?: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export interface CreateCommitOptions {
|
||||
repo: string
|
||||
branch: string
|
||||
message: string
|
||||
files: GiteaCommitFile[]
|
||||
author?: { name: string; email: string }
|
||||
}
|
||||
|
||||
export interface CreatePROptions {
|
||||
repo: string
|
||||
title: string
|
||||
body?: string
|
||||
head: string
|
||||
base?: string
|
||||
}
|
||||
|
||||
export type GiteaError = {
|
||||
message: string
|
||||
url?: string
|
||||
}
|
||||
12
src/lib/stripe/client.ts
Normal file
12
src/lib/stripe/client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { loadStripe, type Stripe } from '@stripe/stripe-js'
|
||||
|
||||
let stripePromise: Promise<Stripe | null> | null = null
|
||||
|
||||
export function getStripe() {
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
|
||||
}
|
||||
return stripePromise
|
||||
}
|
||||
57
src/lib/stripe/config.ts
Normal file
57
src/lib/stripe/config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import Stripe from 'stripe'
|
||||
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder'
|
||||
|
||||
export const stripe = new Stripe(stripeSecretKey)
|
||||
|
||||
export const PLANS = {
|
||||
free: {
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
priceId: null,
|
||||
features: [
|
||||
'1 project',
|
||||
'50 AI messages/month',
|
||||
'Basic templates',
|
||||
'Community support',
|
||||
],
|
||||
limits: {
|
||||
projects: 1,
|
||||
messages: 50,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
name: 'Pro',
|
||||
price: 19,
|
||||
priceId: process.env.STRIPE_PRO_PRICE_ID,
|
||||
features: [
|
||||
'Unlimited projects',
|
||||
'2,000 AI messages/month',
|
||||
'All templates',
|
||||
'Priority support',
|
||||
'API access',
|
||||
],
|
||||
limits: {
|
||||
projects: -1,
|
||||
messages: 2000,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
name: 'Team',
|
||||
price: 49,
|
||||
priceId: process.env.STRIPE_TEAM_PRICE_ID,
|
||||
features: [
|
||||
'Everything in Pro',
|
||||
'10,000 AI messages/month',
|
||||
'Team collaboration',
|
||||
'Admin dashboard',
|
||||
'Custom integrations',
|
||||
],
|
||||
limits: {
|
||||
projects: -1,
|
||||
messages: 10000,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export type PlanKey = keyof typeof PLANS
|
||||
@@ -1,9 +1,17 @@
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
let browserClient: ReturnType<typeof createBrowserClient<Database>> | null = null
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient<Database>(
|
||||
if (browserClient) {
|
||||
return browserClient
|
||||
}
|
||||
|
||||
browserClient = createBrowserClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
return browserClient
|
||||
}
|
||||
|
||||
430
src/lib/supabase/projects.ts
Normal file
430
src/lib/supabase/projects.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { deleteGiteaRepo } from '@/app/(dashboard)/projects/new/actions'
|
||||
import type {
|
||||
DesignPhase,
|
||||
PhaseStatus,
|
||||
BacklogItem,
|
||||
ProjectHealth,
|
||||
AIRecommendation
|
||||
} from '@/types/design-thinking'
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
current_phase: DesignPhase
|
||||
health_score: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProjectPhase {
|
||||
id: string
|
||||
project_id: string
|
||||
phase: DesignPhase
|
||||
status: PhaseStatus
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
notes: string | null
|
||||
iteration: number
|
||||
}
|
||||
|
||||
// Fetch all projects for current user
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.order('updated_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return (data || []) as unknown as Project[]
|
||||
}
|
||||
|
||||
// Fetch single project with phases
|
||||
export async function getProject(projectId: string): Promise<{ project: Project; phases: ProjectPhase[] }> {
|
||||
const supabase = createClient()
|
||||
|
||||
const [projectResult, phasesResult] = await Promise.all([
|
||||
supabase.from('projects').select('*').eq('id', projectId).single(),
|
||||
supabase.from('project_phases').select('*').eq('project_id', projectId).order('iteration', { ascending: false })
|
||||
])
|
||||
|
||||
if (projectResult.error) throw projectResult.error
|
||||
if (phasesResult.error) throw phasesResult.error
|
||||
|
||||
return {
|
||||
project: projectResult.data as unknown as Project,
|
||||
phases: (phasesResult.data || []) as unknown as ProjectPhase[]
|
||||
}
|
||||
}
|
||||
|
||||
// Create new project
|
||||
export async function createProject(name: string, description?: string): Promise<Project> {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
// Use raw SQL-like insert with explicit types
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
name,
|
||||
description: description || null,
|
||||
current_phase: 'empathize' as DesignPhase,
|
||||
health_score: 100
|
||||
}] as never)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
const project = data as unknown as Project
|
||||
|
||||
// Initialize phase tracking
|
||||
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
||||
await supabase.from('project_phases').insert(
|
||||
phases.map((phase, index) => ({
|
||||
project_id: project.id,
|
||||
phase,
|
||||
status: index === 0 ? 'in_progress' : 'not_started',
|
||||
iteration: 1,
|
||||
started_at: index === 0 ? new Date().toISOString() : null
|
||||
})) as never
|
||||
)
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
// Update project phase
|
||||
export async function updateProjectPhase(projectId: string, phase: DesignPhase, status: PhaseStatus): Promise<void> {
|
||||
const supabase = createClient()
|
||||
|
||||
// Update the phase record
|
||||
const { error: phaseError } = await supabase
|
||||
.from('project_phases')
|
||||
.update({
|
||||
status,
|
||||
started_at: status === 'in_progress' ? new Date().toISOString() : undefined,
|
||||
completed_at: status === 'completed' ? new Date().toISOString() : undefined
|
||||
} as never)
|
||||
.eq('project_id', projectId)
|
||||
.eq('phase', phase)
|
||||
|
||||
if (phaseError) throw phaseError
|
||||
|
||||
// Update project's current phase if advancing
|
||||
if (status === 'completed') {
|
||||
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
||||
const currentIndex = phases.indexOf(phase)
|
||||
if (currentIndex < phases.length - 1) {
|
||||
const nextPhase = phases[currentIndex + 1]
|
||||
await supabase
|
||||
.from('projects')
|
||||
.update({ current_phase: nextPhase } as never)
|
||||
.eq('id', projectId)
|
||||
|
||||
// Start the next phase
|
||||
await supabase
|
||||
.from('project_phases')
|
||||
.update({ status: 'in_progress', started_at: new Date().toISOString() } as never)
|
||||
.eq('project_id', projectId)
|
||||
.eq('phase', nextPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop back to a previous phase (start new iteration)
|
||||
export async function loopBackToPhase(projectId: string, fromPhase: DesignPhase, toPhase: DesignPhase): Promise<void> {
|
||||
const supabase = createClient()
|
||||
|
||||
// Get current max iteration
|
||||
const { data: currentPhases } = await supabase
|
||||
.from('project_phases')
|
||||
.select('iteration')
|
||||
.eq('project_id', projectId)
|
||||
.order('iteration', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
const phasesData = currentPhases as unknown as Array<{ iteration: number }> | null
|
||||
const newIteration = (phasesData?.[0]?.iteration || 1) + 1
|
||||
|
||||
// Create new iteration phases from toPhase onwards
|
||||
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
||||
const startIndex = phases.indexOf(toPhase)
|
||||
|
||||
await supabase.from('project_phases').insert(
|
||||
phases.slice(startIndex).map((phase, index) => ({
|
||||
project_id: projectId,
|
||||
phase,
|
||||
status: index === 0 ? 'in_progress' : 'not_started',
|
||||
iteration: newIteration,
|
||||
started_at: index === 0 ? new Date().toISOString() : null
|
||||
})) as never
|
||||
)
|
||||
|
||||
// Update project's current phase
|
||||
await supabase
|
||||
.from('projects')
|
||||
.update({ current_phase: toPhase } as never)
|
||||
.eq('id', projectId)
|
||||
|
||||
// Log activity
|
||||
await supabase.from('project_activities').insert([{
|
||||
project_id: projectId,
|
||||
activity_type: 'loop_back',
|
||||
message: `Looped back from ${fromPhase} to ${toPhase} (iteration ${newIteration})`,
|
||||
metadata: { from_phase: fromPhase, to_phase: toPhase, iteration: newIteration }
|
||||
}] as never)
|
||||
}
|
||||
|
||||
// Fetch backlog items for a project
|
||||
export async function getBacklogItems(projectId: string): Promise<BacklogItem[]> {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('backlog_items')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('priority_score', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return (data || []) as unknown as BacklogItem[]
|
||||
}
|
||||
|
||||
// Create backlog item
|
||||
export async function createBacklogItem(
|
||||
projectId: string,
|
||||
item: Omit<BacklogItem, 'id' | 'priority_score' | 'created_at' | 'updated_at'>
|
||||
): Promise<BacklogItem> {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('backlog_items')
|
||||
.insert([{ ...item, project_id: projectId }] as never)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as unknown as BacklogItem
|
||||
}
|
||||
|
||||
// Update backlog item
|
||||
export async function updateBacklogItem(itemId: string, updates: Partial<BacklogItem>): Promise<BacklogItem> {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('backlog_items')
|
||||
.update(updates as never)
|
||||
.eq('id', itemId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as unknown as BacklogItem
|
||||
}
|
||||
|
||||
// Fetch project health
|
||||
export async function getProjectHealth(projectId: string): Promise<ProjectHealth> {
|
||||
const supabase = createClient()
|
||||
|
||||
// Get latest health snapshot
|
||||
const { data: snapshot } = await supabase
|
||||
.from('project_health_snapshots')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('snapshot_date', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
const snapshotData = snapshot as unknown as {
|
||||
overall_score: number
|
||||
velocity: number
|
||||
blockers: number
|
||||
overdue: number
|
||||
completion_rate: number
|
||||
} | null
|
||||
|
||||
if (snapshotData) {
|
||||
return {
|
||||
overall: snapshotData.overall_score,
|
||||
velocity: snapshotData.velocity,
|
||||
blockers: snapshotData.blockers,
|
||||
overdue: snapshotData.overdue,
|
||||
completion_rate: snapshotData.completion_rate
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate on the fly if no snapshot
|
||||
const { data: items } = await supabase
|
||||
.from('backlog_items')
|
||||
.select('status, due_date')
|
||||
.eq('project_id', projectId)
|
||||
|
||||
const itemsData = items as unknown as Array<{ status: string; due_date: string | null }> | null
|
||||
const total = itemsData?.length || 0
|
||||
const done = itemsData?.filter(i => i.status === 'done').length || 0
|
||||
const blocked = itemsData?.filter(i => i.status === 'blocked').length || 0
|
||||
const overdue = itemsData?.filter(i =>
|
||||
i.due_date && new Date(i.due_date) < new Date() && i.status !== 'done'
|
||||
).length || 0
|
||||
|
||||
return {
|
||||
overall: Math.max(0, 100 - (blocked * 10) - (overdue * 5)),
|
||||
velocity: done,
|
||||
blockers: blocked,
|
||||
overdue,
|
||||
completion_rate: total > 0 ? Math.round((done / total) * 100) : 0
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch AI recommendations
|
||||
export async function getRecommendations(projectId: string): Promise<AIRecommendation[]> {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('ai_recommendations')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.eq('dismissed', false)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return (data || []) as unknown as AIRecommendation[]
|
||||
}
|
||||
|
||||
// Dismiss recommendation
|
||||
export async function dismissRecommendation(recommendationId: string): Promise<void> {
|
||||
const supabase = createClient()
|
||||
await supabase
|
||||
.from('ai_recommendations')
|
||||
.update({ dismissed: true, dismissed_at: new Date().toISOString() } as never)
|
||||
.eq('id', recommendationId)
|
||||
}
|
||||
|
||||
// Fetch project activities
|
||||
export async function getProjectActivities(projectId: string, limit = 20) {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('project_activities')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (error) throw error
|
||||
return (data || []) as unknown as Array<{
|
||||
id: string
|
||||
activity_type: string
|
||||
message: string
|
||||
created_at: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Delete project with cascade (includes Gitea repo deletion)
|
||||
export async function deleteProject(projectId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
try {
|
||||
// First, get the project to check ownership and get gitea_repo
|
||||
const { data: projectData, error: fetchError } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id, gitea_repo, name')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !projectData) {
|
||||
return { success: false, error: 'Project not found' }
|
||||
}
|
||||
|
||||
const project = projectData as unknown as {
|
||||
id: string
|
||||
user_id: string
|
||||
gitea_repo: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (project.user_id !== user.id) {
|
||||
return { success: false, error: 'Not authorized to delete this project' }
|
||||
}
|
||||
|
||||
// Extract repo name from gitea_repo URL if it exists
|
||||
// Format: https://gitea.mylder.io/admin/repo-name
|
||||
if (project.gitea_repo) {
|
||||
const repoMatch = project.gitea_repo.match(/\/([^/]+)$/)
|
||||
const repoName = repoMatch ? repoMatch[1] : null
|
||||
|
||||
if (repoName) {
|
||||
const giteaResult = await deleteGiteaRepo(repoName)
|
||||
if (!giteaResult.success) {
|
||||
console.error('Failed to delete Gitea repo:', giteaResult.error)
|
||||
// Continue with database deletion even if Gitea fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete related data in order (due to foreign key constraints)
|
||||
// Delete agent_runs
|
||||
await supabase
|
||||
.from('agent_runs')
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
|
||||
// Delete messages
|
||||
await supabase
|
||||
.from('messages')
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
|
||||
// Delete backlog items
|
||||
await supabase
|
||||
.from('backlog_items')
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
|
||||
// Delete project activities
|
||||
await supabase
|
||||
.from('project_activities')
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
|
||||
// Delete AI recommendations
|
||||
await supabase
|
||||
.from('ai_recommendations')
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
|
||||
// Delete project phases
|
||||
await supabase
|
||||
.from('project_phases')
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
|
||||
// Delete health snapshots
|
||||
await supabase
|
||||
.from('project_health_snapshots')
|
||||
.delete()
|
||||
.eq('project_id', projectId)
|
||||
|
||||
// Finally, delete the project itself
|
||||
const { error: deleteError } = await supabase
|
||||
.from('projects')
|
||||
.delete()
|
||||
.eq('id', projectId)
|
||||
|
||||
if (deleteError) {
|
||||
return { success: false, error: deleteError.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete project'
|
||||
}
|
||||
}
|
||||
}
|
||||
546
src/styles/design-system.md
Normal file
546
src/styles/design-system.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# Mylder Brand Design System
|
||||
|
||||
## Brand Identity
|
||||
|
||||
**Tagline**: Intelligent Automation, Human Touch
|
||||
|
||||
**Personality**: Professional, Approachable, Innovative, Trustworthy
|
||||
|
||||
**Aesthetic**: Refined Tech-Forward Minimalism with Warm Energy
|
||||
|
||||
---
|
||||
|
||||
## Color System (OKLCH)
|
||||
|
||||
### Philosophy
|
||||
Using OKLCH color space for perceptually uniform colors. Warm stone neutrals replace cold grays to feel more human/approachable for an AI platform. Amber accent represents energy and intelligence.
|
||||
|
||||
### Primary Palette
|
||||
|
||||
#### Brand Colors
|
||||
```css
|
||||
/* Amber - Primary Brand Accent */
|
||||
--brand: oklch(0.75 0.16 75); /* Vibrant amber */
|
||||
--brand-hover: oklch(0.70 0.18 75); /* Darker on hover */
|
||||
--brand-muted: oklch(0.85 0.08 75); /* Soft amber for backgrounds */
|
||||
--brand-foreground: oklch(0.20 0.02 75); /* Dark text on brand */
|
||||
|
||||
/* Stone - Warm Neutrals (replaces zinc/gray) */
|
||||
--stone-50: oklch(0.985 0.002 75); /* Near white, warm */
|
||||
--stone-100: oklch(0.965 0.004 75); /* Subtle warm */
|
||||
--stone-200: oklch(0.925 0.006 75); /* Light warm */
|
||||
--stone-300: oklch(0.87 0.008 75); /* Medium-light */
|
||||
--stone-400: oklch(0.70 0.01 75); /* Medium */
|
||||
--stone-500: oklch(0.55 0.012 75); /* Medium-dark */
|
||||
--stone-600: oklch(0.45 0.014 75); /* Dark */
|
||||
--stone-700: oklch(0.35 0.012 75); /* Darker */
|
||||
--stone-800: oklch(0.25 0.01 75); /* Very dark */
|
||||
--stone-900: oklch(0.18 0.008 75); /* Near black, warm */
|
||||
--stone-950: oklch(0.12 0.006 75); /* Deepest */
|
||||
```
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
#### Light Mode
|
||||
```css
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--background: oklch(0.99 0.002 75); /* Warm white */
|
||||
--background-subtle: oklch(0.975 0.004 75); /* Subtle warmth */
|
||||
--foreground: oklch(0.15 0.01 75); /* Warm black */
|
||||
|
||||
/* Cards & Surfaces */
|
||||
--card: oklch(1 0 0); /* Pure white cards */
|
||||
--card-foreground: oklch(0.15 0.01 75);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.15 0.01 75);
|
||||
|
||||
/* Primary (Dark buttons/CTAs) */
|
||||
--primary: oklch(0.20 0.01 75);
|
||||
--primary-foreground: oklch(0.98 0.002 75);
|
||||
|
||||
/* Secondary (Light buttons) */
|
||||
--secondary: oklch(0.955 0.006 75);
|
||||
--secondary-foreground: oklch(0.25 0.01 75);
|
||||
|
||||
/* Muted (Disabled, hints) */
|
||||
--muted: oklch(0.955 0.006 75);
|
||||
--muted-foreground: oklch(0.50 0.01 75);
|
||||
|
||||
/* Accent (Amber brand) */
|
||||
--accent: oklch(0.92 0.06 75);
|
||||
--accent-foreground: oklch(0.25 0.02 75);
|
||||
|
||||
/* Brand (Primary amber) */
|
||||
--brand: oklch(0.75 0.16 75);
|
||||
--brand-foreground: oklch(0.20 0.02 75);
|
||||
|
||||
/* Destructive */
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
|
||||
/* Success */
|
||||
--success: oklch(0.65 0.18 145);
|
||||
--success-foreground: oklch(0.98 0 0);
|
||||
|
||||
/* Warning */
|
||||
--warning: oklch(0.80 0.15 85);
|
||||
--warning-foreground: oklch(0.25 0.02 85);
|
||||
|
||||
/* Info */
|
||||
--info: oklch(0.65 0.15 240);
|
||||
--info-foreground: oklch(0.98 0 0);
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: oklch(0.90 0.006 75);
|
||||
--input: oklch(0.90 0.006 75);
|
||||
--ring: oklch(0.75 0.16 75); /* Amber focus ring */
|
||||
}
|
||||
```
|
||||
|
||||
#### Dark Mode
|
||||
```css
|
||||
.dark {
|
||||
/* Backgrounds */
|
||||
--background: oklch(0.14 0.008 75); /* Warm dark */
|
||||
--background-subtle: oklch(0.18 0.01 75);
|
||||
--foreground: oklch(0.96 0.004 75); /* Warm white */
|
||||
|
||||
/* Cards & Surfaces */
|
||||
--card: oklch(0.20 0.01 75);
|
||||
--card-foreground: oklch(0.96 0.004 75);
|
||||
--popover: oklch(0.20 0.01 75);
|
||||
--popover-foreground: oklch(0.96 0.004 75);
|
||||
|
||||
/* Primary (Light buttons on dark) */
|
||||
--primary: oklch(0.94 0.004 75);
|
||||
--primary-foreground: oklch(0.18 0.01 75);
|
||||
|
||||
/* Secondary */
|
||||
--secondary: oklch(0.26 0.012 75);
|
||||
--secondary-foreground: oklch(0.94 0.004 75);
|
||||
|
||||
/* Muted */
|
||||
--muted: oklch(0.26 0.012 75);
|
||||
--muted-foreground: oklch(0.65 0.01 75);
|
||||
|
||||
/* Accent (Amber, slightly brighter for dark) */
|
||||
--accent: oklch(0.30 0.04 75);
|
||||
--accent-foreground: oklch(0.94 0.004 75);
|
||||
|
||||
/* Brand (Amber, adjusted for dark) */
|
||||
--brand: oklch(0.78 0.15 75);
|
||||
--brand-foreground: oklch(0.15 0.02 75);
|
||||
|
||||
/* Destructive */
|
||||
--destructive: oklch(0.65 0.20 22);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
|
||||
/* Success */
|
||||
--success: oklch(0.70 0.16 145);
|
||||
--success-foreground: oklch(0.15 0 0);
|
||||
|
||||
/* Warning */
|
||||
--warning: oklch(0.82 0.14 85);
|
||||
--warning-foreground: oklch(0.20 0.02 85);
|
||||
|
||||
/* Info */
|
||||
--info: oklch(0.70 0.14 240);
|
||||
--info-foreground: oklch(0.15 0 0);
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.78 0.15 75);
|
||||
}
|
||||
```
|
||||
|
||||
### Chart Colors (Data Visualization)
|
||||
```css
|
||||
/* Light mode - vibrant, distinguishable */
|
||||
--chart-1: oklch(0.75 0.16 75); /* Amber (brand) */
|
||||
--chart-2: oklch(0.60 0.15 200); /* Teal */
|
||||
--chart-3: oklch(0.65 0.18 280); /* Purple */
|
||||
--chart-4: oklch(0.70 0.16 145); /* Green */
|
||||
--chart-5: oklch(0.65 0.20 25); /* Coral */
|
||||
|
||||
/* Dark mode - adjusted for contrast */
|
||||
--chart-1: oklch(0.78 0.15 75);
|
||||
--chart-2: oklch(0.68 0.14 200);
|
||||
--chart-3: oklch(0.72 0.16 280);
|
||||
--chart-4: oklch(0.75 0.15 145);
|
||||
--chart-5: oklch(0.72 0.18 25);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
```css
|
||||
/* Primary - Display & Headings */
|
||||
--font-display: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
|
||||
/* Body - Reading & UI */
|
||||
--font-body: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
|
||||
/* Mono - Code & Technical */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
```
|
||||
|
||||
### Type Scale (Fluid)
|
||||
```css
|
||||
/* Using clamp() for fluid typography */
|
||||
--text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.8rem); /* 12-13px */
|
||||
--text-sm: clamp(0.8125rem, 0.77rem + 0.21vw, 0.875rem); /* 13-14px */
|
||||
--text-base: clamp(0.9375rem, 0.9rem + 0.19vw, 1rem); /* 15-16px */
|
||||
--text-lg: clamp(1.0625rem, 1rem + 0.31vw, 1.125rem); /* 17-18px */
|
||||
--text-xl: clamp(1.1875rem, 1.1rem + 0.44vw, 1.25rem); /* 19-20px */
|
||||
--text-2xl: clamp(1.375rem, 1.25rem + 0.63vw, 1.5rem); /* 22-24px */
|
||||
--text-3xl: clamp(1.625rem, 1.45rem + 0.88vw, 1.875rem); /* 26-30px */
|
||||
--text-4xl: clamp(2rem, 1.75rem + 1.25vw, 2.25rem); /* 32-36px */
|
||||
--text-5xl: clamp(2.5rem, 2.1rem + 2vw, 3rem); /* 40-48px */
|
||||
--text-6xl: clamp(3rem, 2.5rem + 2.5vw, 3.75rem); /* 48-60px */
|
||||
```
|
||||
|
||||
### Line Heights
|
||||
```css
|
||||
--leading-none: 1;
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
--leading-loose: 2;
|
||||
```
|
||||
|
||||
### Font Weights
|
||||
```css
|
||||
--font-light: 300;
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
--font-extrabold: 800;
|
||||
```
|
||||
|
||||
### Letter Spacing
|
||||
```css
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.025em;
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spacing & Layout
|
||||
|
||||
### Spacing Scale
|
||||
```css
|
||||
/* Base unit: 4px */
|
||||
--space-0: 0;
|
||||
--space-px: 1px;
|
||||
--space-0.5: 0.125rem; /* 2px */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-1.5: 0.375rem; /* 6px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-2.5: 0.625rem; /* 10px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-3.5: 0.875rem; /* 14px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-7: 1.75rem; /* 28px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-9: 2.25rem; /* 36px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-14: 3.5rem; /* 56px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-20: 5rem; /* 80px */
|
||||
--space-24: 6rem; /* 96px */
|
||||
--space-28: 7rem; /* 112px */
|
||||
--space-32: 8rem; /* 128px */
|
||||
```
|
||||
|
||||
### Container Widths
|
||||
```css
|
||||
--container-xs: 20rem; /* 320px */
|
||||
--container-sm: 24rem; /* 384px */
|
||||
--container-md: 28rem; /* 448px */
|
||||
--container-lg: 32rem; /* 512px */
|
||||
--container-xl: 36rem; /* 576px */
|
||||
--container-2xl: 42rem; /* 672px */
|
||||
--container-3xl: 48rem; /* 768px */
|
||||
--container-4xl: 56rem; /* 896px */
|
||||
--container-5xl: 64rem; /* 1024px */
|
||||
--container-6xl: 72rem; /* 1152px */
|
||||
--container-7xl: 80rem; /* 1280px */
|
||||
--container-full: 100%;
|
||||
```
|
||||
|
||||
### Breakpoints
|
||||
```css
|
||||
--screen-sm: 640px;
|
||||
--screen-md: 768px;
|
||||
--screen-lg: 1024px;
|
||||
--screen-xl: 1280px;
|
||||
--screen-2xl: 1536px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
```css
|
||||
--radius-none: 0;
|
||||
--radius-sm: 0.25rem; /* 4px - subtle */
|
||||
--radius-md: 0.5rem; /* 8px - default */
|
||||
--radius-lg: 0.75rem; /* 12px - cards */
|
||||
--radius-xl: 1rem; /* 16px - modals */
|
||||
--radius-2xl: 1.5rem; /* 24px - large surfaces */
|
||||
--radius-3xl: 2rem; /* 32px - hero elements */
|
||||
--radius-full: 9999px; /* Pills, avatars */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shadows
|
||||
|
||||
### Light Mode
|
||||
```css
|
||||
--shadow-xs: 0 1px 2px oklch(0 0 0 / 0.04);
|
||||
--shadow-sm: 0 1px 3px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.06), 0 2px 4px -1px oklch(0 0 0 / 0.04);
|
||||
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.06), 0 4px 6px -2px oklch(0 0 0 / 0.03);
|
||||
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.08), 0 10px 10px -5px oklch(0 0 0 / 0.03);
|
||||
--shadow-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.15);
|
||||
|
||||
/* Brand glow (for CTAs) */
|
||||
--shadow-brand: 0 0 0 1px oklch(0.75 0.16 75 / 0.1),
|
||||
0 4px 16px oklch(0.75 0.16 75 / 0.15);
|
||||
--shadow-brand-lg: 0 0 0 1px oklch(0.75 0.16 75 / 0.15),
|
||||
0 8px 32px oklch(0.75 0.16 75 / 0.2);
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
```css
|
||||
--shadow-xs: 0 1px 2px oklch(0 0 0 / 0.2);
|
||||
--shadow-sm: 0 1px 3px oklch(0 0 0 / 0.3), 0 1px 2px oklch(0 0 0 / 0.2);
|
||||
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.3), 0 2px 4px -1px oklch(0 0 0 / 0.2);
|
||||
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.3), 0 4px 6px -2px oklch(0 0 0 / 0.2);
|
||||
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.35), 0 10px 10px -5px oklch(0 0 0 / 0.2);
|
||||
--shadow-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.5);
|
||||
|
||||
/* Brand glow (brighter for dark) */
|
||||
--shadow-brand: 0 0 0 1px oklch(0.78 0.15 75 / 0.2),
|
||||
0 4px 16px oklch(0.78 0.15 75 / 0.2);
|
||||
--shadow-brand-lg: 0 0 0 1px oklch(0.78 0.15 75 / 0.25),
|
||||
0 8px 32px oklch(0.78 0.15 75 / 0.25);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Motion & Animation
|
||||
|
||||
### Durations
|
||||
```css
|
||||
--duration-instant: 0ms;
|
||||
--duration-fast: 100ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--duration-slower: 500ms;
|
||||
--duration-slowest: 700ms;
|
||||
```
|
||||
|
||||
### Easings
|
||||
```css
|
||||
--ease-linear: linear;
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
```
|
||||
|
||||
### Common Animations
|
||||
```css
|
||||
/* Fade */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Slide up */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale */
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse (for loading states) */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Shimmer (skeleton loading) */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Button Variants
|
||||
```tsx
|
||||
// Primary (brand amber)
|
||||
<Button variant="brand">Get Started</Button>
|
||||
|
||||
// Default (dark)
|
||||
<Button variant="default">Submit</Button>
|
||||
|
||||
// Secondary (light)
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
|
||||
// Ghost (transparent)
|
||||
<Button variant="ghost">Menu</Button>
|
||||
|
||||
// Outline (bordered)
|
||||
<Button variant="outline">Learn More</Button>
|
||||
|
||||
// Destructive (red)
|
||||
<Button variant="destructive">Delete</Button>
|
||||
```
|
||||
|
||||
### Card Styles
|
||||
```tsx
|
||||
// Default card
|
||||
<Card className="bg-card shadow-sm hover:shadow-md transition-shadow">
|
||||
|
||||
// Elevated card (more prominent)
|
||||
<Card className="bg-card shadow-lg">
|
||||
|
||||
// Interactive card
|
||||
<Card className="bg-card shadow-sm hover:shadow-lg hover:border-brand/20 transition-all cursor-pointer">
|
||||
|
||||
// Brand accent card
|
||||
<Card className="bg-gradient-to-br from-brand/5 to-brand/10 border-brand/20">
|
||||
```
|
||||
|
||||
### Input Styles
|
||||
```tsx
|
||||
// Default input
|
||||
<Input className="border-input focus:border-brand focus:ring-brand/20" />
|
||||
|
||||
// With icon
|
||||
<div className="relative">
|
||||
<Icon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="pl-10" />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gradients
|
||||
|
||||
### Brand Gradients
|
||||
```css
|
||||
/* Subtle background */
|
||||
--gradient-brand-subtle: linear-gradient(135deg,
|
||||
oklch(0.75 0.16 75 / 0.05) 0%,
|
||||
oklch(0.75 0.16 75 / 0.1) 100%);
|
||||
|
||||
/* Hero gradient */
|
||||
--gradient-brand-hero: linear-gradient(135deg,
|
||||
oklch(0.75 0.16 75) 0%,
|
||||
oklch(0.70 0.18 65) 100%);
|
||||
|
||||
/* Text gradient */
|
||||
--gradient-brand-text: linear-gradient(90deg,
|
||||
oklch(0.70 0.18 75) 0%,
|
||||
oklch(0.65 0.16 85) 100%);
|
||||
```
|
||||
|
||||
### Background Gradients
|
||||
```css
|
||||
/* Warm fade (light mode) */
|
||||
--gradient-warm: linear-gradient(180deg,
|
||||
oklch(0.99 0.002 75) 0%,
|
||||
oklch(0.975 0.006 75) 100%);
|
||||
|
||||
/* Dark gradient */
|
||||
--gradient-dark: linear-gradient(180deg,
|
||||
oklch(0.14 0.008 75) 0%,
|
||||
oklch(0.10 0.006 75) 100%);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
### Icon Library
|
||||
- Primary: **Lucide React** (consistent with shadcn/ui)
|
||||
- Size scale: 16px, 20px, 24px, 32px
|
||||
- Stroke width: 1.5px (default), 2px (bold)
|
||||
|
||||
### Icon Colors
|
||||
```css
|
||||
/* Default */
|
||||
.icon { color: var(--foreground); }
|
||||
|
||||
/* Muted */
|
||||
.icon-muted { color: var(--muted-foreground); }
|
||||
|
||||
/* Brand */
|
||||
.icon-brand { color: var(--brand); }
|
||||
|
||||
/* On interactive elements */
|
||||
.btn:hover .icon { color: var(--brand); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
### Do
|
||||
- Use warm stone colors for neutrals
|
||||
- Use amber brand color for primary CTAs and accents
|
||||
- Maintain consistent spacing (multiples of 4px)
|
||||
- Use subtle shadows and transitions
|
||||
- Ensure 4.5:1 contrast ratio for text
|
||||
|
||||
### Don't
|
||||
- Mix cold grays with warm neutrals
|
||||
- Overuse the brand amber color
|
||||
- Use pure black (#000) or white (#fff)
|
||||
- Add excessive shadows or effects
|
||||
- Forget dark mode support
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
See `globals.css` for the complete CSS implementation with all variables defined.
|
||||
@@ -95,9 +95,17 @@ export interface Database {
|
||||
slug: string
|
||||
description: string | null
|
||||
gitea_repo: string | null
|
||||
gitea_webhook_id: number | null
|
||||
automation_config: {
|
||||
auto_review: boolean
|
||||
auto_deploy: boolean
|
||||
deployment_target: 'wws' | 'coolify' | 'both' | null
|
||||
coolify_app_uuid: string | null
|
||||
} | null
|
||||
tech_stack: string[]
|
||||
platform: string | null
|
||||
status: 'active' | 'archived' | 'paused'
|
||||
phase_metadata: Json | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -108,9 +116,17 @@ export interface Database {
|
||||
slug: string
|
||||
description?: string | null
|
||||
gitea_repo?: string | null
|
||||
gitea_webhook_id?: number | null
|
||||
automation_config?: {
|
||||
auto_review?: boolean
|
||||
auto_deploy?: boolean
|
||||
deployment_target?: 'wws' | 'coolify' | 'both' | null
|
||||
coolify_app_uuid?: string | null
|
||||
} | null
|
||||
tech_stack?: string[]
|
||||
platform?: string | null
|
||||
status?: 'active' | 'archived' | 'paused'
|
||||
phase_metadata?: Json | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@@ -121,9 +137,17 @@ export interface Database {
|
||||
slug?: string
|
||||
description?: string | null
|
||||
gitea_repo?: string | null
|
||||
gitea_webhook_id?: number | null
|
||||
automation_config?: {
|
||||
auto_review?: boolean
|
||||
auto_deploy?: boolean
|
||||
deployment_target?: 'wws' | 'coolify' | 'both' | null
|
||||
coolify_app_uuid?: string | null
|
||||
} | null
|
||||
tech_stack?: string[]
|
||||
platform?: string | null
|
||||
status?: 'active' | 'archived' | 'paused'
|
||||
phase_metadata?: Json | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@@ -160,22 +184,48 @@ export interface Database {
|
||||
agent_runs: {
|
||||
Row: {
|
||||
id: string
|
||||
message_id: string
|
||||
message_id: string | null
|
||||
project_id: string
|
||||
user_id: string | null
|
||||
command: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
result: Json | null
|
||||
proposed_changes: {
|
||||
description: string
|
||||
files: Array<{
|
||||
path: string
|
||||
content: string
|
||||
operation: 'create' | 'update' | 'delete'
|
||||
}>
|
||||
estimated_impact?: string
|
||||
} | null
|
||||
approval_status: 'pending' | 'approved' | 'rejected' | 'auto_approved'
|
||||
approved_by: string | null
|
||||
approved_at: string | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
message_id: string
|
||||
message_id?: string | null
|
||||
project_id: string
|
||||
user_id?: string
|
||||
command: string
|
||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
result?: Json | null
|
||||
proposed_changes?: {
|
||||
description: string
|
||||
files: Array<{
|
||||
path: string
|
||||
content: string
|
||||
operation: 'create' | 'update' | 'delete'
|
||||
}>
|
||||
estimated_impact?: string
|
||||
} | null
|
||||
approval_status?: 'pending' | 'approved' | 'rejected' | 'auto_approved'
|
||||
approved_by?: string | null
|
||||
approved_at?: string | null
|
||||
started_at?: string | null
|
||||
completed_at?: string | null
|
||||
created_at?: string
|
||||
@@ -187,6 +237,18 @@ export interface Database {
|
||||
command?: string
|
||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
result?: Json | null
|
||||
proposed_changes?: {
|
||||
description: string
|
||||
files: Array<{
|
||||
path: string
|
||||
content: string
|
||||
operation: 'create' | 'update' | 'delete'
|
||||
}>
|
||||
estimated_impact?: string
|
||||
} | null
|
||||
approval_status?: 'pending' | 'approved' | 'rejected' | 'auto_approved'
|
||||
approved_by?: string | null
|
||||
approved_at?: string | null
|
||||
started_at?: string | null
|
||||
completed_at?: string | null
|
||||
created_at?: string
|
||||
@@ -233,6 +295,47 @@ export interface Database {
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
subscriptions: {
|
||||
Row: {
|
||||
id: string
|
||||
user_id: string
|
||||
stripe_customer_id: string | null
|
||||
stripe_subscription_id: string | null
|
||||
plan: 'free' | 'pro' | 'team'
|
||||
status: 'active' | 'canceled' | 'past_due' | 'incomplete'
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
cancel_at_period_end: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
user_id: string
|
||||
stripe_customer_id?: string | null
|
||||
stripe_subscription_id?: string | null
|
||||
plan?: 'free' | 'pro' | 'team'
|
||||
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
|
||||
current_period_start?: string | null
|
||||
current_period_end?: string | null
|
||||
cancel_at_period_end?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
user_id?: string
|
||||
stripe_customer_id?: string | null
|
||||
stripe_subscription_id?: string | null
|
||||
plan?: 'free' | 'pro' | 'team'
|
||||
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
|
||||
current_period_start?: string | null
|
||||
current_period_end?: string | null
|
||||
cancel_at_period_end?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
|
||||
123
src/types/design-thinking.ts
Normal file
123
src/types/design-thinking.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export const DESIGN_PHASES = ['empathize', 'define', 'ideate', 'prototype', 'test'] as const
|
||||
export type DesignPhase = typeof DESIGN_PHASES[number]
|
||||
|
||||
export type PhaseStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'needs_review'
|
||||
|
||||
export interface PhaseConfig {
|
||||
id: DesignPhase
|
||||
icon: string
|
||||
label: string
|
||||
shortDescription: string
|
||||
fullDescription: string
|
||||
commands: string[]
|
||||
color: string
|
||||
questions: string[]
|
||||
}
|
||||
|
||||
export const designPhases: PhaseConfig[] = [
|
||||
{
|
||||
id: 'empathize',
|
||||
icon: '🎯',
|
||||
label: 'Understand',
|
||||
shortDescription: 'Learn about your users',
|
||||
fullDescription: 'Research user needs, pain points, and behaviors',
|
||||
commands: ['/research', '/ux', '/interview'],
|
||||
color: 'purple',
|
||||
questions: [
|
||||
'Who are your users?',
|
||||
'What problems do they face?',
|
||||
'What do they need?'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'define',
|
||||
icon: '📋',
|
||||
label: 'Focus',
|
||||
shortDescription: 'Define the problem',
|
||||
fullDescription: 'Create clear problem statements and success criteria',
|
||||
commands: ['/plan', '/roadmap', '/goal'],
|
||||
color: 'blue',
|
||||
questions: [
|
||||
'What specific problem are we solving?',
|
||||
'How will we measure success?',
|
||||
'What are our constraints?'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ideate',
|
||||
icon: '💡',
|
||||
label: 'Explore',
|
||||
shortDescription: 'Generate solutions',
|
||||
fullDescription: 'Brainstorm ideas and evaluate options',
|
||||
commands: ['/brainstorm', '/ideate', '/options'],
|
||||
color: 'amber',
|
||||
questions: [
|
||||
'What are all possible solutions?',
|
||||
'What are the trade-offs?',
|
||||
'Which ideas should we explore?'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'prototype',
|
||||
icon: '🔨',
|
||||
label: 'Build',
|
||||
shortDescription: 'Create & deploy',
|
||||
fullDescription: 'Build working prototypes and ship features',
|
||||
commands: ['/build', '/deploy', '/ui'],
|
||||
color: 'orange',
|
||||
questions: [
|
||||
'What should we build first?',
|
||||
'How can we test quickly?',
|
||||
'What is the minimum viable feature?'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'test',
|
||||
icon: '✅',
|
||||
label: 'Validate',
|
||||
shortDescription: 'Test & review',
|
||||
fullDescription: 'Validate with users and gather feedback',
|
||||
commands: ['/test', '/review', '/feedback'],
|
||||
color: 'emerald',
|
||||
questions: [
|
||||
'Does this solve the problem?',
|
||||
'What feedback do users have?',
|
||||
'What needs improvement?'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export interface BacklogItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
phase: DesignPhase
|
||||
priority_score: number
|
||||
user_value: number
|
||||
time_criticality: number
|
||||
risk_reduction: number
|
||||
effort: number
|
||||
status: 'backlog' | 'ready' | 'in_progress' | 'done' | 'blocked'
|
||||
depends_on?: string[]
|
||||
assigned_to?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProjectHealth {
|
||||
overall: number
|
||||
velocity: number
|
||||
blockers: number
|
||||
overdue: number
|
||||
completion_rate: number
|
||||
}
|
||||
|
||||
export interface AIRecommendation {
|
||||
id: string
|
||||
type: 'action' | 'warning' | 'insight' | 'optimization'
|
||||
title: string
|
||||
description: string
|
||||
priority: 'low' | 'medium' | 'high'
|
||||
related_items?: string[]
|
||||
action_command?: string
|
||||
}
|
||||
45
supabase/apply-migration.md
Normal file
45
supabase/apply-migration.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Applying the Design Thinking Schema Migration
|
||||
|
||||
## Option 1: Supabase Studio (Recommended)
|
||||
1. Go to https://supabase.mylder.io
|
||||
2. Login with dashboard credentials:
|
||||
- User: `5f7DODtzYzoXKusR`
|
||||
- Password: `VHmrbh9a6QVcXE2b2hMblhPoRsqsd2Gj`
|
||||
3. Navigate to SQL Editor
|
||||
4. Copy contents of `migrations/20251214_design_thinking_schema.sql`
|
||||
5. Execute
|
||||
|
||||
## Option 2: Via psql on VPS
|
||||
```bash
|
||||
# SSH to VPS
|
||||
ssh root@149.102.155.84
|
||||
|
||||
# Find the PostgreSQL container
|
||||
docker ps | grep postgres
|
||||
|
||||
# Execute migration
|
||||
docker exec -i <postgres_container> psql -U supabase -d postgres < /path/to/migration.sql
|
||||
```
|
||||
|
||||
## Option 3: Via docker exec with heredoc
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U supabase postgres <<'SQL'
|
||||
-- Paste contents of 20251214_design_thinking_schema.sql here
|
||||
SQL
|
||||
```
|
||||
|
||||
## Verification
|
||||
After applying, verify tables exist:
|
||||
```sql
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name;
|
||||
```
|
||||
|
||||
Expected tables:
|
||||
- projects
|
||||
- project_phases
|
||||
- backlog_items
|
||||
- ai_recommendations
|
||||
- project_health_snapshots
|
||||
- project_activities
|
||||
70
supabase/migrations/20241214_add_phase_metadata.sql
Normal file
70
supabase/migrations/20241214_add_phase_metadata.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- Add phase_metadata column for agentic workflow tracking
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS phase_metadata JSONB DEFAULT '{
|
||||
"currentPhase": "think",
|
||||
"completedPhases": [],
|
||||
"phaseStatus": "not_started",
|
||||
"activities": [],
|
||||
"metrics": {
|
||||
"totalDuration": 0,
|
||||
"phaseDurations": {},
|
||||
"iterationCount": 0
|
||||
}
|
||||
}'::jsonb;
|
||||
|
||||
-- Create index for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_phase_metadata ON projects USING gin(phase_metadata);
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN projects.phase_metadata IS 'Stores agentic development loop state: currentPhase, completedPhases, phaseStatus, activities, metrics';
|
||||
|
||||
-- Create function to update phase
|
||||
CREATE OR REPLACE FUNCTION update_project_phase(
|
||||
p_project_id UUID,
|
||||
p_new_phase TEXT,
|
||||
p_status TEXT DEFAULT 'in_progress'
|
||||
)
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
v_current_metadata JSONB;
|
||||
v_current_phase TEXT;
|
||||
v_completed_phases JSONB;
|
||||
v_new_activity JSONB;
|
||||
v_updated_metadata JSONB;
|
||||
BEGIN
|
||||
-- Get current metadata
|
||||
SELECT phase_metadata INTO v_current_metadata
|
||||
FROM projects WHERE id = p_project_id;
|
||||
|
||||
v_current_phase := v_current_metadata->>'currentPhase';
|
||||
v_completed_phases := COALESCE(v_current_metadata->'completedPhases', '[]'::jsonb);
|
||||
|
||||
-- Add current phase to completed if transitioning
|
||||
IF v_current_phase IS NOT NULL AND v_current_phase != p_new_phase THEN
|
||||
v_completed_phases := v_completed_phases || to_jsonb(v_current_phase);
|
||||
END IF;
|
||||
|
||||
-- Create activity record
|
||||
v_new_activity := jsonb_build_object(
|
||||
'id', gen_random_uuid(),
|
||||
'type', 'phase_change',
|
||||
'message', 'Transitioned to ' || p_new_phase || ' phase',
|
||||
'timestamp', now()
|
||||
);
|
||||
|
||||
-- Build updated metadata
|
||||
v_updated_metadata := jsonb_build_object(
|
||||
'currentPhase', p_new_phase,
|
||||
'completedPhases', v_completed_phases,
|
||||
'phaseStatus', p_status,
|
||||
'activities', (v_new_activity || COALESCE(v_current_metadata->'activities', '[]'::jsonb))[0:49],
|
||||
'metrics', COALESCE(v_current_metadata->'metrics', '{}'::jsonb)
|
||||
);
|
||||
|
||||
-- Update project
|
||||
UPDATE projects
|
||||
SET phase_metadata = v_updated_metadata, updated_at = now()
|
||||
WHERE id = p_project_id;
|
||||
|
||||
RETURN v_updated_metadata;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
278
supabase/migrations/20251214_design_thinking_schema.sql
Normal file
278
supabase/migrations/20251214_design_thinking_schema.sql
Normal file
@@ -0,0 +1,278 @@
|
||||
-- Design Thinking Schema for Mylder Platform
|
||||
-- Migration: 20251214_design_thinking_schema.sql
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Design phases enum
|
||||
CREATE TYPE design_phase AS ENUM ('empathize', 'define', 'ideate', 'prototype', 'test');
|
||||
CREATE TYPE phase_status AS ENUM ('not_started', 'in_progress', 'completed', 'blocked', 'needs_review');
|
||||
CREATE TYPE backlog_status AS ENUM ('backlog', 'ready', 'in_progress', 'done', 'blocked');
|
||||
CREATE TYPE recommendation_type AS ENUM ('action', 'warning', 'insight', 'optimization');
|
||||
CREATE TYPE recommendation_priority AS ENUM ('low', 'medium', 'high');
|
||||
|
||||
-- Projects table (extend or create)
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
current_phase design_phase DEFAULT 'empathize',
|
||||
health_score INTEGER DEFAULT 100 CHECK (health_score >= 0 AND health_score <= 100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Project phase tracking
|
||||
CREATE TABLE project_phases (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
phase design_phase NOT NULL,
|
||||
status phase_status DEFAULT 'not_started',
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
notes TEXT,
|
||||
iteration INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(project_id, phase, iteration)
|
||||
);
|
||||
|
||||
-- Backlog items with WSJF prioritization
|
||||
CREATE TABLE backlog_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
phase design_phase NOT NULL,
|
||||
status backlog_status DEFAULT 'backlog',
|
||||
|
||||
-- WSJF scoring (1-10 scale)
|
||||
user_value INTEGER DEFAULT 5 CHECK (user_value >= 1 AND user_value <= 10),
|
||||
time_criticality INTEGER DEFAULT 5 CHECK (time_criticality >= 1 AND time_criticality <= 10),
|
||||
risk_reduction INTEGER DEFAULT 5 CHECK (risk_reduction >= 1 AND risk_reduction <= 10),
|
||||
effort INTEGER DEFAULT 5 CHECK (effort >= 1 AND effort <= 10),
|
||||
|
||||
-- Computed priority score: (user_value + time_criticality + risk_reduction) / effort
|
||||
priority_score DECIMAL(4,2) GENERATED ALWAYS AS (
|
||||
(user_value + time_criticality + risk_reduction)::DECIMAL / GREATEST(effort, 1)
|
||||
) STORED,
|
||||
|
||||
-- Dependencies
|
||||
depends_on UUID[] DEFAULT '{}',
|
||||
|
||||
-- Assignment
|
||||
assigned_to UUID REFERENCES auth.users(id),
|
||||
due_date DATE,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- AI recommendations
|
||||
CREATE TABLE ai_recommendations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type recommendation_type NOT NULL,
|
||||
priority recommendation_priority DEFAULT 'medium',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
action_command TEXT,
|
||||
related_items UUID[] DEFAULT '{}',
|
||||
dismissed BOOLEAN DEFAULT FALSE,
|
||||
dismissed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Project health metrics (historical tracking)
|
||||
CREATE TABLE project_health_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
overall_score INTEGER NOT NULL CHECK (overall_score >= 0 AND overall_score <= 100),
|
||||
velocity INTEGER DEFAULT 0,
|
||||
blockers INTEGER DEFAULT 0,
|
||||
overdue INTEGER DEFAULT 0,
|
||||
completion_rate INTEGER DEFAULT 0,
|
||||
snapshot_date DATE DEFAULT CURRENT_DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(project_id, snapshot_date)
|
||||
);
|
||||
|
||||
-- Activity feed for projects
|
||||
CREATE TABLE project_activities (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES auth.users(id),
|
||||
activity_type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_projects_user_id ON projects(user_id);
|
||||
CREATE INDEX idx_project_phases_project_id ON project_phases(project_id);
|
||||
CREATE INDEX idx_backlog_items_project_id ON backlog_items(project_id);
|
||||
CREATE INDEX idx_backlog_items_status ON backlog_items(status);
|
||||
CREATE INDEX idx_backlog_items_priority ON backlog_items(priority_score DESC);
|
||||
CREATE INDEX idx_ai_recommendations_project_id ON ai_recommendations(project_id);
|
||||
CREATE INDEX idx_ai_recommendations_dismissed ON ai_recommendations(dismissed) WHERE dismissed = FALSE;
|
||||
CREATE INDEX idx_project_health_snapshots_project_id ON project_health_snapshots(project_id);
|
||||
CREATE INDEX idx_project_activities_project_id ON project_activities(project_id);
|
||||
CREATE INDEX idx_project_activities_created_at ON project_activities(created_at DESC);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project_phases ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE backlog_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ai_recommendations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project_health_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project_activities ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies: Users can only access their own projects
|
||||
CREATE POLICY "Users can view their own projects" ON projects
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can create their own projects" ON projects
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update their own projects" ON projects
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can delete their own projects" ON projects
|
||||
FOR DELETE USING (auth.uid() = user_id);
|
||||
|
||||
-- RLS for project_phases
|
||||
CREATE POLICY "Users can manage phases of their projects" ON project_phases
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = project_phases.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- RLS for backlog_items
|
||||
CREATE POLICY "Users can manage backlog of their projects" ON backlog_items
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = backlog_items.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- RLS for ai_recommendations
|
||||
CREATE POLICY "Users can view recommendations for their projects" ON ai_recommendations
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = ai_recommendations.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- RLS for project_health_snapshots
|
||||
CREATE POLICY "Users can view health of their projects" ON project_health_snapshots
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = project_health_snapshots.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- RLS for project_activities
|
||||
CREATE POLICY "Users can view activities of their projects" ON project_activities
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = project_activities.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- Functions
|
||||
|
||||
-- Update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER project_phases_updated_at BEFORE UPDATE ON project_phases
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER backlog_items_updated_at BEFORE UPDATE ON backlog_items
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Function to calculate project health
|
||||
CREATE OR REPLACE FUNCTION calculate_project_health(p_project_id UUID)
|
||||
RETURNS INTEGER AS $$
|
||||
DECLARE
|
||||
total_items INTEGER;
|
||||
done_items INTEGER;
|
||||
blocked_items INTEGER;
|
||||
overdue_items INTEGER;
|
||||
health INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status = 'done'),
|
||||
COUNT(*) FILTER (WHERE status = 'blocked'),
|
||||
COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('done', 'blocked'))
|
||||
INTO total_items, done_items, blocked_items, overdue_items
|
||||
FROM backlog_items WHERE project_id = p_project_id;
|
||||
|
||||
IF total_items = 0 THEN
|
||||
RETURN 100;
|
||||
END IF;
|
||||
|
||||
-- Health calculation: base 100, minus penalties
|
||||
health := 100;
|
||||
health := health - (blocked_items * 10); -- -10 per blocker
|
||||
health := health - (overdue_items * 5); -- -5 per overdue
|
||||
health := health + (done_items * 2); -- +2 per completed
|
||||
|
||||
RETURN GREATEST(0, LEAST(100, health));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to record daily health snapshot
|
||||
CREATE OR REPLACE FUNCTION record_health_snapshot(p_project_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
DECLARE
|
||||
v_health INTEGER;
|
||||
v_velocity INTEGER;
|
||||
v_blockers INTEGER;
|
||||
v_overdue INTEGER;
|
||||
v_completion_rate INTEGER;
|
||||
v_total INTEGER;
|
||||
v_done INTEGER;
|
||||
BEGIN
|
||||
v_health := calculate_project_health(p_project_id);
|
||||
|
||||
SELECT COUNT(*) FILTER (WHERE status = 'blocked'),
|
||||
COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('done', 'blocked')),
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status = 'done')
|
||||
INTO v_blockers, v_overdue, v_total, v_done
|
||||
FROM backlog_items WHERE project_id = p_project_id;
|
||||
|
||||
v_completion_rate := CASE WHEN v_total > 0 THEN (v_done * 100 / v_total) ELSE 0 END;
|
||||
|
||||
-- Velocity: items completed in last 7 days
|
||||
SELECT COUNT(*) INTO v_velocity
|
||||
FROM backlog_items
|
||||
WHERE project_id = p_project_id
|
||||
AND status = 'done'
|
||||
AND updated_at >= NOW() - INTERVAL '7 days';
|
||||
|
||||
INSERT INTO project_health_snapshots (project_id, overall_score, velocity, blockers, overdue, completion_rate)
|
||||
VALUES (p_project_id, v_health, v_velocity, v_blockers, v_overdue, v_completion_rate)
|
||||
ON CONFLICT (project_id, snapshot_date)
|
||||
DO UPDATE SET
|
||||
overall_score = EXCLUDED.overall_score,
|
||||
velocity = EXCLUDED.velocity,
|
||||
blockers = EXCLUDED.blockers,
|
||||
overdue = EXCLUDED.overdue,
|
||||
completion_rate = EXCLUDED.completion_rate;
|
||||
|
||||
-- Update project health_score
|
||||
UPDATE projects SET health_score = v_health WHERE id = p_project_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE projects IS 'User projects with design thinking phases';
|
||||
COMMENT ON TABLE project_phases IS 'Track status of each design phase per project iteration';
|
||||
COMMENT ON TABLE backlog_items IS 'Prioritized backlog with WSJF scoring';
|
||||
COMMENT ON COLUMN backlog_items.priority_score IS 'WSJF: (user_value + time_criticality + risk_reduction) / effort';
|
||||
COMMENT ON TABLE ai_recommendations IS 'AI-generated insights and action recommendations';
|
||||
COMMENT ON TABLE project_health_snapshots IS 'Daily snapshots of project health metrics';
|
||||
262
supabase/migrations/20251214_profiles_and_auth.sql
Normal file
262
supabase/migrations/20251214_profiles_and_auth.sql
Normal file
@@ -0,0 +1,262 @@
|
||||
-- Profiles and Auth Triggers for Mylder Platform
|
||||
-- Migration: 20251214_profiles_and_auth.sql
|
||||
|
||||
-- Enable UUID extension (if not already enabled)
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================
|
||||
-- PROFILES TABLE
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for email lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for profiles
|
||||
CREATE POLICY "Users can view their own profile" ON profiles
|
||||
FOR SELECT USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Users can update their own profile" ON profiles
|
||||
FOR UPDATE USING (auth.uid() = id);
|
||||
|
||||
-- Service role can manage all profiles (for admin operations)
|
||||
CREATE POLICY "Service role can manage all profiles" ON profiles
|
||||
FOR ALL USING (
|
||||
auth.jwt() ->> 'role' = 'service_role'
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- TEAMS TABLE
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
stripe_customer_id TEXT,
|
||||
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_owner_id ON teams(owner_id);
|
||||
|
||||
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Team owners can manage their teams" ON teams
|
||||
FOR ALL USING (auth.uid() = owner_id);
|
||||
|
||||
-- ============================================
|
||||
-- TEAM MEMBERS TABLE
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role TEXT DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (team_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id);
|
||||
|
||||
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Team members can view their memberships" ON team_members
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Team owners can manage memberships" ON team_members
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM teams WHERE teams.id = team_members.team_id AND teams.owner_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- SUBSCRIPTIONS TABLE
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'team')),
|
||||
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'canceled', 'past_due', 'incomplete')),
|
||||
current_period_start TIMESTAMPTZ,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
cancel_at_period_end BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer_id ON subscriptions(stripe_customer_id);
|
||||
|
||||
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view their own subscriptions" ON subscriptions
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================
|
||||
-- MESSAGES TABLE (for chat history)
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_project_id ON messages(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
||||
|
||||
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can manage messages in their projects" ON messages
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = messages.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- AGENT RUNS TABLE
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
command TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
|
||||
result JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_project_id ON agent_runs(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
|
||||
|
||||
ALTER TABLE agent_runs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view agent runs in their projects" ON agent_runs
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = agent_runs.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- ISSUES TABLE
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
gitea_id INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
state TEXT DEFAULT 'open' CHECK (state IN ('open', 'closed')),
|
||||
labels TEXT[] DEFAULT '{}',
|
||||
assignee TEXT,
|
||||
milestone TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_project_id ON issues(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_state ON issues(state);
|
||||
|
||||
ALTER TABLE issues ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can manage issues in their projects" ON issues
|
||||
FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM projects WHERE projects.id = issues.project_id AND projects.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- TRIGGER: Auto-create profile on user signup
|
||||
-- ============================================
|
||||
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.profiles (id, email, full_name, avatar_url, role)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.email,
|
||||
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name', split_part(NEW.email, '@', 1)),
|
||||
COALESCE(NEW.raw_user_meta_data->>'avatar_url', NEW.raw_user_meta_data->>'picture'),
|
||||
'user'
|
||||
);
|
||||
|
||||
-- Also create a default free subscription
|
||||
INSERT INTO public.subscriptions (user_id, plan, status)
|
||||
VALUES (NEW.id, 'free', 'active');
|
||||
|
||||
-- Create a default personal team
|
||||
INSERT INTO public.teams (name, owner_id, plan)
|
||||
VALUES (
|
||||
COALESCE(NEW.raw_user_meta_data->>'full_name', split_part(NEW.email, '@', 1)) || '''s Team',
|
||||
NEW.id,
|
||||
'free'
|
||||
);
|
||||
|
||||
-- Add user as owner of their team
|
||||
INSERT INTO public.team_members (team_id, user_id, role)
|
||||
SELECT id, NEW.id, 'owner' FROM public.teams WHERE owner_id = NEW.id LIMIT 1;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create the trigger on auth.users
|
||||
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
||||
|
||||
-- ============================================
|
||||
-- TRIGGER: Update updated_at timestamp
|
||||
-- ============================================
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Add updated_at triggers
|
||||
DROP TRIGGER IF EXISTS profiles_updated_at ON profiles;
|
||||
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS teams_updated_at ON teams;
|
||||
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS subscriptions_updated_at ON subscriptions;
|
||||
CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS issues_updated_at ON issues;
|
||||
CREATE TRIGGER issues_updated_at BEFORE UPDATE ON issues
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- ============================================
|
||||
-- COMMENTS
|
||||
-- ============================================
|
||||
COMMENT ON TABLE profiles IS 'User profiles linked to auth.users';
|
||||
COMMENT ON FUNCTION handle_new_user() IS 'Auto-creates profile, subscription, and personal team on user signup';
|
||||
COMMENT ON TABLE teams IS 'Teams for multi-user collaboration';
|
||||
COMMENT ON TABLE team_members IS 'Team membership with roles';
|
||||
COMMENT ON TABLE subscriptions IS 'User subscription status for billing';
|
||||
COMMENT ON TABLE messages IS 'Chat messages in project context';
|
||||
COMMENT ON TABLE agent_runs IS 'AI agent execution history';
|
||||
COMMENT ON TABLE issues IS 'Project issues synced with Gitea';
|
||||
Reference in New Issue
Block a user