Compare commits
17 Commits
dd03d86642
...
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a51856896 | ||
|
|
77a45f1479 | ||
|
|
993bb08dff | ||
|
|
9221c9eacd | ||
|
|
2329118ff6 | ||
|
|
85ee006ad0 | ||
|
|
1187ca6af9 | ||
|
|
e72f55fedc | ||
|
|
64ce70daa4 | ||
|
|
7ff493a89a | ||
|
|
38d5b4806c | ||
|
|
de8b827562 | ||
|
|
40d60b1ce6 | ||
|
|
72de50dffa | ||
|
|
734823d3f6 | ||
|
|
30bfd88075 | ||
|
|
a49f05e8df |
12
.env.example
@@ -2,7 +2,11 @@ DATABASE_URL=postgresql://codeboard:codeboard@localhost:5432/codeboard
|
|||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
GITHUB_CLIENT_ID=
|
LLM_MODEL=
|
||||||
GITHUB_CLIENT_SECRET=
|
LLM_BASE_URL=
|
||||||
NEXTAUTH_SECRET=
|
AUTH_SECRET=
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
STRIPE_STARTER_PRICE_ID=
|
||||||
|
STRIPE_PRO_PRICE_ID=
|
||||||
|
EMAIL_PASSWORD=
|
||||||
|
|||||||
80
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
name: Deploy CodeBoard
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
COMPOSE_PROJECT_NAME: codeboard
|
||||||
|
DOCKER_HOST: tcp://192.168.1.133:2375
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Verify Docker access
|
||||||
|
run: |
|
||||||
|
docker version
|
||||||
|
docker compose version
|
||||||
|
|
||||||
|
- name: Write environment file
|
||||||
|
run: |
|
||||||
|
cat > .env <<'ENVEOF'
|
||||||
|
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||||
|
LLM_BASE_URL=${{ secrets.LLM_BASE_URL }}
|
||||||
|
LLM_MODEL=${{ secrets.LLM_MODEL }}
|
||||||
|
AUTH_SECRET=${{ secrets.AUTH_SECRET }}
|
||||||
|
STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
|
STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_STARTER_PRICE_ID=${{ secrets.STRIPE_STARTER_PRICE_ID }}
|
||||||
|
STRIPE_PRO_PRICE_ID=${{ secrets.STRIPE_PRO_PRICE_ID }}
|
||||||
|
EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}
|
||||||
|
ENVEOF
|
||||||
|
sed -i 's/^[[:space:]]*//' .env
|
||||||
|
|
||||||
|
- name: Build and deploy
|
||||||
|
run: |
|
||||||
|
echo "Deploying CodeBoard ${{ gitea.ref_name }}..."
|
||||||
|
docker compose build web worker migrate
|
||||||
|
docker compose up -d --no-deps --remove-orphans web worker migrate redis postgres
|
||||||
|
echo "Waiting for migration and startup..."
|
||||||
|
sleep 25
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
STATUS=$(docker inspect --format='{{.State.Running}}' codeboard-web-1 2>/dev/null || true)
|
||||||
|
if [ "$STATUS" = "true" ]; then
|
||||||
|
echo "Container running (attempt $i)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Attempt $i/5 — retrying in 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "Health check failed after 5 attempts"
|
||||||
|
docker compose logs web --tail 50
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="${{ gitea.ref_name }}"
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://gitea.vectry.tech/api/v1/repos/Vectry/codeboard/releases" \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${TAG}\", \"body\": \"Automated release for ${TAG}\", \"draft\": false, \"prerelease\": false}" \
|
||||||
|
|| echo "Release may already exist — skipping"
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: docker image prune -f
|
||||||
@@ -16,6 +16,7 @@ RUN npm install --production=false
|
|||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npx prisma generate --schema=packages/database/prisma/schema.prisma
|
||||||
RUN npx turbo build
|
RUN npx turbo build
|
||||||
|
|
||||||
FROM base AS web
|
FROM base AS web
|
||||||
@@ -24,6 +25,7 @@ RUN addgroup --system --gid 1001 nodejs && \
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/packages/database/prisma ./packages/database/prisma
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000 HOSTNAME="0.0.0.0"
|
ENV PORT=3000 HOSTNAME="0.0.0.0"
|
||||||
@@ -38,6 +40,11 @@ COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
|
|||||||
COPY --from=builder /app/packages/parser/dist ./packages/parser/dist
|
COPY --from=builder /app/packages/parser/dist ./packages/parser/dist
|
||||||
COPY --from=builder /app/packages/llm/dist ./packages/llm/dist
|
COPY --from=builder /app/packages/llm/dist ./packages/llm/dist
|
||||||
COPY --from=builder /app/packages/diagrams/dist ./packages/diagrams/dist
|
COPY --from=builder /app/packages/diagrams/dist ./packages/diagrams/dist
|
||||||
|
COPY --from=builder /app/packages/database/dist ./packages/database/dist
|
||||||
|
COPY --from=builder /app/packages/database/package.json ./packages/database/
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder /app/packages/database/prisma ./packages/database/prisma
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
COPY --from=builder /app/apps/worker/package.json ./apps/worker/
|
COPY --from=builder /app/apps/worker/package.json ./apps/worker/
|
||||||
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
||||||
|
|||||||
51
README.md
@@ -2,6 +2,55 @@
|
|||||||
|
|
||||||
Codebase → Onboarding Docs Generator. Paste a GitHub repo URL, get interactive developer onboarding documentation in minutes.
|
Codebase → Onboarding Docs Generator. Paste a GitHub repo URL, get interactive developer onboarding documentation in minutes.
|
||||||
|
|
||||||
|
**Live at [codeboard.vectry.tech](https://codeboard.vectry.tech)**
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. **Register** at [codeboard.vectry.tech/register](https://codeboard.vectry.tech/register) with your email and password.
|
||||||
|
2. **Log in** to access the dashboard, generation history, and settings.
|
||||||
|
3. **Paste a GitHub URL** and generate interactive onboarding documentation.
|
||||||
|
|
||||||
|
Registration is required for full access. Unauthenticated visitors can view the landing page, but generating documentation, viewing your dashboard, and browsing generation history all require a logged-in account.
|
||||||
|
|
||||||
|
## Plans and Pricing
|
||||||
|
|
||||||
|
CodeBoard offers three billing tiers. One generation equals one documentation build from a repository URL.
|
||||||
|
|
||||||
|
| Plan | Price | Generations | Highlights |
|
||||||
|
|------|-------|-------------|------------|
|
||||||
|
| **Free** | $0 | 15 per day | Public repos, architecture diagrams, interactive docs |
|
||||||
|
| **Starter** | $5 / month | 1,000 per month | Generation history, API key access, priority support |
|
||||||
|
| **Pro** | $20 / month | 100,000 per month | Full history, multiple API keys, dedicated support, custom integrations |
|
||||||
|
|
||||||
|
Manage your subscription in **Settings > Billing** from the dashboard.
|
||||||
|
|
||||||
|
## API Keys (Programmatic Access)
|
||||||
|
|
||||||
|
Paid plans (Starter and Pro) include API key access for programmatic diagram and documentation generation.
|
||||||
|
|
||||||
|
1. Navigate to **Settings > API Keys** in the dashboard.
|
||||||
|
2. Create a new key and copy it immediately -- it is shown only once.
|
||||||
|
3. Include the key in your requests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://codeboard.vectry.tech/api/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"repoUrl": "https://github.com/owner/repo"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Each successful API call counts as one generation against your plan's quota.
|
||||||
|
|
||||||
|
## Generation History
|
||||||
|
|
||||||
|
All generated documentation is saved to your account. From the dashboard you can:
|
||||||
|
|
||||||
|
- Browse past generations per repository.
|
||||||
|
- Compare documentation versions side-by-side to track codebase changes over time.
|
||||||
|
- Re-open any previous generation by its unique link.
|
||||||
|
|
||||||
|
History is per-user -- each account maintains its own generation records.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -57,4 +106,4 @@ MIT
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Built by [Vectry](https://company.repi.fun) — Engineering AI into your workflow.
|
Built by [Vectry](https://vectry.tech) — Engineering AI into your workflow.
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
transpilePackages: ["@codeboard/shared"],
|
transpilePackages: ["@codeboard/shared", "@codeboard/database"],
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default withSentryConfig(config, {
|
||||||
|
silent: !process.env.CI,
|
||||||
|
disableServerWebpackPlugin: true,
|
||||||
|
disableClientWebpackPlugin: true,
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,20 +11,32 @@
|
|||||||
"db:push": "prisma db push --schema=../../packages/database/prisma/schema.prisma"
|
"db:push": "prisma db push --schema=../../packages/database/prisma/schema.prisma"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codeboard/database": "*",
|
||||||
"@codeboard/shared": "*",
|
"@codeboard/shared": "*",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.4.0",
|
"mermaid": "^11.4.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-markdown": "^9.0.0"
|
"react-markdown": "^9.0.0",
|
||||||
|
"stripe": "^20.3.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"@sentry/nextjs": "^8.28.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"postcss": "^8.5.0",
|
"postcss": "^8.5.0",
|
||||||
|
|||||||
BIN
apps/web/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/web/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 539 B |
BIN
apps/web/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 561 B |
22
apps/web/public/llms.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# CodeBoard
|
||||||
|
|
||||||
|
> CodeBoard is a developer tool that generates interactive onboarding documentation from any GitHub repository. Paste a URL and get architecture diagrams, module breakdowns, and getting started guides in minutes.
|
||||||
|
|
||||||
|
CodeBoard uses AI to analyze codebases and produce structured documentation that helps new developers understand unfamiliar projects quickly. It generates visual architecture diagrams, identifies key modules and their relationships, and creates step-by-step getting started guides.
|
||||||
|
|
||||||
|
## Product
|
||||||
|
|
||||||
|
- [CodeBoard App](https://codeboard.vectry.tech): Paste a GitHub URL to generate documentation
|
||||||
|
- [Source Code](https://gitea.repi.fun/repi/codeboard): Repository on Gitea
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Architecture Diagrams**: Auto-generated visual maps of codebase structure and dependencies
|
||||||
|
- **Module Breakdowns**: Detailed analysis of each major component with purpose and key files
|
||||||
|
- **Getting Started Guides**: Step-by-step instructions for setting up and running the project
|
||||||
|
- **Technology Detection**: Identifies frameworks, languages, and tools used in the project
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [Vectry](https://vectry.tech): Built by Vectry, an engineering-first AI consultancy
|
||||||
|
- [AgentLens](https://agentlens.vectry.tech): Sister product — open-source agent observability platform
|
||||||
BIN
apps/web/public/logo-icon.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/web/public/logo-name.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
7
apps/web/sentry.client.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 0.1,
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
7
apps/web/sentry.edge.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 0.1,
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
7
apps/web/sentry.server.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 0.1,
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
70
apps/web/src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!emailValid) { setError("Please enter a valid email address"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/forgot-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) });
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Something went wrong"); setLoading(false); return; }
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch { setError("Something went wrong."); setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Check your email</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">If an account exists for that email, we sent a password reset link. It expires in 1 hour.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-neutral-400"><Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Back to sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Reset your password</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Enter your email and we'll send you a reset link</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-neutral-300">Email</label>
|
||||||
|
<input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", email && !emailValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{email && !emailValid && <p className="text-xs text-red-400">Please enter a valid email address</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Sending..." : "Send reset link"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Remember your password?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/web/src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, CheckCircle, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const verified = searchParams.get("verified") === "true";
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
const passwordValid = password.length >= 8;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!emailValid) { setError("Please enter a valid email address"); return; }
|
||||||
|
if (!passwordValid) { setError("Password must be at least 8 characters"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
const result = await signIn("credentials", { email, password, redirect: false });
|
||||||
|
if (result?.error) { setError("Invalid email or password"); setLoading(false); return; }
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||||
|
<Code2 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Welcome back</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Sign in to your CodeBoard account</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verified && (
|
||||||
|
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-3 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-blue-400 shrink-0" />
|
||||||
|
<p className="text-sm text-blue-400">Email verified! You can now sign in.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-neutral-300">Email</label>
|
||||||
|
<input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", email && !emailValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{email && !emailValid && <p className="text-xs text-red-400">Please enter a valid email address</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-300">Password</label>
|
||||||
|
<input id="password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", password && !passwordValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/forgot-password" className="text-sm text-neutral-500 hover:text-blue-400 transition-colors">Forgot password?</Link>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Don't have an account?{" "}<Link href="/register" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Create one</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/web/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
const passwordValid = password.length >= 8;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!emailValid) { setError("Please enter a valid email address"); return; }
|
||||||
|
if (!passwordValid) { setError("Password must be at least 8 characters"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, ...(name.trim() ? { name: name.trim() } : {}) }),
|
||||||
|
});
|
||||||
|
if (res.status === 429) { const data: { error?: string } = await res.json(); setError(data.error ?? "Too many attempts."); setLoading(false); return; }
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Registration failed"); setLoading(false); return; }
|
||||||
|
const result = await signIn("credentials", { email, password, redirect: false });
|
||||||
|
if (result?.error) { router.push("/login"); return; }
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch { setError("Something went wrong."); setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||||
|
<Code2 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Create your account</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Start generating architecture diagrams with CodeBoard</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-neutral-300">Name <span className="text-neutral-500 font-normal">(optional)</span></label>
|
||||||
|
<input id="name" type="text" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jane Doe"
|
||||||
|
className="w-full px-3 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-neutral-300">Email</label>
|
||||||
|
<input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", email && !emailValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{email && !emailValid && <p className="text-xs text-red-400">Please enter a valid email address</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-300">Password</label>
|
||||||
|
<input id="password" type="password" autoComplete="new-password" required value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", password && !passwordValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Creating account…" : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Already have an account?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (<Suspense><ResetPasswordForm /></Suspense>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const passwordValid = password.length >= 8;
|
||||||
|
const passwordsMatch = password === confirmPassword;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!passwordValid) { setError("Password must be at least 8 characters"); return; }
|
||||||
|
if (!passwordsMatch) { setError("Passwords do not match"); return; }
|
||||||
|
if (!token) { setError("Invalid reset link"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, password }) });
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Something went wrong"); setLoading(false); return; }
|
||||||
|
setSuccess(true);
|
||||||
|
} catch { setError("Something went wrong."); setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Invalid reset link</h1><p className="mt-1 text-sm text-neutral-400">This password reset link is invalid or has expired.</p></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-neutral-400"><Link href="/forgot-password" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Request a new reset link</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Password reset</h1><p className="mt-1 text-sm text-neutral-400">Your password has been successfully reset.</p></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-neutral-400"><Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in with your new password</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Set new password</h1><p className="mt-1 text-sm text-neutral-400">Enter your new password below</p></div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-300">New password</label>
|
||||||
|
<input id="password" type="password" autoComplete="new-password" required value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", password && !passwordValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-300">Confirm password</label>
|
||||||
|
<input id="confirmPassword" type="password" autoComplete="new-password" required value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", confirmPassword && !passwordsMatch ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{confirmPassword && !passwordsMatch && <p className="text-xs text-red-400">Passwords do not match</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Resetting..." : "Reset password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Remember your password?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/src/app/(auth)/verify-email/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2, Mail } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setLoading(true); setMessage(""); setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Failed to resend email"); setLoading(false); return; }
|
||||||
|
setMessage("Verification email sent! Check your inbox.");
|
||||||
|
} catch { setError("Something went wrong."); } finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Check your email</h1><p className="mt-1 text-sm text-neutral-400">We sent a verification link to your inbox</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-blue-500/10 border border-blue-500/20 flex items-center justify-center"><Mail className="w-8 h-8 text-blue-400" /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-400 text-center leading-relaxed">Click the link in the email to verify your account. The link expires in 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
{message && (<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-3"><p className="text-sm text-blue-400">{message}</p></div>)}
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button onClick={handleResend} disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Sending..." : "Resend verification email"}
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Already verified?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
92
apps/web/src/app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes, createHash } from "crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const forgotPasswordSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`forgot:${ip}`, AUTH_RATE_LIMITS.forgotPassword);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await request.json();
|
||||||
|
const parsed = forgotPasswordSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = parsed.data;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.updateMany({
|
||||||
|
where: { userId: user.id, used: false },
|
||||||
|
data: { used: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawToken = randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: tokenHash,
|
||||||
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUrl = `https://codeboard.vectry.tech/reset-password?token=${rawToken}`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: normalizedEmail,
|
||||||
|
subject: "Reset your CodeBoard password",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h2 style="color: #f5f5f5; font-size: 20px; margin-bottom: 16px;">Reset your password</h2>
|
||||||
|
<p style="color: #a3a3a3; font-size: 14px; line-height: 1.6; margin-bottom: 24px;">
|
||||||
|
You requested a password reset for your CodeBoard account. Click the button below to set a new password.
|
||||||
|
</p>
|
||||||
|
<a href="${resetUrl}" style="display: inline-block; background-color: #3b82f6; color: #fff; font-weight: 600; font-size: 14px; padding: 12px 24px; border-radius: 8px; text-decoration: none;">
|
||||||
|
Reset password
|
||||||
|
</a>
|
||||||
|
<p style="color: #737373; font-size: 12px; line-height: 1.5; margin-top: 32px;">
|
||||||
|
This link expires in 1 hour. If you did not request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
apps/web/src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many registration attempts. Please try again later." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await request.json();
|
||||||
|
const parsed = registerSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, name } = parsed.data;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "If this email is available, a confirmation email will be sent." },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: name ?? null,
|
||||||
|
subscription: {
|
||||||
|
create: {
|
||||||
|
tier: "FREE",
|
||||||
|
generationsLimit: 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
|
||||||
|
|
||||||
|
await prisma.emailVerificationToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: tokenHash,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyUrl = `https://codeboard.vectry.tech/verify-email?token=${rawToken}`;
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your CodeBoard email",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
|
||||||
|
<p style="color: #a3a3a3; line-height: 1.6;">
|
||||||
|
Thanks for signing up for CodeBoard. Click the link below to verify your email address.
|
||||||
|
</p>
|
||||||
|
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
|
||||||
|
This link expires in 24 hours. If you didn't create an account, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error("[register] Failed to send verification email:", emailError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "If this email is available, a confirmation email will be sent." },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/web/src/app/api/auth/resend-verification/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return NextResponse.json({ error: "Email already verified" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestToken = await prisma.emailVerificationToken.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latestToken && Date.now() - latestToken.createdAt.getTime() < 60_000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Please wait 60 seconds before requesting another email" },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.emailVerificationToken.updateMany({
|
||||||
|
where: { userId: user.id, used: false },
|
||||||
|
data: { used: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
|
||||||
|
|
||||||
|
await prisma.emailVerificationToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: tokenHash,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyUrl = `https://codeboard.vectry.tech/verify-email?token=${rawToken}`;
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your CodeBoard email",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
|
||||||
|
<p style="color: #a3a3a3; line-height: 1.6;">
|
||||||
|
Click the link below to verify your email address for CodeBoard.
|
||||||
|
</p>
|
||||||
|
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
|
||||||
|
This link expires in 24 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
73
apps/web/src/app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const resetPasswordSchema = z.object({
|
||||||
|
token: z.string().min(1, "Token is required"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`reset:${ip}`, AUTH_RATE_LIMITS.resetPassword);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many attempts. Please try again later." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await request.json();
|
||||||
|
const parsed = resetPasswordSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, password } = parsed.data;
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||||
|
where: { token: tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid or expired reset link" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: resetToken.userId },
|
||||||
|
data: { passwordHash },
|
||||||
|
}),
|
||||||
|
prisma.passwordResetToken.update({
|
||||||
|
where: { id: resetToken.id },
|
||||||
|
data: { used: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/src/app/api/auth/verify-email/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const rawToken = request.nextUrl.searchParams.get("token");
|
||||||
|
|
||||||
|
if (!rawToken) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=missing-token", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
|
||||||
|
|
||||||
|
const verificationToken = await prisma.emailVerificationToken.findUnique({
|
||||||
|
where: { token: tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verificationToken) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=invalid-token", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationToken.used) {
|
||||||
|
return NextResponse.redirect(new URL("/login?verified=true", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationToken.expiresAt < new Date()) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=token-expired", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: verificationToken.userId },
|
||||||
|
data: { emailVerified: true },
|
||||||
|
}),
|
||||||
|
prisma.emailVerificationToken.update({
|
||||||
|
where: { id: verificationToken.id },
|
||||||
|
data: { used: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL("/login?verified=true", request.url));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRedis } from "@/lib/redis";
|
import { getRedis } from "@/lib/redis";
|
||||||
|
import { prisma } from "@codeboard/database";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
@@ -8,13 +9,34 @@ export async function GET(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
|
|
||||||
const result = await redis.get(`codeboard:result:${id}`);
|
let result = await redis.get(`codeboard:result:${id}`);
|
||||||
if (!result) {
|
|
||||||
|
if (result) {
|
||||||
|
return NextResponse.json(JSON.parse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = await prisma.generation.findFirst({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!generation || !generation.result) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Documentation not found" },
|
{ error: "Documentation not found" },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(JSON.parse(result));
|
const docs = generation.result as any;
|
||||||
|
docs.id = id;
|
||||||
|
docs.repoUrl = generation.repoUrl;
|
||||||
|
docs.repoName = generation.repoName;
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
`codeboard:result:${id}`,
|
||||||
|
JSON.stringify(docs),
|
||||||
|
"EX",
|
||||||
|
86400
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(docs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,69 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getQueue } from "@/lib/queue";
|
import { getQueue } from "@/lib/queue";
|
||||||
import { getRedis } from "@/lib/redis";
|
import { getRedis } from "@/lib/redis";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { validateApiKey } from "@/lib/api-key";
|
||||||
|
|
||||||
const GITHUB_URL_RE = /^https?:\/\/(www\.)?github\.com\/[\w.-]+\/[\w.-]+\/?$/;
|
const GITHUB_URL_RE = /^https?:\/\/(www\.)?github\.com\/[\w.-]+\/[\w.-]+\/?$/;
|
||||||
|
|
||||||
|
async function checkUsageLimit(userId: string): Promise<{ allowed: boolean; message?: string }> {
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return { allowed: false, message: "No subscription found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.tier === "FREE") {
|
||||||
|
const redis = getRedis();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const key = `cb:gen:${userId}:${today}`;
|
||||||
|
const count = await redis.incr(key);
|
||||||
|
if (count === 1) await redis.expire(key, 86400);
|
||||||
|
if (count > subscription.generationsLimit) {
|
||||||
|
return { allowed: false, message: "Daily generation limit reached. Upgrade for more." };
|
||||||
|
}
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.generationsUsed >= subscription.generationsLimit) {
|
||||||
|
return { allowed: false, message: "Monthly generation limit reached. Upgrade for more." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { generationsUsed: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
let userId: string | null = null;
|
||||||
|
|
||||||
|
// Try API key auth first
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const result = await validateApiKey(token);
|
||||||
|
if (result) {
|
||||||
|
userId = result.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to session auth
|
||||||
|
if (!userId) {
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user?.id) {
|
||||||
|
userId = session.user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow anonymous for now but without usage tracking
|
||||||
|
// (public generations still work but won't be saved to user history)
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const repoUrl: string = body.repoUrl?.trim();
|
const repoUrl: string = body.repoUrl?.trim();
|
||||||
|
|
||||||
@@ -15,6 +74,17 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check usage limits for authenticated users
|
||||||
|
if (userId) {
|
||||||
|
const usage = await checkUsageLimit(userId);
|
||||||
|
if (!usage.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: usage.message },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const generationId = `gen_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
const generationId = `gen_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
@@ -26,9 +96,9 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const queue = getQueue();
|
const queue = getQueue();
|
||||||
await queue.add("generate", { repoUrl, generationId }, {
|
await queue.add("generate", { repoUrl, generationId, userId }, {
|
||||||
jobId: generationId,
|
jobId: generationId,
|
||||||
removeOnComplete: true,
|
removeOnComplete: { age: 3600 },
|
||||||
removeOnFail: false,
|
removeOnFail: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
34
apps/web/src/app/api/generations/mine/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const generations = await prisma.generation.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
repoUrl: true,
|
||||||
|
repoName: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
duration: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ generations }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user generations:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/web/src/app/api/history/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@codeboard/database";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const repo = searchParams.get("repo");
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "repo parameter required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generations = await prisma.generation.findMany({
|
||||||
|
where: { repoUrl: repo, status: "COMPLETED" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
repoUrl: true,
|
||||||
|
repoName: true,
|
||||||
|
commitHash: true,
|
||||||
|
createdAt: true,
|
||||||
|
duration: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(generations);
|
||||||
|
}
|
||||||
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const apiKey = await prisma.apiKey.findFirst({
|
||||||
|
where: { id, userId: session.user.id, revoked: false },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.apiKey.update({
|
||||||
|
where: { id: apiKey.id },
|
||||||
|
data: { revoked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error revoking API key:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
apps/web/src/app/api/keys/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes, createHash } from "crypto";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const keys = await prisma.apiKey.findMany({
|
||||||
|
where: { userId: session.user.id, revoked: false },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
keyPrefix: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(keys, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error listing API keys:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const MAX_KEYS_PER_USER = 10;
|
||||||
|
const keyCount = await prisma.apiKey.count({
|
||||||
|
where: { userId: session.user.id, revoked: false },
|
||||||
|
});
|
||||||
|
if (keyCount >= MAX_KEYS_PER_USER) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const name =
|
||||||
|
typeof body.name === "string" && body.name.trim()
|
||||||
|
? body.name.trim()
|
||||||
|
: "Default";
|
||||||
|
|
||||||
|
const rawHex = randomBytes(24).toString("hex");
|
||||||
|
const fullKey = `cb_${rawHex}`;
|
||||||
|
const keyPrefix = fullKey.slice(0, 10);
|
||||||
|
const keyHash = createHash("sha256").update(fullKey).digest("hex");
|
||||||
|
|
||||||
|
const apiKey = await prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
name,
|
||||||
|
keyHash,
|
||||||
|
keyPrefix,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
keyPrefix: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ...apiKey, key: fullKey },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating API key:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/web/src/app/api/settings/account/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
subscription: {
|
||||||
|
select: {
|
||||||
|
tier: true,
|
||||||
|
status: true,
|
||||||
|
generationsUsed: true,
|
||||||
|
generationsLimit: true,
|
||||||
|
currentPeriodStart: true,
|
||||||
|
currentPeriodEnd: true,
|
||||||
|
stripeSubscriptionId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...user,
|
||||||
|
subscription: user.subscription
|
||||||
|
? {
|
||||||
|
...user.subscription,
|
||||||
|
hasStripeSubscription: !!user.subscription.stripeSubscriptionId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/web/src/app/api/settings/purge/route.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.generation.deleteMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/web/src/app/api/settings/stats/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalGenerations = await prisma.generation.count({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedGenerations = await prisma.generation.count({
|
||||||
|
where: { userId: session.user.id, status: "COMPLETED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedGenerations = await prisma.generation.count({
|
||||||
|
where: { userId: session.user.id, status: "FAILED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalGenerations,
|
||||||
|
completedGenerations,
|
||||||
|
failedGenerations,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { priceId, tierKey } = body as {
|
||||||
|
priceId?: string;
|
||||||
|
tierKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolvedPriceId = priceId;
|
||||||
|
|
||||||
|
if (!resolvedPriceId && tierKey) {
|
||||||
|
const tierConfig =
|
||||||
|
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
|
||||||
|
if (tierConfig && "priceId" in tierConfig) {
|
||||||
|
resolvedPriceId = tierConfig.priceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedPriceId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "priceId or tierKey is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
|
||||||
|
if (!validPriceIds.includes(resolvedPriceId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid priceId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
let subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
let stripeCustomerId = subscription?.stripeCustomerId;
|
||||||
|
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
const customer = await getStripe().customers.create({
|
||||||
|
email: session.user.email,
|
||||||
|
name: session.user.name ?? undefined,
|
||||||
|
metadata: { userId },
|
||||||
|
});
|
||||||
|
stripeCustomerId = customer.id;
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { stripeCustomerId },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
subscription = await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
stripeCustomerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
"https://codeboard.vectry.tech",
|
||||||
|
"http://localhost:3000",
|
||||||
|
];
|
||||||
|
const requestOrigin = request.headers.get("origin");
|
||||||
|
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||||
|
? requestOrigin!
|
||||||
|
: "https://codeboard.vectry.tech";
|
||||||
|
|
||||||
|
const checkoutSession = await getStripe().checkout.sessions.create({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: [{ price: resolvedPriceId, quantity: 1 }],
|
||||||
|
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${origin}/dashboard/settings`,
|
||||||
|
metadata: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating checkout session:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/web/src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getStripe } from "@/lib/stripe";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { stripeCustomerId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription?.stripeCustomerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No active subscription to manage" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
"https://codeboard.vectry.tech",
|
||||||
|
"http://localhost:3000",
|
||||||
|
];
|
||||||
|
const requestOrigin = request.headers.get("origin");
|
||||||
|
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||||
|
? requestOrigin!
|
||||||
|
: "https://codeboard.vectry.tech";
|
||||||
|
|
||||||
|
const portalSession = await getStripe().billingPortal.sessions.create({
|
||||||
|
customer: subscription.stripeCustomerId,
|
||||||
|
return_url: `${origin}/dashboard/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ url: portalSession.url }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating portal session:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
|
||||||
|
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
|
||||||
|
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
|
||||||
|
return "FREE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function generationsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
|
||||||
|
return TIER_CONFIG[tier].generationsLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckoutCompleted(
|
||||||
|
checkoutSession: Stripe.Checkout.Session
|
||||||
|
) {
|
||||||
|
const userId = checkoutSession.metadata?.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const subscriptionId = checkoutSession.subscription as string;
|
||||||
|
const customerId = checkoutSession.customer as string;
|
||||||
|
|
||||||
|
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
|
||||||
|
const firstItem = sub.items.data[0];
|
||||||
|
const priceId = firstItem?.price?.id ?? null;
|
||||||
|
const tier = tierFromPriceId(priceId);
|
||||||
|
const periodStart = firstItem?.current_period_start
|
||||||
|
? new Date(firstItem.current_period_start * 1000)
|
||||||
|
: new Date();
|
||||||
|
const periodEnd = firstItem?.current_period_end
|
||||||
|
? new Date(firstItem.current_period_end * 1000)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
await prisma.subscription.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: {
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
stripeSubscriptionId: subscriptionId,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
tier,
|
||||||
|
generationsLimit: generationsLimitForTier(tier),
|
||||||
|
generationsUsed: 0,
|
||||||
|
status: "ACTIVE",
|
||||||
|
currentPeriodStart: periodStart,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
stripeSubscriptionId: subscriptionId,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
tier,
|
||||||
|
generationsLimit: generationsLimitForTier(tier),
|
||||||
|
generationsUsed: 0,
|
||||||
|
status: "ACTIVE",
|
||||||
|
currentPeriodStart: periodStart,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
|
||||||
|
const firstItem = sub.items.data[0];
|
||||||
|
const priceId = firstItem?.price?.id ?? null;
|
||||||
|
const tier = tierFromPriceId(priceId);
|
||||||
|
|
||||||
|
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
|
||||||
|
active: "ACTIVE",
|
||||||
|
past_due: "PAST_DUE",
|
||||||
|
canceled: "CANCELED",
|
||||||
|
unpaid: "UNPAID",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
|
||||||
|
const periodStart = firstItem?.current_period_start
|
||||||
|
? new Date(firstItem.current_period_start * 1000)
|
||||||
|
: undefined;
|
||||||
|
const periodEnd = firstItem?.current_period_end
|
||||||
|
? new Date(firstItem.current_period_end * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { stripeSubscriptionId: sub.id },
|
||||||
|
data: {
|
||||||
|
tier,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
generationsLimit: generationsLimitForTier(tier),
|
||||||
|
status: dbStatus,
|
||||||
|
...(periodStart && { currentPeriodStart: periodStart }),
|
||||||
|
...(periodEnd && { currentPeriodEnd: periodEnd }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { stripeSubscriptionId: sub.id },
|
||||||
|
data: {
|
||||||
|
status: "CANCELED",
|
||||||
|
tier: "FREE",
|
||||||
|
generationsLimit: TIER_CONFIG.FREE.generationsLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||||
|
const subDetail = invoice.parent?.subscription_details?.subscription;
|
||||||
|
const subscriptionId =
|
||||||
|
typeof subDetail === "string" ? subDetail : subDetail?.id;
|
||||||
|
|
||||||
|
if (!subscriptionId) return;
|
||||||
|
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { stripeSubscriptionId: subscriptionId },
|
||||||
|
data: { generationsUsed: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.text();
|
||||||
|
const sig = request.headers.get("stripe-signature");
|
||||||
|
|
||||||
|
if (!sig) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing stripe-signature header" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = getStripe().webhooks.constructEvent(
|
||||||
|
body,
|
||||||
|
sig,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET!
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Webhook signature verification failed");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid signature" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
await handleCheckoutCompleted(
|
||||||
|
event.data.object as Stripe.Checkout.Session
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "customer.subscription.updated":
|
||||||
|
await handleSubscriptionUpdated(
|
||||||
|
event.data.object as Stripe.Subscription
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
await handleSubscriptionDeleted(
|
||||||
|
event.data.object as Stripe.Subscription
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "invoice.paid":
|
||||||
|
await handleInvoicePaid(event.data.object as Stripe.Invoice);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling ${event.type}:`, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Webhook handler failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true }, { status: 200 });
|
||||||
|
}
|
||||||
139
apps/web/src/app/dashboard/keys/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Key, Plus, Copy, Check, Trash2, AlertTriangle, RefreshCw, Shield } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
keyPrefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewKeyResponse extends ApiKey { key: string; }
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<NewKeyResponse | null>(null);
|
||||||
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
|
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||||
|
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchKeys = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try { const res = await fetch("/api/keys", { cache: "no-store" }); if (res.ok) setKeys(await res.json()); } catch (e) { console.error("Failed to fetch:", e); } finally { setIsLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchKeys(); }, [fetchKeys]);
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, field: string) => {
|
||||||
|
try { await navigator.clipboard.writeText(text); setCopiedField(field); setTimeout(() => setCopiedField(null), 2000); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newKeyName.trim() || undefined }) });
|
||||||
|
if (res.ok) { const data: NewKeyResponse = await res.json(); setNewlyCreatedKey(data); setShowCreateForm(false); setNewKeyName(""); fetchKeys(); }
|
||||||
|
} catch (e) { console.error("Failed to create:", e); } finally { setIsCreating(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (id: string) => {
|
||||||
|
setRevokingId(id);
|
||||||
|
try { const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); if (res.ok) { setConfirmRevokeId(null); fetchKeys(); } } catch (e) { console.error("Failed to revoke:", e); } finally { setRevokingId(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (d: string) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><h1 className="text-2xl font-bold text-neutral-100">API Keys</h1><p className="text-neutral-400 mt-1">Manage API keys for programmatic access</p></div>
|
||||||
|
<button onClick={() => { setShowCreateForm(true); setNewlyCreatedKey(null); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"><Plus className="w-4 h-4" /> Create New Key</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newlyCreatedKey && (
|
||||||
|
<div className="bg-blue-500/5 border border-blue-500/20 rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0"><Key className="w-5 h-5 text-blue-400" /></div>
|
||||||
|
<div className="flex-1 min-w-0"><h3 className="text-sm font-semibold text-blue-300">API Key Created</h3><p className="text-xs text-blue-400/60 mt-0.5">{newlyCreatedKey.name}</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 px-4 py-3 bg-neutral-950 border border-neutral-800 rounded-lg font-mono text-sm text-neutral-200 truncate select-all">{newlyCreatedKey.key}</div>
|
||||||
|
<button onClick={() => copyToClipboard(newlyCreatedKey.key, "new-key")} aria-label="Copy"
|
||||||
|
className={cn("p-3 rounded-lg border transition-all shrink-0", copiedField === "new-key" ? "bg-blue-500/10 border-blue-500/30 text-blue-400" : "bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200")}>
|
||||||
|
{copiedField === "new-key" ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 bg-amber-500/5 border border-amber-500/20 rounded-lg"><AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" /><p className="text-xs text-amber-300/80">This key won't be shown again. Copy it now.</p></div>
|
||||||
|
<button onClick={() => setNewlyCreatedKey(null)} className="text-xs text-neutral-500 hover:text-neutral-300 transition-colors">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateForm && !newlyCreatedKey && (
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300"><Plus className="w-5 h-5 text-blue-400" /><h2 className="text-sm font-semibold">Create New API Key</h2></div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">Key Name (optional)</label>
|
||||||
|
<input id="key-name" type="text" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder="e.g. Production, Staging"
|
||||||
|
className="w-full px-4 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-blue-500/40 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={handleCreate} disabled={isCreating} className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold disabled:opacity-50 transition-colors">
|
||||||
|
{isCreating ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />} Generate Key
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowCreateForm(false); setNewKeyName(""); }} className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300"><Shield className="w-5 h-5 text-blue-400" /><h2 className="text-lg font-semibold">Active Keys</h2></div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 space-y-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="flex items-center gap-4 animate-pulse"><div className="w-8 h-8 bg-neutral-800 rounded-lg" /><div className="flex-1 space-y-2"><div className="h-4 w-32 bg-neutral-800 rounded" /><div className="h-3 w-48 bg-neutral-800 rounded" /></div><div className="h-8 w-20 bg-neutral-800 rounded" /></div>))}</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<div className="p-12 text-center"><div className="w-12 h-12 rounded-xl bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center mx-auto mb-4"><Key className="w-6 h-6 text-neutral-600" /></div><p className="text-sm text-neutral-400 font-medium">No API keys yet</p><p className="text-xs text-neutral-600 mt-1">Create one for programmatic access</p></div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-neutral-800">
|
||||||
|
{keys.map((apiKey) => (
|
||||||
|
<div key={apiKey.id} className="flex items-center gap-4 px-6 py-4 group transition-colors">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0"><Key className="w-4 h-4 text-neutral-500" /></div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-neutral-200 truncate">{apiKey.name}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<code className="text-xs font-mono text-neutral-500">{apiKey.keyPrefix}••••••••</code>
|
||||||
|
<span className="text-xs text-neutral-600">Created {formatDate(apiKey.createdAt)}</span>
|
||||||
|
{apiKey.lastUsedAt && <span className="text-xs text-neutral-600">Last used {formatDate(apiKey.lastUsedAt)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{confirmRevokeId === apiKey.id ? (
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button onClick={() => setConfirmRevokeId(null)} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
|
||||||
|
<button onClick={() => handleRevoke(apiKey.id)} disabled={revokingId === apiKey.id}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-xs font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors">
|
||||||
|
{revokingId === apiKey.id ? <RefreshCw className="w-3 h-3 animate-spin" /> : <AlertTriangle className="w-3 h-3" />} Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setConfirmRevokeId(apiKey.id)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 border border-neutral-700 text-neutral-500 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 hover:text-red-400 hover:border-red-500/30 transition-all shrink-0">
|
||||||
|
<Trash2 className="w-3 h-3" /> Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
apps/web/src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
FileText,
|
||||||
|
Key,
|
||||||
|
Settings,
|
||||||
|
Menu,
|
||||||
|
ChevronRight,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ href: "/dashboard", label: "Generations", icon: FileText },
|
||||||
|
{ href: "/dashboard/keys", label: "API Keys", icon: Key },
|
||||||
|
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-neutral-900 border-r border-neutral-800">
|
||||||
|
<div className="p-6 border-b border-neutral-800">
|
||||||
|
<Link href="/" className="flex items-center gap-3 group" onClick={onNavigate}>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/30 transition-shadow">
|
||||||
|
<Code2 className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-lg text-neutral-100">CodeBoard</span>
|
||||||
|
<span className="text-xs text-neutral-500">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive =
|
||||||
|
item.href === "/dashboard"
|
||||||
|
? pathname === "/dashboard"
|
||||||
|
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200",
|
||||||
|
isActive
|
||||||
|
? "bg-blue-500/10 text-blue-400 border border-blue-500/20"
|
||||||
|
: "text-neutral-400 hover:text-neutral-100 hover:bg-neutral-800/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{isActive && <ChevronRight className="w-4 h-4" />}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-neutral-800">
|
||||||
|
<div className="px-4 py-3 rounded-lg bg-neutral-800/50 border border-neutral-700/50">
|
||||||
|
<p className="text-xs text-neutral-500">CodeBoard v0.1.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerificationBanner() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
if (dismissed || !session?.user || session.user.isEmailVerified) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setResending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
|
||||||
|
if (res.ok) setSent(true);
|
||||||
|
} catch {} finally { setResending(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
||||||
|
<p className="text-sm text-amber-200 truncate">
|
||||||
|
{sent ? "Verification email sent! Check your inbox." : "Please verify your email address. Check your inbox or"}
|
||||||
|
</p>
|
||||||
|
{!sent && (
|
||||||
|
<button onClick={handleResend} disabled={resending}
|
||||||
|
className="text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors whitespace-nowrap inline-flex items-center gap-1">
|
||||||
|
{resending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{resending ? "sending..." : "click to resend."}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setDismissed(true)} aria-label="Dismiss" className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-950 flex">
|
||||||
|
<aside className="hidden lg:block w-64 h-screen sticky top-0">
|
||||||
|
<Sidebar />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside className={cn(
|
||||||
|
"fixed inset-y-0 left-0 w-72 z-50 transform transition-transform duration-300 ease-in-out lg:hidden",
|
||||||
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
)}>
|
||||||
|
<Sidebar onNavigate={() => setSidebarOpen(false)} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="main-content" className="flex-1 min-w-0">
|
||||||
|
<VerificationBanner />
|
||||||
|
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button onClick={() => setSidebarOpen(true)} aria-label="Open menu"
|
||||||
|
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center">
|
||||||
|
<Code2 className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-neutral-100">CodeBoard</span>
|
||||||
|
</Link>
|
||||||
|
<div className="w-9" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="p-4 sm:p-6 lg:p-8">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/web/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FileText, Clock, CheckCircle, XCircle, Loader2, ExternalLink } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Generation {
|
||||||
|
id: string;
|
||||||
|
repoUrl: string;
|
||||||
|
repoName: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [generations, setGenerations] = useState<Generation[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchGenerations = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const statsRes = await fetch("/api/generations/mine", { cache: "no-store" });
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const data = await statsRes.json();
|
||||||
|
setGenerations(data.generations ?? []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch generations:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGenerations();
|
||||||
|
}, [fetchGenerations]);
|
||||||
|
|
||||||
|
const statusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "COMPLETED": return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||||
|
case "FAILED": return <XCircle className="w-4 h-4 text-red-400" />;
|
||||||
|
default: return <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">My Generations</h1>
|
||||||
|
<p className="text-neutral-400 mt-1">Your architecture diagram generation history</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Loader2 className="w-6 h-6 text-blue-400 animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-neutral-500">Loading generations...</p>
|
||||||
|
</div>
|
||||||
|
) : generations.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<FileText className="w-6 h-6 text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-400 font-medium">No generations yet</p>
|
||||||
|
<p className="text-xs text-neutral-600 mt-1">Generate your first architecture diagram from the <Link href="/" className="text-blue-400 hover:text-blue-300">home page</Link></p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-neutral-800">
|
||||||
|
{generations.map((gen) => (
|
||||||
|
<Link key={gen.id} href={`/docs/${gen.id}`} className="flex items-center gap-4 px-6 py-4 hover:bg-neutral-800/30 transition-colors group">
|
||||||
|
{statusIcon(gen.status)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-neutral-200 truncate">{gen.repoName}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-xs text-neutral-500 truncate">{gen.repoUrl}</span>
|
||||||
|
{gen.duration && (
|
||||||
|
<span className="text-xs text-neutral-600 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" /> {gen.duration}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-neutral-600">{new Date(gen.createdAt).toLocaleDateString()}</span>
|
||||||
|
<ExternalLink className="w-4 h-4 text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
apps/web/src/app/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Key,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
RefreshCw,
|
||||||
|
Database,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
Crown,
|
||||||
|
Zap,
|
||||||
|
ArrowUpRight,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalGenerations: number;
|
||||||
|
completedGenerations: number;
|
||||||
|
failedGenerations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountData {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
subscription: {
|
||||||
|
tier: "FREE" | "STARTER" | "PRO";
|
||||||
|
status: "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID";
|
||||||
|
generationsUsed: number;
|
||||||
|
generationsLimit: number;
|
||||||
|
currentPeriodStart: string | null;
|
||||||
|
currentPeriodEnd: string | null;
|
||||||
|
hasStripeSubscription: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{
|
||||||
|
key: "FREE" as const,
|
||||||
|
name: "Free",
|
||||||
|
price: 0,
|
||||||
|
period: "day",
|
||||||
|
generations: 15,
|
||||||
|
description: "For getting started",
|
||||||
|
features: ["15 generations per day", "Basic diagram viewing", "Community support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "STARTER" as const,
|
||||||
|
name: "Starter",
|
||||||
|
price: 5,
|
||||||
|
period: "month",
|
||||||
|
generations: 1000,
|
||||||
|
description: "For regular use",
|
||||||
|
features: ["1,000 generations per month", "Generation history", "Priority support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PRO" as const,
|
||||||
|
name: "Pro",
|
||||||
|
price: 20,
|
||||||
|
period: "month",
|
||||||
|
generations: 100000,
|
||||||
|
description: "For teams & heavy use",
|
||||||
|
features: ["100,000 generations per month", "Full history", "Dedicated support", "API access"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [account, setAccount] = useState<AccountData | null>(null);
|
||||||
|
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||||
|
const [isLoadingAccount, setIsLoadingAccount] = useState(true);
|
||||||
|
const [isPurging, setIsPurging] = useState(false);
|
||||||
|
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
||||||
|
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
|
||||||
|
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
setIsLoadingStats(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/stats", { cache: "no-store" });
|
||||||
|
if (res.ok) setStats(await res.json());
|
||||||
|
} catch (error) { console.error("Failed to fetch stats:", error); } finally { setIsLoadingStats(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAccount = useCallback(async () => {
|
||||||
|
setIsLoadingAccount(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/account", { cache: "no-store" });
|
||||||
|
if (res.ok) setAccount(await res.json());
|
||||||
|
} catch (error) { console.error("Failed to fetch account:", error); } finally { setIsLoadingAccount(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchStats(); fetchAccount(); }, [fetchStats, fetchAccount]);
|
||||||
|
|
||||||
|
const handlePurgeAll = async () => {
|
||||||
|
setIsPurging(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/purge", { method: "POST" });
|
||||||
|
if (res.ok) { setShowPurgeConfirm(false); fetchStats(); }
|
||||||
|
} catch (error) { console.error("Failed to purge:", error); } finally { setIsPurging(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpgrade = async (tierKey: string) => {
|
||||||
|
setUpgradingTier(tierKey);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stripe/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tierKey }) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.url) window.location.href = data.url;
|
||||||
|
} catch (error) { console.error("Failed to create checkout:", error); } finally { setUpgradingTier(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageSubscription = async () => {
|
||||||
|
setIsOpeningPortal(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stripe/portal", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.url) window.location.href = data.url;
|
||||||
|
} catch (error) { console.error("Failed to open portal:", error); } finally { setIsOpeningPortal(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTier = account?.subscription?.tier ?? "FREE";
|
||||||
|
const generationsUsed = account?.subscription?.generationsUsed ?? 0;
|
||||||
|
const generationsLimit = account?.subscription?.generationsLimit ?? 15;
|
||||||
|
const usagePercent = generationsLimit > 0 ? Math.min((generationsUsed / generationsLimit) * 100, 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
|
||||||
|
<p className="text-neutral-400 mt-1">Account, billing, and configuration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<User className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">Account</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||||
|
{isLoadingAccount ? (
|
||||||
|
<div className="flex items-center gap-3 text-neutral-500"><Loader2 className="w-4 h-4 animate-spin" /><span className="text-sm">Loading account...</span></div>
|
||||||
|
) : account ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
<div><p className="text-xs text-neutral-500 font-medium mb-1">Email</p><p className="text-sm text-neutral-200 font-medium">{account.email}</p></div>
|
||||||
|
<div><p className="text-xs text-neutral-500 font-medium mb-1">Name</p><p className="text-sm text-neutral-200 font-medium">{account.name ?? "\u2014"}</p></div>
|
||||||
|
<div><p className="text-xs text-neutral-500 font-medium mb-1">Member since</p><div className="flex items-center gap-1.5 text-sm text-neutral-200 font-medium"><Calendar className="w-3.5 h-3.5 text-neutral-500" />{new Date(account.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</div></div>
|
||||||
|
</div>
|
||||||
|
) : (<p className="text-sm text-neutral-500">Unable to load account info</p>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Subscription */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<CreditCard className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">Subscription & Billing</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-neutral-400">Current plan</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 border border-blue-500/20 text-blue-400">
|
||||||
|
{currentTier === "PRO" && <Crown className="w-3 h-3" />}
|
||||||
|
{currentTier === "STARTER" && <Zap className="w-3 h-3" />}
|
||||||
|
{currentTier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{currentTier !== "FREE" && account?.subscription?.hasStripeSubscription && (
|
||||||
|
<button onClick={handleManageSubscription} disabled={isOpeningPortal}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-400 bg-neutral-800 border border-neutral-700 rounded-lg hover:text-neutral-200 hover:border-neutral-600 transition-colors disabled:opacity-50">
|
||||||
|
{isOpeningPortal ? <Loader2 className="w-3 h-3 animate-spin" /> : <ArrowUpRight className="w-3 h-3" />} Manage Subscription
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-neutral-400">{generationsUsed.toLocaleString()} of {generationsLimit.toLocaleString()} generations used</span>
|
||||||
|
<span className="text-neutral-500 text-xs">{currentTier === "FREE" ? "15 generations/day" : "This billing period"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div className={cn("h-full rounded-full transition-all duration-500", usagePercent > 90 ? "bg-amber-500" : "bg-blue-500")} style={{ width: `${usagePercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{TIERS.map((tier) => {
|
||||||
|
const isCurrent = currentTier === tier.key;
|
||||||
|
const tierOrder = { FREE: 0, STARTER: 1, PRO: 2 };
|
||||||
|
const isUpgrade = tierOrder[tier.key] > tierOrder[currentTier];
|
||||||
|
const isDowngrade = tierOrder[tier.key] < tierOrder[currentTier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tier.key} className={cn("relative bg-neutral-900 border rounded-xl p-5 flex flex-col transition-colors", isCurrent ? "border-blue-500/40 shadow-[0_0_24px_-6px_rgba(59,130,246,0.12)]" : "border-neutral-800 hover:border-neutral-700")}>
|
||||||
|
{isCurrent && (<div className="absolute -top-2.5 left-4"><span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500 text-white">Current</span></div>)}
|
||||||
|
<div className="mb-4"><h3 className="text-base font-semibold text-neutral-100">{tier.name}</h3><p className="text-xs text-neutral-500 mt-0.5">{tier.description}</p></div>
|
||||||
|
<div className="mb-4"><span className="text-2xl font-bold text-neutral-100">${tier.price}</span><span className="text-sm text-neutral-500">/{tier.period}</span></div>
|
||||||
|
<ul className="space-y-2 mb-5 flex-1">
|
||||||
|
{tier.features.map((f) => (<li key={f} className="flex items-start gap-2 text-xs text-neutral-400"><Check className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />{f}</li>))}
|
||||||
|
</ul>
|
||||||
|
{isCurrent ? (<div className="py-2 text-center text-xs font-medium text-blue-400 bg-blue-500/5 border border-blue-500/10 rounded-lg">Active plan</div>)
|
||||||
|
: isUpgrade ? (<button onClick={() => handleUpgrade(tier.key)} disabled={upgradingTier === tier.key} className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-blue-500 hover:bg-blue-400 text-white rounded-lg transition-colors disabled:opacity-50">{upgradingTier === tier.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />} Upgrade</button>)
|
||||||
|
: isDowngrade ? (<button onClick={handleManageSubscription} disabled={isOpeningPortal} className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-neutral-800 border border-neutral-700 text-neutral-300 rounded-lg hover:text-neutral-100 transition-colors disabled:opacity-50">{isOpeningPortal && <Loader2 className="w-3.5 h-3.5 animate-spin" />} Downgrade</button>)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<Database className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">Data & Storage</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
|
||||||
|
{isLoadingStats ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="animate-pulse"><div className="h-4 w-16 bg-neutral-800 rounded mb-2" /><div className="h-8 w-12 bg-neutral-800 rounded" /></div>))}</div>
|
||||||
|
) : stats ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Total</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.totalGenerations.toLocaleString()}</p></div>
|
||||||
|
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Completed</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.completedGenerations.toLocaleString()}</p></div>
|
||||||
|
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Failed</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.failedGenerations.toLocaleString()}</p></div>
|
||||||
|
</div>
|
||||||
|
) : (<p className="text-sm text-neutral-500">Unable to load statistics</p>)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-neutral-800 flex items-center justify-between">
|
||||||
|
<div><p className="text-sm text-neutral-300 font-medium">Purge All Data</p><p className="text-xs text-neutral-500 mt-0.5">Permanently delete all your generations</p></div>
|
||||||
|
{showPurgeConfirm ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setShowPurgeConfirm(false)} className="px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
|
||||||
|
<button onClick={handlePurgeAll} disabled={isPurging} className="flex items-center gap-2 px-4 py-2 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-sm font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors">
|
||||||
|
{isPurging ? <RefreshCw className="w-4 h-4 animate-spin" /> : <AlertTriangle className="w-4 h-4" />} Confirm Purge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowPurgeConfirm(true)} className="flex items-center gap-2 px-4 py-2 bg-neutral-800 border border-neutral-700 text-neutral-400 rounded-lg text-sm font-medium hover:text-red-400 hover:border-red-500/30 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" /> Purge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<Settings className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">About</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><p className="text-neutral-500">Version</p><p className="text-neutral-200 font-medium">0.1.0</p></div>
|
||||||
|
<div><p className="text-neutral-500">Service</p><p className="text-neutral-200 font-medium">CodeBoard</p></div>
|
||||||
|
<div><p className="text-neutral-500">Database</p><p className="text-neutral-200 font-medium">PostgreSQL</p></div>
|
||||||
|
<div><p className="text-neutral-500">License</p><p className="text-neutral-200 font-medium">MIT</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DocViewer } from "@/components/doc-viewer";
|
import { DocViewer } from "@/components/doc-viewer";
|
||||||
import type { GeneratedDocs } from "@codeboard/shared";
|
import type { GeneratedDocs } from "@codeboard/shared";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Github, ArrowLeft } from "lucide-react";
|
import { Github, ArrowLeft, History } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
async function fetchDocs(id: string): Promise<GeneratedDocs | null> {
|
async function fetchDocs(id: string): Promise<GeneratedDocs | null> {
|
||||||
@@ -45,15 +45,25 @@ export default async function DocsPage({
|
|||||||
Back to Home
|
Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<a
|
<div className="flex items-center gap-4">
|
||||||
href={docs.repoUrl}
|
<Link
|
||||||
target="_blank"
|
href={`/history?repo=${encodeURIComponent(docs.repoUrl)}`}
|
||||||
rel="noopener noreferrer"
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
>
|
||||||
>
|
<History className="w-4 h-4" />
|
||||||
<Github className="w-4 h-4" />
|
Version History
|
||||||
View on GitHub
|
</Link>
|
||||||
</a>
|
|
||||||
|
<a
|
||||||
|
href={docs.repoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Github className="w-4 h-4" />
|
||||||
|
View on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0f;
|
--background: #0a0a0a;
|
||||||
--surface: rgba(255, 255, 255, 0.03);
|
--surface: rgba(255, 255, 255, 0.03);
|
||||||
--surface-hover: rgba(255, 255, 255, 0.06);
|
--surface-hover: rgba(255, 255, 255, 0.06);
|
||||||
--border: rgba(255, 255, 255, 0.08);
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
@@ -15,19 +15,43 @@
|
|||||||
--accent-blue: #3b82f6;
|
--accent-blue: #3b82f6;
|
||||||
--accent-indigo: #6366f1;
|
--accent-indigo: #6366f1;
|
||||||
--accent-purple: #9333ea;
|
--accent-purple: #9333ea;
|
||||||
--accent-cyan: #06b6d4;
|
|
||||||
|
|
||||||
--gradient-primary: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #9333ea 100%);
|
--gradient-primary: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #9333ea 100%);
|
||||||
--gradient-subtle: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(147, 51, 234, 0.1) 100%);
|
--gradient-subtle: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(147, 51, 234, 0.1) 100%);
|
||||||
|
|
||||||
--shadow-glow: 0 0 40px rgba(59, 130, 246, 0.3);
|
--shadow-glow: 0 0 40px rgba(59, 130, 246, 0.3);
|
||||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
|
/* Shared Vectry design language aliases */
|
||||||
|
--surface-page: var(--background);
|
||||||
|
--surface-card: var(--surface);
|
||||||
|
--surface-card-hover: var(--surface-hover);
|
||||||
|
--border-default: var(--border);
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.04);
|
||||||
|
--radius-card: 1rem;
|
||||||
|
--radius-button: 0.5rem;
|
||||||
|
--radius-icon: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-blue);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
[role="button"]:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
@@ -387,3 +411,27 @@ body {
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-animate] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(32px);
|
||||||
|
transition: opacity 0.7s ease-out, transform 0.7s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animate="visible"] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animate][data-animate-delay="1"] { transition-delay: 0.1s; }
|
||||||
|
[data-animate][data-animate-delay="2"] { transition-delay: 0.2s; }
|
||||||
|
[data-animate][data-animate-delay="3"] { transition-delay: 0.3s; }
|
||||||
|
[data-animate][data-animate-delay="4"] { transition-delay: 0.4s; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-animate] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
818
apps/web/src/app/history/page.tsx
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState, useCallback } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { GeneratedDocs } from "@codeboard/shared";
|
||||||
|
import { MermaidDiagram } from "@/components/mermaid-diagram";
|
||||||
|
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||||
|
import { KeyboardShortcutsHelp } from "@/components/keyboard-shortcuts-help";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Clock,
|
||||||
|
GitCommit,
|
||||||
|
History,
|
||||||
|
CheckSquare,
|
||||||
|
Square,
|
||||||
|
GitCompare,
|
||||||
|
X,
|
||||||
|
BookOpen,
|
||||||
|
Layers,
|
||||||
|
Folder,
|
||||||
|
FileCode,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Generation {
|
||||||
|
id: string;
|
||||||
|
repoUrl: string;
|
||||||
|
repoName: string;
|
||||||
|
commitHash: string;
|
||||||
|
createdAt: string;
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number | null): string {
|
||||||
|
if (!seconds) return "Unknown";
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
if (mins > 0) {
|
||||||
|
return `${mins}m ${secs}s`;
|
||||||
|
}
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechStackDiff({
|
||||||
|
leftStack,
|
||||||
|
rightStack,
|
||||||
|
}: {
|
||||||
|
leftStack: string[];
|
||||||
|
rightStack: string[];
|
||||||
|
}) {
|
||||||
|
const leftSet = new Set(leftStack.map((s) => s.toLowerCase()));
|
||||||
|
const rightSet = new Set(rightStack.map((s) => s.toLowerCase()));
|
||||||
|
|
||||||
|
const added = rightStack.filter((s) => !leftSet.has(s.toLowerCase()));
|
||||||
|
const removed = leftStack.filter((s) => !rightSet.has(s.toLowerCase()));
|
||||||
|
const unchanged = leftStack.filter((s) => rightSet.has(s.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{removed.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={`removed-${tech}`}
|
||||||
|
className="px-3 py-1 text-sm bg-red-500/10 border border-red-500/30 rounded-full text-red-300 line-through"
|
||||||
|
title="Removed"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{unchanged.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="px-3 py-1 text-sm bg-white/5 border border-white/10 rounded-full text-zinc-400"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{added.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={`added-${tech}`}
|
||||||
|
className="px-3 py-1 text-sm bg-green-500/10 border border-green-500/30 rounded-full text-green-300"
|
||||||
|
title="Added"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonView({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
leftGen,
|
||||||
|
rightGen,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
left: GeneratedDocs;
|
||||||
|
right: GeneratedDocs;
|
||||||
|
leftGen: Generation;
|
||||||
|
rightGen: Generation;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const leftOverview = left.sections.overview;
|
||||||
|
const rightOverview = right.sections.overview;
|
||||||
|
|
||||||
|
const filesDiff = rightOverview.keyMetrics.files - leftOverview.keyMetrics.files;
|
||||||
|
const modulesDiff =
|
||||||
|
rightOverview.keyMetrics.modules - leftOverview.keyMetrics.modules;
|
||||||
|
const languagesDiff =
|
||||||
|
rightOverview.keyMetrics.languages.length -
|
||||||
|
leftOverview.keyMetrics.languages.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-[#0a0a0a] overflow-auto">
|
||||||
|
<div className="sticky top-0 z-10 border-b border-white/10 bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<GitCompare className="w-6 h-6 text-blue-400" />
|
||||||
|
<h2 className="text-xl font-bold text-white">Version Comparison</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
aria-label="Close comparison view"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Left Panel - Older */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="glass rounded-xl p-4 border-l-4 border-l-zinc-500">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<GitCommit className="w-4 h-4 text-zinc-400" />
|
||||||
|
<code className="text-sm text-zinc-300">
|
||||||
|
{leftGen.commitHash.slice(0, 7)}
|
||||||
|
</code>
|
||||||
|
<span className="text-xs text-zinc-500">•</span>
|
||||||
|
<span className="text-sm text-zinc-400">
|
||||||
|
{formatDate(leftGen.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500">Older version</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-blue-400" />
|
||||||
|
Overview
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-300 text-sm leading-relaxed mb-4">
|
||||||
|
{leftOverview.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="text-sm font-medium text-zinc-400 mb-3">
|
||||||
|
Tech Stack
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{leftOverview.techStack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="px-3 py-1 text-sm bg-white/5 border border-white/10 rounded-full text-zinc-300"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Layers className="w-5 h-5 text-blue-400" />
|
||||||
|
Architecture
|
||||||
|
</h3>
|
||||||
|
<MermaidDiagram chart={leftOverview.architectureDiagram} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="p-4 glass rounded-lg text-center">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{leftOverview.keyMetrics.files}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">Files</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 glass rounded-lg text-center">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{leftOverview.keyMetrics.modules}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">Modules</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 glass rounded-lg text-center">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{leftOverview.keyMetrics.languages.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">Languages</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Newer */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="glass rounded-xl p-4 border-l-4 border-l-green-500">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<GitCommit className="w-4 h-4 text-zinc-400" />
|
||||||
|
<code className="text-sm text-zinc-300">
|
||||||
|
{rightGen.commitHash.slice(0, 7)}
|
||||||
|
</code>
|
||||||
|
<span className="text-xs text-zinc-500">•</span>
|
||||||
|
<span className="text-sm text-zinc-400">
|
||||||
|
{formatDate(rightGen.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-green-400">Newer version</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-blue-400" />
|
||||||
|
Overview
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-300 text-sm leading-relaxed mb-4">
|
||||||
|
{rightOverview.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="text-sm font-medium text-zinc-400 mb-3">
|
||||||
|
Tech Stack Changes
|
||||||
|
</h4>
|
||||||
|
<TechStackDiff
|
||||||
|
leftStack={leftOverview.techStack}
|
||||||
|
rightStack={rightOverview.techStack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Layers className="w-5 h-5 text-blue-400" />
|
||||||
|
Architecture
|
||||||
|
</h3>
|
||||||
|
<MermaidDiagram chart={rightOverview.architectureDiagram} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="p-4 glass rounded-lg text-center relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
filesDiff > 0
|
||||||
|
? "text-green-400"
|
||||||
|
: filesDiff < 0
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rightOverview.keyMetrics.files}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">Files</div>
|
||||||
|
{filesDiff !== 0 && (
|
||||||
|
<div
|
||||||
|
className={`absolute top-1 right-2 text-xs ${
|
||||||
|
filesDiff > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filesDiff > 0 ? "+" : ""}
|
||||||
|
{filesDiff}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 glass rounded-lg text-center relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
modulesDiff > 0
|
||||||
|
? "text-green-400"
|
||||||
|
: modulesDiff < 0
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rightOverview.keyMetrics.modules}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">Modules</div>
|
||||||
|
{modulesDiff !== 0 && (
|
||||||
|
<div
|
||||||
|
className={`absolute top-1 right-2 text-xs ${
|
||||||
|
modulesDiff > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{modulesDiff > 0 ? "+" : ""}
|
||||||
|
{modulesDiff}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 glass rounded-lg text-center relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
languagesDiff > 0
|
||||||
|
? "text-green-400"
|
||||||
|
: languagesDiff < 0
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rightOverview.keyMetrics.languages.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">Languages</div>
|
||||||
|
{languagesDiff !== 0 && (
|
||||||
|
<div
|
||||||
|
className={`absolute top-1 right-2 text-xs ${
|
||||||
|
languagesDiff > 0 ? "text-green-400" : "text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{languagesDiff > 0 ? "+" : ""}
|
||||||
|
{languagesDiff}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Module Comparison */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-6 flex items-center gap-3">
|
||||||
|
<Folder className="w-6 h-6 text-blue-400" />
|
||||||
|
Module Breakdown Comparison
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="glass rounded-xl p-6">
|
||||||
|
<h4 className="font-semibold text-white mb-4 text-zinc-400">
|
||||||
|
Older Version ({left.sections.modules.length} modules)
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-auto">
|
||||||
|
{left.sections.modules.map((module) => (
|
||||||
|
<div
|
||||||
|
key={module.name}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-white/5"
|
||||||
|
>
|
||||||
|
<FileCode className="w-4 h-4 text-zinc-500" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-zinc-300 truncate">
|
||||||
|
{module.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate">
|
||||||
|
{module.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-6">
|
||||||
|
<h4 className="font-semibold text-white mb-4 text-green-400">
|
||||||
|
Newer Version ({right.sections.modules.length} modules)
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-auto">
|
||||||
|
{right.sections.modules.map((module) => {
|
||||||
|
const existedInLeft = left.sections.modules.some(
|
||||||
|
(m) => m.name === module.name
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={module.name}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg ${
|
||||||
|
existedInLeft ? "bg-white/5" : "bg-green-500/10 border border-green-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileCode
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
existedInLeft ? "text-zinc-500" : "text-green-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-zinc-300 truncate">
|
||||||
|
{module.name}
|
||||||
|
{!existedInLeft && (
|
||||||
|
<span className="ml-2 text-xs text-green-400">
|
||||||
|
(new)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate">
|
||||||
|
{module.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const repo = searchParams.get("repo");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [generations, setGenerations] = useState<Generation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [comparing, setComparing] = useState(false);
|
||||||
|
const [leftDoc, setLeftDoc] = useState<GeneratedDocs | null>(null);
|
||||||
|
const [rightDoc, setRightDoc] = useState<GeneratedDocs | null>(null);
|
||||||
|
const [leftGen, setLeftGen] = useState<Generation | null>(null);
|
||||||
|
const [rightGen, setRightGen] = useState<Generation | null>(null);
|
||||||
|
|
||||||
|
const handleKeyboardSelect = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= 0 && index < generations.length) {
|
||||||
|
router.push(`/docs/${generations[index].id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[generations, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { activeIndex, showHelp, setShowHelp } = useKeyboardNav({
|
||||||
|
itemCount: generations.length,
|
||||||
|
onSelect: handleKeyboardSelect,
|
||||||
|
enabled: !loading && !comparing && generations.length > 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!repo) {
|
||||||
|
setLoading(false);
|
||||||
|
setError("No repository URL provided");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/history?repo=${encodeURIComponent(repo)}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch history");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data: Generation[]) => {
|
||||||
|
setGenerations(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [repo]);
|
||||||
|
|
||||||
|
const toggleSelection = (id: string) => {
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (newSelected.has(id)) {
|
||||||
|
newSelected.delete(id);
|
||||||
|
} else if (newSelected.size < 2) {
|
||||||
|
newSelected.add(id);
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompare = async () => {
|
||||||
|
if (selectedIds.size !== 2) return;
|
||||||
|
|
||||||
|
const ids = Array.from(selectedIds);
|
||||||
|
const gen1 = generations.find((g) => g.id === ids[0])!;
|
||||||
|
const gen2 = generations.find((g) => g.id === ids[1])!;
|
||||||
|
|
||||||
|
// Sort by date - older first
|
||||||
|
const [olderGen, newerGen] =
|
||||||
|
new Date(gen1.createdAt) < new Date(gen2.createdAt)
|
||||||
|
? [gen1, gen2]
|
||||||
|
: [gen2, gen1];
|
||||||
|
|
||||||
|
setComparing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [olderDocRes, newerDocRes] = await Promise.all([
|
||||||
|
fetch(`/api/docs/${olderGen.id}`),
|
||||||
|
fetch(`/api/docs/${newerGen.id}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!olderDocRes.ok || !newerDocRes.ok) {
|
||||||
|
throw new Error("Failed to fetch documentation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [olderDoc, newerDoc] = await Promise.all([
|
||||||
|
olderDocRes.json(),
|
||||||
|
newerDocRes.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setLeftDoc(olderDoc);
|
||||||
|
setRightDoc(newerDoc);
|
||||||
|
setLeftGen(olderGen);
|
||||||
|
setRightGen(newerGen);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to compare");
|
||||||
|
setComparing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeComparison = () => {
|
||||||
|
setLeftDoc(null);
|
||||||
|
setRightDoc(null);
|
||||||
|
setLeftGen(null);
|
||||||
|
setRightGen(null);
|
||||||
|
setComparing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||||
|
<span className="text-zinc-400">Loading history...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="glass rounded-xl p-8 text-center max-w-md">
|
||||||
|
<div className="text-red-400 mb-2">Error</div>
|
||||||
|
<p className="text-zinc-400">{error}</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 mt-4 text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="glass rounded-xl p-8 text-center max-w-md">
|
||||||
|
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">
|
||||||
|
No Repository Specified
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 mb-4">
|
||||||
|
Please provide a repository URL to view its history.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 btn-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generations.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="glass rounded-xl p-8 text-center max-w-md">
|
||||||
|
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">No History Found</h2>
|
||||||
|
<p className="text-zinc-400 mb-4">
|
||||||
|
No documentation has been generated for this repository yet.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 btn-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generations.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="border-b border-white/10 bg-black/20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
|
Version History
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-400">
|
||||||
|
{generations[0].repoName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-8 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<GitCommit className="w-8 h-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">
|
||||||
|
Only One Version Exists
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 max-w-md mx-auto">
|
||||||
|
Generate docs again after code changes to compare versions and track
|
||||||
|
how your architecture evolves over time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 p-4 rounded-lg bg-white/5 max-w-md mx-auto text-left">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<GitCommit className="w-4 h-4 text-zinc-400" />
|
||||||
|
<code className="text-sm text-zinc-300">
|
||||||
|
{generations[0].commitHash.slice(0, 7)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Clock className="w-4 h-4 text-zinc-400" />
|
||||||
|
<span className="text-sm text-zinc-300">
|
||||||
|
{formatDate(generations[0].createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-4 h-4 text-zinc-400" />
|
||||||
|
<span className="text-sm text-zinc-300">
|
||||||
|
Generated in {formatDuration(generations[0].duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/docs/${generations[0].id}`}
|
||||||
|
className="inline-flex items-center gap-2 btn-primary mt-8"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
View Documentation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="border-b border-white/10 bg-black/20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
<span className="text-sm text-zinc-500">
|
||||||
|
{selectedIds.size} of 2 selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<History className="w-12 h-12 text-zinc-500 mx-auto mb-4" />
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Version History</h1>
|
||||||
|
<p className="text-zinc-400">{generations[0]?.repoName}</p>
|
||||||
|
<p className="text-sm text-zinc-500 mt-2">
|
||||||
|
Select any 2 versions to compare side-by-side
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{generations.map((gen, index) => {
|
||||||
|
const isSelected = selectedIds.has(gen.id);
|
||||||
|
const canSelect = selectedIds.size < 2 || isSelected;
|
||||||
|
const isKeyboardActive = activeIndex === index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={gen.id}
|
||||||
|
data-keyboard-index={index}
|
||||||
|
className={`glass rounded-xl p-4 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "border-blue-500/50 bg-blue-500/5"
|
||||||
|
: isKeyboardActive
|
||||||
|
? "border-blue-500/30 bg-white/[0.04] ring-1 ring-blue-500/20"
|
||||||
|
: "border-white/10"
|
||||||
|
} ${!canSelect ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => canSelect && toggleSelection(gen.id)}
|
||||||
|
className={`flex-shrink-0 p-2 rounded-lg transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "text-blue-400 hover:bg-blue-500/10"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5"
|
||||||
|
} ${!canSelect ? "cursor-not-allowed" : ""}`}
|
||||||
|
disabled={!canSelect}
|
||||||
|
aria-label={isSelected ? `Deselect version ${gen.commitHash.slice(0, 7)}` : `Select version ${gen.commitHash.slice(0, 7)} for comparison`}
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<CheckSquare className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<GitCommit className="w-4 h-4 text-zinc-500" />
|
||||||
|
<code className="text-sm text-zinc-300">
|
||||||
|
{gen.commitHash.slice(0, 7)}
|
||||||
|
</code>
|
||||||
|
<span className="text-zinc-600">•</span>
|
||||||
|
<span className="text-sm text-zinc-400">
|
||||||
|
{formatDate(gen.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600">•</span>
|
||||||
|
<span className="text-sm text-zinc-500">
|
||||||
|
{formatDuration(gen.duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/docs/${gen.id}`}
|
||||||
|
className="flex-shrink-0 px-3 py-1.5 text-sm text-zinc-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedIds.size === 2 && (
|
||||||
|
<div className="mt-8 flex justify-center animate-slide-up">
|
||||||
|
<button
|
||||||
|
onClick={handleCompare}
|
||||||
|
disabled={comparing}
|
||||||
|
className="inline-flex items-center gap-2 btn-primary"
|
||||||
|
>
|
||||||
|
{comparing ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitCompare className="w-4 h-4" />
|
||||||
|
Compare Selected Versions
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KeyboardShortcutsHelp open={showHelp} onClose={() => setShowHelp(false)} />
|
||||||
|
|
||||||
|
{leftDoc && rightDoc && leftGen && rightGen && (
|
||||||
|
<ComparisonView
|
||||||
|
left={leftDoc}
|
||||||
|
right={rightDoc}
|
||||||
|
leftGen={leftGen}
|
||||||
|
rightGen={rightGen}
|
||||||
|
onClose={closeComparison}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||||
|
<span className="text-zinc-400">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HistoryContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Inter, JetBrains_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
|
import { Providers } from "@/components/providers";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -17,16 +18,51 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL("https://codeboard.vectry.tech"),
|
||||||
title: "CodeBoard — Understand any codebase in 5 minutes",
|
title: "CodeBoard — Understand any codebase in 5 minutes",
|
||||||
description:
|
description:
|
||||||
"Paste a GitHub URL and get interactive onboarding documentation with architecture diagrams, module breakdowns, and getting started guides. Built by Vectry AI consultancy.",
|
"Paste a GitHub URL and get interactive onboarding documentation with architecture diagrams, module breakdowns, and getting started guides. Built by Vectry AI consultancy.",
|
||||||
keywords: ["code analysis", "documentation", "github", "codebase", "AI", "developer tools"],
|
keywords: ["code analysis", "documentation", "github", "codebase", "AI", "developer tools"],
|
||||||
authors: [{ name: "Vectry" }],
|
authors: [{ name: "Vectry" }],
|
||||||
|
creator: "Vectry",
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.ico", sizes: "any" },
|
||||||
|
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||||
|
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||||
|
],
|
||||||
|
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180" }],
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "CodeBoard — Understand any codebase in 5 minutes",
|
title: "CodeBoard — Understand any codebase in 5 minutes",
|
||||||
description:
|
description:
|
||||||
"Paste a GitHub URL and get interactive onboarding documentation with architecture diagrams, module breakdowns, and getting started guides.",
|
"Paste a GitHub URL and get interactive onboarding documentation with architecture diagrams, module breakdowns, and getting started guides.",
|
||||||
type: "website",
|
type: "website",
|
||||||
|
url: "https://codeboard.vectry.tech",
|
||||||
|
siteName: "CodeBoard",
|
||||||
|
locale: "en_US",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "CodeBoard — Understand any codebase in 5 minutes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "CodeBoard — Understand any codebase in 5 minutes",
|
||||||
|
description:
|
||||||
|
"Paste a GitHub URL and get interactive onboarding documentation with architecture diagrams, module breakdowns, and getting started guides.",
|
||||||
|
images: ["/og-image.png"],
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: "https://codeboard.vectry.tech",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,20 +74,28 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased bg-[#0a0a0f] text-white min-h-screen`}
|
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased bg-[#0a0a0a] text-white min-h-screen`}
|
||||||
>
|
>
|
||||||
<div className="relative min-h-screen flex flex-col">
|
<a
|
||||||
<div className="fixed inset-0 bg-gradient-radial pointer-events-none" />
|
href="#main-content"
|
||||||
<div className="fixed inset-0 bg-grid pointer-events-none opacity-50" />
|
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[200] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-[var(--accent-blue)] focus:text-white focus:text-sm focus:font-medium focus:outline-none focus:ring-2 focus:ring-white/50"
|
||||||
|
>
|
||||||
|
Skip to content
|
||||||
|
</a>
|
||||||
|
<Providers>
|
||||||
|
<div className="relative min-h-screen flex flex-col">
|
||||||
|
<div className="fixed inset-0 bg-gradient-radial pointer-events-none" aria-hidden="true" />
|
||||||
|
<div className="fixed inset-0 bg-grid pointer-events-none opacity-50" aria-hidden="true" />
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="flex-1 relative">
|
<main id="main-content" className="flex-1 relative">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { RepoInput } from "@/components/repo-input";
|
import { RepoInput } from "@/components/repo-input";
|
||||||
import { ExampleRepoCard } from "@/components/example-repo-card";
|
import { ExampleRepoCard } from "@/components/example-repo-card";
|
||||||
|
import { ScrollSection } from "@/components/scroll-section";
|
||||||
import {
|
import {
|
||||||
Link2,
|
Link2,
|
||||||
Code2,
|
Code2,
|
||||||
@@ -16,6 +18,9 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
FileCode,
|
FileCode,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Check,
|
||||||
|
Crown,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -73,6 +78,42 @@ export default function HomePage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const pricingTiers = [
|
||||||
|
{
|
||||||
|
name: "Free",
|
||||||
|
price: 0,
|
||||||
|
period: "forever",
|
||||||
|
description: "Get started with CodeBoard",
|
||||||
|
generations: "15 / day",
|
||||||
|
features: ["15 generations per day", "Public repository support", "Interactive documentation", "Architecture diagrams"],
|
||||||
|
cta: "Get Started",
|
||||||
|
href: "/register",
|
||||||
|
highlighted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Starter",
|
||||||
|
price: 5,
|
||||||
|
period: "month",
|
||||||
|
description: "For regular use",
|
||||||
|
generations: "1,000 / month",
|
||||||
|
features: ["1,000 generations per month", "Generation history", "API key access", "Priority support"],
|
||||||
|
cta: "Start Free Trial",
|
||||||
|
href: "/register",
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pro",
|
||||||
|
price: 20,
|
||||||
|
period: "month",
|
||||||
|
description: "For teams & power users",
|
||||||
|
generations: "100,000 / month",
|
||||||
|
features: ["100,000 generations per month", "Full generation history", "Multiple API keys", "Dedicated support", "Custom integrations"],
|
||||||
|
cta: "Start Free Trial",
|
||||||
|
href: "/register",
|
||||||
|
highlighted: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const exampleRepos = [
|
const exampleRepos = [
|
||||||
{
|
{
|
||||||
name: "sindresorhus/p-limit",
|
name: "sindresorhus/p-limit",
|
||||||
@@ -270,8 +311,8 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block w-px bg-zinc-800" />
|
<div className="hidden sm:block w-px bg-zinc-800" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl sm:text-3xl font-bold text-white">100%</div>
|
<div className="text-2xl sm:text-3xl font-bold text-white">Free</div>
|
||||||
<div className="text-sm text-zinc-500">Free for public repos</div>
|
<div className="text-sm text-zinc-500">tier to start</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block w-px bg-zinc-800" />
|
<div className="hidden sm:block w-px bg-zinc-800" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -287,41 +328,45 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<section id="how-it-works" className="py-20 lg:py-32">
|
<section id="how-it-works" className="py-20 lg:py-32">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<ScrollSection>
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
<div className="text-center mb-16">
|
||||||
How It Works
|
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||||
</h2>
|
How It Works
|
||||||
<p className="text-zinc-400 max-w-xl mx-auto">
|
</h2>
|
||||||
Four simple steps to comprehensive codebase documentation
|
<p className="text-zinc-400 max-w-xl mx-auto">
|
||||||
</p>
|
Four simple steps to comprehensive codebase documentation
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollSection>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="hidden lg:block absolute top-24 left-[12.5%] right-[12.5%] h-px bg-gradient-to-r from-transparent via-zinc-700 to-transparent" />
|
<div className="hidden lg:block absolute top-24 left-[12.5%] right-[12.5%] h-px bg-gradient-to-r from-transparent via-zinc-700 to-transparent" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{steps.map((step) => (
|
{steps.map((step, i) => (
|
||||||
<div key={step.number} className="relative group">
|
<ScrollSection key={step.number} delay={i + 1}>
|
||||||
<div className="text-center">
|
<div className="relative group">
|
||||||
<div className="text-6xl font-bold text-zinc-800/50 mb-4 group-hover:text-blue-500/20 transition-colors">
|
<div className="text-center">
|
||||||
{step.number}
|
<div className="text-6xl font-bold text-zinc-800/50 mb-4 group-hover:text-blue-500/20 transition-colors">
|
||||||
|
{step.number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl glass mb-6 group-hover:border-blue-500/30 transition-colors">
|
||||||
|
<step.icon className="w-7 h-7 text-blue-400" />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-blue-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl glass mb-6 group-hover:border-blue-500/30 transition-colors">
|
|
||||||
<step.icon className="w-7 h-7 text-blue-400" />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 rounded-2xl bg-blue-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">
|
|
||||||
{step.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-sm text-zinc-400 leading-relaxed">
|
|
||||||
{step.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollSection>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,22 +375,26 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<section className="py-20 lg:py-32">
|
<section className="py-20 lg:py-32">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<ScrollSection>
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-6">
|
<div className="text-center mb-16">
|
||||||
<Github className="w-4 h-4 text-blue-400" />
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-6">
|
||||||
<span className="text-sm text-zinc-300">Try It Out</span>
|
<Github className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm text-zinc-300">Try It Out</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||||
|
Featured Examples
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 max-w-xl mx-auto">
|
||||||
|
Pre-generated docs ready to explore — or paste any repo URL above
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
</ScrollSection>
|
||||||
Featured Examples
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400 max-w-xl mx-auto">
|
|
||||||
Pre-generated docs ready to explore — or paste any repo URL above
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{exampleRepos.map((repo) => (
|
{exampleRepos.map((repo, i) => (
|
||||||
<ExampleRepoCard key={repo.name} repo={repo} />
|
<ScrollSection key={repo.name} delay={(i % 3) + 1}>
|
||||||
|
<ExampleRepoCard repo={repo} />
|
||||||
|
</ScrollSection>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,40 +402,133 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<section id="features" className="py-20 lg:py-32">
|
<section id="features" className="py-20 lg:py-32">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<ScrollSection>
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
<div className="text-center mb-16">
|
||||||
Everything You Need
|
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||||
</h2>
|
Everything You Need
|
||||||
<p className="text-zinc-400 max-w-xl mx-auto">
|
</h2>
|
||||||
Comprehensive documentation generated automatically from your codebase
|
<p className="text-zinc-400 max-w-xl mx-auto">
|
||||||
</p>
|
Comprehensive documentation generated automatically from your codebase
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollSection>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{features.map((feature) => (
|
{features.map((feature, i) => (
|
||||||
<div
|
<ScrollSection key={feature.title} delay={(i % 2) + 1}>
|
||||||
key={feature.title}
|
<div className="group relative p-8 rounded-2xl glass hover:bg-white/[0.05] transition-all duration-300 hover:-translate-y-1">
|
||||||
className="group relative p-8 rounded-2xl glass hover:bg-white/[0.05] transition-all duration-300 hover:-translate-y-1"
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-500/20 via-indigo-500/20 to-purple-500/20 opacity-0 group-hover:opacity-100 transition-opacity -z-10 blur-xl" />
|
||||||
>
|
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-500/20 via-indigo-500/20 to-purple-500/20 opacity-0 group-hover:opacity-100 transition-opacity -z-10 blur-xl" />
|
|
||||||
|
|
||||||
<div className="flex items-start gap-5">
|
<div className="flex items-start gap-5">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center border border-white/10 group-hover:border-blue-500/30 transition-colors">
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center border border-white/10 group-hover:border-blue-500/30 transition-colors">
|
||||||
<feature.icon className="w-6 h-6 text-blue-400" />
|
<feature.icon className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-300 transition-colors">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-400 leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollSection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="pricing" className="py-20 lg:py-32">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<ScrollSection>
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-6">
|
||||||
|
<Zap className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm text-zinc-300">Pricing</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||||
|
Simple, Transparent <span className="gradient-text">Pricing</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 max-w-xl mx-auto">
|
||||||
|
Start free, scale when you need to
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollSection>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||||
|
{pricingTiers.map((tier, i) => (
|
||||||
|
<ScrollSection key={tier.name} delay={i + 1}>
|
||||||
|
<div
|
||||||
|
className={`relative group h-full rounded-2xl p-8 transition-all duration-300 hover:-translate-y-1 ${
|
||||||
|
tier.highlighted
|
||||||
|
? "glass-strong border-blue-500/30 shadow-lg shadow-blue-500/10"
|
||||||
|
: "glass hover:bg-white/[0.05]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tier.highlighted && (
|
||||||
|
<>
|
||||||
|
<div className="absolute -inset-px rounded-2xl bg-gradient-to-b from-blue-500/20 via-transparent to-blue-500/10 -z-10" />
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/20 border border-blue-500/30 text-xs font-medium text-blue-300">
|
||||||
|
<Crown className="w-3 h-3" />
|
||||||
|
Most Popular
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">{tier.name}</h3>
|
||||||
|
<p className="text-sm text-zinc-500">{tier.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
{tier.price === 0 ? (
|
||||||
|
<span className="text-4xl font-bold text-white">Free</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-4xl font-bold text-white">${tier.price}</span>
|
||||||
|
<span className="text-zinc-500">/ {tier.period}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-zinc-400">
|
||||||
|
<span className="text-blue-400 font-medium">{tier.generations}</span> generations
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="mb-8 space-y-3">
|
||||||
<h3 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-300 transition-colors">
|
{tier.features.map((feature) => (
|
||||||
{feature.title}
|
<div key={feature} className="flex items-start gap-3">
|
||||||
</h3>
|
<div className={`flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center mt-0.5 ${
|
||||||
<p className="text-zinc-400 leading-relaxed">
|
tier.highlighted
|
||||||
{feature.description}
|
? "bg-blue-500/20 text-blue-400"
|
||||||
</p>
|
: "bg-white/10 text-zinc-400"
|
||||||
|
}`}>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-zinc-300">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Link
|
||||||
|
href={tier.href}
|
||||||
|
className={`block w-full text-center py-3 px-6 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||||
|
tier.highlighted
|
||||||
|
? "btn-primary"
|
||||||
|
: "glass border border-white/10 text-white hover:bg-white/10 hover:border-white/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tier.cta}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollSection>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,6 +536,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<section className="py-20 lg:py-32">
|
<section className="py-20 lg:py-32">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<ScrollSection>
|
||||||
<div className="relative rounded-3xl glass-strong p-8 sm:p-12 lg:p-16 overflow-hidden">
|
<div className="relative rounded-3xl glass-strong p-8 sm:p-12 lg:p-16 overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-gradient-to-tr from-indigo-500/10 to-cyan-500/10 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
|
<div className="absolute bottom-0 left-0 w-48 h-48 bg-gradient-to-tr from-indigo-500/10 to-cyan-500/10 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
|
||||||
@@ -441,11 +584,12 @@ export default function HomePage() {
|
|||||||
<span className="hidden sm:inline">•</span>
|
<span className="hidden sm:inline">•</span>
|
||||||
<span>Free for public repositories</span>
|
<span>Free for public repositories</span>
|
||||||
<span className="hidden sm:inline">•</span>
|
<span className="hidden sm:inline">•</span>
|
||||||
<span>No signup required</span>
|
<span>Free tier available</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollSection>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
20
apps/web/src/app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{ userAgent: "GPTBot", allow: "/" },
|
||||||
|
{ userAgent: "ClaudeBot", allow: "/" },
|
||||||
|
{ userAgent: "PerplexityBot", allow: "/" },
|
||||||
|
{ userAgent: "CCBot", disallow: "/" },
|
||||||
|
{ userAgent: "Google-Extended", disallow: "/" },
|
||||||
|
{ userAgent: "Bytespider", disallow: "/" },
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
disallow: ["/api/"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: "https://codeboard.vectry.tech/sitemap.xml",
|
||||||
|
};
|
||||||
|
}
|
||||||
8
apps/web/src/app/sitemap.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const baseUrl = "https://codeboard.vectry.tech";
|
||||||
|
return [
|
||||||
|
{ url: baseUrl, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
|
||||||
|
];
|
||||||
|
}
|
||||||
9
apps/web/src/auth.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
providers: [],
|
||||||
|
session: { strategy: "jwt", maxAge: 7 * 24 * 60 * 60 },
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
91
apps/web/src/auth.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import { compare } from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
import authConfig from "./auth.config";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
isEmailVerified: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@auth/core/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id: string;
|
||||||
|
isEmailVerified: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials, request) {
|
||||||
|
const parsed = loginSchema.safeParse(credentials);
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
const ip = (request instanceof Request
|
||||||
|
? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
: undefined) ?? "unknown";
|
||||||
|
|
||||||
|
const rl = await checkRateLimit(`login:${ip}`, AUTH_RATE_LIMITS.login);
|
||||||
|
if (!rl.allowed) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const isValid = await compare(password, user.passwordHash);
|
||||||
|
if (!isValid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user, trigger }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id as string;
|
||||||
|
}
|
||||||
|
if (trigger === "update" || user) {
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: token.id },
|
||||||
|
select: { emailVerified: true },
|
||||||
|
});
|
||||||
|
if (dbUser) {
|
||||||
|
token.isEmailVerified = dbUser.emailVerified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
session.user.id = token.id;
|
||||||
|
session.user.isEmailVerified = token.isEmailVerified;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
224
apps/web/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Sparkles,
|
||||||
|
History,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
FileText,
|
||||||
|
Command as CommandIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface RecentDiagram {
|
||||||
|
id: string;
|
||||||
|
repoName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [recentDiagrams, setRecentDiagrams] = useState<RecentDiagram[]>([]);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", down);
|
||||||
|
return () => document.removeEventListener("keydown", down);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetch("/api/history")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setRecentDiagrams(
|
||||||
|
data.slice(0, 5).map((item: { id: string; repoName: string }) => ({
|
||||||
|
id: item.id,
|
||||||
|
repoName: item.repoName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const runCommand = useCallback(
|
||||||
|
(command: () => void) => {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
command();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="hidden md:flex items-center gap-2 px-3 py-1.5 text-sm text-zinc-500 hover:text-zinc-300 rounded-lg border border-white/10 bg-white/[0.02] hover:bg-white/[0.05] transition-colors"
|
||||||
|
aria-label="Open command palette"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
<span>Search...</span>
|
||||||
|
<kbd className="ml-2 pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-white/10 bg-white/[0.05] px-1.5 font-mono text-[10px] font-medium text-zinc-400">
|
||||||
|
<span className="text-xs">
|
||||||
|
{typeof navigator !== "undefined" &&
|
||||||
|
navigator.userAgent.includes("Mac")
|
||||||
|
? "\u2318"
|
||||||
|
: "Ctrl"}
|
||||||
|
</span>
|
||||||
|
K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-[100]">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-start justify-center pt-[20vh] px-4">
|
||||||
|
<Command
|
||||||
|
label="Command palette"
|
||||||
|
loop
|
||||||
|
className="w-full max-w-lg rounded-xl border border-white/10 bg-[#111113] shadow-2xl shadow-black/50 overflow-hidden animate-scale-in"
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 border-b border-white/[0.06]">
|
||||||
|
<Search className="w-4 h-4 text-zinc-500 flex-shrink-0" />
|
||||||
|
<Command.Input
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Type a command or search..."
|
||||||
|
className="flex-1 h-12 bg-transparent text-sm text-white placeholder-zinc-500 outline-none"
|
||||||
|
/>
|
||||||
|
<kbd className="flex-shrink-0 inline-flex h-5 items-center rounded border border-white/10 bg-white/[0.05] px-1.5 font-mono text-[10px] text-zinc-500">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Command.List className="max-h-80 overflow-y-auto scrollbar-thin p-2">
|
||||||
|
<Command.Empty className="py-8 text-center text-sm text-zinc-500">
|
||||||
|
No results found.
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
<Command.Group
|
||||||
|
heading="Navigation"
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500"
|
||||||
|
>
|
||||||
|
<Command.Item
|
||||||
|
value="home"
|
||||||
|
onSelect={() => runCommand(() => router.push("/"))}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-zinc-300 cursor-pointer data-[selected=true]:bg-white/[0.06] data-[selected=true]:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4 text-zinc-500" />
|
||||||
|
Home
|
||||||
|
</Command.Item>
|
||||||
|
<Command.Item
|
||||||
|
value="generate"
|
||||||
|
onSelect={() => runCommand(() => router.push("/generate"))}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-zinc-300 cursor-pointer data-[selected=true]:bg-white/[0.06] data-[selected=true]:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 text-zinc-500" />
|
||||||
|
Generate
|
||||||
|
</Command.Item>
|
||||||
|
<Command.Item
|
||||||
|
value="history"
|
||||||
|
onSelect={() => runCommand(() => router.push("/history"))}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-zinc-300 cursor-pointer data-[selected=true]:bg-white/[0.06] data-[selected=true]:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4 text-zinc-500" />
|
||||||
|
History
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
<Command.Separator className="my-2 h-px bg-white/[0.06]" />
|
||||||
|
|
||||||
|
<Command.Group
|
||||||
|
heading="Actions"
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500"
|
||||||
|
>
|
||||||
|
<Command.Item
|
||||||
|
value="new diagram"
|
||||||
|
onSelect={() => runCommand(() => router.push("/"))}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-zinc-300 cursor-pointer data-[selected=true]:bg-white/[0.06] data-[selected=true]:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 text-zinc-500" />
|
||||||
|
New Diagram
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{recentDiagrams.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Command.Separator className="my-2 h-px bg-white/[0.06]" />
|
||||||
|
<Command.Group
|
||||||
|
heading="Recent Diagrams"
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500"
|
||||||
|
>
|
||||||
|
{recentDiagrams.map((diagram) => (
|
||||||
|
<Command.Item
|
||||||
|
key={diagram.id}
|
||||||
|
value={diagram.repoName}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(`/docs/${diagram.id}`))
|
||||||
|
}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-zinc-300 cursor-pointer data-[selected=true]:bg-white/[0.06] data-[selected=true]:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 text-zinc-500" />
|
||||||
|
{diagram.repoName}
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Command.List>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-white/[0.06]">
|
||||||
|
<div className="flex items-center gap-3 text-xs text-zinc-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="inline-flex h-4 items-center rounded border border-white/10 bg-white/[0.05] px-1 font-mono text-[10px]">
|
||||||
|
↑
|
||||||
|
</kbd>
|
||||||
|
<kbd className="inline-flex h-4 items-center rounded border border-white/10 bg-white/[0.05] px-1 font-mono text-[10px]">
|
||||||
|
↓
|
||||||
|
</kbd>
|
||||||
|
navigate
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="inline-flex h-4 items-center rounded border border-white/10 bg-white/[0.05] px-1 font-mono text-[10px]">
|
||||||
|
↵
|
||||||
|
</kbd>
|
||||||
|
select
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-zinc-600">
|
||||||
|
<CommandIcon className="w-3 h-3" />
|
||||||
|
<span>CodeBoard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/web/src/components/keyboard-shortcuts-help.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface KeyboardShortcutsHelpProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shortcut({ keys, label }: { keys: string[]; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-sm text-zinc-300">{label}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<kbd
|
||||||
|
key={key}
|
||||||
|
className="inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded border border-white/10 bg-white/[0.05] px-1.5 font-mono text-xs text-zinc-400"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcutsHelp({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: KeyboardShortcutsHelpProps) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" || e.key === "?") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[90]" ref={overlayRef}>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center px-4">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
className="w-full max-w-md rounded-xl border border-white/10 bg-[#111113] shadow-2xl shadow-black/50 animate-scale-in"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
||||||
|
<h2 className="text-base font-semibold text-white">
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-white/[0.06] transition-colors"
|
||||||
|
aria-label="Close shortcuts help"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">
|
||||||
|
Navigation
|
||||||
|
</h3>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
<Shortcut keys={["j"]} label="Move down" />
|
||||||
|
<Shortcut keys={["k"]} label="Move up" />
|
||||||
|
<Shortcut keys={["Enter"]} label="Open selected" />
|
||||||
|
<Shortcut keys={["Esc"]} label="Clear selection" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">
|
||||||
|
Go To
|
||||||
|
</h3>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
<Shortcut keys={["g", "h"]} label="Go to Home" />
|
||||||
|
<Shortcut keys={["g", "g"]} label="Go to Generate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">
|
||||||
|
General
|
||||||
|
</h3>
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
<Shortcut
|
||||||
|
keys={["\u2318", "K"]}
|
||||||
|
label="Command palette"
|
||||||
|
/>
|
||||||
|
<Shortcut keys={["?"]} label="Show shortcuts" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-t border-white/[0.06]">
|
||||||
|
<p className="text-xs text-zinc-500 text-center">
|
||||||
|
Press <kbd className="inline-flex h-4 items-center rounded border border-white/10 bg-white/[0.05] px-1 font-mono text-[10px] text-zinc-400">?</kbd> or <kbd className="inline-flex h-4 items-center rounded border border-white/10 bg-white/[0.05] px-1 font-mono text-[10px] text-zinc-400">Esc</kbd> to close
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ interface MermaidDiagramProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
|
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [svgHtml, setSvgHtml] = useState<string>("");
|
const [svgHtml, setSvgHtml] = useState<string>("");
|
||||||
@@ -26,7 +25,7 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
|
|||||||
securityLevel: "loose",
|
securityLevel: "loose",
|
||||||
themeVariables: {
|
themeVariables: {
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
background: "#0a0a0f",
|
background: "#0a0a0a",
|
||||||
primaryColor: "#1e3a5f",
|
primaryColor: "#1e3a5f",
|
||||||
primaryTextColor: "#ffffff",
|
primaryTextColor: "#ffffff",
|
||||||
primaryBorderColor: "#3b82f6",
|
primaryBorderColor: "#3b82f6",
|
||||||
@@ -184,22 +183,29 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
|
|||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
style={fullHeight ? { height: "100%" } : { minHeight: "100px" }}
|
style={fullHeight ? { height: "100%" } : { minHeight: "100px" }}
|
||||||
>
|
>
|
||||||
<div
|
{svgHtml ? (
|
||||||
className="mermaid-diagram flex items-center justify-center"
|
<div
|
||||||
style={{
|
className="mermaid-diagram flex items-center justify-center"
|
||||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
style={{
|
||||||
transformOrigin: "center center",
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||||
transition: isPanning ? "none" : "transform 0.15s ease-out",
|
transformOrigin: "center center",
|
||||||
minHeight: fullHeight ? "100%" : "100px",
|
transition: isPanning ? "none" : "transform 0.15s ease-out",
|
||||||
}}
|
minHeight: fullHeight ? "100%" : "100px",
|
||||||
dangerouslySetInnerHTML={svgHtml ? { __html: svgHtml } : undefined}
|
}}
|
||||||
>
|
dangerouslySetInnerHTML={{ __html: svgHtml }}
|
||||||
{!svgHtml && !isReady && (
|
/>
|
||||||
<div className="flex items-center justify-center py-8">
|
) : (
|
||||||
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
<div
|
||||||
</div>
|
className="mermaid-diagram flex items-center justify-center"
|
||||||
)}
|
style={{ minHeight: fullHeight ? "100%" : "100px" }}
|
||||||
</div>
|
>
|
||||||
|
{!isReady && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -207,7 +213,7 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ minHeight: "100px" }} />
|
<div style={{ minHeight: "100px" }} />
|
||||||
<div className="fixed inset-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-sm flex flex-col">
|
<div className="fixed inset-0 z-50 bg-[#0a0a0a]/95 backdrop-blur-sm flex flex-col">
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-b border-white/10">
|
<div className="flex items-center justify-between px-6 py-3 border-b border-white/10">
|
||||||
<span className="text-sm text-zinc-400">Architecture Diagram</span>
|
<span className="text-sm text-zinc-400">Architecture Diagram</span>
|
||||||
{controls}
|
{controls}
|
||||||
|
|||||||
@@ -2,25 +2,34 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Code2, Menu, X, Github } from "lucide-react";
|
import Image from "next/image";
|
||||||
|
import { Menu, X, Github, LogIn, UserCircle } from "lucide-react";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "/#how-it-works", label: "How it Works" },
|
{ href: "/#how-it-works", label: "How it Works" },
|
||||||
{ href: "/#features", label: "Features" },
|
{ href: "/#features", label: "Features" },
|
||||||
|
{ href: "/#pricing", label: "Pricing" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 right-0 z-50">
|
<header className="fixed top-0 left-0 right-0 z-50">
|
||||||
<nav className="glass border-b border-white/5">
|
<nav className="glass border-b border-white/5" aria-label="Main navigation">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<Link href="/" className="flex items-center gap-2 group">
|
<Link href="/" className="flex items-center gap-2 group">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center group-hover:shadow-lg group-hover:shadow-blue-500/25 transition-shadow">
|
<Image
|
||||||
<Code2 className="w-5 h-5 text-white" />
|
src="/logo-icon.png"
|
||||||
</div>
|
alt="CodeBoard"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-lg group-hover:shadow-lg group-hover:shadow-blue-500/25 transition-shadow"
|
||||||
|
/>
|
||||||
<span className="text-lg font-semibold text-white">
|
<span className="text-lg font-semibold text-white">
|
||||||
CodeBoard
|
CodeBoard
|
||||||
</span>
|
</span>
|
||||||
@@ -37,6 +46,8 @@ export function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<CommandPalette />
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://gitea.repi.fun/repi/codeboard"
|
href="https://gitea.repi.fun/repi/codeboard"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -46,6 +57,42 @@ export function Navbar() {
|
|||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
Source
|
Source
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div className="w-24 h-8" />
|
||||||
|
) : session ? (
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<UserCircle className="w-4 h-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut()}
|
||||||
|
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -85,6 +132,50 @@ export function Navbar() {
|
|||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
Source
|
Source
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{status !== "loading" && (
|
||||||
|
<div className="border-t border-white/5 pt-3 mt-3 space-y-3">
|
||||||
|
{session ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||||
|
>
|
||||||
|
<UserCircle className="w-4 h-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
signOut();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className="block text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="block text-center px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
8
apps/web/src/components/providers.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
@@ -52,10 +52,14 @@ export function RepoInput() {
|
|||||||
<form onSubmit={handleSubmit} className="w-full">
|
<form onSubmit={handleSubmit} className="w-full">
|
||||||
<div className="relative flex flex-col sm:flex-row gap-3">
|
<div className="relative flex flex-col sm:flex-row gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">
|
<label htmlFor="repo-url-input" className="sr-only">
|
||||||
|
GitHub repository URL
|
||||||
|
</label>
|
||||||
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" aria-hidden="true">
|
||||||
<Github className="w-5 h-5" />
|
<Github className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id="repo-url-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
28
apps/web/src/components/scroll-section.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useScrollAnimate } from "@/hooks/use-scroll-animate";
|
||||||
|
|
||||||
|
interface ScrollSectionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollSection({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
delay,
|
||||||
|
}: ScrollSectionProps) {
|
||||||
|
const ref = useScrollAnimate<HTMLDivElement>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-animate="hidden"
|
||||||
|
data-animate-delay={delay}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/web/src/hooks/use-keyboard-nav.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface UseKeyboardNavOptions {
|
||||||
|
itemCount: number;
|
||||||
|
onSelect?: (index: number) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardNav({
|
||||||
|
itemCount,
|
||||||
|
onSelect,
|
||||||
|
enabled = true,
|
||||||
|
}: UseKeyboardNavOptions) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const gPrefixRef = useRef(false);
|
||||||
|
const gTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearGPrefix = useCallback(() => {
|
||||||
|
gPrefixRef.current = false;
|
||||||
|
if (gTimerRef.current) {
|
||||||
|
clearTimeout(gTimerRef.current);
|
||||||
|
gTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const isInputFocused = () => {
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (isInputFocused()) return;
|
||||||
|
|
||||||
|
if (gPrefixRef.current) {
|
||||||
|
clearGPrefix();
|
||||||
|
if (e.key === "h") {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "g") {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push("/generate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "j":
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((prev) => {
|
||||||
|
if (itemCount === 0) return -1;
|
||||||
|
return prev < itemCount - 1 ? prev + 1 : prev;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "k":
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((prev) => {
|
||||||
|
if (itemCount === 0) return -1;
|
||||||
|
return prev > 0 ? prev - 1 : 0;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
if (activeIndex >= 0 && onSelect) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(activeIndex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(-1);
|
||||||
|
break;
|
||||||
|
case "g":
|
||||||
|
e.preventDefault();
|
||||||
|
gPrefixRef.current = true;
|
||||||
|
gTimerRef.current = setTimeout(clearGPrefix, 1000);
|
||||||
|
break;
|
||||||
|
case "?":
|
||||||
|
e.preventDefault();
|
||||||
|
setShowHelp((prev) => !prev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handler);
|
||||||
|
clearGPrefix();
|
||||||
|
};
|
||||||
|
}, [enabled, itemCount, activeIndex, onSelect, router, clearGPrefix]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIndex >= 0) {
|
||||||
|
const el = document.querySelector(
|
||||||
|
`[data-keyboard-index="${activeIndex}"]`
|
||||||
|
);
|
||||||
|
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
return { activeIndex, setActiveIndex, showHelp, setShowHelp };
|
||||||
|
}
|
||||||
52
apps/web/src/hooks/use-scroll-animate.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface UseScrollAnimateOptions {
|
||||||
|
threshold?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
once?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollAnimate<T extends HTMLElement = HTMLDivElement>({
|
||||||
|
threshold = 0.1,
|
||||||
|
rootMargin = "0px 0px -60px 0px",
|
||||||
|
once = true,
|
||||||
|
}: UseScrollAnimateOptions = {}) {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia(
|
||||||
|
"(prefers-reduced-motion: reduce)"
|
||||||
|
).matches;
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
el.setAttribute("data-animate", "visible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.setAttribute("data-animate", "visible");
|
||||||
|
if (once) {
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
} else if (!once) {
|
||||||
|
entry.target.setAttribute("data-animate", "hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold, rootMargin }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [threshold, rootMargin, once]);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
13
apps/web/src/instrumentation.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await import("../sentry.server.config");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") {
|
||||||
|
await import("../sentry.edge.config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
33
apps/web/src/lib/api-key.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function validateApiKey(bearerToken: string) {
|
||||||
|
const keyHash = createHash("sha256").update(bearerToken).digest("hex");
|
||||||
|
|
||||||
|
const apiKey = await prisma.apiKey.findFirst({
|
||||||
|
where: { keyHash, revoked: false },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKey) return null;
|
||||||
|
|
||||||
|
prisma.apiKey
|
||||||
|
.update({
|
||||||
|
where: { id: apiKey.id },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: apiKey.userId,
|
||||||
|
user: apiKey.user,
|
||||||
|
subscription: apiKey.user.subscription,
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
36
apps/web/src/lib/email.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
interface SendEmailOptions {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
|
||||||
|
const password = process.env.EMAIL_PASSWORD;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
console.warn(
|
||||||
|
"[email] EMAIL_PASSWORD not set — skipping email send to:",
|
||||||
|
to
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: "smtp.migadu.com",
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: "hunter@repi.fun",
|
||||||
|
pass: password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: "CodeBoard <hunter@repi.fun>",
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
9
apps/web/src/lib/prisma.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from "@codeboard/database";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
53
apps/web/src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { getRedis } from "./redis";
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
windowMs: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitResult {
|
||||||
|
allowed: boolean;
|
||||||
|
remaining: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
config: RateLimitConfig
|
||||||
|
): Promise<RateLimitResult> {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - config.windowMs;
|
||||||
|
const redisKey = `rl:${key}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redis = getRedis();
|
||||||
|
await redis.zremrangebyscore(redisKey, 0, windowStart);
|
||||||
|
const count = await redis.zcard(redisKey);
|
||||||
|
|
||||||
|
if (count >= config.maxAttempts) {
|
||||||
|
const oldestEntry = await redis.zrange(redisKey, 0, 0, "WITHSCORES");
|
||||||
|
const resetAt = oldestEntry.length >= 2
|
||||||
|
? parseInt(oldestEntry[1], 10) + config.windowMs
|
||||||
|
: now + config.windowMs;
|
||||||
|
return { allowed: false, remaining: 0, resetAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
|
||||||
|
await redis.pexpire(redisKey, config.windowMs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxAttempts - count - 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { allowed: true, remaining: config.maxAttempts, resetAt: now + config.windowMs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_RATE_LIMITS = {
|
||||||
|
login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },
|
||||||
|
register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
|
||||||
|
forgotPassword: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
|
||||||
|
resetPassword: { windowMs: 15 * 60 * 1000, maxAttempts: 5 },
|
||||||
|
} as const;
|
||||||
35
apps/web/src/lib/stripe.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
let _stripe: Stripe | null = null;
|
||||||
|
|
||||||
|
export function getStripe(): Stripe {
|
||||||
|
if (!_stripe) {
|
||||||
|
const key = process.env.STRIPE_SECRET_KEY;
|
||||||
|
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
|
||||||
|
_stripe = new Stripe(key, { apiVersion: "2026-01-28.clover" });
|
||||||
|
}
|
||||||
|
return _stripe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIER_CONFIG = {
|
||||||
|
FREE: {
|
||||||
|
name: "Free",
|
||||||
|
generationsLimit: 15,
|
||||||
|
period: "day",
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
STARTER: {
|
||||||
|
name: "Starter",
|
||||||
|
priceId: process.env.STRIPE_STARTER_PRICE_ID!,
|
||||||
|
generationsLimit: 1000,
|
||||||
|
period: "month",
|
||||||
|
price: 5,
|
||||||
|
},
|
||||||
|
PRO: {
|
||||||
|
name: "Pro",
|
||||||
|
priceId: process.env.STRIPE_PRO_PRICE_ID!,
|
||||||
|
generationsLimit: 100000,
|
||||||
|
period: "month",
|
||||||
|
price: 20,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
6
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
90
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import authConfig from "./auth.config";
|
||||||
|
|
||||||
|
const { auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
|
const publicPaths = [
|
||||||
|
"/",
|
||||||
|
"/docs",
|
||||||
|
"/generate",
|
||||||
|
"/history",
|
||||||
|
"/api/auth",
|
||||||
|
"/api/generate",
|
||||||
|
"/api/generations",
|
||||||
|
"/api/health",
|
||||||
|
"/api/stripe/webhook",
|
||||||
|
"/forgot-password",
|
||||||
|
"/reset-password",
|
||||||
|
"/verify-email",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
return publicPaths.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(`${p}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = new Set([
|
||||||
|
"https://codeboard.vectry.tech",
|
||||||
|
"http://localhost:3000",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function corsHeaders(origin: string | null): Record<string, string> {
|
||||||
|
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin)
|
||||||
|
? origin
|
||||||
|
: "https://codeboard.vectry.tech";
|
||||||
|
return {
|
||||||
|
"Access-Control-Allow-Origin": allowedOrigin,
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
const isLoggedIn = !!req.auth;
|
||||||
|
const origin = req.headers.get("origin");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (() => {
|
||||||
|
if (isPublicPath(pathname)) {
|
||||||
|
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/login" || pathname === "/register") {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/dashboard") && !isLoggedIn) {
|
||||||
|
const loginUrl = new URL("/login", req.nextUrl.origin);
|
||||||
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
const headers = corsHeaders(origin);
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
response.headers.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|og-image.png).*)"],
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@codeboard/parser": "*",
|
"@codeboard/parser": "*",
|
||||||
"@codeboard/llm": "*",
|
"@codeboard/llm": "*",
|
||||||
"@codeboard/diagrams": "*",
|
"@codeboard/diagrams": "*",
|
||||||
|
"@codeboard/database": "*",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"simple-git": "^3.27.0"
|
"simple-git": "^3.27.0"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import IORedis from "ioredis";
|
import IORedis from "ioredis";
|
||||||
|
import { prisma } from "@codeboard/database";
|
||||||
import { cloneRepository } from "./jobs/clone.js";
|
import { cloneRepository } from "./jobs/clone.js";
|
||||||
import { parseRepository } from "./jobs/parse.js";
|
import { parseRepository } from "./jobs/parse.js";
|
||||||
import { generateDocs } from "./jobs/generate.js";
|
import { generateDocs } from "./jobs/generate.js";
|
||||||
@@ -7,6 +8,7 @@ import { generateDocs } from "./jobs/generate.js";
|
|||||||
interface GenerateJobData {
|
interface GenerateJobData {
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
generationId: string;
|
generationId: string;
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379");
|
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379");
|
||||||
@@ -38,6 +40,33 @@ export async function processGenerationJob(
|
|||||||
try {
|
try {
|
||||||
await updateProgress(generationId, "CLONING", 10, "Cloning repository...");
|
await updateProgress(generationId, "CLONING", 10, "Cloning repository...");
|
||||||
const cloneResult = await cloneRepository(repoUrl);
|
const cloneResult = await cloneRepository(repoUrl);
|
||||||
|
const commitHash = cloneResult.metadata.lastCommit;
|
||||||
|
|
||||||
|
const existingGeneration = await prisma.generation.findUnique({
|
||||||
|
where: {
|
||||||
|
repoUrl_commitHash: {
|
||||||
|
repoUrl,
|
||||||
|
commitHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingGeneration && existingGeneration.result) {
|
||||||
|
const docs = existingGeneration.result as any;
|
||||||
|
docs.id = generationId;
|
||||||
|
docs.repoUrl = repoUrl;
|
||||||
|
docs.repoName = existingGeneration.repoName;
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
`codeboard:result:${generationId}`,
|
||||||
|
JSON.stringify(docs),
|
||||||
|
"EX",
|
||||||
|
86400
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateProgress(generationId, "COMPLETED", 100, "Using cached documentation!");
|
||||||
|
return { generationId, duration: 0, repoName: existingGeneration.repoName, cached: true };
|
||||||
|
}
|
||||||
|
|
||||||
await updateProgress(
|
await updateProgress(
|
||||||
generationId,
|
generationId,
|
||||||
@@ -72,9 +101,23 @@ export async function processGenerationJob(
|
|||||||
86400
|
86400
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await prisma.generation.create({
|
||||||
|
data: {
|
||||||
|
id: generationId,
|
||||||
|
repoUrl,
|
||||||
|
repoName: cloneResult.metadata.name,
|
||||||
|
commitHash,
|
||||||
|
status: "COMPLETED",
|
||||||
|
progress: 100,
|
||||||
|
result: docs as any,
|
||||||
|
duration,
|
||||||
|
userId: job.data.userId ?? null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await updateProgress(generationId, "COMPLETED", 100, "Done!");
|
await updateProgress(generationId, "COMPLETED", 100, "Done!");
|
||||||
|
|
||||||
return { generationId, duration, repoName: cloneResult.metadata.name };
|
return { generationId, duration, repoName: cloneResult.metadata.name, cached: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
await updateProgress(generationId, "FAILED", 0, message);
|
await updateProgress(generationId, "FAILED", 0, message);
|
||||||
|
|||||||
@@ -7,9 +7,29 @@ services:
|
|||||||
- "4100:3000"
|
- "4100:3000"
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
||||||
|
- AUTH_SECRET=${AUTH_SECRET:-}
|
||||||
|
- AUTH_URL=https://codeboard.vectry.tech
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
- STRIPE_STARTER_PRICE_ID=price_1SzMQbR8i0An4Wz70Elgk5Zd
|
||||||
|
- STRIPE_PRO_PRICE_ID=price_1SzMQrR8i0An4Wz7UseMs0yy
|
||||||
|
- EMAIL_FROM=CodeBoard <noreply@vectry.tech>
|
||||||
|
- EMAIL_HOST=smtp.migadu.com
|
||||||
|
- EMAIL_PORT=465
|
||||||
|
- EMAIL_SECURE=true
|
||||||
|
- EMAIL_USER=hunter@repi.fun
|
||||||
|
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
|
||||||
|
- NEXT_PUBLIC_APP_URL=https://codeboard.vectry.tech
|
||||||
|
- NEXT_PUBLIC_SENTRY_DSN=https://637c487708794aaf8f2399496cd2e6c6@glitchtip.vectry.tech/2
|
||||||
|
- SENTRY_DSN=https://637c487708794aaf8f2399496cd2e6c6@glitchtip.vectry.tech/2
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
@@ -18,6 +38,7 @@ services:
|
|||||||
target: worker
|
target: worker
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
- LLM_MODEL=${LLM_MODEL:-kimi-k2-turbo-preview}
|
- LLM_MODEL=${LLM_MODEL:-kimi-k2-turbo-preview}
|
||||||
@@ -25,8 +46,39 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=codeboard
|
||||||
|
- POSTGRES_PASSWORD=codeboard
|
||||||
|
- POSTGRES_DB=codeboard
|
||||||
|
volumes:
|
||||||
|
- codeboard_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U codeboard"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: builder
|
||||||
|
command: npx prisma migrate deploy --schema=packages/database/prisma/schema.prisma
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
volumes:
|
volumes:
|
||||||
@@ -34,4 +86,5 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
codeboard_postgres_data:
|
||||||
codeboard_redis_data:
|
codeboard_redis_data:
|
||||||
|
|||||||
805
package-lock.json
generated
@@ -23,20 +23,31 @@
|
|||||||
"name": "@codeboard/web",
|
"name": "@codeboard/web",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codeboard/database": "*",
|
||||||
"@codeboard/shared": "*",
|
"@codeboard/shared": "*",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.4.0",
|
"mermaid": "^11.4.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-markdown": "^9.0.0"
|
"react-markdown": "^9.0.0",
|
||||||
|
"stripe": "^20.3.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"postcss": "^8.5.0",
|
"postcss": "^8.5.0",
|
||||||
@@ -44,10 +55,20 @@
|
|||||||
"typescript": "^5.7"
|
"typescript": "^5.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/web/node_modules/nodemailer": {
|
||||||
|
"version": "7.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
|
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"apps/worker": {
|
"apps/worker": {
|
||||||
"name": "@codeboard/worker",
|
"name": "@codeboard/worker",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codeboard/database": "*",
|
||||||
"@codeboard/diagrams": "*",
|
"@codeboard/diagrams": "*",
|
||||||
"@codeboard/llm": "*",
|
"@codeboard/llm": "*",
|
||||||
"@codeboard/parser": "*",
|
"@codeboard/parser": "*",
|
||||||
@@ -118,6 +139,35 @@
|
|||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@auth/core": {
|
||||||
|
"version": "0.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
|
||||||
|
"integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@panva/hkdf": "^1.2.1",
|
||||||
|
"jose": "^6.0.6",
|
||||||
|
"oauth4webapi": "^3.3.0",
|
||||||
|
"preact": "10.24.3",
|
||||||
|
"preact-render-to-string": "6.5.11"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"nodemailer": "^6.8.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1113,6 +1163,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
||||||
@@ -1198,6 +1257,447 @@
|
|||||||
"@prisma/debug": "6.19.2"
|
"@prisma/debug": "6.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"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-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-dialog": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||||
|
"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-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@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",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.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-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-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "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-dismissable-layer/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-focus-guards": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||||
|
"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-focus-scope": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "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-focus-scope/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-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"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-portal": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-layout-effect": "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-portal/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-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "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-primitive": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.4"
|
||||||
|
},
|
||||||
|
"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-primitive/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||||
|
"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-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",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"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-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"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-effect-event": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"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-escape-keydown": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"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-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"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/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -1514,6 +2014,13 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3": {
|
"node_modules/@types/d3": {
|
||||||
"version": "7.4.3",
|
"version": "7.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||||
@@ -1840,6 +2347,16 @@
|
|||||||
"form-data": "^4.0.4"
|
"form-data": "^4.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "6.4.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.22.tgz",
|
||||||
|
"integrity": "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1860,7 +2377,7 @@
|
|||||||
"version": "18.3.7",
|
"version": "18.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
@@ -1921,6 +2438,18 @@
|
|||||||
"node": ">= 8.0.0"
|
"node": ">= 8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/aria-hidden": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -1937,6 +2466,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bullmq": {
|
"node_modules/bullmq": {
|
||||||
"version": "5.67.3",
|
"version": "5.67.3",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.3.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.3.tgz",
|
||||||
@@ -2158,6 +2696,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
@@ -2167,6 +2714,22 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cmdk": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-id": "^1.1.0",
|
||||||
|
"@radix-ui/react-primitive": "^2.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -2872,6 +3435,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||||
@@ -3197,6 +3766,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-proto": {
|
"node_modules/get-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
@@ -3525,6 +4103,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4739,6 +5326,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "5.0.0-beta.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz",
|
||||||
|
"integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "0.41.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"react": "^18.2.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -4835,6 +5449,17 @@
|
|||||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "6.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||||
|
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.5",
|
"version": "0.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
||||||
@@ -4860,6 +5485,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz",
|
||||||
|
"integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ohash": {
|
"node_modules/ohash": {
|
||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
@@ -5068,6 +5702,25 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.24.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "6.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
||||||
|
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
||||||
@@ -5184,6 +5837,75 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -5408,6 +6130,23 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "20.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
|
||||||
|
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/style-to-js": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||||
@@ -5455,6 +6194,16 @@
|
|||||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
@@ -5760,6 +6509,49 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -5896,6 +6688,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zwitch": {
|
"node_modules/zwitch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
"name": "@codeboard/database",
|
"name": "@codeboard/database",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/client.ts",
|
"main": "./dist/client.js",
|
||||||
"types": "./src/client.ts",
|
"types": "./dist/client.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/client.ts"
|
".": {
|
||||||
|
"import": "./dist/client.js",
|
||||||
|
"require": "./dist/client.js",
|
||||||
|
"types": "./dist/client.d.ts"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'database package uses prisma generate'",
|
"build": "prisma generate && tsc",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
|
|||||||
49
packages/database/prisma/migrations/0001_init/migration.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Status" AS ENUM ('QUEUED', 'CLONING', 'PARSING', 'GENERATING', 'RENDERING', 'COMPLETED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Generation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"repoUrl" TEXT NOT NULL,
|
||||||
|
"repoName" TEXT NOT NULL,
|
||||||
|
"commitHash" TEXT NOT NULL,
|
||||||
|
"status" "Status" NOT NULL DEFAULT 'QUEUED',
|
||||||
|
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"result" JSONB,
|
||||||
|
"error" TEXT,
|
||||||
|
"costUsd" DOUBLE PRECISION,
|
||||||
|
"duration" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"viewCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "Generation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"githubId" TEXT NOT NULL,
|
||||||
|
"login" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"avatarUrl" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Generation_repoUrl_idx" ON "Generation"("repoUrl");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Generation_status_idx" ON "Generation"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Generation_repoUrl_commitHash_key" ON "Generation"("repoUrl", "commitHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_githubId_key" ON "User"("githubId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Generation" ADD CONSTRAINT "Generation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SubscriptionTier" AS ENUM ('FREE', 'STARTER', 'PRO');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'PAST_DUE', 'CANCELED', 'UNPAID');
|
||||||
|
|
||||||
|
-- Drop existing foreign key on Generation
|
||||||
|
ALTER TABLE "Generation" DROP CONSTRAINT IF EXISTS "Generation_userId_fkey";
|
||||||
|
|
||||||
|
-- Drop old User table (clean migration — no real users)
|
||||||
|
DROP TABLE IF EXISTS "User" CASCADE;
|
||||||
|
|
||||||
|
-- CreateTable User (new schema with auth fields)
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable Subscription
|
||||||
|
CREATE TABLE "Subscription" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tier" "SubscriptionTier" NOT NULL DEFAULT 'FREE',
|
||||||
|
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"stripeCustomerId" TEXT,
|
||||||
|
"stripeSubscriptionId" TEXT,
|
||||||
|
"stripePriceId" TEXT,
|
||||||
|
"generationsLimit" INTEGER NOT NULL DEFAULT 15,
|
||||||
|
"generationsUsed" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"currentPeriodStart" TIMESTAMP(3),
|
||||||
|
"currentPeriodEnd" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable ApiKey
|
||||||
|
CREATE TABLE "ApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL DEFAULT 'Default',
|
||||||
|
"keyHash" TEXT NOT NULL,
|
||||||
|
"keyPrefix" TEXT NOT NULL,
|
||||||
|
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable PasswordResetToken
|
||||||
|
CREATE TABLE "PasswordResetToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable EmailVerificationToken
|
||||||
|
CREATE TABLE "EmailVerificationToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Subscription_stripeCustomerId_key" ON "Subscription"("stripeCustomerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Subscription_stripeSubscriptionId_key" ON "Subscription"("stripeSubscriptionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "EmailVerificationToken_token_key" ON "EmailVerificationToken"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Generation_userId_idx" ON "Generation"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Generation" ADD CONSTRAINT "Generation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EmailVerificationToken" ADD CONSTRAINT "EmailVerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -7,6 +7,112 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String?
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
image String?
|
||||||
|
stripeCustomerId String? @unique
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
subscription Subscription?
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
generations Generation[]
|
||||||
|
passwordResetTokens PasswordResetToken[]
|
||||||
|
emailVerificationTokens EmailVerificationToken[]
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
tier SubscriptionTier @default(FREE)
|
||||||
|
stripeCustomerId String? @unique
|
||||||
|
stripeSubscriptionId String? @unique
|
||||||
|
stripePriceId String?
|
||||||
|
|
||||||
|
currentPeriodStart DateTime?
|
||||||
|
currentPeriodEnd DateTime?
|
||||||
|
|
||||||
|
generationsUsed Int @default(0)
|
||||||
|
generationsLimit Int @default(15)
|
||||||
|
|
||||||
|
status SubscriptionStatus @default(ACTIVE)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([stripeCustomerId])
|
||||||
|
@@index([stripeSubscriptionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String @default("Default")
|
||||||
|
keyHash String @unique
|
||||||
|
keyPrefix String
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
|
||||||
|
revoked Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([keyHash])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
used Boolean @default(false)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model EmailVerificationToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
used Boolean @default(false)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionTier {
|
||||||
|
FREE
|
||||||
|
STARTER
|
||||||
|
PRO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
ACTIVE
|
||||||
|
PAST_DUE
|
||||||
|
CANCELED
|
||||||
|
UNPAID
|
||||||
|
}
|
||||||
|
|
||||||
model Generation {
|
model Generation {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
repoUrl String
|
repoUrl String
|
||||||
@@ -27,16 +133,7 @@ model Generation {
|
|||||||
@@unique([repoUrl, commitHash])
|
@@unique([repoUrl, commitHash])
|
||||||
@@index([repoUrl])
|
@@index([repoUrl])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
@@index([userId])
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
githubId String @unique
|
|
||||||
login String
|
|
||||||
email String?
|
|
||||||
avatarUrl String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
generations Generation[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Status {
|
enum Status {
|
||||||
|
|||||||
8
packages/database/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
"db:generate": {
|
"db:generate": {
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
|
"db:migrate": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
"db:push": {
|
"db:push": {
|
||||||
"cache": false
|
"cache": false
|
||||||
}
|
}
|
||||||
|
|||||||