Compare commits
19 Commits
b3c375d26d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e72f55fedc | ||
|
|
64ce70daa4 | ||
|
|
7ff493a89a | ||
|
|
38d5b4806c | ||
|
|
de8b827562 | ||
|
|
40d60b1ce6 | ||
|
|
72de50dffa | ||
|
|
734823d3f6 | ||
|
|
30bfd88075 | ||
|
|
a49f05e8df | ||
|
|
dd03d86642 | ||
|
|
31be269aab | ||
|
|
cbe52f32b3 | ||
|
|
029cd82f1a | ||
|
|
327e19df8f | ||
|
|
f4ed838f77 | ||
|
|
03d9c297e2 | ||
|
|
d0c4b1ae28 | ||
|
|
79dad6124f |
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
coverage
|
||||
.DS_Store
|
||||
tmp
|
||||
.vercel
|
||||
*.log
|
||||
.git
|
||||
tests
|
||||
README.md
|
||||
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
DATABASE_URL=postgresql://codeboard:codeboard@localhost:5432/codeboard
|
||||
REDIS_URL=redis://localhost:6379
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
LLM_MODEL=
|
||||
LLM_BASE_URL=
|
||||
AUTH_SECRET=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_STARTER_PRICE_ID=
|
||||
STRIPE_PRO_PRICE_ID=
|
||||
EMAIL_PASSWORD=
|
||||
144
.gitignore
vendored
@@ -1,138 +1,14 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
.next/
|
||||
dist/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.env.*.local
|
||||
coverage/
|
||||
.DS_Store
|
||||
tmp/
|
||||
.vercel
|
||||
*.log
|
||||
generated/
|
||||
|
||||
55
Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY apps/worker/package.json ./apps/worker/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/parser/package.json ./packages/parser/
|
||||
COPY packages/llm/package.json ./packages/llm/
|
||||
COPY packages/diagrams/package.json ./packages/diagrams/
|
||||
COPY packages/database/package.json ./packages/database/
|
||||
RUN npm install --production=false
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate --schema=packages/database/prisma/schema.prisma
|
||||
RUN npx turbo build
|
||||
|
||||
FROM base AS web
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
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/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/database/prisma ./packages/database/prisma
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000 HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
FROM base AS worker
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 workeruser
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps/worker/dist ./apps/worker/dist
|
||||
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/llm/dist ./packages/llm/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/apps/worker/package.json ./apps/worker/
|
||||
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
||||
COPY --from=builder /app/packages/parser/package.json ./packages/parser/
|
||||
COPY --from=builder /app/packages/llm/package.json ./packages/llm/
|
||||
COPY --from=builder /app/packages/diagrams/package.json ./packages/diagrams/
|
||||
USER workeruser
|
||||
CMD ["node", "apps/worker/dist/index.js"]
|
||||
61
README.md
@@ -1,3 +1,60 @@
|
||||
# codeboard
|
||||
# CodeBoard
|
||||
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
codeboard/
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js 14 frontend + API routes
|
||||
│ └── worker/ # BullMQ job processor
|
||||
├── packages/
|
||||
│ ├── shared/ # TypeScript types
|
||||
│ ├── parser/ # Babel-based AST parser (JS/TS) + regex (Python)
|
||||
│ ├── llm/ # OpenAI/Anthropic abstraction + prompt pipeline
|
||||
│ ├── diagrams/ # Mermaid diagram generators
|
||||
│ └── database/ # Prisma schema + client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build all packages
|
||||
npm run build
|
||||
|
||||
# Start with Docker
|
||||
docker compose up
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run dev server (all workspaces)
|
||||
npm run dev
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 14, React 18, Tailwind CSS 4
|
||||
- **Backend**: BullMQ workers, Redis pub/sub for real-time progress
|
||||
- **Parser**: @babel/parser for JS/TS, regex-based for Python
|
||||
- **LLM**: Provider abstraction (OpenAI GPT-4o / Anthropic Claude)
|
||||
- **Diagrams**: Mermaid.js auto-generated architecture & dependency graphs
|
||||
- **Database**: PostgreSQL + Prisma
|
||||
- **Queue**: Redis + BullMQ
|
||||
- **Deployment**: Docker multi-stage builds
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
Built by [Vectry](https://vectry.tech) — Engineering AI into your workflow.
|
||||
|
||||
5
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
7
apps/web/next.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
transpilePackages: ["@codeboard/shared", "@codeboard/database"],
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default config;
|
||||
45
apps/web/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@codeboard/web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate --schema=../../packages/database/prisma/schema.prisma",
|
||||
"db:push": "prisma db push --schema=../../packages/database/prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codeboard/database": "*",
|
||||
"@codeboard/shared": "*",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ioredis": "^5.4.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"next": "^14.2.0",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"nodemailer": "^7.0.7",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-markdown": "^9.0.0",
|
||||
"stripe": "^20.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7"
|
||||
}
|
||||
}
|
||||
5
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
0
apps/web/public/.gitkeep
Normal file
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 |
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));
|
||||
}
|
||||
42
apps/web/src/app/api/docs/[id]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRedis } from "@/lib/redis";
|
||||
import { prisma } from "@codeboard/database";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const redis = getRedis();
|
||||
|
||||
let result = await redis.get(`codeboard:result:${id}`);
|
||||
|
||||
if (result) {
|
||||
return NextResponse.json(JSON.parse(result));
|
||||
}
|
||||
|
||||
const generation = await prisma.generation.findFirst({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!generation || !generation.result) {
|
||||
return NextResponse.json(
|
||||
{ error: "Documentation not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
109
apps/web/src/app/api/generate/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getQueue } from "@/lib/queue";
|
||||
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.-]+\/?$/;
|
||||
|
||||
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) {
|
||||
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 repoUrl: string = body.repoUrl?.trim();
|
||||
|
||||
if (!repoUrl || !GITHUB_URL_RE.test(repoUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid GitHub repository URL" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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 redis = getRedis();
|
||||
await redis.set(
|
||||
`codeboard:status:${generationId}`,
|
||||
JSON.stringify({ status: "QUEUED", progress: 0, message: "Queued..." }),
|
||||
"EX",
|
||||
3600
|
||||
);
|
||||
|
||||
const queue = getQueue();
|
||||
await queue.add("generate", { repoUrl, generationId, userId }, {
|
||||
jobId: generationId,
|
||||
removeOnComplete: { age: 3600 },
|
||||
removeOnFail: false,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ id: generationId, status: "QUEUED" },
|
||||
{ status: 202 }
|
||||
);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
68
apps/web/src/app/api/status/[id]/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getRedis } from "@/lib/redis";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const redis = getRedis();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
);
|
||||
};
|
||||
|
||||
const sub = redis.duplicate();
|
||||
const channel = `codeboard:progress:${id}`;
|
||||
|
||||
const currentStatus = await redis.get(`codeboard:status:${id}`);
|
||||
if (currentStatus) {
|
||||
const parsed = JSON.parse(currentStatus);
|
||||
send("progress", parsed);
|
||||
|
||||
if (parsed.status === "COMPLETED" || parsed.status === "FAILED") {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await sub.subscribe(channel);
|
||||
|
||||
sub.on("message", (_ch: string, message: string) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
send("progress", data);
|
||||
|
||||
if (data.status === "COMPLETED" || data.status === "FAILED") {
|
||||
sub.unsubscribe(channel);
|
||||
sub.quit();
|
||||
controller.close();
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe(channel);
|
||||
sub.quit();
|
||||
send("timeout", { message: "Connection timed out" });
|
||||
controller.close();
|
||||
}, 300_000);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
74
apps/web/src/app/docs/[id]/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { DocViewer } from "@/components/doc-viewer";
|
||||
import type { GeneratedDocs } from "@codeboard/shared";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Github, ArrowLeft, History } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
async function fetchDocs(id: string): Promise<GeneratedDocs | null> {
|
||||
try {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||
const response = await fetch(`${baseUrl}/api/docs/${id}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DocsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const docs = await fetchDocs(params.id);
|
||||
|
||||
if (!docs) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
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 className="flex items-center gap-4">
|
||||
<Link
|
||||
href={`/history?repo=${encodeURIComponent(docs.repoUrl)}`}
|
||||
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Version History
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
|
||||
<DocViewer docs={docs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/app/generate/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Suspense } from "react";
|
||||
import { ProgressTracker } from "@/components/progress-tracker";
|
||||
import { Github, Loader2 } from "lucide-react";
|
||||
|
||||
function GeneratePageContent({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { repo?: string; id?: string };
|
||||
}) {
|
||||
const repoUrl = searchParams.repo || "";
|
||||
const generationId = searchParams.id || "";
|
||||
|
||||
const repoName = repoUrl
|
||||
? repoUrl.replace("https://github.com/", "").replace(/\/$/, "")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl glass mb-6">
|
||||
<Github className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-3">
|
||||
Analyzing Repository
|
||||
</h1>
|
||||
|
||||
<p className="text-zinc-400 font-mono text-sm break-all max-w-md mx-auto">
|
||||
{repoName || repoUrl || "Unknown repository"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{generationId ? (
|
||||
<ProgressTracker generationId={generationId} repoUrl={repoUrl} />
|
||||
) : (
|
||||
<div className="text-center p-8 rounded-2xl glass border-red-500/30">
|
||||
<p className="text-red-400">
|
||||
Missing generation ID. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratePageSkeleton() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl glass mb-6">
|
||||
<Loader2 className="w-8 h-8 text-zinc-400 animate-spin" />
|
||||
</div>
|
||||
<div className="h-8 w-48 bg-zinc-800 rounded animate-pulse mx-auto mb-3" />
|
||||
<div className="h-4 w-64 bg-zinc-800 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-zinc-900/50"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-800 animate-pulse" />
|
||||
<div className="flex-1 h-4 bg-zinc-800 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GeneratePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { repo?: string; id?: string };
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={<GeneratePageSkeleton />}>
|
||||
<GeneratePageContent searchParams={searchParams} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
437
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,437 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--surface: rgba(255, 255, 255, 0.03);
|
||||
--surface-hover: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #71717a;
|
||||
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-indigo: #6366f1;
|
||||
--accent-purple: #9333ea;
|
||||
|
||||
--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%);
|
||||
|
||||
--shadow-glow: 0 0 40px rgba(59, 130, 246, 0.3);
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
: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 {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--text-primary);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-primary);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gradient-border::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: var(--gradient-primary);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glow {
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.glow-subtle {
|
||||
box-shadow: 0 0 60px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(59, 130, 246, 0.6), 0 0 60px rgba(147, 51, 234, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slide-down 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 20s linear infinite;
|
||||
}
|
||||
|
||||
.stagger-1 { animation-delay: 0.1s; }
|
||||
.stagger-2 { animation-delay: 0.2s; }
|
||||
.stagger-3 { animation-delay: 0.3s; }
|
||||
.stagger-4 { animation-delay: 0.4s; }
|
||||
.stagger-5 { animation-delay: 0.5s; }
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-blue-500\/60 {
|
||||
background-color: rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
|
||||
.bg-purple-500\/60 {
|
||||
background-color: rgba(168, 85, 247, 0.6);
|
||||
}
|
||||
|
||||
.bg-green-500\/60 {
|
||||
background-color: rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
.bg-orange-500\/60 {
|
||||
background-color: rgba(249, 115, 22, 0.6);
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
.bg-gradient-radial {
|
||||
background: radial-gradient(ellipse at top, rgba(59, 130, 246, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom, rgba(147, 51, 234, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.noise {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.noise::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
opacity: 0.03;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 30px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--gradient-subtle);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
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>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://codeboard.vectry.tech"),
|
||||
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. Built by Vectry AI consultancy.",
|
||||
keywords: ["code analysis", "documentation", "github", "codebase", "AI", "developer tools"],
|
||||
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: {
|
||||
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.",
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased bg-[#0a0a0a] text-white min-h-screen`}
|
||||
>
|
||||
<a
|
||||
href="#main-content"
|
||||
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 />
|
||||
|
||||
<main id="main-content" className="flex-1 relative">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
597
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import Link from "next/link";
|
||||
import { RepoInput } from "@/components/repo-input";
|
||||
import { ExampleRepoCard } from "@/components/example-repo-card";
|
||||
import { ScrollSection } from "@/components/scroll-section";
|
||||
import {
|
||||
Link2,
|
||||
Code2,
|
||||
Sparkles,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Boxes,
|
||||
Search,
|
||||
BookOpen,
|
||||
ArrowRight,
|
||||
Github,
|
||||
Layers,
|
||||
Workflow,
|
||||
Terminal,
|
||||
FileCode,
|
||||
CheckCircle2,
|
||||
Check,
|
||||
Crown,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function HomePage() {
|
||||
const steps = [
|
||||
{
|
||||
number: "01",
|
||||
icon: Link2,
|
||||
title: "Paste URL",
|
||||
description: "Enter any public GitHub repository URL",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
icon: Code2,
|
||||
title: "Clone & Analyze",
|
||||
description: "We clone and deeply analyze the codebase structure",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
icon: Sparkles,
|
||||
title: "AI Generation",
|
||||
description: "Our AI generates comprehensive documentation",
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
icon: FileText,
|
||||
title: "Interactive Docs",
|
||||
description: "Explore architecture diagrams and module breakdowns",
|
||||
},
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: "Architecture Diagrams",
|
||||
description:
|
||||
"Auto-generated Mermaid diagrams visualizing your codebase structure, dependencies, and data flow.",
|
||||
},
|
||||
{
|
||||
icon: Boxes,
|
||||
title: "Module Breakdowns",
|
||||
description:
|
||||
"Understand each part of the codebase with detailed summaries, key files, and public APIs.",
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: "Pattern Detection",
|
||||
description:
|
||||
"Coding conventions and design patterns automatically identified and documented for you.",
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "Getting Started Guide",
|
||||
description:
|
||||
"Actionable onboarding documentation to get new developers productive in minutes, not days.",
|
||||
},
|
||||
];
|
||||
|
||||
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 = [
|
||||
{
|
||||
name: "sindresorhus/p-limit",
|
||||
description: "Elegant promise concurrency limiter with a simple API and robust error handling.",
|
||||
language: "TypeScript",
|
||||
languageColor: "#3178c6",
|
||||
docId: "gen_1770661300051_y9qnjpu",
|
||||
},
|
||||
{
|
||||
name: "expressjs/express",
|
||||
description: "Fast, unopinionated web framework for Node.js with minimalistic design.",
|
||||
language: "JavaScript",
|
||||
languageColor: "#f1e05a",
|
||||
docId: "gen_1770661300322_glzwj8k",
|
||||
},
|
||||
{
|
||||
name: "pallets/flask",
|
||||
description: "Lightweight Python web framework with simplicity and flexibility at its core.",
|
||||
language: "Python",
|
||||
languageColor: "#3572A5",
|
||||
docId: "gen_1770661300222_ushzuqi",
|
||||
},
|
||||
{
|
||||
name: "colinhacks/zod",
|
||||
description: "TypeScript-first schema validation with static type inference.",
|
||||
language: "TypeScript",
|
||||
languageColor: "#3178c6",
|
||||
docId: "gen_1770661300115_701f52c",
|
||||
},
|
||||
{
|
||||
name: "tiangolo/fastapi",
|
||||
description: "Modern, high-performance Python API framework with automatic OpenAPI docs.",
|
||||
language: "Python",
|
||||
languageColor: "#3572A5",
|
||||
docId: "gen_1770661300275_p7o2e2m",
|
||||
},
|
||||
{
|
||||
name: "redis/node-redis",
|
||||
description: "High-performance Redis client for Node.js with comprehensive feature support.",
|
||||
language: "TypeScript",
|
||||
languageColor: "#3178c6",
|
||||
docId: "gen_1770661300165_a8wzri6",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-8 animate-fade-in opacity-0">
|
||||
<Sparkles className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-zinc-300">Powered by AI</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-7xl font-bold tracking-tight mb-6 animate-slide-up opacity-0">
|
||||
<span className="gradient-text">Understand any codebase</span>
|
||||
<br />
|
||||
<span className="text-white">in minutes</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg sm:text-xl text-zinc-400 max-w-2xl mx-auto mb-10 animate-slide-up opacity-0 stagger-1">
|
||||
Paste a GitHub URL. Get interactive onboarding documentation with
|
||||
architecture diagrams, module breakdowns, and getting started guides.
|
||||
</p>
|
||||
|
||||
<div className="max-w-xl mx-auto mb-16 animate-slide-up opacity-0 stagger-2">
|
||||
<RepoInput />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-4xl mx-auto mb-20 animate-slide-up opacity-0 stagger-3">
|
||||
<div className="relative rounded-xl overflow-hidden border border-white/10 shadow-2xl shadow-blue-500/10">
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-900/80 border-b border-white/10">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/80" />
|
||||
</div>
|
||||
<div className="flex-1 text-center">
|
||||
<span className="text-xs text-zinc-500 font-mono">codeboard-docs.html</span>
|
||||
</div>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0d0d12] p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center border border-blue-500/30">
|
||||
<Layers className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">Architecture Overview</h3>
|
||||
<p className="text-xs text-zinc-500">High-level system design</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-black/40 p-4 font-mono text-xs">
|
||||
<div className="flex items-center gap-2 text-zinc-400 mb-3">
|
||||
<Workflow className="w-4 h-4" />
|
||||
<span>Dependency Graph</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-blue-500/30 border border-blue-500/50" />
|
||||
<span className="text-blue-300">src/index.ts</span>
|
||||
<span className="text-zinc-600">→</span>
|
||||
<div className="w-3 h-3 rounded bg-purple-500/30 border border-purple-500/50" />
|
||||
<span className="text-purple-300">lib/core</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
<span className="text-zinc-600">↳</span>
|
||||
<div className="w-3 h-3 rounded bg-green-500/30 border border-green-500/50" />
|
||||
<span className="text-green-300">utils/parser</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
<span className="text-zinc-600">↳</span>
|
||||
<div className="w-3 h-3 rounded bg-orange-500/30 border border-orange-500/50" />
|
||||
<span className="text-orange-300">api/routes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-black/40 p-4">
|
||||
<div className="flex items-center gap-2 text-zinc-400 mb-3 text-xs font-mono">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<span>Key Metrics</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-3 rounded-lg bg-white/5">
|
||||
<div className="text-xl font-bold text-white">24</div>
|
||||
<div className="text-xs text-zinc-500">Modules</div>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-white/5">
|
||||
<div className="text-xl font-bold text-white">156</div>
|
||||
<div className="text-xs text-zinc-500">Files</div>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-white/5">
|
||||
<div className="text-xl font-bold text-white">8.2k</div>
|
||||
<div className="text-xs text-zinc-500">Lines</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-zinc-400 mb-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Module Breakdown</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ name: "Core Engine", files: 12, color: "blue" },
|
||||
{ name: "API Layer", files: 8, color: "purple" },
|
||||
{ name: "Utilities", files: 15, color: "green" },
|
||||
{ name: "Tests", files: 23, color: "orange" },
|
||||
].map((mod) => (
|
||||
<div
|
||||
key={mod.name}
|
||||
className="p-3 rounded-lg bg-white/5 border border-white/5 hover:border-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-zinc-300">{mod.name}</span>
|
||||
<span className="text-xs text-zinc-500">{mod.files} files</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full bg-${mod.color}-500/60`}
|
||||
style={{ width: `${Math.random() * 40 + 60}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Docs generated in 2m 34s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-indigo-500/20 rounded-xl blur-2xl -z-10 opacity-50" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-8 sm:gap-12 animate-fade-in opacity-0 stagger-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-white">~3 min</div>
|
||||
<div className="text-sm text-zinc-500">Average generation time</div>
|
||||
</div>
|
||||
<div className="hidden sm:block w-px bg-zinc-800" />
|
||||
<div className="text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-white">Free</div>
|
||||
<div className="text-sm text-zinc-500">tier to start</div>
|
||||
</div>
|
||||
<div className="hidden sm:block w-px bg-zinc-800" />
|
||||
<div className="text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-white">10+</div>
|
||||
<div className="text-sm text-zinc-500">Languages supported</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-blue-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<ScrollSection>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-zinc-400 max-w-xl mx-auto">
|
||||
Four simple steps to comprehensive codebase documentation
|
||||
</p>
|
||||
</div>
|
||||
</ScrollSection>
|
||||
|
||||
<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="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{steps.map((step, i) => (
|
||||
<ScrollSection key={step.number} delay={i + 1}>
|
||||
<div className="relative group">
|
||||
<div className="text-center">
|
||||
<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>
|
||||
</ScrollSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section 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">
|
||||
<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>
|
||||
</ScrollSection>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{exampleRepos.map((repo, i) => (
|
||||
<ScrollSection key={repo.name} delay={(i % 3) + 1}>
|
||||
<ExampleRepoCard repo={repo} />
|
||||
</ScrollSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" 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">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
Everything You Need
|
||||
</h2>
|
||||
<p className="text-zinc-400 max-w-xl mx-auto">
|
||||
Comprehensive documentation generated automatically from your codebase
|
||||
</p>
|
||||
</div>
|
||||
</ScrollSection>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{features.map((feature, i) => (
|
||||
<ScrollSection key={feature.title} delay={(i % 2) + 1}>
|
||||
<div 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="flex items-start gap-5">
|
||||
<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">
|
||||
<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 className="mb-8 space-y-3">
|
||||
{tier.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className={`flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center mt-0.5 ${
|
||||
tier.highlighted
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: "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>
|
||||
</ScrollSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 lg:py-32">
|
||||
<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="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="relative text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 mb-8">
|
||||
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||
<span className="text-sm text-zinc-300">Available for projects</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
Built by{" "}
|
||||
<span className="gradient-text">Vectry</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-lg text-zinc-400 mb-4 max-w-xl mx-auto">
|
||||
We're an AI consultancy that builds tools like this for businesses.
|
||||
</p>
|
||||
|
||||
<p className="text-zinc-500 mb-8">
|
||||
Need AI automation for your workflow?
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://vectry.tech"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 btn-primary animate-pulse-glow"
|
||||
>
|
||||
Talk to Us
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-white/10">
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 text-sm text-zinc-500">
|
||||
<a
|
||||
href="https://gitea.repi.fun/repi/codeboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 hover:text-white transition-colors"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
Open Source
|
||||
</a>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>Free for public repositories</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>Free tier available</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollSection>
|
||||
</div>
|
||||
</section>
|
||||
</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;
|
||||
},
|
||||
},
|
||||
});
|
||||
49
apps/web/src/components/code-block.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
interface CodeBlockProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export function CodeBlock({ children, className, inline }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className="px-1.5 py-0.5 rounded bg-white/10 text-blue-300 text-[0.85em] font-mono border border-white/5">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const codeString = String(children).replace(/\n$/, "");
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(codeString);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-4">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/5 border border-white/10 text-zinc-500 hover:text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 z-10"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<pre className="overflow-x-auto rounded-lg bg-black/50 border border-white/10 p-4 text-sm leading-relaxed font-mono scrollbar-thin">
|
||||
<code className={`text-zinc-300 ${className || ""}`}>{codeString}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
383
apps/web/src/components/doc-viewer.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ComponentPropsWithoutRef } from "react";
|
||||
import type { GeneratedDocs, DocsModule } from "@codeboard/shared";
|
||||
import { MermaidDiagram } from "./mermaid-diagram";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import {
|
||||
BookOpen,
|
||||
Boxes,
|
||||
Search,
|
||||
Rocket,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FileCode,
|
||||
GitBranch,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DocViewerProps {
|
||||
docs: GeneratedDocs;
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
pre({ children, ...props }: ComponentPropsWithoutRef<"pre">) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
code({ children, className, ...props }: ComponentPropsWithoutRef<"code"> & { inline?: boolean }) {
|
||||
const isBlock =
|
||||
className?.startsWith("language-") ||
|
||||
(typeof children === "string" && children.includes("\n"));
|
||||
if (isBlock) {
|
||||
return (
|
||||
<CodeBlock className={className}>{children}</CodeBlock>
|
||||
);
|
||||
}
|
||||
return <CodeBlock inline>{children}</CodeBlock>;
|
||||
},
|
||||
};
|
||||
|
||||
function Md({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="prose prose-invert prose-sm max-w-none prose-p:text-zinc-300 prose-p:leading-relaxed prose-headings:text-white prose-strong:text-white prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline prose-li:text-zinc-300 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-transparent prose-pre:p-0">
|
||||
<ReactMarkdown components={markdownComponents}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocViewer({ docs }: DocViewerProps) {
|
||||
const [activeSection, setActiveSection] = useState("overview");
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
const toggleModule = (moduleName: string) => {
|
||||
const newSet = new Set(expandedModules);
|
||||
if (newSet.has(moduleName)) {
|
||||
newSet.delete(moduleName);
|
||||
} else {
|
||||
newSet.add(moduleName);
|
||||
}
|
||||
setExpandedModules(newSet);
|
||||
};
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
setActiveSection(sectionId);
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
setIsSidebarOpen(false);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ id: "overview", label: "Overview", icon: BookOpen },
|
||||
{ id: "modules", label: "Modules", icon: Boxes },
|
||||
{ id: "patterns", label: "Patterns", icon: Search },
|
||||
{ id: "getting-started", label: "Getting Started", icon: Rocket },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="lg:hidden flex items-center gap-2 px-4 py-2 glass rounded-lg text-sm"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Table of Contents
|
||||
{isSidebarOpen ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<aside
|
||||
className={`${
|
||||
isSidebarOpen ? "block" : "hidden"
|
||||
} lg:block w-full lg:w-64 flex-shrink-0`}
|
||||
>
|
||||
<div className="sticky top-24 space-y-1">
|
||||
<p className="px-3 py-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
||||
Contents
|
||||
</p>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors text-left ${
|
||||
activeSection === section.id
|
||||
? "bg-blue-500/20 text-blue-300"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<section.icon className="w-4 h-4" />
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="pt-4 mt-4 border-t border-white/10">
|
||||
<p className="px-3 py-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
||||
Repository
|
||||
</p>
|
||||
<a
|
||||
href={docs.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0 space-y-16">
|
||||
<div className="border-b border-white/10 pb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
{docs.sections.overview.title}
|
||||
</h1>
|
||||
<Md>{docs.sections.overview.description}</Md>
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{docs.sections.overview.techStack.map((tech: string) => (
|
||||
<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>
|
||||
|
||||
<section id="overview" className="scroll-mt-24">
|
||||
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-3">
|
||||
<BookOpen className="w-6 h-6 text-blue-400" />
|
||||
Architecture Overview
|
||||
</h2>
|
||||
|
||||
<div className="glass rounded-xl p-6 mb-6">
|
||||
<MermaidDiagram chart={docs.sections.overview.architectureDiagram} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 glass rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{docs.sections.overview.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">
|
||||
{docs.sections.overview.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">
|
||||
{docs.sections.overview.keyMetrics.languages.length}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">Languages</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="modules" className="scroll-mt-24">
|
||||
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-3">
|
||||
<Boxes className="w-6 h-6 text-blue-400" />
|
||||
Module Breakdown
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{docs.sections.modules.map((module: DocsModule) => (
|
||||
<div
|
||||
key={module.name}
|
||||
className="glass rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleModule(module.name)}
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder className="w-5 h-5 text-blue-400" />
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-white">{module.name}</h3>
|
||||
<p className="text-sm text-zinc-500">{module.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
{expandedModules.has(module.name) ? (
|
||||
<ChevronDown className="w-5 h-5 text-zinc-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedModules.has(module.name) && (
|
||||
<div className="px-5 pb-5 border-t border-white/10">
|
||||
<div className="mt-4 mb-4">
|
||||
<Md>{module.summary}</Md>
|
||||
</div>
|
||||
|
||||
{module.keyFiles.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-zinc-400 mb-2">
|
||||
Key Files
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{module.keyFiles.map((file: { path: string; purpose: string }) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<FileCode className="w-4 h-4 text-zinc-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<code className="text-blue-300">{file.path}</code>
|
||||
{file.purpose && (
|
||||
<p className="text-zinc-500">{file.purpose}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{module.publicApi.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-zinc-400 mb-2">
|
||||
Public API
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{module.publicApi.map((api: string) => (
|
||||
<code
|
||||
key={api}
|
||||
className="px-2 py-1 text-sm bg-blue-500/10 text-blue-300 rounded"
|
||||
>
|
||||
{api}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="patterns" className="scroll-mt-24">
|
||||
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-3">
|
||||
<Search className="w-6 h-6 text-blue-400" />
|
||||
Patterns & Conventions
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="glass rounded-xl p-6">
|
||||
<h3 className="font-semibold text-white mb-4">Coding Conventions</h3>
|
||||
<ul className="space-y-3">
|
||||
{docs.sections.patterns.conventions.map((convention: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-blue-400 mt-1 flex-shrink-0">•</span>
|
||||
<Md>{convention}</Md>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-xl p-6">
|
||||
<h3 className="font-semibold text-white mb-4">Design Patterns</h3>
|
||||
<ul className="space-y-3">
|
||||
{docs.sections.patterns.designPatterns.map((pattern: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-blue-400 mt-1 flex-shrink-0">•</span>
|
||||
<Md>{pattern}</Md>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{docs.sections.patterns.architecturalDecisions.length > 0 && (
|
||||
<div className="mt-6 glass rounded-xl p-6">
|
||||
<h3 className="font-semibold text-white mb-4">
|
||||
Architectural Decisions
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{docs.sections.patterns.architecturalDecisions.map((decision: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<GitBranch className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<Md>{decision}</Md>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="getting-started" className="scroll-mt-24">
|
||||
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-3">
|
||||
<Rocket className="w-6 h-6 text-blue-400" />
|
||||
Getting Started
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="glass rounded-xl p-6">
|
||||
<h3 className="font-semibold text-white mb-4">Prerequisites</h3>
|
||||
<ul className="space-y-2">
|
||||
{docs.sections.gettingStarted.prerequisites.map((prereq: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 mt-1.5 flex-shrink-0" />
|
||||
<Md>{prereq}</Md>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-xl p-6">
|
||||
<h3 className="font-semibold text-white mb-4">Setup Steps</h3>
|
||||
<ol className="space-y-6">
|
||||
{docs.sections.gettingStarted.setupSteps.map((step: string, i: number) => (
|
||||
<li key={i} className="flex gap-4">
|
||||
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-blue-500/20 text-blue-300 text-sm flex items-center justify-center font-medium mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Md>{step}</Md>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-xl p-6 border border-blue-500/20">
|
||||
<h3 className="font-semibold text-white mb-1">First Task</h3>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
A suggested first contribution to help you learn the codebase
|
||||
</p>
|
||||
<Md>{docs.sections.gettingStarted.firstTask}</Md>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{docs.sections.dependencyGraph && (
|
||||
<section className="scroll-mt-24">
|
||||
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-3">
|
||||
<GitBranch className="w-6 h-6 text-blue-400" />
|
||||
Dependency Graph
|
||||
</h2>
|
||||
<div className="glass rounded-xl p-6">
|
||||
<MermaidDiagram chart={docs.sections.dependencyGraph} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
apps/web/src/components/example-repo-card.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Loader2, BookOpen } from "lucide-react";
|
||||
|
||||
interface ExampleRepo {
|
||||
name: string;
|
||||
description: string;
|
||||
language: string;
|
||||
languageColor: string;
|
||||
docId?: string;
|
||||
}
|
||||
|
||||
interface ExampleRepoCardProps {
|
||||
repo: ExampleRepo;
|
||||
}
|
||||
|
||||
export function ExampleRepoCard({ repo }: ExampleRepoCardProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = async () => {
|
||||
if (repo.docId) {
|
||||
router.push(`/docs/${repo.docId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const repoUrl = `https://github.com/${repo.name}`;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ repoUrl }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to start generation");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
router.push(`/generate?repo=${encodeURIComponent(repoUrl)}&id=${data.id}`);
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative p-6 rounded-2xl glass hover:bg-white/[0.05] transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-blue-500/10">
|
||||
<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-center justify-between mb-4">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${repo.languageColor}20`,
|
||||
color: repo.languageColor,
|
||||
border: `1px solid ${repo.languageColor}40`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: repo.languageColor }}
|
||||
/>
|
||||
{repo.language}
|
||||
</div>
|
||||
{repo.docId && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-white mb-2 group-hover:text-blue-300 transition-colors font-mono">
|
||||
{repo.name}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-zinc-400 mb-6 leading-relaxed line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200
|
||||
${repo.docId
|
||||
? "bg-gradient-to-r from-green-600/20 to-emerald-600/20 hover:from-green-600 hover:to-emerald-600 border border-green-500/30 hover:border-transparent text-green-300 hover:text-white"
|
||||
: "bg-gradient-to-r from-blue-600/20 to-indigo-600/20 hover:from-blue-600 hover:to-indigo-600 border border-blue-500/30 hover:border-transparent text-blue-300 hover:text-white"
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed group/btn`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Starting...</span>
|
||||
</>
|
||||
) : repo.docId ? (
|
||||
<>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>View Docs</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover/btn:translate-x-1 transition-transform" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Generate Docs</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover/btn:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/components/footer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { Code2, Github, ArrowUpRight } from "lucide-react";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-white/10 bg-black/30">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||
<Code2 className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white">
|
||||
CodeBoard
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href="https://vectry.tech"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Built by Vectry
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://gitea.repi.fun/repi/codeboard"
|
||||
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" />
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-white/5 text-center">
|
||||
<p className="text-sm text-zinc-500">
|
||||
© {new Date().getFullYear()} CodeBoard. Built by{" "}
|
||||
<a
|
||||
href="https://vectry.tech"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Vectry
|
||||
</a>
|
||||
. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
245
apps/web/src/components/mermaid-diagram.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import mermaid from "mermaid";
|
||||
import { Maximize2, Minimize2, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
chart: string;
|
||||
}
|
||||
|
||||
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [svgHtml, setSvgHtml] = useState<string>("");
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const panStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "dark",
|
||||
securityLevel: "loose",
|
||||
themeVariables: {
|
||||
darkMode: true,
|
||||
background: "#0a0a0a",
|
||||
primaryColor: "#1e3a5f",
|
||||
primaryTextColor: "#ffffff",
|
||||
primaryBorderColor: "#3b82f6",
|
||||
lineColor: "#6366f1",
|
||||
secondaryColor: "#1f2937",
|
||||
tertiaryColor: "#374151",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
},
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
curve: "basis",
|
||||
},
|
||||
});
|
||||
setIsReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !chart) return;
|
||||
|
||||
const renderChart = async () => {
|
||||
try {
|
||||
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const { svg } = await mermaid.render(id, chart);
|
||||
setSvgHtml(svg);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to render diagram"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderChart();
|
||||
}, [chart, isReady]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setZoom((prev) => Math.min(Math.max(0.3, prev + delta), 5));
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
setIsPanning(true);
|
||||
panStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
||||
},
|
||||
[pan]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanning) return;
|
||||
setPan({
|
||||
x: e.clientX - panStart.current.x,
|
||||
y: e.clientY - panStart.current.y,
|
||||
});
|
||||
},
|
||||
[isPanning]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev);
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsFullscreen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isFullscreen]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p className="text-red-400 text-sm mb-2">Failed to render diagram</p>
|
||||
<pre className="text-xs text-red-300/70 overflow-x-auto">{chart}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controls = (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(z + 0.25, 5))}
|
||||
className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 w-12 text-center tabular-nums">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(z - 0.25, 0.3))}
|
||||
className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-white/10 mx-1" />
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Reset view"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const diagramView = (fullHeight?: boolean) => (
|
||||
<div
|
||||
className={`overflow-hidden ${isPanning ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={fullHeight ? { height: "100%" } : { minHeight: "100px" }}
|
||||
>
|
||||
{svgHtml ? (
|
||||
<div
|
||||
className="mermaid-diagram flex items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
transition: isPanning ? "none" : "transform 0.15s ease-out",
|
||||
minHeight: fullHeight ? "100%" : "100px",
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: svgHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="mermaid-diagram flex items-center justify-center"
|
||||
style={{ minHeight: fullHeight ? "100%" : "100px" }}
|
||||
>
|
||||
{!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>
|
||||
);
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ minHeight: "100px" }} />
|
||||
<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">
|
||||
<span className="text-sm text-zinc-400">Architecture Diagram</span>
|
||||
{controls}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{diagramView(true)}
|
||||
</div>
|
||||
<div className="px-6 py-2 border-t border-white/10 text-center">
|
||||
<span className="text-xs text-zinc-600">
|
||||
Scroll to zoom · Drag to pan · Esc to close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-end mb-2">{controls}</div>
|
||||
{diagramView()}
|
||||
<div className="mt-2 text-center">
|
||||
<span className="text-xs text-zinc-600">
|
||||
Scroll to zoom · Drag to pan
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/web/src/components/navbar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
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() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/#how-it-works", label: "How it Works" },
|
||||
{ href: "/#features", label: "Features" },
|
||||
{ href: "/#pricing", label: "Pricing" },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50">
|
||||
<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="flex items-center justify-between h-16">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<Image
|
||||
src="/logo-icon.png"
|
||||
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">
|
||||
CodeBoard
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<CommandPalette />
|
||||
|
||||
<a
|
||||
href="https://gitea.repi.fun/repi/codeboard"
|
||||
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" />
|
||||
Source
|
||||
</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>
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 text-zinc-400 hover:text-white"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="md:hidden border-t border-white/5 bg-black/50 backdrop-blur-xl">
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<a
|
||||
href="https://gitea.repi.fun/repi/codeboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
Source
|
||||
</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>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
213
apps/web/src/components/progress-tracker.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { GenerationStatus } from "@codeboard/shared";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ProgressData {
|
||||
status: GenerationStatus;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ProgressTrackerProps {
|
||||
generationId: string;
|
||||
repoUrl: string;
|
||||
}
|
||||
|
||||
const STEPS: { status: GenerationStatus; label: string }[] = [
|
||||
{ status: "QUEUED", label: "Queued" },
|
||||
{ status: "CLONING", label: "Cloning Repository" },
|
||||
{ status: "PARSING", label: "Analyzing Code" },
|
||||
{ status: "GENERATING", label: "Generating Docs" },
|
||||
{ status: "RENDERING", label: "Finalizing" },
|
||||
];
|
||||
|
||||
export function ProgressTracker({
|
||||
generationId,
|
||||
repoUrl,
|
||||
}: ProgressTrackerProps) {
|
||||
const [data, setData] = useState<ProgressData>({
|
||||
status: "QUEUED",
|
||||
progress: 0,
|
||||
message: "Waiting in queue...",
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(`/api/status/${generationId}`);
|
||||
|
||||
eventSource.addEventListener("progress", (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
setData(parsed);
|
||||
|
||||
if (parsed.status === "COMPLETED" || parsed.status === "FAILED") {
|
||||
eventSource.close();
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to parse progress data");
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("timeout", () => {
|
||||
setError("Connection timed out. Please refresh the page.");
|
||||
eventSource.close();
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setError("Connection error. Please refresh the page.");
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [generationId]);
|
||||
|
||||
const getStepIndex = (status: GenerationStatus) => {
|
||||
if (status === "COMPLETED") return STEPS.length;
|
||||
if (status === "FAILED") return -1;
|
||||
return STEPS.findIndex((s) => s.status === status);
|
||||
};
|
||||
|
||||
const currentStepIndex = getStepIndex(data.status);
|
||||
const isCompleted = data.status === "COMPLETED";
|
||||
const isFailed = data.status === "FAILED";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="relative h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out"
|
||||
style={{ width: `${data.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-zinc-400">{data.message}</span>
|
||||
<span className="text-zinc-500 font-mono">{data.progress}%</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{STEPS.map((step, index) => {
|
||||
const isActive = index === currentStepIndex;
|
||||
const isDone = index < currentStepIndex || isCompleted;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.status}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl border transition-all duration-300 ${
|
||||
isActive
|
||||
? "bg-blue-500/10 border-blue-500/30"
|
||||
: isDone
|
||||
? "bg-zinc-900/50 border-zinc-800"
|
||||
: "bg-transparent border-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isActive
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: isDone
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-zinc-800 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isDone ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
isActive
|
||||
? "text-white"
|
||||
: isDone
|
||||
? "text-zinc-300"
|
||||
: "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isFailed && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-400 font-medium">Generation Failed</p>
|
||||
<p className="text-red-400/70 text-sm mt-1">
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div className="p-6 rounded-xl bg-green-500/10 border border-green-500/20 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Documentation Ready!
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-sm mb-6">
|
||||
Your interactive documentation has been generated successfully.
|
||||
</p>
|
||||
<Link
|
||||
href={`/docs/${generationId}`}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-medium rounded-xl transition-all"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
View Documentation
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isFailed && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-400 font-medium">Connection Error</p>
|
||||
<p className="text-red-400/70 text-sm mt-1">{error}</p>
|
||||
</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>;
|
||||
}
|
||||
110
apps/web/src/components/repo-input.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Github, Loader2, ArrowRight } from "lucide-react";
|
||||
|
||||
const GITHUB_URL_REGEX =
|
||||
/^https?:\/\/(www\.)?github\.com\/[\w.-]+\/[\w.-]+\/?$/;
|
||||
|
||||
export function RepoInput() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const isValid = GITHUB_URL_REGEX.test(url);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValid) {
|
||||
setError("Please enter a valid GitHub repository URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ repoUrl: url.trim() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to start generation");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
router.push(`/generate?repo=${encodeURIComponent(url)}&id=${data.id}`);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An unexpected error occurred"
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="relative flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<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" />
|
||||
</div>
|
||||
<input
|
||||
id="repo-url-input"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://github.com/user/repo"
|
||||
className="w-full pl-12 pr-4 py-4 bg-black/40 border border-white/10 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500/50 focus:ring-2 focus:ring-blue-500/20 transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !url}
|
||||
className="flex items-center justify-center gap-2 px-6 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-medium rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed min-w-[160px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Starting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Generate Docs</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<div className={`w-2 h-2 rounded-full ${isValid ? "bg-green-500" : "bg-zinc-600"}`} />
|
||||
<span>
|
||||
{isValid
|
||||
? "Valid GitHub URL"
|
||||
: "Enter a public GitHub repository URL"}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
13
apps/web/src/lib/queue.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { getRedis } from "./redis";
|
||||
|
||||
let queue: Queue | null = null;
|
||||
|
||||
export function getQueue(): Queue {
|
||||
if (!queue) {
|
||||
queue = new Queue("codeboard-generate", {
|
||||
connection: getRedis(),
|
||||
});
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
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;
|
||||
12
apps/web/src/lib/redis.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import IORedis from "ioredis";
|
||||
|
||||
let redis: IORedis | null = null;
|
||||
|
||||
export function getRedis(): IORedis {
|
||||
if (!redis) {
|
||||
redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return redis;
|
||||
}
|
||||
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).*)"],
|
||||
};
|
||||
23
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
26
apps/worker/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@codeboard/worker",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codeboard/shared": "*",
|
||||
"@codeboard/parser": "*",
|
||||
"@codeboard/llm": "*",
|
||||
"@codeboard/diagrams": "*",
|
||||
"@codeboard/database": "*",
|
||||
"bullmq": "^5.34.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"simple-git": "^3.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7"
|
||||
}
|
||||
}
|
||||
42
apps/worker/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Worker } from "bullmq";
|
||||
import IORedis from "ioredis";
|
||||
import { processGenerationJob } from "./processor.js";
|
||||
|
||||
const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
|
||||
const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null });
|
||||
|
||||
const worker = new Worker(
|
||||
"codeboard-generate",
|
||||
async (job) => {
|
||||
console.log(`[worker] Processing job ${job.id}: ${job.data.repoUrl}`);
|
||||
return processGenerationJob(job);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 2,
|
||||
removeOnComplete: { count: 100 },
|
||||
removeOnFail: { count: 50 },
|
||||
}
|
||||
);
|
||||
|
||||
worker.on("completed", (job) => {
|
||||
console.log(`[worker] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
console.error(`[worker] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on("ready", () => {
|
||||
console.log("[worker] Ready and waiting for jobs on codeboard-generate");
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
console.log("[worker] Shutting down...");
|
||||
await worker.close();
|
||||
await connection.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
87
apps/worker/src/jobs/clone.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { simpleGit } from "simple-git";
|
||||
import { mkdtemp, readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { CloneResult } from "@codeboard/shared";
|
||||
|
||||
async function countFiles(dir: string): Promise<{ files: number; lines: number }> {
|
||||
let files = 0;
|
||||
let lines = 0;
|
||||
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const sub = await countFiles(fullPath);
|
||||
files += sub.files;
|
||||
lines += sub.lines;
|
||||
} else {
|
||||
files++;
|
||||
const fileStat = await stat(fullPath);
|
||||
lines += Math.ceil(fileStat.size / 40);
|
||||
}
|
||||
}
|
||||
|
||||
return { files, lines };
|
||||
}
|
||||
|
||||
export async function cloneRepository(repoUrl: string): Promise<CloneResult> {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "codeboard-"));
|
||||
const git = simpleGit();
|
||||
|
||||
await git.clone(repoUrl, tmpDir, ["--depth", "1", "--single-branch"]);
|
||||
|
||||
const localGit = simpleGit(tmpDir);
|
||||
const log = await localGit.log({ maxCount: 1 });
|
||||
const lastCommit = log.latest?.hash ?? "unknown";
|
||||
|
||||
const repoName = repoUrl
|
||||
.replace(/\.git$/, "")
|
||||
.split("/")
|
||||
.slice(-1)[0] ?? "unknown";
|
||||
|
||||
const { files: totalFiles, lines: totalLines } = await countFiles(tmpDir);
|
||||
|
||||
const languageCounts: Record<string, number> = {};
|
||||
const extMap: Record<string, string> = {
|
||||
".ts": "TypeScript", ".tsx": "TypeScript",
|
||||
".js": "JavaScript", ".jsx": "JavaScript",
|
||||
".py": "Python", ".go": "Go",
|
||||
".rs": "Rust", ".java": "Java",
|
||||
".rb": "Ruby", ".php": "PHP",
|
||||
};
|
||||
|
||||
async function scanLanguages(dir: string) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await scanLanguages(fullPath);
|
||||
} else {
|
||||
const ext = entry.name.slice(entry.name.lastIndexOf("."));
|
||||
const lang = extMap[ext];
|
||||
if (lang) {
|
||||
const fileStat = await stat(fullPath);
|
||||
languageCounts[lang] = (languageCounts[lang] ?? 0) + fileStat.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await scanLanguages(tmpDir);
|
||||
|
||||
return {
|
||||
localPath: tmpDir,
|
||||
metadata: {
|
||||
name: repoName,
|
||||
description: "",
|
||||
defaultBranch: "main",
|
||||
languages: languageCounts,
|
||||
stars: 0,
|
||||
lastCommit,
|
||||
totalFiles,
|
||||
totalLines,
|
||||
},
|
||||
};
|
||||
}
|
||||
26
apps/worker/src/jobs/generate.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { CodeStructure, GeneratedDocs } from "@codeboard/shared";
|
||||
import { createProvider, generateDocumentation } from "@codeboard/llm";
|
||||
|
||||
export async function generateDocs(
|
||||
codeStructure: CodeStructure,
|
||||
onProgress?: (stage: string, progress: number) => void
|
||||
): Promise<GeneratedDocs> {
|
||||
const apiKey =
|
||||
process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? "";
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"No LLM API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY."
|
||||
);
|
||||
}
|
||||
|
||||
const providerType = process.env.OPENAI_API_KEY ? "openai" : "anthropic";
|
||||
const provider = createProvider({
|
||||
provider: providerType,
|
||||
apiKey,
|
||||
model: process.env.LLM_MODEL,
|
||||
baseUrl: process.env.LLM_BASE_URL,
|
||||
});
|
||||
|
||||
return generateDocumentation(codeStructure, provider, onProgress);
|
||||
}
|
||||
8
apps/worker/src/jobs/parse.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { CodeStructure } from "@codeboard/shared";
|
||||
import { analyzeRepository } from "@codeboard/parser";
|
||||
|
||||
export async function parseRepository(
|
||||
localPath: string
|
||||
): Promise<CodeStructure> {
|
||||
return analyzeRepository(localPath);
|
||||
}
|
||||
126
apps/worker/src/processor.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Job } from "bullmq";
|
||||
import IORedis from "ioredis";
|
||||
import { prisma } from "@codeboard/database";
|
||||
import { cloneRepository } from "./jobs/clone.js";
|
||||
import { parseRepository } from "./jobs/parse.js";
|
||||
import { generateDocs } from "./jobs/generate.js";
|
||||
|
||||
interface GenerateJobData {
|
||||
repoUrl: string;
|
||||
generationId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379");
|
||||
|
||||
async function updateProgress(
|
||||
generationId: string,
|
||||
status: string,
|
||||
progress: number,
|
||||
message?: string
|
||||
) {
|
||||
await redis.publish(
|
||||
`codeboard:progress:${generationId}`,
|
||||
JSON.stringify({ status, progress, message })
|
||||
);
|
||||
await redis.set(
|
||||
`codeboard:status:${generationId}`,
|
||||
JSON.stringify({ status, progress, message }),
|
||||
"EX",
|
||||
3600
|
||||
);
|
||||
}
|
||||
|
||||
export async function processGenerationJob(
|
||||
job: Job<GenerateJobData>
|
||||
): Promise<unknown> {
|
||||
const { repoUrl, generationId } = job.data;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await updateProgress(generationId, "CLONING", 10, "Cloning repository...");
|
||||
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(
|
||||
generationId,
|
||||
"PARSING",
|
||||
30,
|
||||
`Analyzing ${cloneResult.metadata.totalFiles} files...`
|
||||
);
|
||||
const codeStructure = await parseRepository(cloneResult.localPath);
|
||||
|
||||
await updateProgress(
|
||||
generationId,
|
||||
"GENERATING",
|
||||
50,
|
||||
`Generating docs for ${codeStructure.modules.length} modules...`
|
||||
);
|
||||
|
||||
const docs = await generateDocs(codeStructure, (stage, progress) => {
|
||||
const mappedProgress = 50 + Math.floor(progress * 0.4);
|
||||
updateProgress(generationId, "GENERATING", mappedProgress, `${stage}...`);
|
||||
});
|
||||
|
||||
docs.id = generationId;
|
||||
docs.repoUrl = repoUrl;
|
||||
docs.repoName = cloneResult.metadata.name;
|
||||
|
||||
const duration = Math.floor((Date.now() - startTime) / 1000);
|
||||
|
||||
await redis.set(
|
||||
`codeboard:result:${generationId}`,
|
||||
JSON.stringify(docs),
|
||||
"EX",
|
||||
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!");
|
||||
|
||||
return { generationId, duration, repoName: cloneResult.metadata.name, cached: false };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
await updateProgress(generationId, "FAILED", 0, message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
8
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
88
docker-compose.yml
Normal file
@@ -0,0 +1,88 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
target: web
|
||||
ports:
|
||||
- "4100:3000"
|
||||
environment:
|
||||
- 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
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: always
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
target: worker
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- LLM_MODEL=${LLM_MODEL:-kimi-k2-turbo-preview}
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-https://api.moonshot.ai/v1}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
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:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- codeboard_redis_data:/data
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
codeboard_postgres_data:
|
||||
codeboard_redis_data:
|
||||
6766
package-lock.json
generated
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "codeboard",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"clean": "turbo clean",
|
||||
"db:generate": "turbo db:generate",
|
||||
"db:push": "turbo db:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"simple-git": "^3.30.0",
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2",
|
||||
"typescript": "^5.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "npm@10.8.2"
|
||||
}
|
||||
28
packages/database/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@codeboard/database",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "./dist/client.js",
|
||||
"types": "./dist/client.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/client.js",
|
||||
"require": "./dist/client.js",
|
||||
"types": "./dist/client.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "prisma generate && tsc",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"clean": "rm -rf generated"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.3.0",
|
||||
"typescript": "^5.7"
|
||||
}
|
||||
}
|
||||
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"
|
||||
147
packages/database/prisma/schema.prisma
Normal file
@@ -0,0 +1,147 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
repoUrl String
|
||||
repoName String
|
||||
commitHash String
|
||||
status Status @default(QUEUED)
|
||||
progress Int @default(0)
|
||||
result Json?
|
||||
error String?
|
||||
costUsd Float?
|
||||
duration Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
viewCount Int @default(0)
|
||||
|
||||
@@unique([repoUrl, commitHash])
|
||||
@@index([repoUrl])
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum Status {
|
||||
QUEUED
|
||||
CLONING
|
||||
PARSING
|
||||
GENERATING
|
||||
RENDERING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
12
packages/database/src/client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
export { PrismaClient } from "@prisma/client";
|
||||
8
packages/database/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||