Compare commits
80 Commits
24812df89b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c87a052e | |||
| de1cd96ec2 | |||
| 62c52747f5 | |||
| b1670c7035 | |||
| eacec74701 | |||
| a591c55fc4 | |||
| 0797717bc1 | |||
| b51fb6da21 | |||
| 14835e2e0a | |||
| 6f79a88abd | |||
| d9d3514647 | |||
| b9511601bc | |||
| 6ccfdee65f | |||
| 0bdf598984 | |||
| 4b2289adfa | |||
| 5fbba46a3d | |||
| acb63a2a5a | |||
| ccc6cc1da5 | |||
| 74f8dc4282 | |||
| bada63452f | |||
| 06238f958a | |||
| 26429f3517 | |||
| a11caafd22 | |||
| 8eb6c149cb | |||
| 062e836ff9 | |||
| d9bea73032 | |||
| 532f851d1f | |||
| f1b4622043 | |||
| f47205c473 | |||
| f9f3156d49 | |||
| 2e3b405893 | |||
| 9e20125938 | |||
| 317abf047b | |||
| 63dded42a6 | |||
| 46efc41812 | |||
| c35e7dbe56 | |||
| cff5cd6014 | |||
| 99c1a83b9f | |||
| 8f95270b12 | |||
| ff0e1592fa | |||
| 1af5595e35 | |||
| e3c6b9a9b4 | |||
| 528d6bb1e3 | |||
| 2aace73e33 | |||
| 6901a97db3 | |||
| 3e9bf7c0e0 | |||
| 0e204ced89 | |||
| 7979fd0518 | |||
| 22f4c4f1c3 | |||
| 5778abe6c3 | |||
| 70c94dc60c | |||
| 57c0744b56 | |||
| aa23a552c4 | |||
| 853e41705f | |||
| 1ef3f9baa3 | |||
| 01848ba682 | |||
| 86f8960aa3 | |||
| 2e0bc95bb0 | |||
| 01c2d33208 | |||
| 65adcd4897 | |||
| 01dd9fda8e | |||
| 9c277fd8e3 | |||
| 231aeb4402 | |||
| fc59333383 | |||
| 7b228ebad2 | |||
| 7f61295637 | |||
| 8c19dc1802 | |||
| 28b48b74af | |||
| f7d38317b2 | |||
| 18f61fe95c | |||
| 61a43ad9b4 | |||
| ad3abd427b | |||
| 93a2b4a0ac | |||
| 3cd477c295 | |||
| 3770ba1853 | |||
| 39167dbe45 | |||
| 812645cae8 | |||
| e08fcf674b | |||
| bb021be088 | |||
| ab100f2e76 |
61
.env.example
Normal file
61
.env.example
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets.
|
||||||
|
|
||||||
|
# --- Postgres: local `next dev` (Coolify internal hostnames do NOT work on your laptop) ---
|
||||||
|
# npm run db:local:up then npm run db:local:push with:
|
||||||
|
# DATABASE_URL=postgresql://vibn:vibn@localhost:5433/vibn
|
||||||
|
# POSTGRES_URL=postgresql://vibn:vibn@localhost:5433/vibn
|
||||||
|
|
||||||
|
# --- Postgres: production / Coolify (from Coolify UI, reachable from where the app runs) ---
|
||||||
|
# Coolify: open the Postgres service → expose/publish a host port → use SERVER_IP:HOST_PORT (not internal UUID host).
|
||||||
|
# From repo root, master-ai/.coolify.env with COOLIFY_URL + COOLIFY_API_TOKEN: npm run db:sync:coolify
|
||||||
|
# Example shape: postgresql://USER:PASSWORD@34.19.250.135:YOUR_PUBLISHED_PORT/vibn
|
||||||
|
# External/cloud: set DB_SSL=true if the DB requires TLS.
|
||||||
|
DATABASE_URL=
|
||||||
|
POSTGRES_URL=
|
||||||
|
|
||||||
|
# --- Public URL of this Next app (OAuth callbacks, runner callbacks) ---
|
||||||
|
# Local Google OAuth (must match the host/port you open in the browser):
|
||||||
|
# NEXTAUTH_URL=http://localhost:3000
|
||||||
|
# Google Cloud Console → OAuth client → Authorized redirect URIs (exact):
|
||||||
|
# http://localhost:3000/api/auth/callback/google
|
||||||
|
# If you use 127.0.0.1 or another port, use that consistently everywhere.
|
||||||
|
# Prisma adapter needs Postgres + tables: set DATABASE_URL then run: npx prisma db push
|
||||||
|
NEXTAUTH_URL=https://vibnai.com
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
|
||||||
|
# --- vibn-agent-runner (same Docker network: http://<service-name>:3333 — or public https://agents.vibnai.com) ---
|
||||||
|
AGENT_RUNNER_URL=http://localhost:3333
|
||||||
|
|
||||||
|
# --- Shared secret: must match runner. Required for PATCH session + POST /events ingest ---
|
||||||
|
AGENT_RUNNER_SECRET=
|
||||||
|
|
||||||
|
# --- Optional: one-shot DDL via POST /api/admin/migrate ---
|
||||||
|
# ADMIN_MIGRATE_SECRET=
|
||||||
|
|
||||||
|
# --- Gitea (git.vibnai.com) — admin token used to create per-workspace orgs/repos ---
|
||||||
|
# Token must have admin scope to create orgs. Per-workspace repos are created
|
||||||
|
# under "vibn-{workspace-slug}" orgs; legacy projects remain under GITEA_ADMIN_USER.
|
||||||
|
GITEA_API_URL=https://git.vibnai.com
|
||||||
|
GITEA_API_TOKEN=
|
||||||
|
GITEA_ADMIN_USER=mark
|
||||||
|
GITEA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# --- Coolify (coolify.vibnai.com) — admin token used to create per-workspace Projects ---
|
||||||
|
# Each Vibn workspace gets one Coolify Project (named "vibn-ws-{slug}") that
|
||||||
|
# acts as the tenant boundary. All apps + DBs for that workspace live there.
|
||||||
|
COOLIFY_URL=https://coolify.vibnai.com
|
||||||
|
COOLIFY_API_TOKEN=
|
||||||
|
COOLIFY_SERVER_UUID=jws4g4cgssss4cw48s488woc
|
||||||
|
|
||||||
|
# --- Google OAuth / Gemini (see .google.env locally) ---
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Local dev: skip Google (next dev only) ---
|
||||||
|
# NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL=you@example.com
|
||||||
|
# Skip NextAuth session for API + project UI (same email must own rows in fs_users)
|
||||||
|
# NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH=true
|
||||||
|
# Optional: require password for dev-local provider (omit to allow localhost Host only)
|
||||||
|
# DEV_LOCAL_AUTH_SECRET=
|
||||||
|
# Optional display name for the dev user row
|
||||||
|
# DEV_LOCAL_AUTH_NAME=Local dev
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
|||||||
COPY --from=builder /app/node_modules/@next-auth ./node_modules/@next-auth
|
COPY --from=builder /app/node_modules/@next-auth ./node_modules/@next-auth
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Scaffold templates are read at runtime via fs — must be in the runner image
|
||||||
|
COPY --from=builder /app/lib/scaffold ./lib/scaffold
|
||||||
|
|
||||||
# Copy and set up entrypoint
|
# Copy and set up entrypoint
|
||||||
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function FeaturesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container py-8 md:py-12 lg:py-24">
|
<div className="container py-8 md:py-12 lg:py-24">
|
||||||
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4">
|
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4">
|
||||||
<h1 className="text-4xl font-extrabold leading-tight tracking-tighter md:text-6xl lg:leading-[1.1]">
|
<h1 className="font-serif text-4xl font-bold leading-tight tracking-tight md:text-6xl lg:leading-[1.1]">
|
||||||
Powerful Features for AI Developers
|
Powerful Features for AI Developers
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[750px] text-center text-lg text-muted-foreground">
|
<p className="max-w-[750px] text-center text-lg text-muted-foreground">
|
||||||
@@ -30,7 +30,7 @@ export default function FeaturesPage() {
|
|||||||
<div className="mx-auto grid max-w-6xl grid-cols-1 gap-6 pt-12 md:grid-cols-2 lg:grid-cols-3">
|
<div className="mx-auto grid max-w-6xl grid-cols-1 gap-6 pt-12 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Code2 className="h-12 w-12 text-blue-600" />
|
<Code2 className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Automatic Session Tracking</CardTitle>
|
<CardTitle>Automatic Session Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Every coding session is automatically captured with zero configuration.
|
Every coding session is automatically captured with zero configuration.
|
||||||
@@ -48,7 +48,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Brain className="h-12 w-12 text-purple-600" />
|
<Brain className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>AI Usage Analytics</CardTitle>
|
<CardTitle>AI Usage Analytics</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Deep insights into how you and your team use AI tools.
|
Deep insights into how you and your team use AI tools.
|
||||||
@@ -66,7 +66,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<DollarSign className="h-12 w-12 text-green-600" />
|
<DollarSign className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Cost Tracking</CardTitle>
|
<CardTitle>Cost Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Real-time cost monitoring for all your AI services.
|
Real-time cost monitoring for all your AI services.
|
||||||
@@ -84,7 +84,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Clock className="h-12 w-12 text-orange-600" />
|
<Clock className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Productivity Metrics</CardTitle>
|
<CardTitle>Productivity Metrics</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Track your velocity and identify productivity patterns.
|
Track your velocity and identify productivity patterns.
|
||||||
@@ -102,7 +102,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Github className="h-12 w-12 text-gray-600" />
|
<Github className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>GitHub Integration</CardTitle>
|
<CardTitle>GitHub Integration</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Connect your repositories for comprehensive code analysis.
|
Connect your repositories for comprehensive code analysis.
|
||||||
@@ -120,7 +120,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Sparkles className="h-12 w-12 text-pink-600" />
|
<Sparkles className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Smart Summaries</CardTitle>
|
<CardTitle>Smart Summaries</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
AI-powered summaries of your work and progress.
|
AI-powered summaries of your work and progress.
|
||||||
@@ -138,7 +138,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Users className="h-12 w-12 text-cyan-600" />
|
<Users className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Team Collaboration</CardTitle>
|
<CardTitle>Team Collaboration</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Built for teams working with AI tools together.
|
Built for teams working with AI tools together.
|
||||||
@@ -156,7 +156,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<FileCode className="h-12 w-12 text-indigo-600" />
|
<FileCode className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Code Quality Tracking</CardTitle>
|
<CardTitle>Code Quality Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Monitor code quality and AI-generated code effectiveness.
|
Monitor code quality and AI-generated code effectiveness.
|
||||||
@@ -174,7 +174,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<TrendingUp className="h-12 w-12 text-emerald-600" />
|
<TrendingUp className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Trend Analysis</CardTitle>
|
<CardTitle>Trend Analysis</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Understand long-term patterns in your development process.
|
Understand long-term patterns in your development process.
|
||||||
@@ -192,7 +192,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Shield className="h-12 w-12 text-red-600" />
|
<Shield className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Privacy & Security</CardTitle>
|
<CardTitle>Privacy & Security</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your code and data stay private and secure.
|
Your code and data stay private and secure.
|
||||||
@@ -210,7 +210,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Zap className="h-12 w-12 text-yellow-600" />
|
<Zap className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Real-Time Insights</CardTitle>
|
<CardTitle>Real-Time Insights</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Get instant feedback as you code.
|
Get instant feedback as you code.
|
||||||
@@ -228,7 +228,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<BarChart3 className="h-12 w-12 text-violet-600" />
|
<BarChart3 className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Custom Reports</CardTitle>
|
<CardTitle>Custom Reports</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Create custom reports tailored to your needs.
|
Create custom reports tailored to your needs.
|
||||||
47
app/(justine)/layout.tsx
Normal file
47
app/(justine)/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||||
|
import { homepage } from "@/marketing/content/homepage";
|
||||||
|
import { JustineNav } from "@/marketing/components/justine/JustineNav";
|
||||||
|
import { JustineFooter } from "@/marketing/components/justine/JustineFooter";
|
||||||
|
import "../styles/justine/01-homepage.css";
|
||||||
|
|
||||||
|
const justineJakarta = Plus_Jakarta_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
variable: "--font-justine-jakarta",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: homepage.meta.title,
|
||||||
|
description: homepage.meta.description,
|
||||||
|
openGraph: {
|
||||||
|
title: homepage.meta.title,
|
||||||
|
description: homepage.meta.description,
|
||||||
|
url: "https://www.vibnai.com",
|
||||||
|
siteName: "VIBN",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: homepage.meta.title,
|
||||||
|
description: homepage.meta.description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JustineLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-justine
|
||||||
|
className={`${justineJakarta.variable} flex min-h-screen flex-col`}
|
||||||
|
>
|
||||||
|
<JustineNav />
|
||||||
|
<main>{children}</main>
|
||||||
|
<JustineFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(justine)/page.tsx
Normal file
5
app/(justine)/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { JustineHomePage } from "@/marketing/components/justine/JustineHomePage";
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
return <JustineHomePage />;
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { homepage } from "@/marketing/content/homepage";
|
|
||||||
import { Footer } from "@/marketing/components";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: homepage.meta.title,
|
|
||||||
description: homepage.meta.description,
|
|
||||||
openGraph: {
|
|
||||||
title: homepage.meta.title,
|
|
||||||
description: homepage.meta.description,
|
|
||||||
url: "https://www.vibnai.com",
|
|
||||||
siteName: "VIBN",
|
|
||||||
type: "website",
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: "summary_large_image",
|
|
||||||
title: homepage.meta.title,
|
|
||||||
description: homepage.meta.description,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MarketingLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col">
|
|
||||||
{/* Navigation */}
|
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
||||||
<div className="container flex h-16 items-center">
|
|
||||||
<div className="flex gap-6 md:gap-10">
|
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
|
||||||
<img
|
|
||||||
src="/vibn-black-circle-logo.png"
|
|
||||||
alt="Vib'n"
|
|
||||||
className="h-8 w-8"
|
|
||||||
/>
|
|
||||||
<span className="text-xl font-bold">Vib'n</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
|
||||||
<nav className="flex items-center space-x-6">
|
|
||||||
<Link
|
|
||||||
href="/#features"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
Features
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/#how-it-works"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
How It Works
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/#pricing"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="https://github.com/MawkOne/viben"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Link href="/auth">
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/auth">
|
|
||||||
<Button size="sm">Get Started</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex-1 w-full">{children}</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import {
|
|
||||||
Hero,
|
|
||||||
EmotionalHook,
|
|
||||||
WhoItsFor,
|
|
||||||
Transformation,
|
|
||||||
Features,
|
|
||||||
HowItWorks,
|
|
||||||
Pricing,
|
|
||||||
CTA,
|
|
||||||
} from "@/marketing/components";
|
|
||||||
|
|
||||||
export default function LandingPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Hero />
|
|
||||||
<EmotionalHook />
|
|
||||||
<WhoItsFor />
|
|
||||||
<Transformation />
|
|
||||||
<Features />
|
|
||||||
<HowItWorks />
|
|
||||||
<Pricing />
|
|
||||||
<CTA />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ function typeColor(t: string) {
|
|||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
{ id: "atlas", label: "Atlas" },
|
{ id: "atlas", label: "Vibn" },
|
||||||
{ id: "build", label: "Builds" },
|
{ id: "build", label: "Builds" },
|
||||||
{ id: "deploy", label: "Deploys" },
|
{ id: "deploy", label: "Deploys" },
|
||||||
{ id: "user", label: "You" },
|
{ id: "user", label: "You" },
|
||||||
@@ -58,10 +58,10 @@ export default function ActivityPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.9rem",
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
|
||||||
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4,
|
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
Activity
|
Activity
|
||||||
@@ -81,7 +81,7 @@ export default function ActivityPage() {
|
|||||||
background: filter === f.id ? "#1a1a1a" : "#fff",
|
background: filter === f.id ? "#1a1a1a" : "#fff",
|
||||||
color: filter === f.id ? "#fff" : "#6b6560",
|
color: filter === f.id ? "#fff" : "#6b6560",
|
||||||
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
|
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
|
||||||
cursor: "pointer", fontFamily: "Outfit, sans-serif",
|
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "customers",
|
||||||
|
label: "Customers",
|
||||||
|
icon: "◉",
|
||||||
|
title: "Customer List",
|
||||||
|
desc: "Every user who has signed up, their plan, usage, last seen, and lifecycle stage. Filter, search, and act on any segment.",
|
||||||
|
items: ["User Directory", "Lifecycle Stages", "Plan & Billing", "Activity Timeline", "Segment Builder"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "usage",
|
||||||
|
label: "Usage",
|
||||||
|
icon: "∿",
|
||||||
|
title: "Usage & Activity",
|
||||||
|
desc: "How users interact with your product — feature adoption, session frequency, retention curves, and activation funnels.",
|
||||||
|
items: ["Feature Adoption", "Session Metrics", "Retention Curves", "Activation Funnel", "Power Users"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "events",
|
||||||
|
label: "Events",
|
||||||
|
icon: "◬",
|
||||||
|
title: "Events & Tracking",
|
||||||
|
desc: "Every event your product fires — page views, clicks, conversions, and custom events — all tagged and queryable.",
|
||||||
|
items: ["Event Stream", "Custom Events", "Page Views", "Conversion Events", "Tag Manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reports",
|
||||||
|
label: "Reports",
|
||||||
|
icon: "▭",
|
||||||
|
title: "Reports",
|
||||||
|
desc: "MRR, churn, DAU/MAU, cohort analysis, and revenue reports. Export or share with your team on a schedule.",
|
||||||
|
items: ["Revenue (MRR/ARR)", "Churn Report", "DAU / MAU", "Cohort Analysis", "Custom Reports", "Scheduled Exports"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionId = typeof SECTIONS[number]["id"];
|
||||||
|
|
||||||
|
const NAV_GROUP: React.CSSProperties = {
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
|
letterSpacing: "0.09em", textTransform: "uppercase",
|
||||||
|
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AnalyticsInner() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const activeId = (searchParams.get("section") ?? "customers") as SectionId;
|
||||||
|
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
|
||||||
|
|
||||||
|
const setSection = (id: string) =>
|
||||||
|
router.push(`/${workspace}/project/${projectId}/analytics?section=${id}`, { scroll: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* Left nav */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||||
|
<div style={NAV_GROUP}>Analytics</div>
|
||||||
|
{SECTIONS.map(s => {
|
||||||
|
const isActive = activeId === s.id;
|
||||||
|
return (
|
||||||
|
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||||
|
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||||
|
padding: "6px 12px", borderRadius: 5,
|
||||||
|
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||||
|
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||||
|
{active.items.map(item => (
|
||||||
|
<div key={item} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||||
|
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||||
|
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||||
|
borderRadius: 12, padding: "24px 28px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We're building this section next. Shape it by telling us what you need.</div>
|
||||||
|
</div>
|
||||||
|
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||||
|
Give feedback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<AnalyticsInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx
Normal file
133
app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "emails",
|
||||||
|
label: "Emails",
|
||||||
|
icon: "◈",
|
||||||
|
title: "Email",
|
||||||
|
desc: "Transactional and support emails — onboarding sequences, password resets, billing receipts, and support replies — all in one place.",
|
||||||
|
items: ["Onboarding Sequence", "Transactional Emails", "Support Replies", "Billing Notices", "Digests & Summaries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chat",
|
||||||
|
label: "Chat Support",
|
||||||
|
icon: "◎",
|
||||||
|
title: "Chat Support",
|
||||||
|
desc: "Live chat and AI-powered support widget embedded in your product. Routes to human agents when needed, logs every conversation.",
|
||||||
|
items: ["Live Chat Widget", "AI First Response", "Agent Handoff", "Conversation History", "Canned Responses"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "support-site",
|
||||||
|
label: "Support Site",
|
||||||
|
icon: "▭",
|
||||||
|
title: "Support Site",
|
||||||
|
desc: "Your public help centre — searchable docs, FAQs, guides, and tutorials. Deflects support tickets before they're created.",
|
||||||
|
items: ["Help Articles", "FAQs", "Video Guides", "Release Notes", "Status Page"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "communications",
|
||||||
|
label: "Communications",
|
||||||
|
icon: "↗",
|
||||||
|
title: "In-App Communications",
|
||||||
|
desc: "Announcements, tooltips, banners, and nudges shown directly inside your product to guide and inform users.",
|
||||||
|
items: ["In-App Banners", "Tooltips & Tours", "Feature Announcements", "NPS Surveys", "Feedback Prompts"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionId = typeof SECTIONS[number]["id"];
|
||||||
|
|
||||||
|
const NAV_GROUP: React.CSSProperties = {
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
|
letterSpacing: "0.09em", textTransform: "uppercase",
|
||||||
|
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AssistInner() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const activeId = (searchParams.get("section") ?? "emails") as SectionId;
|
||||||
|
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
|
||||||
|
|
||||||
|
const setSection = (id: string) =>
|
||||||
|
router.push(`/${workspace}/project/${projectId}/assist?section=${id}`, { scroll: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* Left nav */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||||
|
<div style={NAV_GROUP}>Assist</div>
|
||||||
|
{SECTIONS.map(s => {
|
||||||
|
const isActive = activeId === s.id;
|
||||||
|
return (
|
||||||
|
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||||
|
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||||
|
padding: "6px 12px", borderRadius: 5,
|
||||||
|
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||||
|
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||||
|
{active.items.map(item => (
|
||||||
|
<div key={item} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||||
|
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||||
|
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||||
|
borderRadius: 12, padding: "24px 28px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We're building this section next. Shape it by telling us what you need.</div>
|
||||||
|
</div>
|
||||||
|
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||||
|
Give feedback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<AssistInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
1499
app/[workspace]/project/[projectId]/(workspace)/build/page.tsx
Normal file
1499
app/[workspace]/project/[projectId]/(workspace)/build/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,7 @@ export default function DeploymentPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -77,11 +77,11 @@ export default function DeploymentPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 560 }}>
|
<div style={{ maxWidth: 560 }}>
|
||||||
<h3 style={{
|
<h3 style={{
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.2rem",
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
|
||||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
Deployment
|
Deployment
|
||||||
@@ -103,7 +103,7 @@ export default function DeploymentPage() {
|
|||||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#3d5afe", fontWeight: 500 }}>{project.coolifyDeployUrl}</div>
|
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#3d5afe", fontWeight: 500 }}>{project.coolifyDeployUrl}</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={project.coolifyDeployUrl} target="_blank" rel="noopener noreferrer"
|
<a href={project.coolifyDeployUrl} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Open ↗
|
Open ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@ export default function DeploymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
|
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
|
||||||
<a href={`https://${project.customDomain}`} target="_blank" rel="noopener noreferrer"
|
<a href={`https://${project.customDomain}`} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Open ↗
|
Open ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +130,7 @@ export default function DeploymentPage() {
|
|||||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#6b6560", fontWeight: 500 }}>{project.giteaRepo}</div>
|
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#6b6560", fontWeight: 500 }}>{project.giteaRepo}</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
View ↗
|
View ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ export default function DeploymentPage() {
|
|||||||
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
||||||
{!hasPRD
|
{!hasPRD
|
||||||
? "Complete your PRD with Atlas first, then build and deploy."
|
? "Complete your PRD with Vibn first, then build and deploy."
|
||||||
: !hasRepo
|
: !hasRepo
|
||||||
? "No repository yet — the Architect agent will scaffold one from your PRD."
|
? "No repository yet — the Architect agent will scaffold one from your PRD."
|
||||||
: "No deployment yet — kick off a build to get a live URL."}
|
: "No deployment yet — kick off a build to get a live URL."}
|
||||||
@@ -166,7 +166,7 @@ export default function DeploymentPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleConnectDomain}
|
onClick={handleConnectDomain}
|
||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
style={{ padding: "9px 18px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif", opacity: connecting ? 0.6 : 1 }}
|
style={{ padding: "9px 18px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", opacity: connecting ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
{connecting ? "Connecting…" : "Connect"}
|
{connecting ? "Connecting…" : "Connect"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useState, useEffect } from "react";
|
import { use, useState, useEffect, Suspense } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
SCAFFOLD_REGISTRY, THEME_REGISTRY,
|
SCAFFOLD_REGISTRY, THEME_REGISTRY,
|
||||||
@@ -360,7 +361,7 @@ const LIBRARY_STYLE_OPTIONS: Record<string, LibraryStyleOptions> = {
|
|||||||
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
|
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>{label}</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{label}</span>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +383,7 @@ function OptionChip({
|
|||||||
display: "flex", alignItems: "center", gap: 5,
|
display: "flex", alignItems: "center", gap: 5,
|
||||||
padding: multi ? "4px 9px" : "4px 11px",
|
padding: multi ? "4px 9px" : "4px 11px",
|
||||||
borderRadius: 5, border: "1px solid",
|
borderRadius: 5, border: "1px solid",
|
||||||
fontSize: "0.72rem", fontFamily: "Outfit", cursor: "pointer",
|
fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
transition: "all 0.1s",
|
transition: "all 0.1s",
|
||||||
borderColor: active ? "#1a1a1a" : "#e0dcd4",
|
borderColor: active ? "#1a1a1a" : "#e0dcd4",
|
||||||
background: active ? "#1a1a1a" : "#fff",
|
background: active ? "#1a1a1a" : "#fff",
|
||||||
@@ -410,7 +411,7 @@ function ModeToggle({ value, onChange }: { value: string; onChange: (v: "dark" |
|
|||||||
key={m}
|
key={m}
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
style={{
|
style={{
|
||||||
padding: "4px 14px", border: "none", fontSize: "0.72rem", fontFamily: "Outfit",
|
padding: "4px 14px", border: "none", fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: "pointer", fontWeight: active ? 600 : 400,
|
cursor: "pointer", fontWeight: active ? 600 : 400,
|
||||||
background: active ? "#1a1a1a" : "transparent",
|
background: active ? "#1a1a1a" : "transparent",
|
||||||
color: active ? "#fff" : "#8a8478",
|
color: active ? "#fff" : "#8a8478",
|
||||||
@@ -621,7 +622,7 @@ function SurfaceSection({
|
|||||||
{ScaffoldComponent
|
{ScaffoldComponent
|
||||||
? <ScaffoldComponent themeColor={activeColorTheme ?? undefined} config={designConfig} />
|
? <ScaffoldComponent themeColor={activeColorTheme ?? undefined} config={designConfig} />
|
||||||
: (
|
: (
|
||||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "Outfit" }}>
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Select a library below to preview
|
Select a library below to preview
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -646,7 +647,7 @@ function SurfaceSection({
|
|||||||
style={{
|
style={{
|
||||||
flex: 1, padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
|
flex: 1, padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
|
||||||
background: "#fff", color: "#1a1a1a", fontSize: "0.76rem", fontWeight: 600,
|
background: "#fff", color: "#1a1a1a", fontSize: "0.76rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit", cursor: "pointer", transition: "opacity 0.15s",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", transition: "opacity 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.opacity = "0.7")}
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.7")}
|
||||||
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
@@ -659,7 +660,7 @@ function SurfaceSection({
|
|||||||
flex: 1, padding: "7px 14px", borderRadius: 7, border: `1px solid ${previewId && !saving ? "#1a1a1a" : "#e0dcd4"}`,
|
flex: 1, padding: "7px 14px", borderRadius: 7, border: `1px solid ${previewId && !saving ? "#1a1a1a" : "#e0dcd4"}`,
|
||||||
background: previewId && !saving ? "#1a1a1a" : "#e0dcd4",
|
background: previewId && !saving ? "#1a1a1a" : "#e0dcd4",
|
||||||
color: previewId && !saving ? "#fff" : "#b5b0a6",
|
color: previewId && !saving ? "#fff" : "#b5b0a6",
|
||||||
fontSize: "0.76rem", fontWeight: 600, fontFamily: "Outfit",
|
fontSize: "0.76rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: !previewId || saving ? "not-allowed" : "pointer",
|
cursor: !previewId || saving ? "not-allowed" : "pointer",
|
||||||
transition: "opacity 0.15s",
|
transition: "opacity 0.15s",
|
||||||
}}
|
}}
|
||||||
@@ -669,7 +670,7 @@ function SurfaceSection({
|
|||||||
)}
|
)}
|
||||||
{activeTheme && (
|
{activeTheme && (
|
||||||
<a href={activeTheme.url} target="_blank" rel="noopener noreferrer"
|
<a href={activeTheme.url} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ fontSize: "0.72rem", color: "#b5b0a6", textDecoration: "none", fontFamily: "Outfit", flexShrink: 0 }}
|
style={{ fontSize: "0.72rem", color: "#b5b0a6", textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flexShrink: 0 }}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
>Docs ↗</a>
|
>Docs ↗</a>
|
||||||
@@ -678,7 +679,7 @@ function SurfaceSection({
|
|||||||
|
|
||||||
{/* 2. Library */}
|
{/* 2. Library */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Library</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Library</span>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
{surface.themes.map(theme => {
|
{surface.themes.map(theme => {
|
||||||
const isActive = theme.id === previewId;
|
const isActive = theme.id === previewId;
|
||||||
@@ -692,7 +693,7 @@ function SurfaceSection({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: 4,
|
display: "flex", alignItems: "center", gap: 4,
|
||||||
padding: "4px 11px", borderRadius: 5, border: "1px solid",
|
padding: "4px 11px", borderRadius: 5, border: "1px solid",
|
||||||
fontSize: "0.72rem", fontFamily: "Outfit", cursor: dimmed ? "not-allowed" : "pointer",
|
fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: dimmed ? "not-allowed" : "pointer",
|
||||||
transition: "all 0.1s", opacity: dimmed ? 0.35 : 1,
|
transition: "all 0.1s", opacity: dimmed ? 0.35 : 1,
|
||||||
borderColor: isActive ? "#1a1a1a" : "#e0dcd4",
|
borderColor: isActive ? "#1a1a1a" : "#e0dcd4",
|
||||||
background: isActive ? "#1a1a1a" : "#fff",
|
background: isActive ? "#1a1a1a" : "#fff",
|
||||||
@@ -719,7 +720,7 @@ function SurfaceSection({
|
|||||||
{/* 4. Colour */}
|
{/* 4. Colour */}
|
||||||
{availableColorThemes.length > 0 && (
|
{availableColorThemes.length > 0 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Colour</span>
|
||||||
<div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
{availableColorThemes.map(ct => (
|
{availableColorThemes.map(ct => (
|
||||||
<button
|
<button
|
||||||
@@ -739,7 +740,7 @@ function SurfaceSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{activeColorTheme && (
|
{activeColorTheme && (
|
||||||
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "Outfit" }}>{activeColorTheme.label}</span>
|
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{activeColorTheme.label}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -800,7 +801,7 @@ function SurfaceSection({
|
|||||||
{/* Colour swatches when locked (read-only) */}
|
{/* Colour swatches when locked (read-only) */}
|
||||||
{isLocked && availableColorThemes.length > 0 && (
|
{isLocked && availableColorThemes.length > 0 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Colour</span>
|
||||||
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
|
||||||
{availableColorThemes.map(ct => (
|
{availableColorThemes.map(ct => (
|
||||||
<button key={ct.id} title={ct.label} disabled
|
<button key={ct.id} title={ct.label} disabled
|
||||||
@@ -862,8 +863,8 @@ function SurfacePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "28px 32px", fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ padding: "28px 32px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Design surfaces
|
Design surfaces
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
|
||||||
@@ -897,7 +898,7 @@ function SurfacePicker({
|
|||||||
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
|
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
|
||||||
background: isSelected ? "#1a1a1a08" : "#fff",
|
background: isSelected ? "#1a1a1a08" : "#fff",
|
||||||
boxShadow: isSelected ? "0 0 0 1px #1a1a1a" : "0 1px 2px #1a1a1a05",
|
boxShadow: isSelected ? "0 0 0 1px #1a1a1a" : "0 1px 2px #1a1a1a05",
|
||||||
cursor: "pointer", transition: "all 0.12s", fontFamily: "Outfit",
|
cursor: "pointer", transition: "all 0.12s", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
|
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
|
||||||
@@ -936,7 +937,7 @@ function SurfacePicker({
|
|||||||
padding: "9px 20px", borderRadius: 7, border: "none",
|
padding: "9px 20px", borderRadius: 7, border: "none",
|
||||||
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
|
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
|
||||||
color: selected.size === 0 || saving ? "#b5b0a6" : "#fff",
|
color: selected.size === 0 || saving ? "#b5b0a6" : "#fff",
|
||||||
fontSize: "0.82rem", fontWeight: 600, fontFamily: "Outfit",
|
fontSize: "0.82rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: selected.size === 0 || saving ? "not-allowed" : "pointer",
|
cursor: selected.size === 0 || saving ? "not-allowed" : "pointer",
|
||||||
transition: "opacity 0.15s",
|
transition: "opacity 0.15s",
|
||||||
}}
|
}}
|
||||||
@@ -946,7 +947,7 @@ function SurfacePicker({
|
|||||||
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`} →
|
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`} →
|
||||||
</button>
|
</button>
|
||||||
{selected.size === 0 && (
|
{selected.size === 0 && (
|
||||||
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit" }}>
|
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Select at least one surface to continue
|
Select at least one surface to continue
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -958,8 +959,9 @@ function SurfacePicker({
|
|||||||
// Page
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
|
function DesignPageInner({ projectId }: { projectId: string }) {
|
||||||
const { projectId } = use(params);
|
const searchParams = useSearchParams();
|
||||||
|
const requestedSurface = searchParams.get("surface");
|
||||||
|
|
||||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||||
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
|
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
|
||||||
@@ -979,7 +981,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
setSurfaces(loaded);
|
setSurfaces(loaded);
|
||||||
setSurfaceThemes(d.surfaceThemes ?? {});
|
setSurfaceThemes(d.surfaceThemes ?? {});
|
||||||
setSelectedThemes(d.surfaceThemes ?? {});
|
setSelectedThemes(d.surfaceThemes ?? {});
|
||||||
if (loaded.length > 0) setActiveSurfaceId(loaded[0]);
|
// Honour ?surface= param if valid, otherwise default to first
|
||||||
|
const initial = requestedSurface && loaded.includes(requestedSurface)
|
||||||
|
? requestedSurface
|
||||||
|
: loaded[0] ?? null;
|
||||||
|
setActiveSurfaceId(initial);
|
||||||
return loaded;
|
return loaded;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1050,7 +1056,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<div style={{ width: 18, height: 18, borderRadius: "50%", border: "2px solid #e8e4dc", borderTopColor: "#1a1a1a", animation: "spin 0.8s linear infinite" }} />
|
<div style={{ width: 18, height: 18, borderRadius: "50%", border: "2px solid #e8e4dc", borderTopColor: "#1a1a1a", animation: "spin 0.8s linear infinite" }} />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
</div>
|
</div>
|
||||||
@@ -1066,7 +1072,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
const lockedCount = Object.keys(surfaceThemes).length;
|
const lockedCount = Object.keys(surfaceThemes).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
|
||||||
{/* Left nav */}
|
{/* Left nav */}
|
||||||
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
|
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
|
||||||
@@ -1089,7 +1095,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
color: isActive ? "#1a1a1a" : "#6b6560",
|
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||||
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
|
||||||
cursor: "pointer", transition: "all 0.12s", position: "relative",
|
cursor: "pointer", transition: "all 0.12s", position: "relative",
|
||||||
fontFamily: "Outfit",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isActive) (e.currentTarget.style.background = "#f6f4f0"); }}
|
onMouseEnter={e => { if (!isActive) (e.currentTarget.style.background = "#f6f4f0"); }}
|
||||||
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
|
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
|
||||||
@@ -1107,11 +1113,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
|
|
||||||
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
|
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||||
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
|
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
|
||||||
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "Outfit", marginBottom: 6 }}>✓ All locked</p>
|
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 6 }}>✓ All locked</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSurfaces([])}
|
onClick={() => setSurfaces([])}
|
||||||
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "Outfit", padding: 0 }}
|
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", padding: 0 }}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
|
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
|
||||||
>
|
>
|
||||||
@@ -1137,3 +1143,12 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
|
||||||
|
const { projectId } = use(params);
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<DesignPageInner projectId={projectId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ export default function GrowPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 560 }}>
|
<div style={{ maxWidth: 560 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Grow
|
Grow
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
||||||
144
app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx
Normal file
144
app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "marketing-site",
|
||||||
|
label: "Marketing Site",
|
||||||
|
icon: "◌",
|
||||||
|
title: "Marketing Site",
|
||||||
|
desc: "Your public-facing website — hero, features, pricing, blog, and landing pages. Connected to your design surface and deployed via your infrastructure.",
|
||||||
|
items: ["Hero & Landing", "Features", "Pricing Page", "Blog", "Case Studies", "About"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "communications",
|
||||||
|
label: "Communications",
|
||||||
|
icon: "◈",
|
||||||
|
title: "Communications",
|
||||||
|
desc: "Outbound messaging — product announcements, newsletters, launch emails, and drip campaigns sent to your audience.",
|
||||||
|
items: ["Announcements", "Newsletter", "Launch Sequence", "Drip Campaigns"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "channels",
|
||||||
|
label: "Channels",
|
||||||
|
icon: "↗",
|
||||||
|
title: "Distribution Channels",
|
||||||
|
desc: "Where your product gets discovered — SEO, social, Product Hunt, app stores, partnerships, and paid acquisition.",
|
||||||
|
items: ["SEO & Search", "Social Media", "Product Hunt", "App Stores", "Partnerships", "Paid Ads"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pages",
|
||||||
|
label: "Pages",
|
||||||
|
icon: "▭",
|
||||||
|
title: "Pages",
|
||||||
|
desc: "Individual landing pages for campaigns, experiments, and specific audience segments. Build, publish, and A/B test.",
|
||||||
|
items: ["Campaign Pages", "A/B Tests", "Event Pages", "Partner Pages", "Waitlist"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionId = typeof SECTIONS[number]["id"];
|
||||||
|
|
||||||
|
const NAV_GROUP: React.CSSProperties = {
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
|
letterSpacing: "0.09em", textTransform: "uppercase",
|
||||||
|
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
function GrowthInner() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const activeId = (searchParams.get("section") ?? "marketing-site") as SectionId;
|
||||||
|
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
|
||||||
|
|
||||||
|
const setSection = (id: string) =>
|
||||||
|
router.push(`/${workspace}/project/${projectId}/growth?section=${id}`, { scroll: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* Left nav */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||||
|
<div style={NAV_GROUP}>Growth</div>
|
||||||
|
{SECTIONS.map(s => {
|
||||||
|
const isActive = activeId === s.id;
|
||||||
|
return (
|
||||||
|
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||||
|
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||||
|
padding: "6px 12px", borderRadius: 5,
|
||||||
|
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||||
|
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature items */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||||
|
{active.items.map(item => (
|
||||||
|
<div key={item} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||||
|
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||||
|
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||||
|
borderRadius: 12, padding: "24px 28px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
gap: 20,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>
|
||||||
|
{active.title} is coming to VIBN
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
|
||||||
|
We're building this section next. Shape it by telling us what you need.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style={{
|
||||||
|
background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8,
|
||||||
|
padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600,
|
||||||
|
cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
Give feedback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GrowthPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<GrowthInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect } from "react";
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InfraApp {
|
||||||
|
name: string;
|
||||||
|
domain?: string | null;
|
||||||
|
coolifyServiceUuid?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectData {
|
||||||
|
giteaRepo?: string;
|
||||||
|
giteaRepoUrl?: string;
|
||||||
|
apps?: InfraApp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab definitions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: "builds", label: "Builds", icon: "⬡" },
|
||||||
|
{ id: "databases", label: "Databases", icon: "◫" },
|
||||||
|
{ id: "services", label: "Services", icon: "◎" },
|
||||||
|
{ id: "environment", label: "Environment", icon: "≡" },
|
||||||
|
{ id: "domains", label: "Domains", icon: "◬" },
|
||||||
|
{ id: "logs", label: "Logs", icon: "≈" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabId = typeof TABS[number]["id"];
|
||||||
|
|
||||||
|
// ── Shared empty state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: "flex", flexDirection: "column",
|
||||||
|
alignItems: "center", justifyContent: "center",
|
||||||
|
padding: 60, textAlign: "center", gap: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "1.5rem", color: "#b5b0a6",
|
||||||
|
}}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8, padding: "8px 18px",
|
||||||
|
background: "#1a1a1a", color: "#fff",
|
||||||
|
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
|
||||||
|
opacity: 0.4, cursor: "default",
|
||||||
|
}}>
|
||||||
|
Coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Builds tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BuildsTab({ project }: { project: ProjectData | null }) {
|
||||||
|
const apps = project?.apps ?? [];
|
||||||
|
if (apps.length === 0) {
|
||||||
|
return (
|
||||||
|
<ComingSoonPanel
|
||||||
|
icon="⬡"
|
||||||
|
title="No deployments yet"
|
||||||
|
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||||
|
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Deployed Apps
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{apps.map(app => (
|
||||||
|
<div key={app.name} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}>⬡</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
|
||||||
|
{app.domain && (
|
||||||
|
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||||
|
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Databases tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DatabasesTab() {
|
||||||
|
return (
|
||||||
|
<ComingSoonPanel
|
||||||
|
icon="◫"
|
||||||
|
title="Databases"
|
||||||
|
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Services tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ServicesTab() {
|
||||||
|
return (
|
||||||
|
<ComingSoonPanel
|
||||||
|
icon="◎"
|
||||||
|
title="Services"
|
||||||
|
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Environment tab ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EnvironmentTab({ project }: { project: ProjectData | null }) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||||
|
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Environment Variables & Secrets
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
overflow: "hidden", marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
{/* Header row */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||||
|
padding: "10px 18px", background: "#faf8f5",
|
||||||
|
borderBottom: "1px solid #e8e4dc",
|
||||||
|
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
|
||||||
|
letterSpacing: "0.06em", textTransform: "uppercase",
|
||||||
|
}}>
|
||||||
|
<span>Key</span><span>Value</span><span />
|
||||||
|
</div>
|
||||||
|
{/* Placeholder rows */}
|
||||||
|
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
|
||||||
|
<div key={k} style={{
|
||||||
|
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||||
|
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
|
||||||
|
alignItems: "center",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
|
||||||
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}>••••••••</span>
|
||||||
|
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||||
|
<button style={{
|
||||||
|
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
|
||||||
|
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
|
||||||
|
cursor: "pointer", width: "100%",
|
||||||
|
}}>
|
||||||
|
+ Add variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
|
||||||
|
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Domains tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DomainsTab({ project }: { project: ProjectData | null }) {
|
||||||
|
const apps = (project?.apps ?? []).filter(a => a.domain);
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||||
|
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Domains & SSL
|
||||||
|
</div>
|
||||||
|
{apps.length > 0 ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
|
||||||
|
{apps.map(app => (
|
||||||
|
<div key={app.name} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
|
||||||
|
{app.domain}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||||
|
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
|
||||||
|
padding: "32px 24px", textAlign: "center", marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
|
||||||
|
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button style={{
|
||||||
|
background: "#1a1a1a", color: "#fff", border: "none",
|
||||||
|
borderRadius: 8, padding: "9px 20px",
|
||||||
|
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}>
|
||||||
|
+ Add domain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logs tab ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LogsTab({ project }: { project: ProjectData | null }) {
|
||||||
|
const apps = project?.apps ?? [];
|
||||||
|
if (apps.length === 0) {
|
||||||
|
return (
|
||||||
|
<ComingSoonPanel
|
||||||
|
icon="≈"
|
||||||
|
title="No logs yet"
|
||||||
|
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, maxWidth: 900 }}>
|
||||||
|
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Runtime Logs
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
|
||||||
|
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
|
||||||
|
lineHeight: 1.6, minHeight: 200,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
|
||||||
|
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inner page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function InfrastructurePageInner() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
|
||||||
|
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
|
||||||
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/projects/${projectId}/apps`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const setTab = (id: TabId) => {
|
||||||
|
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* ── Left sub-nav ── */}
|
||||||
|
<div style={{
|
||||||
|
width: 190, flexShrink: 0,
|
||||||
|
borderRight: "1px solid #e8e4dc",
|
||||||
|
background: "#faf8f5",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
padding: "16px 8px",
|
||||||
|
gap: 2,
|
||||||
|
overflow: "auto",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
|
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||||
|
padding: "0 8px 10px",
|
||||||
|
}}>
|
||||||
|
Infrastructure
|
||||||
|
</div>
|
||||||
|
{TABS.map(tab => {
|
||||||
|
const active = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setTab(tab.id)}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 9,
|
||||||
|
padding: "7px 10px", borderRadius: 6,
|
||||||
|
background: active ? "#f0ece4" : "transparent",
|
||||||
|
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
|
||||||
|
color: active ? "#1a1a1a" : "#6b6560",
|
||||||
|
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
|
{activeTab === "builds" && <BuildsTab project={project} />}
|
||||||
|
{activeTab === "databases" && <DatabasesTab />}
|
||||||
|
{activeTab === "services" && <ServicesTab />}
|
||||||
|
{activeTab === "environment" && <EnvironmentTab project={project} />}
|
||||||
|
{activeTab === "domains" && <DomainsTab project={project} />}
|
||||||
|
{activeTab === "logs" && <LogsTab project={project} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function InfrastructurePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<InfrastructurePageInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ export default function InsightsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 560 }}>
|
<div style={{ maxWidth: 560 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Insights
|
Insights
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||||
|
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||||
|
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||||
|
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
productName: string;
|
||||||
|
name?: string;
|
||||||
|
stage?: "discovery" | "architecture" | "building" | "active";
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
|
creationStage?: string;
|
||||||
|
sourceData?: {
|
||||||
|
chatText?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
liveUrl?: string;
|
||||||
|
hosting?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
analysisResult?: Record<string, unknown>;
|
||||||
|
migrationPlan?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectOverviewPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const { status: authStatus } = useSession();
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authStatus !== "authenticated") {
|
||||||
|
if (authStatus === "unauthenticated") setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/projects/${projectId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setProject(d.project))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [authStatus, projectId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
||||||
|
Project not found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = project.productName || project.name || "Untitled";
|
||||||
|
const mode = project.creationMode ?? "fresh";
|
||||||
|
|
||||||
|
if (mode === "chat-import") {
|
||||||
|
return (
|
||||||
|
<ChatImportMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "code-import") {
|
||||||
|
return (
|
||||||
|
<CodeImportMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult}
|
||||||
|
creationStage={project.creationStage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "migration") {
|
||||||
|
return (
|
||||||
|
<MigrateMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult}
|
||||||
|
migrationPlan={project.migrationPlan}
|
||||||
|
creationStage={project.creationStage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: "fresh" — wraps AtlasChat with decision banner
|
||||||
|
return (
|
||||||
|
<FreshIdeaMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
459
app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx
Normal file
459
app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
// Maps each PRD section to the discovery phase that populates it
|
||||||
|
const PRD_SECTIONS = [
|
||||||
|
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
||||||
|
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
||||||
|
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
||||||
|
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
||||||
|
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
||||||
|
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
||||||
|
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||||
|
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||||
|
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||||
|
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||||
|
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||||
|
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SavedPhase {
|
||||||
|
phase: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
saved_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
||||||
|
border: "1px solid #e8e4dc", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
style={{
|
||||||
|
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||||
|
{phase.summary}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
||||||
|
{expanded ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && entries.length > 0 && (
|
||||||
|
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k} style={{ marginTop: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
||||||
|
}}>
|
||||||
|
{k.replace(/_/g, " ")}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
||||||
|
{formatValue(v)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
|
||||||
|
interface ArchInfra { name: string; reason: string }
|
||||||
|
interface ArchPackage { name: string; description: string }
|
||||||
|
interface ArchIntegration { name: string; required?: boolean; notes?: string }
|
||||||
|
interface Architecture {
|
||||||
|
productName?: string;
|
||||||
|
productType?: string;
|
||||||
|
summary?: string;
|
||||||
|
apps?: ArchApp[];
|
||||||
|
packages?: ArchPackage[];
|
||||||
|
infrastructure?: ArchInfra[];
|
||||||
|
integrations?: ArchIntegration[];
|
||||||
|
designSurfaces?: string[];
|
||||||
|
riskNotes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArchitectureView({ arch }: { arch: Architecture }) {
|
||||||
|
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const Card = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
|
||||||
|
);
|
||||||
|
const Tag = ({ label }: { label: string }) => (
|
||||||
|
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
{arch.summary && (
|
||||||
|
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
|
||||||
|
{arch.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(arch.apps ?? []).length > 0 && (
|
||||||
|
<Section title="Applications">
|
||||||
|
{arch.apps!.map(a => (
|
||||||
|
<Card key={a.name}>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
|
||||||
|
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
|
||||||
|
{a.tech?.map(t => <Tag key={t} label={t} />)}
|
||||||
|
{a.screens && a.screens.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.packages ?? []).length > 0 && (
|
||||||
|
<Section title="Shared Packages">
|
||||||
|
{arch.packages!.map(p => (
|
||||||
|
<Card key={p.name}>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||||
|
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
|
||||||
|
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.infrastructure ?? []).length > 0 && (
|
||||||
|
<Section title="Infrastructure">
|
||||||
|
{arch.infrastructure!.map(i => (
|
||||||
|
<Card key={i.name}>
|
||||||
|
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.integrations ?? []).length > 0 && (
|
||||||
|
<Section title="Integrations">
|
||||||
|
{arch.integrations!.map(i => (
|
||||||
|
<Card key={i.name}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
|
||||||
|
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
|
||||||
|
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
|
||||||
|
</div>
|
||||||
|
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.riskNotes ?? []).length > 0 && (
|
||||||
|
<Section title="Architectural Risks">
|
||||||
|
{arch.riskNotes!.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}>⚠</span>
|
||||||
|
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PRDPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const [prd, setPrd] = useState<string | null>(null);
|
||||||
|
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||||
|
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
||||||
|
const [archGenerating, setArchGenerating] = useState(false);
|
||||||
|
const [archError, setArchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
||||||
|
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||||
|
]).then(([projectData, phaseData]) => {
|
||||||
|
setPrd(projectData?.project?.prd ?? null);
|
||||||
|
setArchitecture(projectData?.project?.architecture ?? null);
|
||||||
|
setSavedPhases(phaseData?.phases ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleGenerateArchitecture = async () => {
|
||||||
|
setArchGenerating(true);
|
||||||
|
setArchError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||||
|
setArchitecture(data.architecture);
|
||||||
|
setActiveTab("architecture");
|
||||||
|
} catch (e) {
|
||||||
|
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setArchGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||||
|
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||||
|
|
||||||
|
const sections = PRD_SECTIONS.map(s => ({
|
||||||
|
...s,
|
||||||
|
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||||
|
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doneCount = sections.filter(s => s.isDone).length;
|
||||||
|
const totalPct = Math.round((doneCount / sections.length) * 100);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "prd" as const, label: "PRD", available: true },
|
||||||
|
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
|
||||||
|
{/* Tab bar — only when at least one doc exists */}
|
||||||
|
{(prd || architecture) && (
|
||||||
|
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
|
||||||
|
{tabs.map(t => {
|
||||||
|
const isActive = activeTab === t.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => t.available && setActiveTab(t.id)}
|
||||||
|
disabled={!t.available}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
|
||||||
|
background: isActive ? "#1a1a1a" : "transparent",
|
||||||
|
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
|
||||||
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}>—</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next step banner — PRD done but no architecture yet */}
|
||||||
|
{prd && !architecture && activeTab === "prd" && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 24, padding: "18px 22px",
|
||||||
|
background: "#1a1a1a", borderRadius: 10,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
gap: 16, flexWrap: "wrap",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||||
|
Next: Generate technical architecture
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||||
|
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||||
|
</div>
|
||||||
|
{archError && (
|
||||||
|
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateArchitecture}
|
||||||
|
disabled={archGenerating}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||||
|
background: archGenerating ? "#4a4640" : "#fff",
|
||||||
|
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||||
|
fontSize: "0.82rem", fontWeight: 700,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
cursor: archGenerating ? "default" : "pointer",
|
||||||
|
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||||
|
transition: "opacity 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{archGenerating && (
|
||||||
|
<span style={{
|
||||||
|
width: 12, height: 12, borderRadius: "50%",
|
||||||
|
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||||
|
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Architecture tab */}
|
||||||
|
{activeTab === "architecture" && architecture && (
|
||||||
|
<ArchitectureView arch={architecture} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRD tab — finalized */}
|
||||||
|
{activeTab === "prd" && prd && (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||||
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||||
|
Product Requirements
|
||||||
|
</h3>
|
||||||
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||||
|
PRD complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||||
|
padding: "28px 32px", lineHeight: 1.8,
|
||||||
|
fontSize: "0.88rem", color: "#2a2824",
|
||||||
|
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
}}>
|
||||||
|
{prd}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||||
|
{activeTab === "prd" && !prd && (
|
||||||
|
/* ── Section progress view ── */
|
||||||
|
<div style={{ maxWidth: 680 }}>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 16,
|
||||||
|
padding: "16px 20px", background: "#fff",
|
||||||
|
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
||||||
|
}}>
|
||||||
|
{totalPct}%
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
||||||
|
<div style={{
|
||||||
|
height: "100%", borderRadius: 2,
|
||||||
|
width: `${totalPct}%`, background: "#1a1a1a",
|
||||||
|
transition: "width 0.6s ease",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||||
|
{doneCount}/{sections.length} sections
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
{sections.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
style={{
|
||||||
|
padding: "14px 18px", marginBottom: 6,
|
||||||
|
background: "#fff", borderRadius: 10,
|
||||||
|
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
||||||
|
animationDelay: `${i * 0.04}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
{/* Status icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||||
|
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "0.65rem", fontWeight: 700,
|
||||||
|
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
||||||
|
}}>
|
||||||
|
{s.isDone ? "✓" : "○"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
flex: 1, fontSize: "0.84rem",
|
||||||
|
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
||||||
|
fontWeight: s.isDone ? 500 : 400,
|
||||||
|
}}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{s.isDone && s.savedPhase && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: "#2e7d32", background: "#2e7d3210",
|
||||||
|
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!s.isDone && !s.phaseId && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: "#b5b0a6", padding: "2px 7px",
|
||||||
|
}}>
|
||||||
|
generated
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable phase data */}
|
||||||
|
{s.isDone && s.savedPhase && (
|
||||||
|
<PhaseDataCard phase={s.savedPhase} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending hint */}
|
||||||
|
{!s.isDone && (
|
||||||
|
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||||
|
{s.phaseId
|
||||||
|
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||||
|
: "Will be generated when PRD is finalized"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{doneCount === 0 && (
|
||||||
|
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||||
|
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ export default function ProjectSettingsPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -131,10 +131,10 @@ export default function ProjectSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 480 }}>
|
<div style={{ maxWidth: 480 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Project Settings
|
Project Settings
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface App {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
tech: string[];
|
|
||||||
screens: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Package {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Infra {
|
|
||||||
name: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Integration {
|
|
||||||
name: string;
|
|
||||||
required: boolean;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Architecture {
|
|
||||||
productName: string;
|
|
||||||
productType: string;
|
|
||||||
summary: string;
|
|
||||||
apps: App[];
|
|
||||||
packages: Package[];
|
|
||||||
infrastructure: Infra[];
|
|
||||||
integrations: Integration[];
|
|
||||||
designSurfaces: string[];
|
|
||||||
riskNotes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
|
|
||||||
letterSpacing: "0.12em", textTransform: "uppercase",
|
|
||||||
marginBottom: 10, marginTop: 28,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppCard({ app }: { app: App }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
|
|
||||||
mobile: "📱", worker: "⚙️", engine: "🎯",
|
|
||||||
};
|
|
||||||
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
marginBottom: 8, overflow: "hidden",
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(o => !o)}
|
|
||||||
style={{
|
|
||||||
width: "100%", textAlign: "left", background: "none", border: "none",
|
|
||||||
cursor: "pointer", padding: "14px 18px",
|
|
||||||
display: "flex", alignItems: "center", gap: 12,
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
|
|
||||||
apps/{app.name}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#6b6560", background: "#f0ece4",
|
|
||||||
padding: "2px 7px", borderRadius: 4,
|
|
||||||
}}>
|
|
||||||
{app.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
|
|
||||||
{app.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
|
|
||||||
{app.tech.length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
|
||||||
{app.tech.map((t, i) => (
|
|
||||||
<span key={i} style={{
|
|
||||||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#4a4640", background: "#f6f4f0",
|
|
||||||
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
|
|
||||||
}}>{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{app.screens.length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
||||||
{app.screens.map((s, i) => (
|
|
||||||
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
|
|
||||||
<span style={{ color: "#c5c0b8" }}>→</span> {s}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BuildPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const projectId = params.projectId as string;
|
|
||||||
const workspace = params.workspace as string;
|
|
||||||
|
|
||||||
const [prd, setPrd] = useState<string | null>(null);
|
|
||||||
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
|
||||||
const [architectureConfirmed, setArchitectureConfirmed] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`/api/projects/${projectId}/architecture`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => {
|
|
||||||
setPrd(d.prd);
|
|
||||||
setArchitecture(d.architecture ?? null);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
|
|
||||||
// Also check confirmed flag
|
|
||||||
fetch(`/api/projects/${projectId}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
|
|
||||||
.catch(() => {});
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const handleGenerate = async (force = false) => {
|
|
||||||
setGenerating(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/projects/${projectId}/architecture`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ forceRegenerate: force }),
|
|
||||||
});
|
|
||||||
const d = await res.json();
|
|
||||||
if (!res.ok) throw new Error(d.error || "Generation failed");
|
|
||||||
setArchitecture(d.architecture);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Something went wrong");
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setConfirming(true);
|
|
||||||
try {
|
|
||||||
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
|
|
||||||
setArchitectureConfirmed(true);
|
|
||||||
} catch { /* swallow */ } finally {
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No PRD yet
|
|
||||||
if (!prd) {
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
|
||||||
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
|
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
|
||||||
Complete your PRD first
|
|
||||||
</h3>
|
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
|
|
||||||
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
|
|
||||||
</p>
|
|
||||||
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
|
|
||||||
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
|
||||||
background: "#1a1a1a", color: "#fff",
|
|
||||||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}>
|
|
||||||
Continue with Atlas →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PRD exists but no architecture yet — prompt to generate
|
|
||||||
if (!architecture) {
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
<div style={{ textAlign: "center", maxWidth: 440 }}>
|
|
||||||
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗️</div>
|
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
|
|
||||||
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
|
|
||||||
</h3>
|
|
||||||
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
|
|
||||||
The AI will read your PRD and recommend the technical structure — apps, services, database, and integrations. You'll review it before anything gets built.
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(false)}
|
|
||||||
disabled={generating}
|
|
||||||
style={{
|
|
||||||
padding: "11px 28px", borderRadius: 8,
|
|
||||||
background: generating ? "#8a8478" : "#1a1a1a",
|
|
||||||
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
|
|
||||||
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
|
||||||
transition: "background 0.15s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generating ? "Analysing PRD…" : "Generate architecture →"}
|
|
||||||
</button>
|
|
||||||
{generating && (
|
|
||||||
<p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
|
|
||||||
This takes about 15–30 seconds
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Architecture loaded — show full review UI
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
|
||||||
Architecture
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
|
|
||||||
{architecture.productType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
{architectureConfirmed && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#2e7d32", background: "#2e7d3210",
|
|
||||||
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
|
|
||||||
}}>
|
|
||||||
✓ Confirmed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(true)}
|
|
||||||
disabled={generating}
|
|
||||||
style={{
|
|
||||||
padding: "6px 14px", borderRadius: 6,
|
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
|
||||||
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generating ? "Regenerating…" : "Regenerate"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 18, padding: "16px 20px",
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7,
|
|
||||||
}}>
|
|
||||||
{architecture.summary}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Apps */}
|
|
||||||
<SectionLabel>Apps — monorepo/apps/</SectionLabel>
|
|
||||||
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)}
|
|
||||||
|
|
||||||
{/* Packages */}
|
|
||||||
<SectionLabel>Shared packages — monorepo/packages/</SectionLabel>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
|
||||||
{architecture.packages.map((pkg, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
padding: "12px 16px",
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>
|
|
||||||
packages/{pkg.name}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.76rem", color: "#8a8478", marginTop: 4, lineHeight: 1.5 }}>
|
|
||||||
{pkg.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Infrastructure */}
|
|
||||||
{architecture.infrastructure.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionLabel>Infrastructure</SectionLabel>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
{architecture.infrastructure.map((infra, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
|
|
||||||
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#3d5afe", background: "#3d5afe0d",
|
|
||||||
border: "1px solid #3d5afe20", padding: "2px 7px", borderRadius: 4,
|
|
||||||
flexShrink: 0, marginTop: 1,
|
|
||||||
}}>
|
|
||||||
{infra.name}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>
|
|
||||||
{infra.reason}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Integrations */}
|
|
||||||
{architecture.integrations.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionLabel>External integrations</SectionLabel>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
{architecture.integrations.map((intg, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
|
|
||||||
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: intg.required ? "#9a7b3a" : "#8a8478",
|
|
||||||
background: intg.required ? "#d4a04a12" : "#f6f4f0",
|
|
||||||
border: `1px solid ${intg.required ? "#d4a04a30" : "#e8e4dc"}`,
|
|
||||||
padding: "2px 7px", borderRadius: 4, flexShrink: 0, marginTop: 1,
|
|
||||||
}}>
|
|
||||||
{intg.required ? "required" : "optional"}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{intg.name}</div>
|
|
||||||
<div style={{ fontSize: "0.75rem", color: "#8a8478", marginTop: 2 }}>{intg.notes}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Risk notes */}
|
|
||||||
{architecture.riskNotes.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionLabel>Architecture risks</SectionLabel>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
{architecture.riskNotes.map((risk, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff8f0", border: "1px solid #ffe0b2",
|
|
||||||
borderRadius: 8, padding: "10px 16px",
|
|
||||||
fontSize: "0.78rem", color: "#6d4c00", lineHeight: 1.55,
|
|
||||||
display: "flex", gap: 8,
|
|
||||||
}}>
|
|
||||||
<span>⚠️</span><span>{risk}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirm section */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 32, padding: "20px 24px",
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 12,
|
|
||||||
borderLeft: "3px solid #1a1a1a",
|
|
||||||
}}>
|
|
||||||
{architectureConfirmed ? (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
|
|
||||||
✓ Architecture confirmed
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 14px" }}>
|
|
||||||
You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet.
|
|
||||||
</p>
|
|
||||||
<Link href={`/${workspace}/project/${projectId}/design`} style={{
|
|
||||||
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
|
||||||
background: "#1a1a1a", color: "#fff",
|
|
||||||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}>
|
|
||||||
Choose your design →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
|
|
||||||
Does this look right?
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 16px", lineHeight: 1.6 }}>
|
|
||||||
Review the structure above. You can regenerate if something's off, or confirm to move to design.
|
|
||||||
You can always come back and adjust before the build starts — nothing gets scaffolded yet.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: "flex", gap: 10 }}>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={confirming}
|
|
||||||
style={{
|
|
||||||
padding: "9px 22px", borderRadius: 7,
|
|
||||||
background: confirming ? "#8a8478" : "#1a1a1a",
|
|
||||||
color: "#fff", border: "none",
|
|
||||||
fontSize: "0.78rem", fontWeight: 600,
|
|
||||||
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{confirming ? "Confirming…" : "Confirm architecture →"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(true)}
|
|
||||||
disabled={generating}
|
|
||||||
style={{
|
|
||||||
padding: "9px 18px", borderRadius: 7,
|
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
|
||||||
fontSize: "0.78rem", color: "#8a8478",
|
|
||||||
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Regenerate
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ height: 40 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ interface ProjectData {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
featureCount?: number;
|
featureCount?: number;
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProjectData(projectId: string): Promise<ProjectData> {
|
async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||||
@@ -31,6 +32,7 @@ async function getProjectData(projectId: string): Promise<ProjectData> {
|
|||||||
createdAt: created_at,
|
createdAt: created_at,
|
||||||
updatedAt: updated_at,
|
updatedAt: updated_at,
|
||||||
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
||||||
|
creationMode: data?.creationMode ?? "fresh",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -62,6 +64,7 @@ export default async function ProjectLayout({
|
|||||||
createdAt={project.createdAt}
|
createdAt={project.createdAt}
|
||||||
updatedAt={project.updatedAt}
|
updatedAt={project.updatedAt}
|
||||||
featureCount={project.featureCount}
|
featureCount={project.featureCount}
|
||||||
|
creationMode={project.creationMode}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ProjectShell>
|
</ProjectShell>
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { AtlasChat } from "@/components/AtlasChat";
|
|
||||||
import { OrchestratorChat } from "@/components/OrchestratorChat";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) {
|
|
||||||
const [show, setShow] = useState(false);
|
|
||||||
const url = typeof window !== "undefined"
|
|
||||||
? `${window.location.origin}/${workspace}/project/${projectId}/overview`
|
|
||||||
: "";
|
|
||||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}&bgcolor=f6f4f0&color=1a1a1a&margin=2`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShow(s => !s)}
|
|
||||||
title="Open on your phone"
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 6,
|
|
||||||
padding: "6px 12px", borderRadius: 7,
|
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
|
||||||
fontSize: "0.72rem", fontFamily: "Outfit, sans-serif",
|
|
||||||
color: "#8a8478", cursor: "pointer",
|
|
||||||
transition: "border-color 0.12s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
|
||||||
>
|
|
||||||
📱 Open on phone
|
|
||||||
</button>
|
|
||||||
{show && (
|
|
||||||
<div style={{
|
|
||||||
position: "absolute", top: "calc(100% + 8px)", right: 0,
|
|
||||||
background: "#fff", borderRadius: 12,
|
|
||||||
border: "1px solid #e8e4dc",
|
|
||||||
boxShadow: "0 8px 24px #1a1a1a12",
|
|
||||||
padding: "16px", zIndex: 50,
|
|
||||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
|
||||||
minWidth: 220,
|
|
||||||
}}>
|
|
||||||
<img src={qrSrc} alt="QR code" width={180} height={180} style={{ borderRadius: 8 }} />
|
|
||||||
<p style={{ fontSize: "0.72rem", color: "#8a8478", textAlign: "center", margin: 0, fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
Scan to open Atlas on your phone
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: "0.65rem", color: "#b5b0a6", textAlign: "center", margin: 0, fontFamily: "IBM Plex Mono, monospace", wordBreak: "break-all" }}>
|
|
||||||
{url}
|
|
||||||
</p>
|
|
||||||
<button onClick={() => setShow(false)} style={{ fontSize: "0.68rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer" }}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
productName: string;
|
|
||||||
stage?: "discovery" | "architecture" | "building" | "active";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectOverviewPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const projectId = params.projectId as string;
|
|
||||||
const workspace = params.workspace as string;
|
|
||||||
const { status: authStatus } = useSession();
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authStatus !== "authenticated") {
|
|
||||||
if (authStatus === "unauthenticated") setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch(`/api/projects/${projectId}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setProject(d.project))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [authStatus, projectId]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
|
||||||
Project not found.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
|
||||||
{/* Desktop-only: Open on phone button */}
|
|
||||||
<style>{`@media (max-width: 768px) { .vibn-phone-btn { display: none !important; } }`}</style>
|
|
||||||
<div className="vibn-phone-btn" style={{
|
|
||||||
position: "absolute", top: 14, right: 248,
|
|
||||||
zIndex: 20,
|
|
||||||
}}>
|
|
||||||
<MobileQRButton projectId={projectId} workspace={workspace} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AtlasChat
|
|
||||||
projectId={projectId}
|
|
||||||
projectName={project.productName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
|
|
||||||
// Maps each PRD section to the discovery phase that populates it
|
|
||||||
const PRD_SECTIONS = [
|
|
||||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
|
||||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
|
||||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
|
||||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
|
||||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
|
||||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
|
||||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
|
||||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
|
||||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
|
||||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
|
|
||||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
|
||||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface SavedPhase {
|
|
||||||
phase: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
saved_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(v: unknown): string {
|
|
||||||
if (v === null || v === undefined) return "—";
|
|
||||||
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
|
||||||
return String(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
|
||||||
border: "1px solid #e8e4dc", overflow: "hidden",
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(e => !e)}
|
|
||||||
style={{
|
|
||||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
|
||||||
background: "none", border: "none", cursor: "pointer",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
|
||||||
{phase.summary}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
|
||||||
{expanded ? "▲" : "▼"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{expanded && entries.length > 0 && (
|
|
||||||
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
|
||||||
{entries.map(([k, v]) => (
|
|
||||||
<div key={k} style={{ marginTop: 10 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
|
||||||
}}>
|
|
||||||
{k.replace(/_/g, " ")}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
|
||||||
{formatValue(v)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PRDPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const projectId = params.projectId as string;
|
|
||||||
const [prd, setPrd] = useState<string | null>(null);
|
|
||||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([
|
|
||||||
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
|
||||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
|
||||||
]).then(([projectData, phaseData]) => {
|
|
||||||
setPrd(projectData?.project?.prd ?? null);
|
|
||||||
setSavedPhases(phaseData?.phases ?? []);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
|
||||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
|
||||||
|
|
||||||
const sections = PRD_SECTIONS.map(s => ({
|
|
||||||
...s,
|
|
||||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
|
||||||
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const doneCount = sections.filter(s => s.isDone).length;
|
|
||||||
const totalPct = Math.round((doneCount / sections.length) * 100);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
{prd ? (
|
|
||||||
/* ── Finalized PRD view ── */
|
|
||||||
<div style={{ maxWidth: 760 }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
|
||||||
Product Requirements
|
|
||||||
</h3>
|
|
||||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
|
||||||
PRD complete
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
|
||||||
padding: "28px 32px", lineHeight: 1.8,
|
|
||||||
fontSize: "0.88rem", color: "#2a2824",
|
|
||||||
whiteSpace: "pre-wrap", fontFamily: "Outfit, sans-serif",
|
|
||||||
}}>
|
|
||||||
{prd}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ── Section progress view ── */
|
|
||||||
<div style={{ maxWidth: 680 }}>
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 16,
|
|
||||||
padding: "16px 20px", background: "#fff",
|
|
||||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
|
||||||
}}>
|
|
||||||
{totalPct}%
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
|
||||||
<div style={{
|
|
||||||
height: "100%", borderRadius: 2,
|
|
||||||
width: `${totalPct}%`, background: "#1a1a1a",
|
|
||||||
transition: "width 0.6s ease",
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
|
||||||
{doneCount}/{sections.length} sections
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sections */}
|
|
||||||
{sections.map((s, i) => (
|
|
||||||
<div
|
|
||||||
key={s.id}
|
|
||||||
style={{
|
|
||||||
padding: "14px 18px", marginBottom: 6,
|
|
||||||
background: "#fff", borderRadius: 10,
|
|
||||||
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
|
||||||
animationDelay: `${i * 0.04}s`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
||||||
{/* Status icon */}
|
|
||||||
<div style={{
|
|
||||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
|
||||||
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
fontSize: "0.65rem", fontWeight: 700,
|
|
||||||
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
|
||||||
}}>
|
|
||||||
{s.isDone ? "✓" : "○"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span style={{
|
|
||||||
flex: 1, fontSize: "0.84rem",
|
|
||||||
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
|
||||||
fontWeight: s.isDone ? 500 : 400,
|
|
||||||
}}>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{s.isDone && s.savedPhase && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#2e7d32", background: "#2e7d3210",
|
|
||||||
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
saved
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!s.isDone && !s.phaseId && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#b5b0a6", padding: "2px 7px",
|
|
||||||
}}>
|
|
||||||
generated
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expandable phase data */}
|
|
||||||
{s.isDone && s.savedPhase && (
|
|
||||||
<PhaseDataCard phase={s.savedPhase} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pending hint */}
|
|
||||||
{!s.isDone && (
|
|
||||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
|
||||||
{s.phaseId
|
|
||||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Atlas`
|
|
||||||
: "Will be generated when PRD is finalized"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{doneCount === 0 && (
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
|
||||||
Continue chatting with Atlas — saved phases will appear here automatically.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -59,7 +59,7 @@ function StatusTag({ status }: { status?: string }) {
|
|||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
display: "inline-flex", alignItems: "center", gap: 5,
|
||||||
padding: "3px 9px", borderRadius: 4,
|
padding: "3px 9px", borderRadius: 4,
|
||||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||||
color, background: bg, fontFamily: "Outfit, sans-serif",
|
color, background: bg, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
<StatusDot status={status} /> {label}
|
<StatusDot status={status} /> {label}
|
||||||
</span>
|
</span>
|
||||||
@@ -76,6 +76,7 @@ export default function ProjectsPage() {
|
|||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -134,13 +135,13 @@ export default function ProjectsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.9rem",
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
|
||||||
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
|
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
|
||||||
lineHeight: 1.15, marginBottom: 4,
|
lineHeight: 1.15, marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
@@ -158,7 +159,7 @@ export default function ProjectsPage() {
|
|||||||
background: "#1a1a1a", color: "#fff",
|
background: "#1a1a1a", color: "#fff",
|
||||||
border: "1px solid #1a1a1a",
|
border: "1px solid #1a1a1a",
|
||||||
fontSize: "0.78rem", fontWeight: 600,
|
fontSize: "0.78rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
|
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
|
||||||
@@ -188,15 +189,17 @@ export default function ProjectsPage() {
|
|||||||
width: "100%", display: "flex", alignItems: "center",
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
padding: "18px 22px", borderRadius: 10,
|
padding: "18px 22px", borderRadius: 10,
|
||||||
background: "#fff", border: "1px solid #e8e4dc",
|
background: "#fff", border: "1px solid #e8e4dc",
|
||||||
cursor: "pointer", fontFamily: "Outfit, sans-serif",
|
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
textDecoration: "none", boxShadow: "0 1px 2px #1a1a1a05",
|
textDecoration: "none", boxShadow: "0 1px 2px #1a1a1a05",
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
setHoveredId(p.id);
|
||||||
e.currentTarget.style.borderColor = "#d0ccc4";
|
e.currentTarget.style.borderColor = "#d0ccc4";
|
||||||
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
|
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
setHoveredId(null);
|
||||||
e.currentTarget.style.borderColor = "#e8e4dc";
|
e.currentTarget.style.borderColor = "#e8e4dc";
|
||||||
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
|
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
|
||||||
}}
|
}}
|
||||||
@@ -209,7 +212,7 @@ export default function ProjectsPage() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: "Newsreader, serif",
|
fontFamily: "var(--font-lora), ui-serif, serif",
|
||||||
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
|
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
|
||||||
}}>
|
}}>
|
||||||
{p.productName[0]?.toUpperCase() ?? "P"}
|
{p.productName[0]?.toUpperCase() ?? "P"}
|
||||||
@@ -247,19 +250,19 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete (hover) */}
|
{/* Delete (visible on row hover) */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); setProjectToDelete(p); }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 16, padding: "5px 8px", borderRadius: 6,
|
marginLeft: 16, padding: "6px 8px", borderRadius: 6,
|
||||||
border: "none", background: "transparent",
|
border: "none", background: "transparent",
|
||||||
color: "#b5b0a6", cursor: "pointer",
|
color: "#c0bab2", cursor: "pointer",
|
||||||
opacity: 0, transition: "opacity 0.15s",
|
opacity: hoveredId === p.id ? 1 : 0,
|
||||||
fontFamily: "Outfit, sans-serif",
|
transition: "opacity 0.15s, color 0.15s",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
className="delete-btn"
|
onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.color = "#d32f2f"}
|
onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.color = "#b5b0a6"}
|
|
||||||
title="Delete project"
|
title="Delete project"
|
||||||
>
|
>
|
||||||
<Trash2 style={{ width: 14, height: 14 }} />
|
<Trash2 style={{ width: 14, height: 14 }} />
|
||||||
@@ -275,7 +278,7 @@ export default function ProjectsPage() {
|
|||||||
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
padding: "22px", borderRadius: 10,
|
padding: "22px", borderRadius: 10,
|
||||||
background: "transparent", border: "1px dashed #d0ccc4",
|
background: "transparent", border: "1px dashed #d0ccc4",
|
||||||
cursor: "pointer", fontFamily: "Outfit, sans-serif",
|
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
color: "#b5b0a6", fontSize: "0.84rem", fontWeight: 500,
|
color: "#b5b0a6", fontSize: "0.84rem", fontWeight: 500,
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
animationDelay: `${projects.length * 0.05}s`,
|
animationDelay: `${projects.length * 0.05}s`,
|
||||||
@@ -292,11 +295,11 @@ export default function ProjectsPage() {
|
|||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!loading && projects.length === 0 && (
|
{!loading && projects.length === 0 && (
|
||||||
<div style={{ textAlign: "center", paddingTop: 64 }}>
|
<div style={{ textAlign: "center", paddingTop: 64 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
||||||
No projects yet
|
No projects yet
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 24 }}>
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 24 }}>
|
||||||
Tell Atlas what you want to build and it will figure out the rest.
|
Tell Vibn what you want to build and it will figure out the rest.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNew(true)}
|
onClick={() => setShowNew(true)}
|
||||||
@@ -304,7 +307,7 @@ export default function ProjectsPage() {
|
|||||||
padding: "10px 22px", borderRadius: 7,
|
padding: "10px 22px", borderRadius: 7,
|
||||||
background: "#1a1a1a", color: "#fff",
|
background: "#1a1a1a", color: "#fff",
|
||||||
border: "none", fontSize: "0.84rem", fontWeight: 600,
|
border: "none", fontSize: "0.84rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create your first project
|
Create your first project
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { auth } from '@/lib/firebase/config';
|
import { useSession } from 'next-auth/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react';
|
import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { WorkspaceKeysPanel } from '@/components/workspace/WorkspaceKeysPanel';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -31,6 +32,7 @@ export default function SettingsPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const workspace = params.workspace as string;
|
const workspace = params.workspace as string;
|
||||||
|
const { data: session, status } = useSession();
|
||||||
const [settings, setSettings] = useState<WorkspaceSettings | null>(null);
|
const [settings, setSettings] = useState<WorkspaceSettings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -38,51 +40,19 @@ export default function SettingsPage() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
if (status === 'loading') return;
|
||||||
loadUserProfile();
|
setDisplayName(session?.user?.name ?? '');
|
||||||
}, []);
|
setEmail(session?.user?.email ?? '');
|
||||||
|
|
||||||
const loadSettings = async () => {
|
|
||||||
try {
|
|
||||||
const user = auth.currentUser;
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const token = await user.getIdToken();
|
|
||||||
const response = await fetch(`/api/workspace/${workspace}/settings`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setSettings(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading settings:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}, [session, status]);
|
||||||
};
|
|
||||||
|
|
||||||
const loadUserProfile = () => {
|
|
||||||
const user = auth.currentUser;
|
|
||||||
if (user) {
|
|
||||||
setDisplayName(user.displayName || '');
|
|
||||||
setEmail(user.email || '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const user = auth.currentUser;
|
if (!session?.user) {
|
||||||
if (!user) {
|
|
||||||
toast.error('Please sign in');
|
toast.error('Please sign in');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update profile logic would go here
|
|
||||||
toast.success('Profile updated successfully');
|
toast.success('Profile updated successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving profile:', error);
|
console.error('Error saving profile:', error);
|
||||||
@@ -177,6 +147,9 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Workspace tenancy + AI access keys */}
|
||||||
|
<WorkspaceKeysPanel workspaceSlug={workspace} />
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
307
app/api/admin/migrate/route.ts
Normal file
307
app/api/admin/migrate/route.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/admin/migrate
|
||||||
|
*
|
||||||
|
* One-shot migration endpoint. Requires the ADMIN_MIGRATE_SECRET env var
|
||||||
|
* to be set and passed as x-admin-secret header (or ?secret= query param).
|
||||||
|
*
|
||||||
|
* Idempotent — safe to call multiple times (all statements use IF NOT EXISTS).
|
||||||
|
*
|
||||||
|
* curl -X POST https://vibnai.com/api/admin/migrate \
|
||||||
|
* -H "x-admin-secret: <ADMIN_MIGRATE_SECRET>"
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const secret = process.env.ADMIN_MIGRATE_SECRET ?? "";
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incoming =
|
||||||
|
req.headers.get("x-admin-secret") ??
|
||||||
|
new URL(req.url).searchParams.get("secret") ??
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (incoming !== secret) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ statement: string; ok: boolean; error?: string }> = [];
|
||||||
|
|
||||||
|
// Inline the DDL so this works even if the SQL file isn't on the runtime fs
|
||||||
|
const statements = [
|
||||||
|
`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS fs_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_users_email_idx ON fs_users ((data->>'email'))`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_users_user_id_idx ON fs_users (user_id)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS fs_projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
workspace TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_projects_user_idx ON fs_projects (user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx ON fs_projects (workspace)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS fs_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_sessions_user_idx ON fs_sessions (user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_sessions_project_idx ON fs_sessions ((data->>'projectId'))`,
|
||||||
|
|
||||||
|
// agent_sessions uses TEXT for project_id to match fs_projects.id
|
||||||
|
`CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
app_name TEXT NOT NULL,
|
||||||
|
app_path TEXT NOT NULL,
|
||||||
|
task TEXT NOT NULL,
|
||||||
|
plan JSONB,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
output JSONB NOT NULL DEFAULT '[]',
|
||||||
|
changed_files JSONB NOT NULL DEFAULT '[]',
|
||||||
|
error TEXT,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS agent_sessions_project_idx ON agent_sessions (project_id, created_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions (status)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS agent_session_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
seq INT NOT NULL,
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
client_event_id UUID UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(session_id, seq)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx ON agent_session_events (session_id, seq)`,
|
||||||
|
|
||||||
|
// NextAuth / Prisma tables
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
email_verified TIMESTAMPTZ,
|
||||||
|
image TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_account_id TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
access_token TEXT,
|
||||||
|
expires_at INTEGER,
|
||||||
|
token_type TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
id_token TEXT,
|
||||||
|
session_state TEXT,
|
||||||
|
UNIQUE (provider, provider_account_id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_token TEXT UNIQUE NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires TIMESTAMPTZ NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS verification_tokens (
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
expires TIMESTAMPTZ NOT NULL,
|
||||||
|
UNIQUE (identifier, token)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// ── Vibn workspaces (logical tenancy on top of Coolify) ──────────
|
||||||
|
// One workspace per Vibn account. Holds a Coolify Project UUID
|
||||||
|
// (the team boundary inside Coolify) and a Gitea org name.
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_workspaces (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
coolify_project_uuid TEXT,
|
||||||
|
coolify_team_id INT,
|
||||||
|
gitea_org TEXT,
|
||||||
|
provision_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
provision_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_workspaces_owner_idx ON vibn_workspaces (owner_user_id)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_workspace_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workspace_id, user_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_workspace_members_user_idx ON vibn_workspace_members (user_id)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_workspace_api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '["workspace:*"]'::jsonb,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_workspace_api_keys_workspace_idx ON vibn_workspace_api_keys (workspace_id)`,
|
||||||
|
|
||||||
|
// Tag projects with the workspace they belong to (nullable until backfill).
|
||||||
|
// The pre-existing fs_projects.workspace TEXT column stays for the legacy slug;
|
||||||
|
// this new UUID FK is the canonical link.
|
||||||
|
`ALTER TABLE fs_projects ADD COLUMN IF NOT EXISTS vibn_workspace_id UUID REFERENCES vibn_workspaces(id) ON DELETE SET NULL`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_projects_vibn_workspace_idx ON fs_projects (vibn_workspace_id)`,
|
||||||
|
|
||||||
|
// ── Per-workspace Gitea bot user (for direct AI access) ──────────
|
||||||
|
// Each workspace gets its own Gitea user with a PAT scoped to the
|
||||||
|
// workspace's org, so AI agents can `git clone` / push directly
|
||||||
|
// without ever touching the root admin token.
|
||||||
|
//
|
||||||
|
// Token is encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
|
||||||
|
// Layout: iv(12) || ciphertext || authTag(16), base64-encoded.
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_username TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_user_id INT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_token_encrypted TEXT`,
|
||||||
|
|
||||||
|
// ── Phase 4: workspace-owned deploy infra ────────────────────────
|
||||||
|
// Lets AI agents create Coolify applications/databases/services
|
||||||
|
// against a Gitea repo the bot can read, routed to the right
|
||||||
|
// server and Docker destination, and exposed under the workspace's
|
||||||
|
// own subdomain namespace.
|
||||||
|
//
|
||||||
|
// coolify_server_uuid — which Coolify server the workspace deploys to
|
||||||
|
// coolify_destination_uuid — Docker network / destination on that server
|
||||||
|
// coolify_environment_name — Coolify environment (default "production")
|
||||||
|
// coolify_private_key_uuid — workspace-wide SSH deploy key (Coolify-side UUID)
|
||||||
|
// gitea_bot_ssh_key_id — Gitea key id for the matching public key (for rotation)
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_server_uuid TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_destination_uuid TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_environment_name TEXT NOT NULL DEFAULT 'production'`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_private_key_uuid TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_ssh_key_id INT`,
|
||||||
|
|
||||||
|
// ── Phase 5.1: domains (OpenSRS) + DNS + billing ledger ──────────
|
||||||
|
//
|
||||||
|
// vibn_domains — owned domains + their registration lifecycle
|
||||||
|
// vibn_domain_events — audit trail (register, attach, renew, expire)
|
||||||
|
// vibn_billing_ledger — money in/out at the workspace level
|
||||||
|
//
|
||||||
|
// Reg credentials for a domain (OpenSRS manage-user password) are
|
||||||
|
// encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
|
||||||
|
//
|
||||||
|
// Workspace residency preference for DNS:
|
||||||
|
// dns_provider = 'cloud_dns' (default, public records)
|
||||||
|
// dns_provider = 'cira_dzone' (strict Canadian residency, future)
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS dns_provider TEXT NOT NULL DEFAULT 'cloud_dns'`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
tld TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
registrar TEXT NOT NULL DEFAULT 'opensrs',
|
||||||
|
registrar_order_id TEXT,
|
||||||
|
registrar_username TEXT,
|
||||||
|
registrar_password_enc TEXT,
|
||||||
|
period_years INT NOT NULL DEFAULT 1,
|
||||||
|
whois_privacy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
registered_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
dns_provider TEXT,
|
||||||
|
dns_zone_id TEXT,
|
||||||
|
dns_nameservers JSONB,
|
||||||
|
last_reconciled_at TIMESTAMPTZ,
|
||||||
|
price_paid_cents INT,
|
||||||
|
price_currency TEXT,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (domain)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domains_workspace_idx ON vibn_domains (workspace_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domains_status_idx ON vibn_domains (status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domains_expires_idx ON vibn_domains (expires_at) WHERE expires_at IS NOT NULL`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_domain_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
domain_id UUID NOT NULL REFERENCES vibn_domains(id) ON DELETE CASCADE,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domain_events_domain_idx ON vibn_domain_events (domain_id, created_at DESC)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_billing_ledger (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
amount_cents INT NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'CAD',
|
||||||
|
ref_type TEXT,
|
||||||
|
ref_id TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_workspace_idx ON vibn_billing_ledger (workspace_id, created_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_ref_idx ON vibn_billing_ledger (ref_type, ref_id)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const stmt of statements) {
|
||||||
|
const label = stmt.trim().split("\n")[0].trim().slice(0, 80);
|
||||||
|
try {
|
||||||
|
await query(stmt, []);
|
||||||
|
results.push({ statement: label, ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
statement: label,
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter(r => !r.ok);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: failed.length === 0, results },
|
||||||
|
{ status: failed.length === 0 ? 200 : 207 }
|
||||||
|
);
|
||||||
|
}
|
||||||
1302
app/api/mcp/route.ts
1302
app/api/mcp/route.ts
File diff suppressed because it is too large
Load Diff
204
app/api/projects/[projectId]/advisor/route.ts
Normal file
204
app/api/projects/[projectId]/advisor/route.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Assist COO — proxies to the agent runner's Orchestrator.
|
||||||
|
*
|
||||||
|
* The Orchestrator (Claude Sonnet 4.6, Tier B) has full tool access:
|
||||||
|
* Gitea — read repos, files, issues, commits
|
||||||
|
* Coolify — app status, deploy logs, trigger deploys
|
||||||
|
* Web search, memory, agent spawning
|
||||||
|
*
|
||||||
|
* This route loads project-specific context (PRD, phases, apps, sessions)
|
||||||
|
* and injects it as knowledge_context into the orchestrator's system prompt.
|
||||||
|
*/
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context loader — everything the COO needs to know about the project
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildKnowledgeContext(projectId: string, email: string): Promise<string> {
|
||||||
|
const [projectRows, phaseRows, sessionRows] = await Promise.all([
|
||||||
|
query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, email]
|
||||||
|
).catch(() => [] as { data: Record<string, unknown> }[]),
|
||||||
|
query<{ phase: string; title: string; summary: string }>(
|
||||||
|
`SELECT phase, title, summary FROM atlas_phases
|
||||||
|
WHERE project_id = $1 ORDER BY saved_at ASC`,
|
||||||
|
[projectId]
|
||||||
|
).catch(() => [] as { phase: string; title: string; summary: string }[]),
|
||||||
|
query<{ task: string; status: string }>(
|
||||||
|
`SELECT data->>'task' as task, data->>'status' as status
|
||||||
|
FROM fs_sessions WHERE data->>'projectId' = $1
|
||||||
|
ORDER BY created_at DESC LIMIT 8`,
|
||||||
|
[projectId]
|
||||||
|
).catch(() => [] as { task: string; status: string }[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const d = projectRows[0]?.data ?? {};
|
||||||
|
const name = (d.name as string) ?? 'Unknown Project';
|
||||||
|
const vision = (d.productVision as string) ?? (d.vision as string) ?? '';
|
||||||
|
const giteaRepo = (d.giteaRepo as string) ?? '';
|
||||||
|
const prd = (d.prd as string) ?? '';
|
||||||
|
const architecture = d.architecture as Record<string, unknown> | null ?? null;
|
||||||
|
const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? [];
|
||||||
|
const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? '';
|
||||||
|
const theiaUrl = (d.theiaWorkspaceUrl as string) ?? '';
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// COO persona — injected so the orchestrator knows its role for this session
|
||||||
|
lines.push(`## Your role for this conversation
|
||||||
|
You are the personal AI COO for "${name}" — a trusted executive partner to the founder.
|
||||||
|
The founder talks to you. You figure out what needs to happen and get it done.
|
||||||
|
You delegate to specialist agents (Coder, PM, Marketing) when work is needed.
|
||||||
|
|
||||||
|
Operating principles:
|
||||||
|
- Use your tools proactively. Don't guess — check Gitea for what's been built, check Coolify for app status.
|
||||||
|
- Before delegating any work: state the scope in plain English and confirm with the founder.
|
||||||
|
- Be brief. No preamble, no "Great question!".
|
||||||
|
- You decide the technical approach — never ask the founder to choose.
|
||||||
|
- Be honest when you're uncertain or when data isn't available.
|
||||||
|
- Do NOT spawn agents on the protected platform repos (vibn-frontend, theia-code-os, vibn-agent-runner, vibn-api, master-ai).`);
|
||||||
|
|
||||||
|
// Project identity
|
||||||
|
lines.push(`\n## Project: ${name}`);
|
||||||
|
if (vision) lines.push(`Vision: ${vision}`);
|
||||||
|
if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`);
|
||||||
|
if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`);
|
||||||
|
if (theiaUrl) lines.push(`Theia IDE: ${theiaUrl}`);
|
||||||
|
|
||||||
|
// Architecture document
|
||||||
|
if (architecture) {
|
||||||
|
const archApps = (architecture.apps as Array<{ name: string; type: string; description: string }> ?? [])
|
||||||
|
.map(a => ` - ${a.name} (${a.type}): ${a.description}`).join('\n');
|
||||||
|
const archInfra = (architecture.infrastructure as Array<{ name: string; reason: string }> ?? [])
|
||||||
|
.map(i => ` - ${i.name}: ${i.reason}`).join('\n');
|
||||||
|
lines.push(`\n## Technical Architecture\nSummary: ${architecture.summary ?? ''}\n\nApps:\n${archApps}\n\nInfrastructure:\n${archInfra}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRD or discovery phases
|
||||||
|
if (prd) {
|
||||||
|
// Claude Sonnet has a 200k token context — pass the full PRD, no truncation needed
|
||||||
|
lines.push(`\n## Product Requirements Document\n${prd}`);
|
||||||
|
} else if (phaseRows.length > 0) {
|
||||||
|
lines.push(`\n## Discovery phases completed (${phaseRows.length})`);
|
||||||
|
for (const p of phaseRows) {
|
||||||
|
lines.push(`- ${p.title}: ${p.summary}`);
|
||||||
|
}
|
||||||
|
lines.push(`(PRD not yet finalized — Vibn discovery is in progress)`);
|
||||||
|
} else {
|
||||||
|
lines.push(`\n## Product discovery: not yet started`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deployed apps
|
||||||
|
if (apps.length > 0) {
|
||||||
|
lines.push(`\n## Deployed apps`);
|
||||||
|
for (const a of apps) {
|
||||||
|
const url = a.domain ? `https://${a.domain}` : '(no domain yet)';
|
||||||
|
const uuid = a.coolifyServiceUuid ? ` [uuid: ${a.coolifyServiceUuid}]` : '';
|
||||||
|
lines.push(`- ${a.name} → ${url}${uuid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent agent work
|
||||||
|
const validSessions = sessionRows.filter(s => s.task);
|
||||||
|
if (validSessions.length > 0) {
|
||||||
|
lines.push(`\n## Recent agent sessions (what's been worked on)`);
|
||||||
|
for (const s of validSessions) {
|
||||||
|
lines.push(`- [${s.status ?? 'unknown'}] ${s.task}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message, history = [] } = await req.json() as {
|
||||||
|
message: string;
|
||||||
|
history: Array<{ role: 'user' | 'model'; content: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!message?.trim()) {
|
||||||
|
return new Response('Message required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project context (best-effort)
|
||||||
|
let knowledgeContext = '';
|
||||||
|
try {
|
||||||
|
knowledgeContext = await buildKnowledgeContext(projectId, session.user.email);
|
||||||
|
} catch { /* proceed without — orchestrator still works */ }
|
||||||
|
|
||||||
|
// Convert history: frontend uses "model", orchestrator uses "assistant"
|
||||||
|
const llmHistory = history
|
||||||
|
.filter(h => h.content?.trim())
|
||||||
|
.map(h => ({
|
||||||
|
role: (h.role === 'model' ? 'assistant' : 'user') as 'assistant' | 'user',
|
||||||
|
content: h.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Call the orchestrator on the agent runner
|
||||||
|
let orchRes: Response;
|
||||||
|
try {
|
||||||
|
orchRes = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
// Scoped session per project so in-memory context persists within a browser session
|
||||||
|
session_id: `coo_${projectId}_${session.user.email.split('@')[0]}`,
|
||||||
|
history: llmHistory,
|
||||||
|
knowledge_context: knowledgeContext,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Response(`Agent runner unreachable: ${msg}`, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orchRes.ok) {
|
||||||
|
const err = await orchRes.text();
|
||||||
|
return new Response(`Orchestrator error: ${err}`, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await orchRes.json() as { reply?: string; error?: string };
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return new Response(result.error, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = result.reply ?? '(no response)';
|
||||||
|
|
||||||
|
// Return as a streaming response — single chunk (orchestrator is non-streaming)
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(reply));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/approve
|
||||||
|
*
|
||||||
|
* Called by the frontend when the user clicks "Approve & commit".
|
||||||
|
* Verifies ownership, then asks the agent runner to git commit + push
|
||||||
|
* the changes it made in the workspace, and triggers a Coolify deploy.
|
||||||
|
*
|
||||||
|
* Body: { commitMessage: string }
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
const COOLIFY_API_URL = process.env.COOLIFY_API_URL ?? "";
|
||||||
|
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? "";
|
||||||
|
|
||||||
|
interface AppEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
coolifyServiceUuid?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { commitMessage?: string };
|
||||||
|
const commitMessage = body.commitMessage?.trim();
|
||||||
|
if (!commitMessage) {
|
||||||
|
return NextResponse.json({ error: "commitMessage is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership + fetch project data (giteaRepo, apps list)
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectData = rows[0].data;
|
||||||
|
const giteaRepo = projectData?.giteaRepo as string | undefined;
|
||||||
|
if (!giteaRepo) {
|
||||||
|
return NextResponse.json({ error: "No Gitea repo linked to this project" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the session to get the appName (so we can find the right Coolify UUID)
|
||||||
|
const sessionRows = await query<{ app_name: string; status: string }>(
|
||||||
|
`SELECT app_name, status FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`,
|
||||||
|
[sessionId, projectId]
|
||||||
|
);
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (sessionRows[0].status !== "done") {
|
||||||
|
return NextResponse.json({ error: "Session must be in 'done' state to approve" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = sessionRows[0].app_name;
|
||||||
|
|
||||||
|
// Find the matching Coolify UUID from project.data.apps[]
|
||||||
|
const apps: AppEntry[] = (projectData?.apps ?? []) as AppEntry[];
|
||||||
|
const matchedApp = apps.find(a => a.name === appName);
|
||||||
|
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
|
||||||
|
|
||||||
|
// Call agent runner to commit + push
|
||||||
|
const approveRes = await fetch(`${AGENT_RUNNER_URL}/agent/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
giteaRepo,
|
||||||
|
commitMessage,
|
||||||
|
coolifyApiUrl: COOLIFY_API_URL,
|
||||||
|
coolifyApiToken: COOLIFY_API_TOKEN,
|
||||||
|
coolifyAppUuid,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveData = await approveRes.json() as {
|
||||||
|
ok: boolean;
|
||||||
|
committed?: boolean;
|
||||||
|
deployed?: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!approveRes.ok || !approveData.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: approveData.error ?? "Agent runner returned an error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark session as approved in DB
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'approved', completed_at = COALESCE(completed_at, now()), updated_at = now(),
|
||||||
|
output = output || $1::jsonb
|
||||||
|
WHERE id = $2::uuid`,
|
||||||
|
[
|
||||||
|
JSON.stringify([{
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "done",
|
||||||
|
text: `✓ ${approveData.message ?? "Committed and pushed."}${approveData.deployed ? " Deployment triggered." : ""}`,
|
||||||
|
}]),
|
||||||
|
sessionId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
committed: approveData.committed,
|
||||||
|
deployed: approveData.deployed,
|
||||||
|
message: approveData.message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/approve]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to approve session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/[projectId]/agent/sessions/[sessionId]/events?afterSeq=0
|
||||||
|
* List persisted agent events for replay (user session auth).
|
||||||
|
*
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/events
|
||||||
|
* Batch append from vibn-agent-runner (x-agent-runner-secret).
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query, getPool } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
export interface AgentSessionEventRow {
|
||||||
|
seq: number;
|
||||||
|
ts: string;
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0);
|
||||||
|
|
||||||
|
const rows = await query<AgentSessionEventRow>(
|
||||||
|
`SELECT e.seq, e.ts::text, e.type, e.payload
|
||||||
|
FROM agent_session_events e
|
||||||
|
JOIN agent_sessions s ON s.id = e.session_id
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE e.session_id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
AND e.seq > $4
|
||||||
|
ORDER BY e.seq ASC
|
||||||
|
LIMIT 2000`,
|
||||||
|
[sessionId, projectId, session.user.email, afterSeq]
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxSeq = rows.length ? rows[rows.length - 1].seq : afterSeq;
|
||||||
|
|
||||||
|
return NextResponse.json({ events: rows, maxSeq });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/.../events GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to list events" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type IngestBody = {
|
||||||
|
events: Array<{
|
||||||
|
clientEventId: string;
|
||||||
|
ts: string;
|
||||||
|
type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
const secret = process.env.AGENT_RUNNER_SECRET ?? "";
|
||||||
|
const incomingSecret = req.headers.get("x-agent-runner-secret") ?? "";
|
||||||
|
if (secret && incomingSecret !== secret) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
|
||||||
|
let body: IngestBody;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as IngestBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.events?.length) {
|
||||||
|
return NextResponse.json({ ok: true, inserted: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await client.query<{ n: string }>(
|
||||||
|
`SELECT 1 AS n FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`,
|
||||||
|
[sessionId, projectId]
|
||||||
|
);
|
||||||
|
if (exists.rowCount === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query("SELECT pg_advisory_xact_lock(hashtext($1::text))", [sessionId]);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (const ev of body.events) {
|
||||||
|
if (!ev.clientEventId || !ev.type || !ev.ts) continue;
|
||||||
|
|
||||||
|
const maxRes = await client.query<{ m: string }>(
|
||||||
|
`SELECT COALESCE(MAX(seq), 0)::text AS m FROM agent_session_events WHERE session_id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
const nextSeq = Number(maxRes.rows[0].m) + 1;
|
||||||
|
|
||||||
|
const ins = await client.query(
|
||||||
|
`INSERT INTO agent_session_events (session_id, project_id, seq, ts, type, payload, client_event_id)
|
||||||
|
VALUES ($1::uuid, $2, $3, $4::timestamptz, $5, $6::jsonb, $7::uuid)
|
||||||
|
ON CONFLICT (client_event_id) DO NOTHING`,
|
||||||
|
[
|
||||||
|
sessionId,
|
||||||
|
projectId,
|
||||||
|
nextSeq,
|
||||||
|
ev.ts,
|
||||||
|
ev.type,
|
||||||
|
JSON.stringify(ev.payload ?? {}),
|
||||||
|
ev.clientEventId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (ins.rowCount) inserted += ins.rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return NextResponse.json({ ok: true, inserted });
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
console.error("[agent/sessions/.../events POST]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to ingest events" }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/.../agent/sessions/.../events/stream?afterSeq=0
|
||||||
|
* Server-Sent Events: tail agent_session_events while the session is active.
|
||||||
|
*/
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query, queryOne } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
/** Long-lived SSE — raise if your host defaults to a shorter limit (e.g. Vercel). */
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
const TERMINAL = new Set(["done", "approved", "failed", "stopped"]);
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
let afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0);
|
||||||
|
|
||||||
|
const allowed = await queryOne<{ n: string }>(
|
||||||
|
`SELECT 1 AS n FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const signal = req.signal;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (obj: object) => {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
let idleAfterTerminal = 0;
|
||||||
|
let lastHeartbeat = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const rows = await query<{ seq: number; ts: string; type: string; payload: Record<string, unknown> }>(
|
||||||
|
`SELECT e.seq, e.ts::text, e.type, e.payload
|
||||||
|
FROM agent_session_events e
|
||||||
|
WHERE e.session_id = $1::uuid AND e.seq > $2
|
||||||
|
ORDER BY e.seq ASC
|
||||||
|
LIMIT 200`,
|
||||||
|
[sessionId, afterSeq]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
afterSeq = row.seq;
|
||||||
|
send({ seq: row.seq, ts: row.ts, type: row.type, payload: row.payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = await queryOne<{ status: string }>(
|
||||||
|
`SELECT status FROM agent_sessions WHERE id = $1::uuid LIMIT 1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
const status = st?.status ?? "";
|
||||||
|
const terminal = TERMINAL.has(status);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
if (terminal) {
|
||||||
|
idleAfterTerminal++;
|
||||||
|
if (idleAfterTerminal >= 3) {
|
||||||
|
send({ type: "_stream.end", seq: afterSeq });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
idleAfterTerminal = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
idleAfterTerminal = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastHeartbeat > 20000) {
|
||||||
|
send({ type: "_heartbeat", t: now });
|
||||||
|
lastHeartbeat = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 750));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[events/stream]", e);
|
||||||
|
try {
|
||||||
|
send({ type: "_stream.error", message: "stream failed" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/retry
|
||||||
|
*
|
||||||
|
* Re-run a failed or stopped session, optionally with a follow-up instruction.
|
||||||
|
* Resets the session row to `running` and fires the agent-runner again.
|
||||||
|
*
|
||||||
|
* Body: { continueTask?: string }
|
||||||
|
* continueTask — if provided, appended to the original task so the agent
|
||||||
|
* understands what was already tried
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({})) as { continueTask?: string };
|
||||||
|
|
||||||
|
// Verify ownership and load the original session
|
||||||
|
const rows = await query<{
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
app_name: string;
|
||||||
|
app_path: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.project_id, s.app_name, s.app_path, s.task, s.status
|
||||||
|
FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = rows[0];
|
||||||
|
|
||||||
|
if (!["failed", "stopped"].includes(s.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Session is ${s.status} — can only retry failed or stopped sessions` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch giteaRepo from the project
|
||||||
|
const proj = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id::text = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
const giteaRepo = proj[0]?.data?.giteaRepo as string | undefined;
|
||||||
|
|
||||||
|
// Clear persisted event timeline so SSE / replay matches the new run (no-op if table missing)
|
||||||
|
try {
|
||||||
|
await query(`DELETE FROM agent_session_events WHERE session_id = $1::uuid`, [sessionId]);
|
||||||
|
} catch {
|
||||||
|
/* table may not exist until admin migrate */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the session row so the frontend shows it as running again
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'running',
|
||||||
|
error = NULL,
|
||||||
|
output = '[]'::jsonb,
|
||||||
|
changed_files = '[]'::jsonb,
|
||||||
|
started_at = now(),
|
||||||
|
completed_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-fire the agent runner
|
||||||
|
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
projectId,
|
||||||
|
appName: s.app_name,
|
||||||
|
appPath: s.app_path,
|
||||||
|
giteaRepo,
|
||||||
|
task: s.task,
|
||||||
|
continueTask: body.continueTask?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn("[retry] runner not reachable:", err.message);
|
||||||
|
query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'failed', error = 'Agent runner not reachable', completed_at = now(), updated_at = now()
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ sessionId, status: "running" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[retry POST]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to retry session", details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts
Normal file
122
app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/[projectId]/agent/sessions/[sessionId]
|
||||||
|
* Fetch a session's full state — status, output log, changed files.
|
||||||
|
* Frontend polls this (or will switch to WebSocket in Phase 3).
|
||||||
|
*
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/stop
|
||||||
|
* (handled in /stop/route.ts)
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
id: string;
|
||||||
|
app_name: string;
|
||||||
|
app_path: string;
|
||||||
|
task: string;
|
||||||
|
plan: unknown;
|
||||||
|
status: string;
|
||||||
|
output: Array<{ ts: string; type: string; text: string }>;
|
||||||
|
changed_files: Array<{ path: string; status: string }>;
|
||||||
|
error: string | null;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.app_name, s.app_path, s.task, s.plan,
|
||||||
|
s.status, s.output, s.changed_files, s.error,
|
||||||
|
s.created_at, s.started_at, s.completed_at
|
||||||
|
FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ session: rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/[id] GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Internal endpoint called by vibn-agent-runner to append output lines
|
||||||
|
* and update status. Requires x-agent-runner-secret header.
|
||||||
|
*/
|
||||||
|
const secret = process.env.AGENT_RUNNER_SECRET ?? "";
|
||||||
|
const incomingSecret = req.headers.get("x-agent-runner-secret") ?? "";
|
||||||
|
if (secret && incomingSecret !== secret) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sessionId } = await params;
|
||||||
|
const body = await req.json() as {
|
||||||
|
status?: string;
|
||||||
|
outputLine?: { ts: string; type: string; text: string };
|
||||||
|
changedFile?: { path: string; status: string };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updates: string[] = ["updated_at = now()"];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (body.status) {
|
||||||
|
updates.push(`status = $${idx++}`);
|
||||||
|
values.push(body.status);
|
||||||
|
if (["done", "approved", "failed", "stopped"].includes(body.status)) {
|
||||||
|
updates.push(`completed_at = now()`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.error) {
|
||||||
|
updates.push(`error = $${idx++}`);
|
||||||
|
values.push(body.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.outputLine) {
|
||||||
|
updates.push(`output = output || $${idx++}::jsonb`);
|
||||||
|
values.push(JSON.stringify([body.outputLine]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.changedFile) {
|
||||||
|
updates.push(`changed_files = changed_files || $${idx++}::jsonb`);
|
||||||
|
values.push(JSON.stringify([body.changedFile]));
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(sessionId);
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions SET ${updates.join(", ")} WHERE id = $${idx}::uuid`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/[id] PATCH]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to update session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const rows = await query<{ status: string }>(
|
||||||
|
`SELECT s.status FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3 LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows[0].status !== "running" && rows[0].status !== "pending") {
|
||||||
|
return NextResponse.json({ error: "Session is not running" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the agent runner to stop (best-effort)
|
||||||
|
fetch(`${AGENT_RUNNER_URL}/agent/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sessionId }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Mark as stopped in DB immediately
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'stopped', completed_at = now(), updated_at = now(),
|
||||||
|
output = output || '[{"ts": "now", "type": "info", "text": "Stopped by user."}]'::jsonb
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/stop]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to stop session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
173
app/api/projects/[projectId]/agent/sessions/route.ts
Normal file
173
app/api/projects/[projectId]/agent/sessions/route.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Agent Sessions API
|
||||||
|
*
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions
|
||||||
|
* Create a new agent session and kick it off via vibn-agent-runner.
|
||||||
|
* Body: { appName, appPath, task }
|
||||||
|
*
|
||||||
|
* GET /api/projects/[projectId]/agent/sessions
|
||||||
|
* List all sessions for a project, newest first.
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
// Verify the agent_sessions table is reachable. If it doesn't exist yet,
|
||||||
|
// throw a descriptive error instead of a generic "Failed to create session".
|
||||||
|
// Run POST /api/admin/migrate once to create the table.
|
||||||
|
async function ensureTable() {
|
||||||
|
await query(
|
||||||
|
`SELECT 1 FROM agent_sessions LIMIT 0`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST — create session ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { appName, appPath, task } = body as {
|
||||||
|
appName: string;
|
||||||
|
appPath: string;
|
||||||
|
task: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!appName || !appPath || !task?.trim()) {
|
||||||
|
return NextResponse.json({ error: "appName, appPath and task are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTable();
|
||||||
|
|
||||||
|
// Verify ownership and fetch giteaRepo
|
||||||
|
const owns = await query<{ id: string; data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.id, p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (owns.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaRepo = owns[0].data?.giteaRepo as string | undefined;
|
||||||
|
|
||||||
|
// Find the Coolify UUID for this specific app so the runner can trigger a deploy
|
||||||
|
interface AppEntry { name: string; coolifyServiceUuid?: string | null; }
|
||||||
|
const apps = (owns[0].data?.apps ?? []) as AppEntry[];
|
||||||
|
const matchedApp = apps.find(a => a.name === appName);
|
||||||
|
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
|
||||||
|
|
||||||
|
// Create the session row
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`INSERT INTO agent_sessions (project_id, app_name, app_path, task, status, started_at)
|
||||||
|
VALUES ($1::text, $2, $3, $4, 'running', now())
|
||||||
|
RETURNING id`,
|
||||||
|
[projectId, appName, appPath, task.trim()]
|
||||||
|
);
|
||||||
|
const sessionId = rows[0].id;
|
||||||
|
|
||||||
|
// Fire-and-forget: call agent-runner to start the execution loop.
|
||||||
|
// autoApprove: true — agent commits + deploys automatically on completion.
|
||||||
|
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
projectId,
|
||||||
|
appName,
|
||||||
|
appPath,
|
||||||
|
giteaRepo,
|
||||||
|
task: task.trim(),
|
||||||
|
autoApprove: true,
|
||||||
|
coolifyAppUuid,
|
||||||
|
}),
|
||||||
|
}).catch(err => {
|
||||||
|
// Agent runner may not be wired yet — log but don't fail
|
||||||
|
console.warn("[agent] runner not reachable:", err.message);
|
||||||
|
// Mark session as failed if runner unreachable
|
||||||
|
query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'failed',
|
||||||
|
error = 'Agent runner not reachable',
|
||||||
|
completed_at = now(),
|
||||||
|
output = jsonb_build_array(jsonb_build_object(
|
||||||
|
'ts', now()::text,
|
||||||
|
'type', 'error',
|
||||||
|
'text', 'Agent runner service is not connected yet. Phase 2 implementation pending.'
|
||||||
|
))
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ sessionId }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions POST]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create session", details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET — list sessions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTable();
|
||||||
|
|
||||||
|
const sessions = await query<{
|
||||||
|
id: string;
|
||||||
|
app_name: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
output: Array<{ ts: string; type: string; text: string }>;
|
||||||
|
changed_files: Array<{ path: string; status: string }>;
|
||||||
|
error: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.app_name, s.task, s.status,
|
||||||
|
s.created_at, s.started_at, s.completed_at,
|
||||||
|
s.output, s.changed_files, s.error
|
||||||
|
FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.project_id::text = $1 AND u.data->>'email' = $2
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ sessions });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions GET]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to list sessions", details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/api/projects/[projectId]/analysis-status/route.ts
Normal file
37
app/api/projects/[projectId]/analysis-status/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = rows[0].data ?? {};
|
||||||
|
const stage = (data.analysisStage as string) ?? 'cloning';
|
||||||
|
const analysisResult = stage === 'done' ? data.analysisResult : undefined;
|
||||||
|
|
||||||
|
return NextResponse.json({ stage, analysisResult });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analysis-status]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/api/projects/[projectId]/analyze-chats/route.ts
Normal file
126
app/api/projects/[projectId]/analyze-chats/route.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.2, maxOutputTokens: 4096 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonBlock(raw: string): unknown {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const cleaned = trimmed.startsWith('```')
|
||||||
|
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||||
|
: trimmed;
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { chatText?: string };
|
||||||
|
const chatText = body.chatText?.trim() || '';
|
||||||
|
|
||||||
|
if (!chatText) {
|
||||||
|
return NextResponse.json({ error: 'chatText is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project ownership
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractionPrompt = `You are a product analyst. A founder has pasted AI chat conversation history below.
|
||||||
|
|
||||||
|
Extract and categorise the following from those conversations. Return ONLY valid JSON — no markdown, no explanation.
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"decisions": ["string — concrete decisions already made"],
|
||||||
|
"ideas": ["string — product ideas and features mentioned"],
|
||||||
|
"openQuestions": ["string — unresolved questions that still need answers"],
|
||||||
|
"architecture": ["string — technical architecture notes, stack choices, infra decisions"],
|
||||||
|
"targetUsers": ["string — user segments, personas, or target audiences mentioned"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Each array can be empty if nothing was found for that category. Extract real content — be specific and concise. Max 10 items per bucket.
|
||||||
|
|
||||||
|
--- CHAT HISTORY START ---
|
||||||
|
${chatText.slice(0, 12000)}
|
||||||
|
--- CHAT HISTORY END ---
|
||||||
|
|
||||||
|
Return only the JSON object:`;
|
||||||
|
|
||||||
|
const raw = await callGemini(extractionPrompt);
|
||||||
|
|
||||||
|
let analysisResult: {
|
||||||
|
decisions: string[];
|
||||||
|
ideas: string[];
|
||||||
|
openQuestions: string[];
|
||||||
|
architecture: string[];
|
||||||
|
targetUsers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
analysisResult = parseJsonBlock(raw) as typeof analysisResult;
|
||||||
|
} catch {
|
||||||
|
// Fallback: return empty buckets with a note
|
||||||
|
analysisResult = {
|
||||||
|
decisions: [],
|
||||||
|
ideas: [],
|
||||||
|
openQuestions: ["Could not parse extracted insights — try pasting more structured conversation"],
|
||||||
|
architecture: [],
|
||||||
|
targetUsers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save analysis result to project data
|
||||||
|
const current = rows[0].data ?? {};
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
analysisResult,
|
||||||
|
creationStage: 'review',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ analysisResult });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-chats]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
216
app/api/projects/[projectId]/analyze-repo/route.ts
Normal file
216
app/api/projects/[projectId]/analyze-repo/route.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.2, maxOutputTokens: 6000 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonBlock(raw: string): unknown {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const cleaned = trimmed.startsWith('```')
|
||||||
|
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||||
|
: trimmed;
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a file safely, returning empty string on failure
|
||||||
|
function safeRead(path: string, maxBytes = 8000): string {
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return '';
|
||||||
|
const content = readFileSync(path, 'utf8');
|
||||||
|
return content.slice(0, maxBytes);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk directory and collect file listing (relative paths), limited to avoid huge outputs
|
||||||
|
function walkDir(dir: string, depth = 0, maxDepth = 4, acc: string[] = []): string[] {
|
||||||
|
if (depth > maxDepth) return acc;
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__' || e.name === '.git') continue;
|
||||||
|
const full = join(dir, e.name);
|
||||||
|
const rel = full.replace(dir + '/', '');
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
acc.push(rel + '/');
|
||||||
|
walkDir(full, depth + 1, maxDepth, acc);
|
||||||
|
} else {
|
||||||
|
acc.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStage(projectId: string, currentData: Record<string, unknown>, stage: string) {
|
||||||
|
const updated = { ...currentData, analysisStage: stage, updatedAt: new Date().toISOString() };
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { repoUrl?: string };
|
||||||
|
const repoUrl = body.repoUrl?.trim() || '';
|
||||||
|
|
||||||
|
if (!repoUrl.startsWith('http')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid repository URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentData = rows[0].data ?? {};
|
||||||
|
currentData = await updateStage(projectId, currentData, 'cloning');
|
||||||
|
|
||||||
|
// Clone repo into temp dir (fire and forget — status is polled separately)
|
||||||
|
const tmpDir = `/tmp/vibn-${projectId}`;
|
||||||
|
|
||||||
|
// Run async so the request returns quickly and client can poll
|
||||||
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
|
// Clean up any existing clone
|
||||||
|
if (existsSync(tmpDir)) {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
execSync(`git clone --depth=1 "${repoUrl}" "${tmpDir}"`, {
|
||||||
|
timeout: 60_000,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = { ...currentData };
|
||||||
|
data = await updateStage(projectId, data, 'reading');
|
||||||
|
|
||||||
|
// Read key files
|
||||||
|
const manifest: Record<string, string> = {};
|
||||||
|
const keyFiles = [
|
||||||
|
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
||||||
|
'requirements.txt', 'Pipfile', 'pyproject.toml',
|
||||||
|
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
||||||
|
'README.md', '.env.example', '.env.sample',
|
||||||
|
'next.config.js', 'next.config.ts', 'next.config.mjs',
|
||||||
|
'vite.config.ts', 'vite.config.js',
|
||||||
|
'tsconfig.json',
|
||||||
|
'prisma/schema.prisma', 'schema.prisma',
|
||||||
|
];
|
||||||
|
for (const f of keyFiles) {
|
||||||
|
const content = safeRead(join(tmpDir, f));
|
||||||
|
if (content) manifest[f] = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileListing = walkDir(tmpDir).slice(0, 300).join('\n');
|
||||||
|
|
||||||
|
data = await updateStage(projectId, data, 'analyzing');
|
||||||
|
|
||||||
|
const analysisPrompt = `You are a senior full-stack architect. Analyse this repository and return a structured architecture map.
|
||||||
|
|
||||||
|
File listing (top-level):
|
||||||
|
${fileListing}
|
||||||
|
|
||||||
|
Key file contents:
|
||||||
|
${Object.entries(manifest).map(([k, v]) => `\n### ${k}\n${v}`).join('')}
|
||||||
|
|
||||||
|
Return ONLY valid JSON with this structure:
|
||||||
|
{
|
||||||
|
"summary": "1-2 sentence project summary",
|
||||||
|
"rows": [
|
||||||
|
{ "category": "Tech Stack", "item": "Next.js 15", "status": "found", "detail": "next.config.ts present" },
|
||||||
|
{ "category": "Database", "item": "PostgreSQL", "status": "found", "detail": "prisma/schema.prisma detected" },
|
||||||
|
{ "category": "Auth", "item": "Authentication", "status": "missing", "detail": "No auth library detected" }
|
||||||
|
],
|
||||||
|
"suggestedSurfaces": ["marketing", "admin"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Categories to cover: Tech Stack, Infrastructure, Database, API Surface, Frontend, Auth, Third-party, Missing / Gaps
|
||||||
|
Status values: "found", "partial", "missing"
|
||||||
|
suggestedSurfaces should only include items from: ["marketing", "web-app", "admin", "api"]
|
||||||
|
Suggest surfaces that are MISSING or incomplete in the current codebase.
|
||||||
|
|
||||||
|
Return only the JSON:`;
|
||||||
|
|
||||||
|
const raw = await callGemini(analysisPrompt);
|
||||||
|
let analysisResult;
|
||||||
|
try {
|
||||||
|
analysisResult = parseJsonBlock(raw);
|
||||||
|
} catch {
|
||||||
|
analysisResult = {
|
||||||
|
summary: 'Could not fully parse the repository structure.',
|
||||||
|
rows: [{ category: 'Tech Stack', item: 'Repository detected', status: 'found', detail: fileListing.split('\n').slice(0, 5).join(', ') }],
|
||||||
|
suggestedSurfaces: ['marketing'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save result and mark done
|
||||||
|
const finalData = {
|
||||||
|
...data,
|
||||||
|
analysisStage: 'done',
|
||||||
|
analysisResult,
|
||||||
|
creationStage: 'mapping',
|
||||||
|
sourceData: { ...(data.sourceData as object || {}), repoUrl },
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(finalData)]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-repo] background error', err);
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify({ ...currentData, analysisStage: 'error', analysisError: String(err) })]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Clean up
|
||||||
|
try { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ started: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-repo]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/api/projects/[projectId]/analyze/route.ts
Normal file
121
app/api/projects/[projectId]/analyze/route.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||||
|
|
||||||
|
// GET — check the current analysis status for a project
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const rows = await query<{ data: any }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const project = rows[0].data;
|
||||||
|
|
||||||
|
if (!project.isImport) {
|
||||||
|
return NextResponse.json({ isImport: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = project.importAnalysisJobId;
|
||||||
|
let jobStatus: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
// Fetch live job status from agent runner if we have a job ID
|
||||||
|
if (jobId) {
|
||||||
|
try {
|
||||||
|
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/jobs/${jobId}`);
|
||||||
|
if (jobRes.ok) {
|
||||||
|
jobStatus = await jobRes.json() as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Sync terminal status back to the project record
|
||||||
|
const runnerStatus = jobStatus.status as string | undefined;
|
||||||
|
if (runnerStatus && runnerStatus !== project.importAnalysisStatus) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = jsonb_set(data, '{importAnalysisStatus}', $1::jsonb) WHERE id = $2`,
|
||||||
|
[JSON.stringify(runnerStatus), projectId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Agent runner unreachable — return last known status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
isImport: true,
|
||||||
|
status: project.importAnalysisStatus ?? 'pending',
|
||||||
|
jobId,
|
||||||
|
job: jobStatus,
|
||||||
|
githubRepoUrl: project.githubRepoUrl,
|
||||||
|
giteaRepo: project.giteaRepo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST — (re-)trigger an analysis job for a project
|
||||||
|
export async function POST(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const rows = await query<{ data: any }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const project = rows[0].data;
|
||||||
|
|
||||||
|
if (!project.giteaRepo) {
|
||||||
|
return NextResponse.json({ error: 'Project has no Gitea repo' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/agent/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent: 'ImportAnalyzer',
|
||||||
|
task: `Analyze this codebase${project.githubRepoUrl ? ` (originally from ${project.githubRepoUrl})` : ''} and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
|
||||||
|
repo: project.giteaRepo,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!jobRes.ok) {
|
||||||
|
const detail = await jobRes.text();
|
||||||
|
return NextResponse.json({ error: 'Failed to start analysis', details: detail }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobData = await jobRes.json() as { jobId?: string };
|
||||||
|
const jobId = jobData.jobId ?? null;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
|
||||||
|
[JSON.stringify(jobId), projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, jobId, status: 'running' });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to start analysis', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export async function GET(
|
|||||||
let apps: { name: string; path: string }[] = [];
|
let apps: { name: string; path: string }[] = [];
|
||||||
|
|
||||||
if (giteaRepo) {
|
if (giteaRepo) {
|
||||||
|
// First: try the standard turborepo apps/ directory
|
||||||
try {
|
try {
|
||||||
const contents: Array<{ name: string; path: string; type: string }> =
|
const contents: Array<{ name: string; path: string; type: string }> =
|
||||||
await giteaGet(`/repos/${giteaRepo}/contents/apps`);
|
await giteaGet(`/repos/${giteaRepo}/contents/apps`);
|
||||||
@@ -55,11 +56,64 @@ export async function GET(
|
|||||||
.filter((item) => item.type === 'dir')
|
.filter((item) => item.type === 'dir')
|
||||||
.map(({ name, path }) => ({ name, path }));
|
.map(({ name, path }) => ({ name, path }));
|
||||||
} catch {
|
} catch {
|
||||||
// Repo may not have an apps/ dir yet — return empty list gracefully
|
// No apps/ dir — fall through to import detection below
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: no apps/ dir — scan repo root for deployable components.
|
||||||
|
// Works for any project structure (imported, single-repo, monorepo variants).
|
||||||
|
if (apps.length === 0) {
|
||||||
|
try {
|
||||||
|
// Try CODEBASE_MAP.md first (written by ImportAnalyzer for imported repos)
|
||||||
|
const mapFile = await giteaGet(`/repos/${giteaRepo}/contents/CODEBASE_MAP.md`).catch(() => null);
|
||||||
|
if (mapFile?.content) {
|
||||||
|
const decoded = Buffer.from(mapFile.content, 'base64').toString('utf-8');
|
||||||
|
const matches = [...decoded.matchAll(/###\s+.+?[—–-]\s+[`]?([^`\n(]+)[`]?/g)];
|
||||||
|
const parsedApps = matches
|
||||||
|
.map(m => m[1].trim().replace(/^`|`$/g, '').replace(/\/$/, ''))
|
||||||
|
.filter(p => p && p.length > 0 && !p.includes(' ') && !p.startsWith('http') && p !== '.')
|
||||||
|
.map(p => ({ name: p.split('/').pop() ?? p, path: p }));
|
||||||
|
if (parsedApps.length > 0) apps = parsedApps;
|
||||||
|
}
|
||||||
|
} catch { /* CODEBASE_MAP not available */ }
|
||||||
|
|
||||||
|
// Scan top-level dirs for app signals
|
||||||
|
if (apps.length === 0) {
|
||||||
|
try {
|
||||||
|
const SKIP = new Set(['docs', 'scripts', 'keys', '.github', 'node_modules', '.git', 'dist', 'build', 'coverage']);
|
||||||
|
const APP_SIGNALS = ['package.json', 'requirements.txt', 'pyproject.toml', 'Dockerfile', 'next.config.ts', 'next.config.js', 'vite.config.ts', 'main.py', 'app.py', 'index.js', 'server.ts'];
|
||||||
|
|
||||||
|
const root: Array<{ name: string; path: string; type: string }> =
|
||||||
|
await giteaGet(`/repos/${giteaRepo}/contents/`);
|
||||||
|
|
||||||
|
// Check if the root itself is an app (single-repo projects)
|
||||||
|
const rootIsApp = root.some(f => f.type === 'file' && APP_SIGNALS.includes(f.name));
|
||||||
|
if (rootIsApp) {
|
||||||
|
// Repo root is the app — use repo name as label, empty string as path
|
||||||
|
apps = [{ name: giteaRepo.split('/').pop() ?? 'app', path: '' }];
|
||||||
|
} else {
|
||||||
|
// Scan subdirs
|
||||||
|
const dirs = root.filter(i => i.type === 'dir' && !SKIP.has(i.name));
|
||||||
|
const candidates = await Promise.all(
|
||||||
|
dirs.map(async (dir) => {
|
||||||
|
try {
|
||||||
|
const sub: Array<{ name: string; type: string }> = await giteaGet(`/repos/${giteaRepo}/contents/${dir.path}`);
|
||||||
|
return sub.some(f => APP_SIGNALS.includes(f.name)) ? { name: dir.name, path: dir.path } : null;
|
||||||
|
} catch { return null; }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
apps = candidates.filter((a): a is { name: string; path: string } => a !== null);
|
||||||
|
}
|
||||||
|
} catch { /* scan failed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: expose the repo root so the file tree still works
|
||||||
|
if (apps.length === 0) {
|
||||||
|
apps = [{ name: giteaRepo.split('/').pop() ?? 'app', path: '' }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ apps, designPackages, giteaRepo });
|
return NextResponse.json({ apps, designPackages, giteaRepo, isImport: !!(data.isImport || data.creationMode === 'migration') });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export async function POST(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
// For init, send the greeting prompt but don't store it as a user message
|
// For init, send the greeting prompt but don't store it as a user message
|
||||||
message: isInit
|
message: isInit
|
||||||
? "Begin the conversation. Introduce yourself as Atlas and ask what the user is building. Do not acknowledge this as an internal trigger."
|
? "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger."
|
||||||
: message,
|
: message,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
history,
|
history,
|
||||||
@@ -146,7 +146,7 @@ export async function POST(
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
console.error("[atlas-chat] Agent runner error:", text);
|
console.error("[atlas-chat] Agent runner error:", text);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Atlas is unavailable. Please try again." },
|
{ error: "Vibn is unavailable. Please try again." },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
108
app/api/projects/[projectId]/file/route.ts
Normal file
108
app/api/projects/[projectId]/file/route.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/[projectId]/file?path=apps/admin
|
||||||
|
*
|
||||||
|
* Returns directory listing or file content from the project's Gitea repo.
|
||||||
|
* Response for directory: { type: "dir", items: [{ name, path, type }] }
|
||||||
|
* Response for file: { type: "file", content: string, encoding: "utf8" | "base64" }
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||||
|
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
|
||||||
|
|
||||||
|
async function giteaGet(path: string) {
|
||||||
|
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
|
||||||
|
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
|
||||||
|
next: { revalidate: 10 },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico',
|
||||||
|
'woff', 'woff2', 'ttf', 'eot',
|
||||||
|
'zip', 'tar', 'gz', 'pdf',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isBinary(name: string): boolean {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
return BINARY_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const filePath = searchParams.get('path') ?? '';
|
||||||
|
|
||||||
|
// Verify ownership + get giteaRepo
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaRepo = rows[0].data?.giteaRepo as string | undefined;
|
||||||
|
if (!giteaRepo) {
|
||||||
|
return NextResponse.json({ error: 'No Gitea repo connected' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedPath = filePath ? encodeURIComponent(filePath).replace(/%2F/g, '/') : '';
|
||||||
|
const apiPath = `/repos/${giteaRepo}/contents/${encodedPath}`;
|
||||||
|
const data = await giteaGet(apiPath);
|
||||||
|
|
||||||
|
// Directory listing
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const items = data
|
||||||
|
.map((item: { name: string; path: string; type: string; size?: number }) => ({
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
type: item.type, // "file" | "dir" | "symlink"
|
||||||
|
size: item.size,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Dirs first
|
||||||
|
if (a.type === 'dir' && b.type !== 'dir') return -1;
|
||||||
|
if (a.type !== 'dir' && b.type === 'dir') return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return NextResponse.json({ type: 'dir', items });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single file
|
||||||
|
const item = data as { name: string; content?: string; encoding?: string; size?: number };
|
||||||
|
if (isBinary(item.name)) {
|
||||||
|
return NextResponse.json({ type: 'file', content: '(binary file)', encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea returns base64-encoded content
|
||||||
|
const raw = item.content ?? '';
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = Buffer.from(raw.replace(/\n/g, ''), 'base64').toString('utf8');
|
||||||
|
} catch {
|
||||||
|
content = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ type: 'file', content, encoding: 'utf8', name: item.name });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[file API]', err);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
139
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.3, maxOutputTokens: 8000 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as {
|
||||||
|
analysisResult?: Record<string, unknown>;
|
||||||
|
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = rows[0].data ?? {};
|
||||||
|
const projectName = (current.productName as string) || (current.name as string) || 'the product';
|
||||||
|
const { analysisResult, sourceData } = body;
|
||||||
|
|
||||||
|
const prompt = `You are a senior DevOps and platform migration architect. Generate a comprehensive, phased migration plan in Markdown for migrating an existing product into a new infrastructure (VIBN — a self-hosted PaaS).
|
||||||
|
|
||||||
|
Product: ${projectName}
|
||||||
|
Repo: ${sourceData?.repoUrl || 'Not provided'}
|
||||||
|
Live URL: ${sourceData?.liveUrl || 'Not provided'}
|
||||||
|
Current hosting: ${sourceData?.hosting || 'Unknown'}
|
||||||
|
|
||||||
|
Architecture audit summary:
|
||||||
|
${analysisResult?.summary || 'No audit data provided.'}
|
||||||
|
|
||||||
|
Detected components:
|
||||||
|
${JSON.stringify(analysisResult?.rows || [], null, 2).slice(0, 3000)}
|
||||||
|
|
||||||
|
Generate a complete migration plan with exactly these 4 phases:
|
||||||
|
|
||||||
|
# ${projectName} — Migration Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Brief 2-3 sentence description of the migration approach and guiding principle (non-destructive duplication).
|
||||||
|
|
||||||
|
## Phase 1: Mirror
|
||||||
|
Set up parallel infrastructure on VIBN without touching production.
|
||||||
|
- [ ] Clone repository to VIBN Gitea
|
||||||
|
- [ ] Configure Coolify application
|
||||||
|
- [ ] Set up identical database schema
|
||||||
|
- [ ] Configure environment variables
|
||||||
|
- [ ] Verify build passes
|
||||||
|
|
||||||
|
## Phase 2: Validate
|
||||||
|
Run both systems in parallel and compare outputs.
|
||||||
|
- [ ] Route 5% of traffic to new infrastructure (or test internally)
|
||||||
|
- [ ] Compare API responses between old and new
|
||||||
|
- [ ] Run full end-to-end test suite
|
||||||
|
- [ ] Validate data sync between databases
|
||||||
|
- [ ] Sign off on performance benchmarks
|
||||||
|
|
||||||
|
## Phase 3: Cutover
|
||||||
|
Redirect production traffic to the new infrastructure.
|
||||||
|
- [ ] Update DNS records to point to VIBN load balancer
|
||||||
|
- [ ] Monitor error rates and latency for 24h
|
||||||
|
- [ ] Validate all integrations (auth, payments, third-party APIs)
|
||||||
|
- [ ] Keep old infrastructure on standby for 7 days
|
||||||
|
|
||||||
|
## Phase 4: Decommission
|
||||||
|
Remove old infrastructure after successful validation period.
|
||||||
|
- [ ] Confirm all data has been migrated
|
||||||
|
- [ ] Archive old repository access
|
||||||
|
- [ ] Terminate old hosting resources
|
||||||
|
- [ ] Update all internal documentation
|
||||||
|
|
||||||
|
## Risk Register
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Database migration failure | Medium | High | Full backup before any migration step |
|
||||||
|
| DNS propagation delay | Low | Medium | Use low TTL before cutover |
|
||||||
|
| Third-party integration breakage | Medium | High | Test all webhooks and OAuth in Phase 2 |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
At any phase, revert by: pointing DNS back to original infrastructure. Data written during parallel run must be synced back manually. Old infrastructure MUST remain live until Phase 4 completes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Write a thorough, specific plan. Use real details from the audit where available. Every checklist item should be actionable. Return only the Markdown document.`;
|
||||||
|
|
||||||
|
const migrationPlan = await callGemini(prompt);
|
||||||
|
|
||||||
|
// Save to project
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
migrationPlan,
|
||||||
|
creationStage: 'plan',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ migrationPlan });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[generate-migration-plan]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/api/projects/[projectId]/preview-url/route.ts
Normal file
95
app/api/projects/[projectId]/preview-url/route.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
import { listApplications, CoolifyApplication } from '@/lib/coolify';
|
||||||
|
|
||||||
|
const GITEA_BASE = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||||
|
|
||||||
|
export interface PreviewApp {
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
status: string;
|
||||||
|
coolifyUuid: string | null;
|
||||||
|
gitRepo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Load project — get the Gitea repo name
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = rows[0].data ?? {};
|
||||||
|
const giteaRepo = data.giteaRepo as string | undefined; // e.g. "mark/scout-ai-engine"
|
||||||
|
|
||||||
|
if (!giteaRepo) {
|
||||||
|
return NextResponse.json({ apps: [], message: 'No Gitea repo linked to this project' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build the possible Gitea remote URLs for this repo (with and without .git)
|
||||||
|
const repoBase = `${GITEA_BASE}/${giteaRepo}`;
|
||||||
|
const repoUrls = new Set([repoBase, `${repoBase}.git`]);
|
||||||
|
|
||||||
|
// 3. Fetch all Coolify applications and match by git_repository
|
||||||
|
let coolifyApps: CoolifyApplication[] = [];
|
||||||
|
try {
|
||||||
|
coolifyApps = await listApplications();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[preview-url] Coolify fetch failed:', err);
|
||||||
|
// Fall back to stored data
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = coolifyApps.filter(app =>
|
||||||
|
app.git_repository && repoUrls.has(app.git_repository.replace(/\/$/, ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matched.length > 0) {
|
||||||
|
const apps: PreviewApp[] = matched.map(app => ({
|
||||||
|
name: app.name,
|
||||||
|
url: app.fqdn
|
||||||
|
? (app.fqdn.startsWith('http') ? app.fqdn : `https://${app.fqdn}`)
|
||||||
|
: null,
|
||||||
|
status: app.status ?? 'unknown',
|
||||||
|
coolifyUuid: app.uuid,
|
||||||
|
gitRepo: app.git_repository ?? null,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ apps, source: 'coolify' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: use whatever URL was stored by the Coolify webhook
|
||||||
|
const lastDeployment = (data as any).contextSnapshot?.lastDeployment ?? null;
|
||||||
|
if (lastDeployment?.url) {
|
||||||
|
const apps: PreviewApp[] = [{
|
||||||
|
name: giteaRepo.split('/').pop() ?? 'app',
|
||||||
|
url: lastDeployment.url.startsWith('http') ? lastDeployment.url : `https://${lastDeployment.url}`,
|
||||||
|
status: lastDeployment.status === 'finished' ? 'running' : lastDeployment.status ?? 'unknown',
|
||||||
|
coolifyUuid: lastDeployment.applicationUuid ?? null,
|
||||||
|
gitRepo: giteaRepo,
|
||||||
|
}];
|
||||||
|
return NextResponse.json({ apps, source: 'webhook' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
apps: [],
|
||||||
|
message: `No Coolify app found for repo: ${giteaRepo}`,
|
||||||
|
giteaRepo,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
|
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
|
||||||
import { pushTurborepoScaffold } from '@/lib/scaffold';
|
import { pushTurborepoScaffold } from '@/lib/scaffold';
|
||||||
import { createProject as createCoolifyProject, createMonorepoAppService } from '@/lib/coolify';
|
import { createMonorepoAppService } from '@/lib/coolify';
|
||||||
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
|
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
|
||||||
|
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
|
||||||
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
||||||
|
|
||||||
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
|
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
|
||||||
@@ -15,7 +15,7 @@ const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-s
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,19 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const firebaseUserId = users[0]!.id;
|
const firebaseUserId = users[0]!.id;
|
||||||
|
|
||||||
|
// Resolve (and lazily provision) the user's workspace. Provides:
|
||||||
|
// - vibnWorkspace.coolify_project_uuid → namespace for Coolify apps/DBs
|
||||||
|
// - vibnWorkspace.gitea_org → owner for Gitea repos
|
||||||
|
// If provisioning failed for either, we fall back to legacy admin
|
||||||
|
// identifiers so the project create still succeeds (with degraded isolation).
|
||||||
|
let vibnWorkspace = await getOrCreateProvisionedWorkspace({
|
||||||
|
userId: firebaseUserId,
|
||||||
|
email,
|
||||||
|
displayName: session.user.name ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoOwner = vibnWorkspace.gitea_org ?? GITEA_ADMIN_USER;
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const {
|
const {
|
||||||
projectName,
|
projectName,
|
||||||
@@ -66,6 +79,7 @@ export async function POST(request: Request) {
|
|||||||
githubRepoId,
|
githubRepoId,
|
||||||
githubRepoUrl,
|
githubRepoUrl,
|
||||||
githubDefaultBranch,
|
githubDefaultBranch,
|
||||||
|
githubToken,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Check slug uniqueness
|
// Check slug uniqueness
|
||||||
@@ -96,14 +110,15 @@ export async function POST(request: Request) {
|
|||||||
description: `${projectName} — managed by Vibn`,
|
description: `${projectName} — managed by Vibn`,
|
||||||
private: true,
|
private: true,
|
||||||
auto_init: false,
|
auto_init: false,
|
||||||
|
owner: repoOwner,
|
||||||
});
|
});
|
||||||
console.log(`[API] Gitea repo created: ${GITEA_ADMIN_USER}/${repoName}`);
|
console.log(`[API] Gitea repo created: ${repoOwner}/${repoName}`);
|
||||||
} catch (createErr) {
|
} catch (createErr) {
|
||||||
const msg = createErr instanceof Error ? createErr.message : String(createErr);
|
const msg = createErr instanceof Error ? createErr.message : String(createErr);
|
||||||
// 409 = repo already exists — link to it instead of failing
|
// 409 = repo already exists — link to it instead of failing
|
||||||
if (msg.includes('409')) {
|
if (msg.includes('409')) {
|
||||||
console.log(`[API] Gitea repo already exists, linking to ${GITEA_ADMIN_USER}/${repoName}`);
|
console.log(`[API] Gitea repo already exists, linking to ${repoOwner}/${repoName}`);
|
||||||
repo = await getRepo(GITEA_ADMIN_USER, repoName);
|
repo = await getRepo(repoOwner, repoName);
|
||||||
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
|
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
|
||||||
} else {
|
} else {
|
||||||
throw createErr;
|
throw createErr;
|
||||||
@@ -115,17 +130,37 @@ export async function POST(request: Request) {
|
|||||||
giteaCloneUrl = repo.clone_url;
|
giteaCloneUrl = repo.clone_url;
|
||||||
giteaSshUrl = repo.ssh_url;
|
giteaSshUrl = repo.ssh_url;
|
||||||
|
|
||||||
// Push Turborepo monorepo scaffold as initial commit
|
// If a GitHub repo was provided, mirror it as-is.
|
||||||
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
|
// Otherwise push the default Turborepo scaffold.
|
||||||
|
if (githubRepoUrl) {
|
||||||
|
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||||
|
const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
github_url: githubRepoUrl,
|
||||||
|
gitea_repo: `${repoOwner}/${repoName}`,
|
||||||
|
project_name: projectName,
|
||||||
|
github_token: githubToken || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!mirrorRes.ok) {
|
||||||
|
const detail = await mirrorRes.text();
|
||||||
|
throw new Error(`GitHub mirror failed: ${detail}`);
|
||||||
|
}
|
||||||
|
console.log(`[API] GitHub repo mirrored to ${giteaRepo}`);
|
||||||
|
} else {
|
||||||
|
await pushTurborepoScaffold(repoOwner, repoName, slug, projectName);
|
||||||
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Register webhook — skip if one already points to this project
|
// Register webhook — skip if one already points to this project
|
||||||
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
|
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
|
||||||
const existingHooks = await listWebhooks(GITEA_ADMIN_USER, repoName).catch(() => []);
|
const existingHooks = await listWebhooks(repoOwner, repoName).catch(() => []);
|
||||||
const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
|
const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
|
||||||
|
|
||||||
if (!alreadyHooked) {
|
if (!alreadyHooked) {
|
||||||
const hook = await createWebhook(GITEA_ADMIN_USER, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
|
const hook = await createWebhook(repoOwner, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
|
||||||
giteaWebhookId = hook.id;
|
giteaWebhookId = hook.id;
|
||||||
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
|
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -139,7 +174,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// 2. Provision Coolify project + per-app services
|
// 2. Provision per-app services under the workspace's Coolify Project
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com';
|
const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com';
|
||||||
const appNames = ['product', 'website', 'admin', 'storybook'] as const;
|
const appNames = ['product', 'website', 'admin', 'storybook'] as const;
|
||||||
@@ -147,19 +182,17 @@ export async function POST(request: Request) {
|
|||||||
name: string; path: string; coolifyServiceUuid: string | null; domain: string | null;
|
name: string; path: string; coolifyServiceUuid: string | null; domain: string | null;
|
||||||
}> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null }));
|
}> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null }));
|
||||||
|
|
||||||
if (giteaCloneUrl) {
|
// The workspace's Coolify Project IS our team boundary. All Vibn
|
||||||
try {
|
// projects for a workspace share one Coolify Project namespace.
|
||||||
const coolifyProject = await createCoolifyProject(
|
const coolifyProjectUuid: string | null = vibnWorkspace.coolify_project_uuid;
|
||||||
projectName,
|
|
||||||
`Vibn project: ${projectName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (giteaCloneUrl && coolifyProjectUuid) {
|
||||||
for (const app of provisionedApps) {
|
for (const app of provisionedApps) {
|
||||||
try {
|
try {
|
||||||
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
|
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
|
||||||
const service = await createMonorepoAppService({
|
const service = await createMonorepoAppService({
|
||||||
projectUuid: coolifyProject.uuid,
|
projectUuid: coolifyProjectUuid,
|
||||||
appName: app.name,
|
appName: `${slug}-${app.name}`, // unique within the workspace's Coolify Project
|
||||||
gitRepo: giteaCloneUrl,
|
gitRepo: giteaCloneUrl,
|
||||||
domain,
|
domain,
|
||||||
});
|
});
|
||||||
@@ -170,9 +203,8 @@ export async function POST(request: Request) {
|
|||||||
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
|
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (coolifyErr) {
|
} else if (!coolifyProjectUuid) {
|
||||||
console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr);
|
console.warn('[API] Workspace has no Coolify Project UUID — skipped app provisioning. Run /api/workspaces/{slug}/provision to retry.');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -236,17 +268,23 @@ export async function POST(request: Request) {
|
|||||||
theiaError,
|
theiaError,
|
||||||
// Context snapshot (kept fresh by webhooks)
|
// Context snapshot (kept fresh by webhooks)
|
||||||
contextSnapshot: null,
|
contextSnapshot: null,
|
||||||
|
// Coolify project — one per VIBN project, scopes all app services + DBs
|
||||||
|
coolifyProjectUuid,
|
||||||
// Turborepo monorepo apps — each gets its own Coolify service
|
// Turborepo monorepo apps — each gets its own Coolify service
|
||||||
turboVersion: '2.3.3',
|
turboVersion: '2.3.3',
|
||||||
apps: provisionedApps,
|
apps: provisionedApps,
|
||||||
|
// Import metadata
|
||||||
|
isImport: !!githubRepoUrl,
|
||||||
|
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
|
||||||
|
importAnalysisJobId: null as string | null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO fs_projects (id, data, user_id, workspace, slug)
|
INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id)
|
||||||
VALUES ($1, $2::jsonb, $3, $4, $5)
|
VALUES ($1, $2::jsonb, $3, $4, $5, $6)
|
||||||
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug]);
|
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]);
|
||||||
|
|
||||||
// Associate any unlinked sessions for this workspace path
|
// Associate any unlinked sessions for this workspace path
|
||||||
if (workspacePath) {
|
if (workspacePath) {
|
||||||
@@ -262,7 +300,40 @@ export async function POST(request: Request) {
|
|||||||
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]);
|
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped');
|
// ──────────────────────────────────────────────
|
||||||
|
// 5. If this is an import, trigger the analysis agent
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
let analysisJobId: string | null = null;
|
||||||
|
if (githubRepoUrl && giteaRepo) {
|
||||||
|
try {
|
||||||
|
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||||
|
const jobRes = await fetch(`${agentRunnerUrl}/api/agent/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent: 'ImportAnalyzer',
|
||||||
|
task: `Analyze this imported codebase (originally from ${githubRepoUrl}) and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
|
||||||
|
repo: giteaRepo,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (jobRes.ok) {
|
||||||
|
const jobData = await jobRes.json() as { jobId?: string };
|
||||||
|
analysisJobId = jobData.jobId ?? null;
|
||||||
|
// Store the job ID on the project record
|
||||||
|
if (analysisJobId) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
|
||||||
|
[JSON.stringify(analysisJobId), projectId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[API] Import analysis job started: ${analysisJobId}`);
|
||||||
|
}
|
||||||
|
} catch (analysisErr) {
|
||||||
|
console.error('[API] Failed to start import analysis (non-fatal):', analysisErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped', '| import:', !!githubRepoUrl);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -275,6 +346,8 @@ export async function POST(request: Request) {
|
|||||||
giteaError: giteaError ?? undefined,
|
giteaError: giteaError ?? undefined,
|
||||||
theiaWorkspaceUrl,
|
theiaWorkspaceUrl,
|
||||||
theiaError: theiaError ?? undefined,
|
theiaError: theiaError ?? undefined,
|
||||||
|
isImport: !!githubRepoUrl,
|
||||||
|
analysisJobId: analysisJobId ?? undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[POST /api/projects/create] Error:', error);
|
console.error('[POST /api/projects/create] Error:', error);
|
||||||
|
|||||||
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal file
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/workspaces/[slug]/apps/[uuid]/deploy
|
||||||
|
*
|
||||||
|
* Trigger a deploy on a Coolify app. Guard: app must belong to this
|
||||||
|
* workspace's Coolify project before we forward the call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
deployApplication,
|
||||||
|
getApplicationInProject,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tenant check before any mutation.
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
const result = await deployApplication(uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deploymentUuid: result.deployment_uuid,
|
||||||
|
appUuid: uuid,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Deploy failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal file
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid]/deployments
|
||||||
|
*
|
||||||
|
* Recent deployments for an app. Tenant-checked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
listApplicationDeployments,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
const deployments = await listApplicationDeployments(uuid);
|
||||||
|
return NextResponse.json({ deployments });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts
Normal file
114
app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid]/domains — list current domains
|
||||||
|
* PATCH /api/workspaces/[slug]/apps/[uuid]/domains — replace domain set
|
||||||
|
*
|
||||||
|
* Body: { domains: string[] } — each must end with .{workspace}.vibnai.com.
|
||||||
|
* We enforce workspace-subdomain policy here to prevent AI-driven
|
||||||
|
* hijacking of other workspaces' subdomains.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
setApplicationDomains,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import {
|
||||||
|
isDomainUnderWorkspace,
|
||||||
|
parseDomainsString,
|
||||||
|
workspaceAppFqdn,
|
||||||
|
slugify,
|
||||||
|
} from '@/lib/naming';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: app.uuid,
|
||||||
|
name: app.name,
|
||||||
|
domains: parseDomainsString(app.domains ?? app.fqdn ?? ''),
|
||||||
|
workspaceDomainSuffix: `${ws.slug}.vibnai.com`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let app;
|
||||||
|
try {
|
||||||
|
app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { domains?: string[] } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Array.isArray(body.domains) ? body.domains : [];
|
||||||
|
if (raw.length === 0) {
|
||||||
|
return NextResponse.json({ error: '`domains` must be a non-empty array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize + policy-check.
|
||||||
|
const normalized: string[] = [];
|
||||||
|
for (const d of raw) {
|
||||||
|
if (typeof d !== 'string' || !d.trim()) continue;
|
||||||
|
const clean = d.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
|
||||||
|
if (!isDomainUnderWorkspace(clean, ws.slug)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Domain ${clean} is not allowed; must end with .${ws.slug}.vibnai.com`,
|
||||||
|
hint: `Use ${workspaceAppFqdn(ws.slug, slugify(app.name))}`,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
normalized.push(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setApplicationDomains(uuid, normalized, { forceOverride: true });
|
||||||
|
return NextResponse.json({ ok: true, uuid, domains: normalized });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify domain update failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal file
154
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid]/envs — list env vars
|
||||||
|
* PATCH /api/workspaces/[slug]/apps/[uuid]/envs — upsert one env var
|
||||||
|
* body: { key, value, is_preview?, is_build_time?, is_literal?, is_multiline? }
|
||||||
|
* DELETE /api/workspaces/[slug]/apps/[uuid]/envs?key=FOO — delete one env var
|
||||||
|
*
|
||||||
|
* Tenant boundary: the app must belong to the workspace's Coolify project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
deleteApplicationEnv,
|
||||||
|
getApplicationInProject,
|
||||||
|
listApplicationEnvs,
|
||||||
|
TenantError,
|
||||||
|
upsertApplicationEnv,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
async function verify(request: Request, slug: string, uuid: string) {
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return { error: principal };
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet' },
|
||||||
|
{ status: 503 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return { error: NextResponse.json({ error: err.message }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { principal };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const check = await verify(request, slug, uuid);
|
||||||
|
if ('error' in check) return check.error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envs = await listApplicationEnvs(uuid);
|
||||||
|
// Redact values by default for API-key callers — they can re-fetch
|
||||||
|
// with ?reveal=true when they need the actual values (e.g. to copy
|
||||||
|
// a DATABASE_URL). Session callers always get full values.
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const reveal =
|
||||||
|
check.principal.source === 'session' || url.searchParams.get('reveal') === 'true';
|
||||||
|
return NextResponse.json({
|
||||||
|
envs: envs.map(e => ({
|
||||||
|
key: e.key,
|
||||||
|
value: reveal ? e.value : maskValue(e.value),
|
||||||
|
isPreview: e.is_preview ?? false,
|
||||||
|
isBuildTime: e.is_build_time ?? false,
|
||||||
|
isLiteral: e.is_literal ?? false,
|
||||||
|
isMultiline: e.is_multiline ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const check = await verify(request, slug, uuid);
|
||||||
|
if ('error' in check) return check.error;
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
is_preview?: boolean;
|
||||||
|
is_build_time?: boolean;
|
||||||
|
is_literal?: boolean;
|
||||||
|
is_multiline?: boolean;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.value !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Fields "key" and "value" are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const env = await upsertApplicationEnv(uuid, {
|
||||||
|
key: body.key,
|
||||||
|
value: body.value,
|
||||||
|
is_preview: body.is_preview ?? false,
|
||||||
|
is_build_time: body.is_build_time ?? false,
|
||||||
|
is_literal: body.is_literal ?? false,
|
||||||
|
is_multiline: body.is_multiline ?? false,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, key: env.key });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const check = await verify(request, slug, uuid);
|
||||||
|
if ('error' in check) return check.error;
|
||||||
|
|
||||||
|
const key = new URL(request.url).searchParams.get('key');
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json({ error: 'Query param "key" is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteApplicationEnv(uuid, key);
|
||||||
|
return NextResponse.json({ ok: true, key });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskValue(v: string): string {
|
||||||
|
if (!v) return '';
|
||||||
|
if (v.length <= 4) return '•'.repeat(v.length);
|
||||||
|
return `${v.slice(0, 2)}${'•'.repeat(Math.min(v.length - 4, 10))}${v.slice(-2)}`;
|
||||||
|
}
|
||||||
181
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal file
181
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid] — app details
|
||||||
|
* PATCH /api/workspaces/[slug]/apps/[uuid] — update fields (name/branch/build config)
|
||||||
|
* DELETE /api/workspaces/[slug]/apps/[uuid]?confirm=<name>
|
||||||
|
* — destroy app. Volumes kept by default.
|
||||||
|
*
|
||||||
|
* All verify the app's project uuid matches the workspace's before
|
||||||
|
* acting. DELETE additionally requires `?confirm=<exact-resource-name>`
|
||||||
|
* to prevent AI-driven accidents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
projectUuidOf,
|
||||||
|
TenantError,
|
||||||
|
updateApplication,
|
||||||
|
deleteApplication,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: app.uuid,
|
||||||
|
name: app.name,
|
||||||
|
status: app.status,
|
||||||
|
fqdn: app.fqdn ?? null,
|
||||||
|
domains: app.domains ?? null,
|
||||||
|
gitRepository: app.git_repository ?? null,
|
||||||
|
gitBranch: app.git_branch ?? null,
|
||||||
|
projectUuid: projectUuidOf(app),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenancy first (400-style fail fast on cross-tenant access).
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist which Coolify fields we expose to AI-level callers.
|
||||||
|
// Domains are managed via the dedicated /domains subroute.
|
||||||
|
const allowed = new Set([
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'git_branch',
|
||||||
|
'build_pack',
|
||||||
|
'ports_exposes',
|
||||||
|
'install_command',
|
||||||
|
'build_command',
|
||||||
|
'start_command',
|
||||||
|
'base_directory',
|
||||||
|
'dockerfile_location',
|
||||||
|
'is_auto_deploy_enabled',
|
||||||
|
'is_force_https_enabled',
|
||||||
|
'static_image',
|
||||||
|
]);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
if (allowed.has(k) && v !== undefined) patch[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateApplication(uuid, patch);
|
||||||
|
return NextResponse.json({ ok: true, uuid });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the app and verify tenancy.
|
||||||
|
let app;
|
||||||
|
try {
|
||||||
|
app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require `?confirm=<exact app name>` to prevent accidental destroys.
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const confirm = url.searchParams.get('confirm');
|
||||||
|
if (confirm !== app.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Confirmation required',
|
||||||
|
hint: `Pass ?confirm=${app.name} to delete this app`,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: preserve volumes (user data). Caller can opt in.
|
||||||
|
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
|
||||||
|
const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false';
|
||||||
|
const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false';
|
||||||
|
const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteApplication(uuid, {
|
||||||
|
deleteConfigurations,
|
||||||
|
deleteVolumes,
|
||||||
|
deleteConnectedNetworks,
|
||||||
|
dockerCleanup,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, deleted: { uuid, name: app.name, volumesKept: !deleteVolumes } });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
232
app/api/workspaces/[slug]/apps/route.ts
Normal file
232
app/api/workspaces/[slug]/apps/route.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps — list Coolify apps in this workspace
|
||||||
|
* POST /api/workspaces/[slug]/apps — create a new app from a Gitea repo
|
||||||
|
*
|
||||||
|
* Auth: session OR `Bearer vibn_sk_...`. The workspace's
|
||||||
|
* `coolify_project_uuid` acts as the tenant boundary — any app whose
|
||||||
|
* Coolify project uuid doesn't match is filtered out even if the
|
||||||
|
* token issuer accidentally had wider reach.
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* {
|
||||||
|
* repo: string, // "my-api" or "{org}/my-api"
|
||||||
|
* branch?: string, // default: "main"
|
||||||
|
* name?: string, // default: derived from repo
|
||||||
|
* ports?: string, // default: "3000"
|
||||||
|
* buildPack?: "nixpacks"|"static"|"dockerfile"|"dockercompose"
|
||||||
|
* domain?: string, // default: {app}.{workspace}.vibnai.com
|
||||||
|
* envs?: Record<string,string>
|
||||||
|
* instantDeploy?: boolean, // default: true
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
listApplicationsInProject,
|
||||||
|
projectUuidOf,
|
||||||
|
createPrivateDeployKeyApp,
|
||||||
|
upsertApplicationEnv,
|
||||||
|
getApplication,
|
||||||
|
deployApplication,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import {
|
||||||
|
slugify,
|
||||||
|
workspaceAppFqdn,
|
||||||
|
toDomainsString,
|
||||||
|
isDomainUnderWorkspace,
|
||||||
|
giteaSshUrl,
|
||||||
|
} from '@/lib/naming';
|
||||||
|
import { getRepo } from '@/lib/gitea';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet', apps: [] },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apps = await listApplicationsInProject(ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
|
||||||
|
apps: apps.map(a => ({
|
||||||
|
uuid: a.uuid,
|
||||||
|
name: a.name,
|
||||||
|
status: a.status,
|
||||||
|
fqdn: a.fqdn ?? null,
|
||||||
|
domains: a.domains ?? null,
|
||||||
|
gitRepository: a.git_repository ?? null,
|
||||||
|
gitBranch: a.git_branch ?? null,
|
||||||
|
projectUuid: projectUuidOf(a),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (
|
||||||
|
!ws.coolify_project_uuid ||
|
||||||
|
!ws.coolify_private_key_uuid ||
|
||||||
|
!ws.gitea_org
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace not fully provisioned (need Coolify project + deploy key + Gitea org)' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
repo?: string;
|
||||||
|
branch?: string;
|
||||||
|
name?: string;
|
||||||
|
ports?: string;
|
||||||
|
buildPack?: 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose';
|
||||||
|
domain?: string;
|
||||||
|
envs?: Record<string, string>;
|
||||||
|
instantDeploy?: boolean;
|
||||||
|
description?: string;
|
||||||
|
baseDirectory?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
buildCommand?: string;
|
||||||
|
startCommand?: string;
|
||||||
|
dockerfileLocation?: string;
|
||||||
|
};
|
||||||
|
let body: Body = {};
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Body;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.repo || typeof body.repo !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing "repo" field' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept either "repo-name" (assumed in workspace org) or "org/repo".
|
||||||
|
const parts = body.repo.replace(/\.git$/, '').split('/');
|
||||||
|
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
|
||||||
|
const repoName = parts.length === 2 ? parts[1] : parts[0];
|
||||||
|
if (repoOrg !== ws.gitea_org) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid repo name' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the repo actually exists in Gitea (fail fast).
|
||||||
|
const repo = await getRepo(repoOrg, repoName);
|
||||||
|
if (!repo) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Repo ${repoOrg}/${repoName} not found in Gitea` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = slugify(body.name ?? repoName);
|
||||||
|
const fqdn = body.domain
|
||||||
|
? body.domain.replace(/^https?:\/\//, '')
|
||||||
|
: workspaceAppFqdn(ws.slug, appName);
|
||||||
|
if (!isDomainUnderWorkspace(fqdn, ws.slug)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createPrivateDeployKeyApp({
|
||||||
|
projectUuid: ws.coolify_project_uuid,
|
||||||
|
serverUuid: ws.coolify_server_uuid ?? undefined,
|
||||||
|
environmentName: ws.coolify_environment_name,
|
||||||
|
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
||||||
|
privateKeyUuid: ws.coolify_private_key_uuid,
|
||||||
|
gitRepository: giteaSshUrl(repoOrg, repoName),
|
||||||
|
gitBranch: body.branch ?? repo.default_branch ?? 'main',
|
||||||
|
portsExposes: body.ports ?? '3000',
|
||||||
|
buildPack: body.buildPack ?? 'nixpacks',
|
||||||
|
name: appName,
|
||||||
|
description: body.description ?? `AI-created from ${repoOrg}/${repoName}`,
|
||||||
|
domains: toDomainsString([fqdn]),
|
||||||
|
isAutoDeployEnabled: true,
|
||||||
|
isForceHttpsEnabled: true,
|
||||||
|
// We defer the first deploy until envs are attached so they
|
||||||
|
// show up in the initial build.
|
||||||
|
instantDeploy: false,
|
||||||
|
baseDirectory: body.baseDirectory,
|
||||||
|
installCommand: body.installCommand,
|
||||||
|
buildCommand: body.buildCommand,
|
||||||
|
startCommand: body.startCommand,
|
||||||
|
dockerfileLocation: body.dockerfileLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach env vars (best-effort — don't fail the whole create on one bad key).
|
||||||
|
if (body.envs && typeof body.envs === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(body.envs)) {
|
||||||
|
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
|
||||||
|
try {
|
||||||
|
await upsertApplicationEnv(created.uuid, { key, value });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[apps.POST] upsert env failed', key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now kick off the first deploy (unless the caller opted out).
|
||||||
|
let deploymentUuid: string | null = null;
|
||||||
|
if (body.instantDeploy !== false) {
|
||||||
|
try {
|
||||||
|
const dep = await deployApplication(created.uuid);
|
||||||
|
deploymentUuid = dep.deployment_uuid ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[apps.POST] initial deploy failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a hydrated object (status / urls) for the UI.
|
||||||
|
const app = await getApplication(created.uuid);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
uuid: app.uuid,
|
||||||
|
name: app.name,
|
||||||
|
status: app.status,
|
||||||
|
domain: fqdn,
|
||||||
|
url: `https://${fqdn}`,
|
||||||
|
gitRepository: app.git_repository ?? null,
|
||||||
|
gitBranch: app.git_branch ?? null,
|
||||||
|
deploymentUuid,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/api/workspaces/[slug]/auth/[uuid]/route.ts
Normal file
92
app/api/workspaces/[slug]/auth/[uuid]/route.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/auth/[uuid] — provider details
|
||||||
|
* DELETE /api/workspaces/[slug]/auth/[uuid]?confirm=<name>
|
||||||
|
* Volumes KEPT by default (don't blow away user accounts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getServiceInProject,
|
||||||
|
deleteService,
|
||||||
|
projectUuidOf,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svc = await getServiceInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: svc.uuid,
|
||||||
|
name: svc.name,
|
||||||
|
status: svc.status ?? null,
|
||||||
|
projectUuid: projectUuidOf(svc),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let svc;
|
||||||
|
try {
|
||||||
|
svc = await getServiceInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const confirm = url.searchParams.get('confirm');
|
||||||
|
if (confirm !== svc.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Confirmation required', hint: `Pass ?confirm=${svc.name}` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteService(uuid, {
|
||||||
|
deleteConfigurations: true,
|
||||||
|
deleteVolumes,
|
||||||
|
deleteConnectedNetworks: true,
|
||||||
|
dockerCleanup: true,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/api/workspaces/[slug]/auth/route.ts
Normal file
180
app/api/workspaces/[slug]/auth/route.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Workspace authentication providers.
|
||||||
|
*
|
||||||
|
* GET /api/workspaces/[slug]/auth — list auth-provider services
|
||||||
|
* POST /api/workspaces/[slug]/auth — provision one of the vetted providers
|
||||||
|
*
|
||||||
|
* AI-callers can only create providers from an allowlist — we deliberately
|
||||||
|
* skip the rest of Coolify's ~300 one-click templates so this endpoint
|
||||||
|
* stays focused on "auth for my app". The allowlist:
|
||||||
|
*
|
||||||
|
* pocketbase — lightweight (SQLite-backed) auth + data
|
||||||
|
* authentik — feature-rich self-hosted IDP
|
||||||
|
* keycloak — industry-standard OIDC/SAML
|
||||||
|
* keycloak-with-postgres
|
||||||
|
* pocket-id — passkey-first OIDC
|
||||||
|
* pocket-id-with-postgresql
|
||||||
|
* logto — dev-first IDP
|
||||||
|
* supertokens-with-postgresql — session/auth backend
|
||||||
|
*
|
||||||
|
* (Zitadel is not on Coolify's service catalog — callers that ask for
|
||||||
|
* it get a descriptive 400 so the AI knows to pick a supported one.)
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* { provider: "pocketbase", name?: "auth" }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
listServicesInProject,
|
||||||
|
createService,
|
||||||
|
getService,
|
||||||
|
projectUuidOf,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import { slugify } from '@/lib/naming';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vetted auth-provider service ids. Keys are what callers pass as
|
||||||
|
* `provider`; values are the Coolify service-template slugs.
|
||||||
|
*/
|
||||||
|
const AUTH_PROVIDERS: Record<string, string> = {
|
||||||
|
pocketbase: 'pocketbase',
|
||||||
|
authentik: 'authentik',
|
||||||
|
keycloak: 'keycloak',
|
||||||
|
'keycloak-with-postgres': 'keycloak-with-postgres',
|
||||||
|
'pocket-id': 'pocket-id',
|
||||||
|
'pocket-id-with-postgresql': 'pocket-id-with-postgresql',
|
||||||
|
logto: 'logto',
|
||||||
|
'supertokens-with-postgresql': 'supertokens-with-postgresql',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Anything in this set is Coolify-supported but not an auth provider (used for filtering the list view). */
|
||||||
|
const AUTH_PROVIDER_SLUGS = new Set(Object.values(AUTH_PROVIDERS));
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet', providers: [] },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const all = await listServicesInProject(ws.coolify_project_uuid);
|
||||||
|
// Coolify's list endpoint only returns summaries (no service_type) so
|
||||||
|
// we fetch each service individually to classify it by template slug.
|
||||||
|
// This is O(n) in services-per-workspace — acceptable at single-digit
|
||||||
|
// scales — and avoids name-based heuristics that break on custom names.
|
||||||
|
const detailed = await Promise.all(all.map(s => getService(s.uuid).catch(() => s)));
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
|
||||||
|
providers: detailed
|
||||||
|
.filter(s => {
|
||||||
|
const t = resolveProviderSlug(s);
|
||||||
|
return !!t && AUTH_PROVIDER_SLUGS.has(t);
|
||||||
|
})
|
||||||
|
.map(s => ({
|
||||||
|
uuid: s.uuid,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status ?? null,
|
||||||
|
provider: resolveProviderSlug(s),
|
||||||
|
projectUuid: projectUuidOf(s),
|
||||||
|
})),
|
||||||
|
allowedProviders: Object.keys(AUTH_PROVIDERS),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { provider?: string; name?: string; description?: string; instantDeploy?: boolean } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerKey = (body.provider ?? '').toLowerCase().trim();
|
||||||
|
const coolifyType = AUTH_PROVIDERS[providerKey];
|
||||||
|
if (!coolifyType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Unsupported provider "${providerKey}". Allowed: ${Object.keys(AUTH_PROVIDERS).join(', ')}`,
|
||||||
|
hint: 'Zitadel is not on Coolify v4 service catalog — use keycloak or authentik instead.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = slugify(body.name ?? providerKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createService({
|
||||||
|
projectUuid: ws.coolify_project_uuid,
|
||||||
|
type: coolifyType,
|
||||||
|
name,
|
||||||
|
description: body.description ?? `AI-provisioned ${providerKey} for ${ws.slug}`,
|
||||||
|
serverUuid: ws.coolify_server_uuid ?? undefined,
|
||||||
|
environmentName: ws.coolify_environment_name,
|
||||||
|
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
||||||
|
instantDeploy: body.instantDeploy ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = await getService(created.uuid);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
uuid: svc.uuid,
|
||||||
|
name: svc.name,
|
||||||
|
provider: providerKey,
|
||||||
|
status: svc.status ?? null,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative: Coolify stores the template slug on `service_type`.
|
||||||
|
* Fall back to a name-prefix match so services created before that field
|
||||||
|
* existed still classify correctly.
|
||||||
|
*/
|
||||||
|
function resolveProviderSlug(svc: { name: string; service_type?: string }): string {
|
||||||
|
if (svc.service_type && AUTH_PROVIDER_SLUGS.has(svc.service_type)) return svc.service_type;
|
||||||
|
const candidates = Object.values(AUTH_PROVIDERS).sort((a, b) => b.length - a.length);
|
||||||
|
for (const slug of candidates) {
|
||||||
|
if (svc.name === slug || svc.name.startsWith(`${slug}-`) || svc.name.startsWith(`${slug}_`)) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/bootstrap.sh
|
||||||
|
*
|
||||||
|
* One-shot installer. Intended usage inside a repo:
|
||||||
|
*
|
||||||
|
* curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" \
|
||||||
|
* https://vibnai.com/api/workspaces/<slug>/bootstrap.sh | sh
|
||||||
|
*
|
||||||
|
* Writes three files into the cwd:
|
||||||
|
* - .cursor/rules/vibn-workspace.mdc (system prompt for AI agents)
|
||||||
|
* - .cursor/mcp.json (registers /api/mcp as an MCP server)
|
||||||
|
* - .env.local (appends VIBN_* envs; never overwrites)
|
||||||
|
*
|
||||||
|
* Auth: caller MUST already have a `vibn_sk_...` token. We embed the
|
||||||
|
* same token in the generated mcp.json so Cursor agents can re-use it.
|
||||||
|
* Session auth works too but then nothing is embedded (the user gets
|
||||||
|
* placeholder strings to fill in themselves).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
|
||||||
|
const APP_BASE = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ?? 'https://vibnai.com';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
const tokenFromHeader = extractBearer(request);
|
||||||
|
// For API-key callers we can safely echo the token they sent us
|
||||||
|
// back into the generated files. For session callers we emit a
|
||||||
|
// placeholder — we don't want to re-issue long-lived tokens from
|
||||||
|
// a cookie-authenticated browser request.
|
||||||
|
const embedToken =
|
||||||
|
principal.source === 'api_key' && tokenFromHeader
|
||||||
|
? tokenFromHeader
|
||||||
|
: '<paste your vibn_sk_ token here>';
|
||||||
|
|
||||||
|
const script = buildScript({
|
||||||
|
slug: ws.slug,
|
||||||
|
giteaOrg: ws.gitea_org ?? '(unprovisioned)',
|
||||||
|
coolifyProjectUuid: ws.coolify_project_uuid ?? '(unprovisioned)',
|
||||||
|
appBase: APP_BASE,
|
||||||
|
token: embedToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(script, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/x-shellscript; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBearer(request: Request): string | null {
|
||||||
|
const a = request.headers.get('authorization');
|
||||||
|
if (!a) return null;
|
||||||
|
const m = /^Bearer\s+(vibn_sk_[A-Za-z0-9_-]+)/i.exec(a.trim());
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScript(opts: {
|
||||||
|
slug: string;
|
||||||
|
giteaOrg: string;
|
||||||
|
coolifyProjectUuid: string;
|
||||||
|
appBase: string;
|
||||||
|
token: string;
|
||||||
|
}): string {
|
||||||
|
const { slug, giteaOrg, coolifyProjectUuid, appBase, token } = opts;
|
||||||
|
|
||||||
|
// Build the file bodies in TS so we can shell-escape them cleanly
|
||||||
|
// using base64. The script itself does no string interpolation on
|
||||||
|
// these payloads — it just decodes and writes.
|
||||||
|
const rule = buildCursorRule({ slug, giteaOrg, coolifyProjectUuid, appBase });
|
||||||
|
const mcp = JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
[`vibn-${slug}`]: {
|
||||||
|
url: `${appBase}/api/mcp`,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const env = `VIBN_API_BASE=${appBase}\nVIBN_WORKSPACE=${slug}\nVIBN_API_KEY=${token}\n`;
|
||||||
|
|
||||||
|
const b64Rule = Buffer.from(rule, 'utf8').toString('base64');
|
||||||
|
const b64Mcp = Buffer.from(mcp, 'utf8').toString('base64');
|
||||||
|
const b64Env = Buffer.from(env, 'utf8').toString('base64');
|
||||||
|
|
||||||
|
return `#!/usr/bin/env sh
|
||||||
|
# Vibn workspace bootstrap — generated ${new Date().toISOString()}
|
||||||
|
# Workspace: ${slug}
|
||||||
|
#
|
||||||
|
# Writes .cursor/rules/vibn-workspace.mdc, .cursor/mcp.json,
|
||||||
|
# and appends VIBN_* env vars to .env.local (never overwrites).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
mkdir -p .cursor/rules
|
||||||
|
|
||||||
|
echo "${b64Rule}" | base64 -d > .cursor/rules/vibn-workspace.mdc
|
||||||
|
echo " wrote .cursor/rules/vibn-workspace.mdc"
|
||||||
|
|
||||||
|
echo "${b64Mcp}" | base64 -d > .cursor/mcp.json
|
||||||
|
echo " wrote .cursor/mcp.json"
|
||||||
|
|
||||||
|
if [ -f .env.local ] && grep -q '^VIBN_API_BASE=' .env.local 2>/dev/null; then
|
||||||
|
echo " .env.local already has VIBN_* — skipping env append"
|
||||||
|
else
|
||||||
|
printf '\\n# Vibn workspace ${slug}\\n' >> .env.local
|
||||||
|
echo "${b64Env}" | base64 -d >> .env.local
|
||||||
|
echo " appended VIBN_* to .env.local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f .gitignore ] && ! grep -q '^.env.local$' .gitignore 2>/dev/null; then
|
||||||
|
echo '.env.local' >> .gitignore
|
||||||
|
echo " added .env.local to .gitignore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Vibn workspace '${slug}' is wired up."
|
||||||
|
echo "Restart Cursor to pick up the new MCP server."
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCursorRule(opts: {
|
||||||
|
slug: string;
|
||||||
|
giteaOrg: string;
|
||||||
|
coolifyProjectUuid: string;
|
||||||
|
appBase: string;
|
||||||
|
}): string {
|
||||||
|
const { slug, giteaOrg, coolifyProjectUuid, appBase } = opts;
|
||||||
|
return `---
|
||||||
|
description: Vibn workspace "${slug}" — one-shot setup for AI agents
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vibn workspace: ${slug}
|
||||||
|
|
||||||
|
You are acting on behalf of the Vibn workspace **${slug}**. All AI
|
||||||
|
integration with Gitea and Coolify happens through the Vibn REST API,
|
||||||
|
which enforces tenancy for you.
|
||||||
|
|
||||||
|
## How to act
|
||||||
|
|
||||||
|
1. Before any git or deploy work, call:
|
||||||
|
\`GET ${appBase}/api/workspaces/${slug}/gitea-credentials\`
|
||||||
|
with \`Authorization: Bearer $VIBN_API_KEY\` to get a
|
||||||
|
workspace-scoped bot username, PAT, and clone URL template.
|
||||||
|
|
||||||
|
2. Use the returned \`cloneUrlTemplate\` (with \`{{repo}}\` substituted)
|
||||||
|
as the git remote. Never pass the root admin token to git.
|
||||||
|
|
||||||
|
3. For deploys, logs, env vars, call the workspace-scoped Coolify
|
||||||
|
endpoints under \`${appBase}/api/workspaces/${slug}/apps/...\`.
|
||||||
|
Any cross-tenant attempt is rejected with HTTP 403.
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Gitea org: \`${giteaOrg}\`
|
||||||
|
- Coolify project uuid: \`${coolifyProjectUuid}\`
|
||||||
|
- API base: \`${appBase}\`
|
||||||
|
|
||||||
|
## Useful endpoints
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|-------:|----------------------------------------------------------------|
|
||||||
|
| GET | /api/workspaces/${slug} |
|
||||||
|
| GET | /api/workspaces/${slug}/gitea-credentials |
|
||||||
|
| GET | /api/workspaces/${slug}/apps |
|
||||||
|
| GET | /api/workspaces/${slug}/apps/{uuid} |
|
||||||
|
| POST | /api/workspaces/${slug}/apps/{uuid}/deploy |
|
||||||
|
| GET | /api/workspaces/${slug}/apps/{uuid}/envs |
|
||||||
|
| PATCH | /api/workspaces/${slug}/apps/{uuid}/envs |
|
||||||
|
| DELETE | /api/workspaces/${slug}/apps/{uuid}/envs?key=FOO |
|
||||||
|
| POST | /api/mcp (JSON { tool, params } — see GET /api/mcp for list) |
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never print or commit \`$VIBN_API_KEY\`.
|
||||||
|
- Prefer PRs over force-pushing \`main\`.
|
||||||
|
- If you see HTTP 403 on Coolify ops, you're trying to touch an app
|
||||||
|
outside this workspace — stop and ask the user.
|
||||||
|
- Re-run \`bootstrap.sh\` instead of hand-editing these files.
|
||||||
|
`;
|
||||||
|
}
|
||||||
158
app/api/workspaces/[slug]/databases/[uuid]/route.ts
Normal file
158
app/api/workspaces/[slug]/databases/[uuid]/route.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/databases/[uuid] — database details (incl. URLs)
|
||||||
|
* PATCH /api/workspaces/[slug]/databases/[uuid] — update fields
|
||||||
|
* DELETE /api/workspaces/[slug]/databases/[uuid]?confirm=<name>
|
||||||
|
* Volumes KEPT by default (data). Pass &delete_volumes=true to drop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getDatabaseInProject,
|
||||||
|
updateDatabase,
|
||||||
|
deleteDatabase,
|
||||||
|
projectUuidOf,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDatabaseInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: db.uuid,
|
||||||
|
name: db.name,
|
||||||
|
type: db.type ?? null,
|
||||||
|
status: db.status,
|
||||||
|
isPublic: db.is_public ?? false,
|
||||||
|
publicPort: db.public_port ?? null,
|
||||||
|
internalUrl: db.internal_db_url ?? null,
|
||||||
|
externalUrl: db.external_db_url ?? null,
|
||||||
|
projectUuid: projectUuidOf(db),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getDatabaseInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set([
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'is_public',
|
||||||
|
'public_port',
|
||||||
|
'image',
|
||||||
|
'limits_memory',
|
||||||
|
'limits_cpus',
|
||||||
|
]);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
if (allowed.has(k) && v !== undefined) patch[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateDatabase(uuid, patch);
|
||||||
|
return NextResponse.json({ ok: true, uuid });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let db;
|
||||||
|
try {
|
||||||
|
db = await getDatabaseInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const confirm = url.searchParams.get('confirm');
|
||||||
|
if (confirm !== db.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Confirmation required', hint: `Pass ?confirm=${db.name}` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: preserve volumes (it's a database — user data lives there).
|
||||||
|
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
|
||||||
|
const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false';
|
||||||
|
const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false';
|
||||||
|
const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteDatabase(uuid, {
|
||||||
|
deleteConfigurations,
|
||||||
|
deleteVolumes,
|
||||||
|
deleteConnectedNetworks,
|
||||||
|
dockerCleanup,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deleted: { uuid, name: db.name, volumesKept: !deleteVolumes },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/api/workspaces/[slug]/databases/route.ts
Normal file
161
app/api/workspaces/[slug]/databases/route.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/databases — list databases in this workspace
|
||||||
|
* POST /api/workspaces/[slug]/databases — provision a new database
|
||||||
|
*
|
||||||
|
* Supported `type` values (all that Coolify v4 can deploy):
|
||||||
|
* postgresql | mysql | mariadb | mongodb | redis | keydb | dragonfly | clickhouse
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* {
|
||||||
|
* type: "postgresql",
|
||||||
|
* name?: "my-db",
|
||||||
|
* isPublic?: true, // expose a host port for remote clients
|
||||||
|
* publicPort?: 5433,
|
||||||
|
* image?: "postgres:16",
|
||||||
|
* credentials?: { ... } // type-specific (e.g. postgres_user)
|
||||||
|
* limits?: { memory?: "1G", cpus?: "1" },
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Tenancy: every returned record is filtered to the workspace's own
|
||||||
|
* Coolify project UUID.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
listDatabasesInProject,
|
||||||
|
createDatabase,
|
||||||
|
getDatabase,
|
||||||
|
projectUuidOf,
|
||||||
|
type CoolifyDatabaseType,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import { slugify } from '@/lib/naming';
|
||||||
|
|
||||||
|
const SUPPORTED_TYPES: readonly CoolifyDatabaseType[] = [
|
||||||
|
'postgresql',
|
||||||
|
'mysql',
|
||||||
|
'mariadb',
|
||||||
|
'mongodb',
|
||||||
|
'redis',
|
||||||
|
'keydb',
|
||||||
|
'dragonfly',
|
||||||
|
'clickhouse',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet', databases: [] },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbs = await listDatabasesInProject(ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
|
||||||
|
databases: dbs.map(d => ({
|
||||||
|
uuid: d.uuid,
|
||||||
|
name: d.name,
|
||||||
|
type: d.type ?? null,
|
||||||
|
status: d.status,
|
||||||
|
isPublic: d.is_public ?? false,
|
||||||
|
publicPort: d.public_port ?? null,
|
||||||
|
projectUuid: projectUuidOf(d),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
publicPort?: number;
|
||||||
|
image?: string;
|
||||||
|
credentials?: Record<string, unknown>;
|
||||||
|
limits?: { memory?: string; cpus?: string };
|
||||||
|
instantDeploy?: boolean;
|
||||||
|
};
|
||||||
|
let body: Body = {};
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Body;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = body.type as CoolifyDatabaseType | undefined;
|
||||||
|
if (!type || !SUPPORTED_TYPES.includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `\`type\` must be one of: ${SUPPORTED_TYPES.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = slugify(body.name ?? `${type}-${Date.now().toString(36)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createDatabase({
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description: body.description,
|
||||||
|
projectUuid: ws.coolify_project_uuid,
|
||||||
|
serverUuid: ws.coolify_server_uuid ?? undefined,
|
||||||
|
environmentName: ws.coolify_environment_name,
|
||||||
|
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
||||||
|
isPublic: body.isPublic,
|
||||||
|
publicPort: body.publicPort,
|
||||||
|
image: body.image,
|
||||||
|
credentials: body.credentials,
|
||||||
|
limits: body.limits,
|
||||||
|
instantDeploy: body.instantDeploy ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = await getDatabase(created.uuid);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
uuid: db.uuid,
|
||||||
|
name: db.name,
|
||||||
|
type: db.type ?? type,
|
||||||
|
status: db.status,
|
||||||
|
isPublic: db.is_public ?? false,
|
||||||
|
publicPort: db.public_port ?? null,
|
||||||
|
internalUrl: db.internal_db_url ?? null,
|
||||||
|
externalUrl: db.external_db_url ?? null,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs
|
||||||
|
*
|
||||||
|
* Raw deployment logs. We can't tell from a deployment UUID alone
|
||||||
|
* which project it belongs to, so we require `?appUuid=...` and
|
||||||
|
* verify that app belongs to the workspace first. This keeps the
|
||||||
|
* tenant boundary intact even though Coolify's log endpoint is
|
||||||
|
* global.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
getDeploymentLogs,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; deploymentUuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, deploymentUuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUuid = new URL(request.url).searchParams.get('appUuid');
|
||||||
|
if (!appUuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Query param "appUuid" is required for tenant enforcement' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(appUuid, ws.coolify_project_uuid);
|
||||||
|
const logs = await getDeploymentLogs(deploymentUuid);
|
||||||
|
return NextResponse.json(logs);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/api/workspaces/[slug]/domains/[domain]/attach/route.ts
Normal file
77
app/api/workspaces/[slug]/domains/[domain]/attach/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/workspaces/[slug]/domains/[domain]/attach
|
||||||
|
*
|
||||||
|
* Wires a registered domain up to a Coolify app (or arbitrary IP/CNAME)
|
||||||
|
* in one call. Idempotent — safe to retry.
|
||||||
|
*
|
||||||
|
* The heavy lifting lives in `lib/domain-attach.ts` so the MCP tool of the
|
||||||
|
* same name executes the same workflow.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* appUuid?: string,
|
||||||
|
* ip?: string,
|
||||||
|
* cname?: string,
|
||||||
|
* subdomains?: string[] // default ["@", "www"]
|
||||||
|
* updateRegistrarNs?: boolean // default true
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { attachDomain, AttachError, type AttachInput } from '@/lib/domain-attach';
|
||||||
|
import { getDomainForWorkspace } from '@/lib/domains';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; domain: string }> },
|
||||||
|
) {
|
||||||
|
const { slug, domain: domainRaw } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
const apex = decodeURIComponent(domainRaw).toLowerCase().trim();
|
||||||
|
|
||||||
|
const row = await getDomainForWorkspace(ws.id, apex);
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: AttachInput = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
// empty body is acceptable for a no-op attach check
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await attachDomain(ws, row, body);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
domain: {
|
||||||
|
id: result.domain.id,
|
||||||
|
domain: result.domain.domain,
|
||||||
|
dnsProvider: result.domain.dns_provider,
|
||||||
|
dnsZoneId: result.domain.dns_zone_id,
|
||||||
|
dnsNameservers: result.domain.dns_nameservers,
|
||||||
|
},
|
||||||
|
zone: result.zone,
|
||||||
|
records: result.records,
|
||||||
|
registrarNsUpdate: result.registrarNsUpdate,
|
||||||
|
coolifyUpdate: result.coolifyUpdate,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AttachError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message, tag: err.tag, ...(err.extra ?? {}) },
|
||||||
|
{ status: err.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('[domains.attach] unexpected', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Attach failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/api/workspaces/[slug]/domains/[domain]/route.ts
Normal file
63
app/api/workspaces/[slug]/domains/[domain]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/domains/[domain]
|
||||||
|
*
|
||||||
|
* Returns the full domain record (sans encrypted registrar password) plus
|
||||||
|
* recent lifecycle events. Used by the UI and agents to check status after
|
||||||
|
* a register call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
import { getDomainForWorkspace } from '@/lib/domains';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; domain: string }> },
|
||||||
|
) {
|
||||||
|
const { slug, domain } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const row = await getDomainForWorkspace(principal.workspace.id, decodeURIComponent(domain));
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await query<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
created_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT id, type, payload, created_at
|
||||||
|
FROM vibn_domain_events
|
||||||
|
WHERE domain_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20`,
|
||||||
|
[row.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: row.id,
|
||||||
|
domain: row.domain,
|
||||||
|
tld: row.tld,
|
||||||
|
status: row.status,
|
||||||
|
registrar: row.registrar,
|
||||||
|
registrarOrderId: row.registrar_order_id,
|
||||||
|
registrarUsername: row.registrar_username,
|
||||||
|
periodYears: row.period_years,
|
||||||
|
whoisPrivacy: row.whois_privacy,
|
||||||
|
autoRenew: row.auto_renew,
|
||||||
|
registeredAt: row.registered_at,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
dnsProvider: row.dns_provider,
|
||||||
|
dnsZoneId: row.dns_zone_id,
|
||||||
|
dnsNameservers: row.dns_nameservers,
|
||||||
|
pricePaidCents: row.price_paid_cents,
|
||||||
|
priceCurrency: row.price_currency,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
}
|
||||||
238
app/api/workspaces/[slug]/domains/route.ts
Normal file
238
app/api/workspaces/[slug]/domains/route.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Workspace-owned domains.
|
||||||
|
*
|
||||||
|
* GET /api/workspaces/[slug]/domains — list domains owned by the workspace
|
||||||
|
* POST /api/workspaces/[slug]/domains — register a domain through OpenSRS
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* {
|
||||||
|
* domain: "example.com",
|
||||||
|
* period?: 1,
|
||||||
|
* whoisPrivacy?: true,
|
||||||
|
* contact: {
|
||||||
|
* first_name, last_name, org_name?,
|
||||||
|
* address1, address2?, city, state, country, postal_code,
|
||||||
|
* phone, fax?, email
|
||||||
|
* },
|
||||||
|
* nameservers?: string[],
|
||||||
|
* ca?: { cprCategory, legalType } // required for .ca
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Safety rails:
|
||||||
|
* - `OPENSRS_MODE=test` is strongly recommended until we've verified live
|
||||||
|
* registration end-to-end. The handler reads the current mode from env
|
||||||
|
* and echoes it in the response so agents can tell.
|
||||||
|
* - We guard against duplicate POSTs by reusing an existing `pending` row
|
||||||
|
* for the same (workspace, domain) pair — caller can retry safely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
domainTld,
|
||||||
|
registerDomain,
|
||||||
|
OpenSrsError,
|
||||||
|
minPeriodFor,
|
||||||
|
type RegistrationContact,
|
||||||
|
} from '@/lib/opensrs';
|
||||||
|
import {
|
||||||
|
createDomainIntent,
|
||||||
|
getDomainForWorkspace,
|
||||||
|
listDomainsForWorkspace,
|
||||||
|
markDomainFailed,
|
||||||
|
markDomainRegistered,
|
||||||
|
recordDomainEvent,
|
||||||
|
recordLedgerEntry,
|
||||||
|
} from '@/lib/domains';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const rows = await listDomainsForWorkspace(principal.workspace.id);
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: principal.workspace.slug },
|
||||||
|
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||||
|
domains: rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
domain: r.domain,
|
||||||
|
tld: r.tld,
|
||||||
|
status: r.status,
|
||||||
|
registrar: r.registrar,
|
||||||
|
periodYears: r.period_years,
|
||||||
|
whoisPrivacy: r.whois_privacy,
|
||||||
|
autoRenew: r.auto_renew,
|
||||||
|
registeredAt: r.registered_at,
|
||||||
|
expiresAt: r.expires_at,
|
||||||
|
dnsProvider: r.dns_provider,
|
||||||
|
dnsNameservers: r.dns_nameservers,
|
||||||
|
pricePaidCents: r.price_paid_cents,
|
||||||
|
priceCurrency: r.price_currency,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostBody {
|
||||||
|
domain?: string;
|
||||||
|
period?: number;
|
||||||
|
whoisPrivacy?: boolean;
|
||||||
|
contact?: RegistrationContact;
|
||||||
|
nameservers?: string[];
|
||||||
|
ca?: { cprCategory: string; legalType: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
let body: PostBody = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (body.domain ?? '').toString().trim().toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||||
|
if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) {
|
||||||
|
return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const contactValidation = validateContact(body.contact);
|
||||||
|
if (contactValidation) return contactValidation;
|
||||||
|
|
||||||
|
const tld = domainTld(raw);
|
||||||
|
if (tld === 'ca' && !body.ca) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '.ca registration requires { ca: { cprCategory, legalType } }' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = minPeriodFor(tld, typeof body.period === 'number' ? body.period : 1);
|
||||||
|
|
||||||
|
// Reuse an existing pending intent to keep POSTs idempotent.
|
||||||
|
let intent = await getDomainForWorkspace(principal.workspace.id, raw);
|
||||||
|
if (intent && intent.status === 'active') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Domain ${raw} is already registered in this workspace`, domainId: intent.id },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!intent) {
|
||||||
|
intent = await createDomainIntent({
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
domain: raw,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
periodYears: period,
|
||||||
|
whoisPrivacy: body.whoisPrivacy ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordDomainEvent({
|
||||||
|
domainId: intent.id,
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
type: 'register.attempt',
|
||||||
|
payload: { period, mode: process.env.OPENSRS_MODE ?? 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await registerDomain({
|
||||||
|
domain: raw,
|
||||||
|
period,
|
||||||
|
contact: body.contact as RegistrationContact,
|
||||||
|
nameservers: body.nameservers,
|
||||||
|
whoisPrivacy: body.whoisPrivacy ?? true,
|
||||||
|
ca: body.ca,
|
||||||
|
});
|
||||||
|
|
||||||
|
const priceCents: number | null = null; // registrar price is captured at search time; later we'll pull the real reseller debit from get_balance_changes
|
||||||
|
const currency = process.env.OPENSRS_CURRENCY ?? 'CAD';
|
||||||
|
|
||||||
|
const updated = await markDomainRegistered({
|
||||||
|
domainId: intent.id,
|
||||||
|
registrarOrderId: result.orderId,
|
||||||
|
registrarUsername: result.regUsername,
|
||||||
|
registrarPassword: result.regPassword,
|
||||||
|
periodYears: period,
|
||||||
|
pricePaidCents: priceCents,
|
||||||
|
priceCurrency: currency,
|
||||||
|
registeredAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (priceCents) {
|
||||||
|
await recordLedgerEntry({
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
kind: 'debit',
|
||||||
|
amountCents: priceCents,
|
||||||
|
currency,
|
||||||
|
refType: 'domain.register',
|
||||||
|
refId: intent.id,
|
||||||
|
note: `Register ${raw} (${period}y)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordDomainEvent({
|
||||||
|
domainId: intent.id,
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
type: 'register.success',
|
||||||
|
payload: { orderId: result.orderId, period, mode: process.env.OPENSRS_MODE ?? 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||||
|
domain: {
|
||||||
|
id: updated.id,
|
||||||
|
domain: updated.domain,
|
||||||
|
tld: updated.tld,
|
||||||
|
status: updated.status,
|
||||||
|
periodYears: updated.period_years,
|
||||||
|
registeredAt: updated.registered_at,
|
||||||
|
expiresAt: updated.expires_at,
|
||||||
|
registrarOrderId: updated.registrar_order_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await markDomainFailed(intent.id, message);
|
||||||
|
if (err instanceof OpenSrsError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Registration failed', registrarCode: err.code, details: err.message },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Registration failed', details: message },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateContact(c?: RegistrationContact): NextResponse | null {
|
||||||
|
if (!c) return NextResponse.json({ error: '`contact` is required' }, { status: 400 });
|
||||||
|
const required: (keyof RegistrationContact)[] = [
|
||||||
|
'first_name', 'last_name', 'address1', 'city', 'state', 'country', 'postal_code', 'phone', 'email',
|
||||||
|
];
|
||||||
|
for (const k of required) {
|
||||||
|
if (!c[k] || typeof c[k] !== 'string' || !(c[k] as string).trim()) {
|
||||||
|
return NextResponse.json({ error: `contact.${k} is required` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^[A-Z]{2}$/.test(c.country)) {
|
||||||
|
return NextResponse.json({ error: 'contact.country must be an ISO 3166-1 alpha-2 code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!/@/.test(c.email)) {
|
||||||
|
return NextResponse.json({ error: 'contact.email must be a valid email' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
94
app/api/workspaces/[slug]/domains/search/route.ts
Normal file
94
app/api/workspaces/[slug]/domains/search/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/workspaces/[slug]/domains/search
|
||||||
|
*
|
||||||
|
* Checks availability + pricing for one or more candidate domains against
|
||||||
|
* OpenSRS. Stateless; doesn't touch the DB.
|
||||||
|
*
|
||||||
|
* Body: { names: string[], period?: number }
|
||||||
|
* - names: up to 25 fully-qualified names (e.g. "vibnai.com", "vibn.io")
|
||||||
|
* - period: desired registration period in years (default 1). Auto-bumped
|
||||||
|
* to the registry minimum for quirky TLDs (e.g. .ai = 2 yrs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { checkDomain, OpenSrsError } from '@/lib/opensrs';
|
||||||
|
|
||||||
|
const MAX_NAMES = 25;
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
let body: { names?: unknown; period?: unknown } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = Array.isArray(body.names)
|
||||||
|
? (body.names as unknown[]).filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
if (names.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Body must contain { names: string[] } with at least one domain' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (names.length > MAX_NAMES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Too many names (max ${MAX_NAMES})` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const period = typeof body.period === 'number' && body.period > 0 ? body.period : 1;
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
names.map(async raw => {
|
||||||
|
const name = raw.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||||
|
try {
|
||||||
|
const r = await checkDomain(name, period);
|
||||||
|
return {
|
||||||
|
domain: name,
|
||||||
|
available: r.available,
|
||||||
|
price: r.price ?? null,
|
||||||
|
currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'),
|
||||||
|
period: r.period ?? period,
|
||||||
|
responseCode: r.responseCode,
|
||||||
|
responseText: r.responseText,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof OpenSrsError) {
|
||||||
|
return {
|
||||||
|
domain: name,
|
||||||
|
available: false,
|
||||||
|
price: null,
|
||||||
|
currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
|
||||||
|
period,
|
||||||
|
error: err.message,
|
||||||
|
responseCode: err.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
domain: name,
|
||||||
|
available: false,
|
||||||
|
price: null,
|
||||||
|
currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
|
||||||
|
period,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: principal.workspace.slug },
|
||||||
|
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
85
app/api/workspaces/[slug]/gitea-credentials/route.ts
Normal file
85
app/api/workspaces/[slug]/gitea-credentials/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/gitea-credentials
|
||||||
|
*
|
||||||
|
* Returns a ready-to-use git clone URL for the workspace's Gitea org,
|
||||||
|
* plus the bot username/token. This is the one endpoint an AI agent
|
||||||
|
* calls before doing any git work — it hides all the admin/org/bot
|
||||||
|
* bookkeeping behind a single bearer-auth request.
|
||||||
|
*
|
||||||
|
* Auth: NextAuth session (owner) OR `Bearer vibn_sk_...` scoped to
|
||||||
|
* this workspace. Never returns credentials for a different workspace.
|
||||||
|
*
|
||||||
|
* The plaintext PAT is decrypted on the server on every call — we
|
||||||
|
* never persist it in logs or client state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces';
|
||||||
|
|
||||||
|
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
// If the bot has never been provisioned, do it now. Idempotent.
|
||||||
|
let workspace = principal.workspace;
|
||||||
|
if (!workspace.gitea_bot_token_encrypted || !workspace.gitea_org) {
|
||||||
|
try {
|
||||||
|
workspace = await ensureWorkspaceProvisioned(workspace);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Provisioning failed',
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = getWorkspaceBotCredentials(workspace);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Workspace has no Gitea bot yet',
|
||||||
|
provisionStatus: workspace.provision_status,
|
||||||
|
provisionError: workspace.provision_error,
|
||||||
|
hint:
|
||||||
|
'POST /api/workspaces/' +
|
||||||
|
slug +
|
||||||
|
'/provision to retry bot provisioning.',
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = GITEA_API_URL.replace(/\/$/, '');
|
||||||
|
const host = new URL(apiBase).host;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: workspace.slug, giteaOrg: creds.org },
|
||||||
|
bot: {
|
||||||
|
username: creds.username,
|
||||||
|
// Full plaintext PAT — treat like a password.
|
||||||
|
token: creds.token,
|
||||||
|
},
|
||||||
|
gitea: {
|
||||||
|
apiBase,
|
||||||
|
host,
|
||||||
|
// Templates for the agent. Substitute {{repo}} with the repo name.
|
||||||
|
cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`,
|
||||||
|
sshRemoteTemplate: `git@${host}:${creds.org}/{{repo}}.git`,
|
||||||
|
webUrlTemplate: `${apiBase}/${creds.org}/{{repo}}`,
|
||||||
|
},
|
||||||
|
principal: {
|
||||||
|
source: principal.source,
|
||||||
|
apiKeyId: principal.apiKeyId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
28
app/api/workspaces/[slug]/keys/[keyId]/route.ts
Normal file
28
app/api/workspaces/[slug]/keys/[keyId]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* DELETE /api/workspaces/[slug]/keys/[keyId] — revoke a workspace API key
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal, revokeWorkspaceApiKey } from '@/lib/auth/workspace-auth';
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; keyId: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, keyId } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
if (principal.source !== 'session') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'API keys can only be revoked from a signed-in session' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await revokeWorkspaceApiKey(principal.workspace.id, keyId);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Key not found or already revoked' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ revoked: true });
|
||||||
|
}
|
||||||
72
app/api/workspaces/[slug]/keys/route.ts
Normal file
72
app/api/workspaces/[slug]/keys/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Per-workspace API keys for AI agents.
|
||||||
|
*
|
||||||
|
* GET /api/workspaces/[slug]/keys — list keys (no secrets)
|
||||||
|
* POST /api/workspaces/[slug]/keys — mint a new key
|
||||||
|
*
|
||||||
|
* The full plaintext key is returned ONCE in the POST response and never
|
||||||
|
* persisted; only its sha256 hash is stored.
|
||||||
|
*
|
||||||
|
* API-key principals can list their own workspace's keys but cannot mint
|
||||||
|
* new ones (use the session UI for that).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { listWorkspaceApiKeys, mintWorkspaceApiKey } from '@/lib/auth/workspace-auth';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const keys = await listWorkspaceApiKeys(principal.workspace.id);
|
||||||
|
return NextResponse.json({ keys });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
if (principal.source !== 'session') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'API keys can only be created from a signed-in session' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { name?: string; scopes?: string[] };
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as { name?: string; scopes?: string[] };
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (body.name ?? '').trim();
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'Field "name" is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const minted = await mintWorkspaceApiKey({
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
name,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
scopes: body.scopes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: minted.id,
|
||||||
|
name: minted.name,
|
||||||
|
prefix: minted.prefix,
|
||||||
|
createdAt: minted.created_at,
|
||||||
|
// ↓ Only returned ONCE. Client must store this — we never see it again.
|
||||||
|
token: minted.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
29
app/api/workspaces/[slug]/provision/route.ts
Normal file
29
app/api/workspaces/[slug]/provision/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/workspaces/[slug]/provision — (re)run Coolify + Gitea provisioning
|
||||||
|
*
|
||||||
|
* Idempotent. Useful when initial provisioning during signin or first
|
||||||
|
* project create failed because Coolify or Gitea was unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { ensureWorkspaceProvisioned } from '@/lib/workspaces';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const updated = await ensureWorkspaceProvisioned(principal.workspace);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
slug: updated.slug,
|
||||||
|
coolifyProjectUuid: updated.coolify_project_uuid,
|
||||||
|
giteaOrg: updated.gitea_org,
|
||||||
|
provisionStatus: updated.provision_status,
|
||||||
|
provisionError: updated.provision_error,
|
||||||
|
});
|
||||||
|
}
|
||||||
35
app/api/workspaces/[slug]/route.ts
Normal file
35
app/api/workspaces/[slug]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug] — workspace details
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const w = principal.workspace;
|
||||||
|
return NextResponse.json({
|
||||||
|
id: w.id,
|
||||||
|
slug: w.slug,
|
||||||
|
name: w.name,
|
||||||
|
coolifyProjectUuid: w.coolify_project_uuid,
|
||||||
|
coolifyTeamId: w.coolify_team_id,
|
||||||
|
giteaOrg: w.gitea_org,
|
||||||
|
giteaBotUsername: w.gitea_bot_username,
|
||||||
|
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
|
||||||
|
provisionStatus: w.provision_status,
|
||||||
|
provisionError: w.provision_error,
|
||||||
|
createdAt: w.created_at,
|
||||||
|
updatedAt: w.updated_at,
|
||||||
|
principal: {
|
||||||
|
source: principal.source,
|
||||||
|
apiKeyId: principal.apiKeyId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
69
app/api/workspaces/route.ts
Normal file
69
app/api/workspaces/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces — list workspaces the caller can access
|
||||||
|
*
|
||||||
|
* Auth:
|
||||||
|
* - NextAuth session: returns the user's owned + member workspaces
|
||||||
|
* - vibn_sk_... API key: returns just the one workspace the key is bound to
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from '@/lib/auth/session-server';
|
||||||
|
import { queryOne } from '@/lib/db-postgres';
|
||||||
|
import { ensureWorkspaceForUser, listWorkspacesForUser } from '@/lib/workspaces';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (request.headers.get('authorization')?.toLowerCase().startsWith('bearer vibn_sk_')) {
|
||||||
|
const principal = await requireWorkspacePrincipal(request);
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
return NextResponse.json({ workspaces: [serializeWorkspace(principal.workspace)] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRow = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||||
|
[session.user.email]
|
||||||
|
);
|
||||||
|
if (!userRow) {
|
||||||
|
return NextResponse.json({ workspaces: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration path: users who signed in before the signIn hook was
|
||||||
|
// added (or before vibn_workspaces existed) have no row yet. Create
|
||||||
|
// one on first list so the UI never shows an empty state for them.
|
||||||
|
let list = await listWorkspacesForUser(userRow.id);
|
||||||
|
if (list.length === 0) {
|
||||||
|
try {
|
||||||
|
await ensureWorkspaceForUser({
|
||||||
|
userId: userRow.id,
|
||||||
|
email: session.user.email,
|
||||||
|
displayName: session.user.name ?? null,
|
||||||
|
});
|
||||||
|
list = await listWorkspacesForUser(userRow.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[api/workspaces] lazy ensure failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ workspaces: list.map(serializeWorkspace) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeWorkspace(w: import('@/lib/workspaces').VibnWorkspace) {
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
slug: w.slug,
|
||||||
|
name: w.name,
|
||||||
|
coolifyProjectUuid: w.coolify_project_uuid,
|
||||||
|
giteaOrg: w.gitea_org,
|
||||||
|
giteaBotUsername: w.gitea_bot_username,
|
||||||
|
giteaBotReady: !!(w.gitea_bot_username && w.gitea_bot_token_encrypted),
|
||||||
|
provisionStatus: w.provision_status,
|
||||||
|
provisionError: w.provision_error,
|
||||||
|
createdAt: w.created_at,
|
||||||
|
updatedAt: w.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,22 +5,27 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { useEffect, Suspense } from "react";
|
import { useEffect, Suspense } from "react";
|
||||||
import NextAuthComponent from "@/app/components/NextAuthComponent";
|
import NextAuthComponent from "@/app/components/NextAuthComponent";
|
||||||
|
|
||||||
|
function deriveWorkspace(email: string): string {
|
||||||
|
return email.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-account";
|
||||||
|
}
|
||||||
|
|
||||||
function AuthPageInner() {
|
function AuthPageInner() {
|
||||||
const { status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated" && session?.user?.email) {
|
||||||
const callbackUrl = searchParams.get("callbackUrl");
|
const callbackUrl = searchParams.get("callbackUrl");
|
||||||
// Only follow external callbackUrls we control (Theia subdomain)
|
// Only follow external callbackUrls we control (Theia subdomain)
|
||||||
if (callbackUrl && callbackUrl.startsWith("https://theia.vibnai.com")) {
|
if (callbackUrl && callbackUrl.startsWith("https://theia.vibnai.com")) {
|
||||||
window.location.href = callbackUrl;
|
window.location.href = callbackUrl;
|
||||||
} else {
|
} else {
|
||||||
router.push("/marks-account/projects");
|
const workspace = deriveWorkspace(session.user.email);
|
||||||
|
router.push(`/${workspace}/projects`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [status, router, searchParams]);
|
}, [status, session, router, searchParams]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function NextAuthComponent() {
|
|||||||
try {
|
try {
|
||||||
// Sign in with Google using NextAuth
|
// Sign in with Google using NextAuth
|
||||||
await signIn("google", {
|
await signIn("google", {
|
||||||
callbackUrl: "/marks-account/projects",
|
callbackUrl: "/auth",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Google sign-in error:", error);
|
console.error("Google sign-in error:", error);
|
||||||
|
|||||||
102
app/globals.css
102
app/globals.css
@@ -8,11 +8,22 @@
|
|||||||
@keyframes vibn-breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.15)} }
|
@keyframes vibn-breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.15)} }
|
||||||
.vibn-enter { animation: vibn-enter 0.35s ease both; }
|
.vibn-enter { animation: vibn-enter 0.35s ease both; }
|
||||||
|
|
||||||
|
/* Marketing — Justine ink & parchment (no blue/purple chrome) */
|
||||||
|
.vibn-gradient-text {
|
||||||
|
background-image: linear-gradient(90deg, var(--vibn-mid) 0%, var(--vibn-ink) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
.vibn-cta-surface {
|
||||||
|
background-image: linear-gradient(to bottom right, var(--vibn-cream), var(--vibn-parch));
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-outfit);
|
--font-sans: var(--font-inter);
|
||||||
--font-serif: var(--font-newsreader);
|
--font-serif: var(--font-lora);
|
||||||
--font-mono: var(--font-ibm-plex-mono);
|
--font-mono: var(--font-ibm-plex-mono);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
@@ -51,38 +62,50 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
/* Stackless warm beige palette */
|
/* Justine UX pack — ink & parchment (aligned with master-ai/justine/00_design-tokens.css) */
|
||||||
--background: #f6f4f0;
|
--vibn-ink: #1a1510;
|
||||||
--foreground: #1a1a1a;
|
--vibn-ink2: #2c2c2a;
|
||||||
--card: #ffffff;
|
--vibn-ink3: #444441;
|
||||||
--card-foreground: #1a1a1a;
|
--vibn-mid: #5f5e5a;
|
||||||
--popover: #ffffff;
|
--vibn-muted: #888780;
|
||||||
--popover-foreground: #1a1a1a;
|
--vibn-stone: #b4b2a9;
|
||||||
--primary: #1a1a1a;
|
--vibn-parch: #d3d1c7;
|
||||||
--primary-foreground: #ffffff;
|
--vibn-cream: #f1efe8;
|
||||||
--secondary: #f0ece4;
|
--vibn-paper: #f7f4ee;
|
||||||
--secondary-foreground: #1a1a1a;
|
--vibn-white: #fdfcfa;
|
||||||
--muted: #f0ece4;
|
--vibn-border: #e8e2d9;
|
||||||
--muted-foreground: #a09a90;
|
|
||||||
--accent: #eae6de;
|
--background: var(--vibn-paper);
|
||||||
--accent-foreground: #1a1a1a;
|
--foreground: var(--vibn-ink);
|
||||||
--destructive: #d32f2f;
|
--card: var(--vibn-white);
|
||||||
--border: #e8e4dc;
|
--card-foreground: var(--vibn-ink);
|
||||||
--input: #e0dcd4;
|
--popover: var(--vibn-white);
|
||||||
--ring: #d0ccc4;
|
--popover-foreground: var(--vibn-ink);
|
||||||
|
--primary: var(--vibn-ink);
|
||||||
|
--primary-foreground: var(--vibn-paper);
|
||||||
|
--secondary: var(--vibn-cream);
|
||||||
|
--secondary-foreground: var(--vibn-ink);
|
||||||
|
--muted: var(--vibn-cream);
|
||||||
|
--muted-foreground: var(--vibn-muted);
|
||||||
|
--accent: var(--vibn-cream);
|
||||||
|
--accent-foreground: var(--vibn-ink);
|
||||||
|
--destructive: #b42318;
|
||||||
|
--border: var(--vibn-border);
|
||||||
|
--input: var(--vibn-border);
|
||||||
|
--ring: var(--vibn-stone);
|
||||||
--chart-1: oklch(0.70 0.15 60);
|
--chart-1: oklch(0.70 0.15 60);
|
||||||
--chart-2: oklch(0.70 0.12 210);
|
--chart-2: oklch(0.70 0.12 210);
|
||||||
--chart-3: oklch(0.55 0.10 220);
|
--chart-3: oklch(0.55 0.10 220);
|
||||||
--chart-4: oklch(0.40 0.08 230);
|
--chart-4: oklch(0.40 0.08 230);
|
||||||
--chart-5: oklch(0.75 0.15 70);
|
--chart-5: oklch(0.75 0.15 70);
|
||||||
--sidebar: #ffffff;
|
--sidebar: var(--vibn-white);
|
||||||
--sidebar-foreground: #1a1a1a;
|
--sidebar-foreground: var(--vibn-ink);
|
||||||
--sidebar-primary: #1a1a1a;
|
--sidebar-primary: var(--vibn-ink);
|
||||||
--sidebar-primary-foreground: #ffffff;
|
--sidebar-primary-foreground: var(--vibn-paper);
|
||||||
--sidebar-accent: #f6f4f0;
|
--sidebar-accent: var(--vibn-paper);
|
||||||
--sidebar-accent-foreground: #1a1a1a;
|
--sidebar-accent-foreground: var(--vibn-ink);
|
||||||
--sidebar-border: #e8e4dc;
|
--sidebar-border: var(--vibn-border);
|
||||||
--sidebar-ring: #d0ccc4;
|
--sidebar-ring: var(--vibn-stone);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -111,8 +134,8 @@
|
|||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.85 0.02 85);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.18 0.02 60);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
@@ -125,21 +148,24 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-outfit), 'Outfit', sans-serif;
|
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: var(--font-lora), ui-serif, Georgia, serif;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
font-family: var(--font-outfit), 'Outfit', sans-serif;
|
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
input, textarea, select {
|
input, textarea, select {
|
||||||
font-family: var(--font-outfit), 'Outfit', sans-serif;
|
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
input::placeholder {
|
input::placeholder {
|
||||||
color: #b5b0a6;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
::selection {
|
::selection {
|
||||||
background: #1a1a1a;
|
background: var(--foreground);
|
||||||
color: #fff;
|
color: var(--background);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
@@ -149,7 +175,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #d0ccc4;
|
background: var(--vibn-stone);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Outfit, Newsreader, IBM_Plex_Mono } from "next/font/google";
|
import { Inter, Lora, IBM_Plex_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Providers } from "@/app/components/Providers";
|
import { Providers } from "@/app/components/Providers";
|
||||||
|
|
||||||
const outfit = Outfit({
|
const inter = Inter({
|
||||||
variable: "--font-outfit",
|
variable: "--font-inter",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["300", "400", "500", "600", "700"],
|
weight: ["400", "500", "600", "700"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const newsreader = Newsreader({
|
const lora = Lora({
|
||||||
variable: "--font-newsreader",
|
variable: "--font-lora",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500"],
|
weight: ["400", "500", "600", "700"],
|
||||||
style: ["normal", "italic"],
|
style: ["normal", "italic"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,8 +24,8 @@ const ibmPlexMono = IBM_Plex_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "VIBN — Build with Atlas",
|
title: "VIBN — Build with Vibn",
|
||||||
description: "Chat with Atlas to define your product, then let AI build it.",
|
description: "Chat with Vibn to define your product, then let AI build it.",
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
"mobile-web-app-capable": "yes",
|
"mobile-web-app-capable": "yes",
|
||||||
"msapplication-TileColor": "#1a1a1a",
|
"msapplication-TileColor": "#1a1510",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,12 +47,12 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#1a1a1a" />
|
<meta name="theme-color" content="#1a1510" />
|
||||||
<link rel="apple-touch-icon" href="/vibn-logo-circle.png" />
|
<link rel="apple-touch-icon" href="/vibn-logo-circle.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${outfit.variable} ${newsreader.variable} ${ibmPlexMono.variable} antialiased`}
|
className={`${inter.variable} ${lora.variable} ${ibmPlexMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<Providers>
|
<Providers>
|
||||||
{children}
|
{children}
|
||||||
@@ -69,4 +69,3 @@ export default function RootLayout({
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
355
app/styles/justine/01-homepage.css
Normal file
355
app/styles/justine/01-homepage.css
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* Verbatim from justine/01_homepage.html <style>, scoped under [data-justine].
|
||||||
|
* Do not mix Tailwind/shadcn tokens on surfaces inside this root.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[data-justine] {
|
||||||
|
--ink: #1a1a1a;
|
||||||
|
--ink2: #2c2c2a;
|
||||||
|
--ink3: #444441;
|
||||||
|
--mid: #6b7280;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--stone: #b4b2a9;
|
||||||
|
--parch: #d3d1c7;
|
||||||
|
--cream: #f1efe8;
|
||||||
|
--paper: #f7f4ee;
|
||||||
|
--white: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--serif: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
|
||||||
|
--sans: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
|
||||||
|
|
||||||
|
font-family: var(--sans);
|
||||||
|
background: linear-gradient(to bottom, #fafafe, #f0eeff);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] > main {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .f {
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] nav {
|
||||||
|
background: rgba(250, 250, 250, 0.95);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 52px;
|
||||||
|
height: 62px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .btn-ink {
|
||||||
|
background: linear-gradient(135deg, #2e2a5e, #4338ca);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 22px;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
[data-justine] .btn-ink:hover {
|
||||||
|
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15), 0 0 0 6px rgba(99, 102, 241, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .btn-ink-lg {
|
||||||
|
background: linear-gradient(135deg, #2e2a5e, #4338ca);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px 36px;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
[data-justine] .btn-ink-lg:hover {
|
||||||
|
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15), 0 0 0 6px rgba(99, 102, 241, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .gradient-em {
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .gradient-text {
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .gradient-num {
|
||||||
|
background: linear-gradient(135deg, #2e2a5e, #4338ca);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .empathy-card {
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid rgba(99, 102, 241, 0.8);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
box-shadow: 0 10px 30px rgba(30, 27, 75, 0.05);
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
[data-justine] .empathy-card:hover {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #fafaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .hero-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 96px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
[data-justine] .empathy-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 72px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
[data-justine] .phase-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-justine] .wyg-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
[data-justine] .quote-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.6fr 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
[data-justine] .stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .footer-tagline {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .hamburger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
[data-justine] .hamburger span {
|
||||||
|
display: block;
|
||||||
|
width: 22px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ink);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
[data-justine] .hamburger.open span:nth-child(1) {
|
||||||
|
transform: translateY(7px) rotate(45deg);
|
||||||
|
}
|
||||||
|
[data-justine] .hamburger.open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
[data-justine] .hamburger.open span:nth-child(3) {
|
||||||
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .mobile-menu {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 62px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(250, 250, 250, 0.98);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 20px 24px 28px;
|
||||||
|
z-index: 49;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
box-shadow: 0 8px 24px rgba(30, 27, 75, 0.08);
|
||||||
|
}
|
||||||
|
[data-justine] .mobile-menu.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
[data-justine] .mobile-menu a {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 13px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
[data-justine] .mobile-menu a:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
[data-justine] .mobile-menu .mobile-menu-cta {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] footer {
|
||||||
|
background: rgba(250, 250, 250, 0.95);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 32px 52px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-justine] .footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
[data-justine] nav {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
[data-justine] .nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-justine] .nav-right-btns {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
[data-justine] .hamburger {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
[data-justine] .hero-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 44px;
|
||||||
|
}
|
||||||
|
[data-justine] .hero-section {
|
||||||
|
padding: 52px 24px 48px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .empathy-section {
|
||||||
|
padding: 56px 24px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .empathy-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
[data-justine] .how-section {
|
||||||
|
padding: 64px 24px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .phase-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
[data-justine] .phase-grid > div {
|
||||||
|
border-right: none !important;
|
||||||
|
padding: 28px 24px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .wyg-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
[data-justine] .wyg-grid > div {
|
||||||
|
border-right: none !important;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 32px 24px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .wyg-grid > div:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
[data-justine] .wyg-section {
|
||||||
|
padding: 0 24px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .quote-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
[data-justine] .quote-side {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
[data-justine] .quote-section {
|
||||||
|
padding: 32px 24px 28px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .stats-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
[data-justine] .stats-grid > div {
|
||||||
|
padding: 28px 16px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .stats-grid > div:nth-child(odd) {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
[data-justine] .stats-grid > div:nth-child(3),
|
||||||
|
[data-justine] .stats-grid > div:nth-child(4) {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
[data-justine] .stats-grid > div:nth-child(even) {
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
[data-justine] .stats-section {
|
||||||
|
padding: 0 24px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .cta-section {
|
||||||
|
padding: 56px 20px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .cta-card {
|
||||||
|
padding: 44px 28px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .hero-h1 {
|
||||||
|
font-size: 40px !important;
|
||||||
|
line-height: 1.1 !important;
|
||||||
|
}
|
||||||
|
[data-justine] .hero-sub {
|
||||||
|
font-size: 15px !important;
|
||||||
|
}
|
||||||
|
[data-justine] footer {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 24px !important;
|
||||||
|
}
|
||||||
|
[data-justine] .footer-links {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ function MessageRow({
|
|||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: "0.68rem", fontWeight: 700,
|
fontSize: "0.68rem", fontWeight: 700,
|
||||||
color: isAtlas ? "#fff" : "#8a8478",
|
color: isAtlas ? "#fff" : "#8a8478",
|
||||||
fontFamily: isAtlas ? "Newsreader, serif" : "Outfit, sans-serif",
|
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
{isAtlas ? "A" : userInitial}
|
{isAtlas ? "A" : userInitial}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,14 +149,14 @@ function MessageRow({
|
|||||||
<div style={{
|
<div style={{
|
||||||
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
|
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
|
||||||
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
|
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
{isAtlas ? "Atlas" : "You"}
|
{isAtlas ? "Vibn" : "You"}
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72,
|
fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72,
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
whiteSpace: isAtlas ? "normal" : "pre-wrap",
|
whiteSpace: isAtlas ? "normal" : "pre-wrap",
|
||||||
}}>
|
}}>
|
||||||
{renderContent(clean)}
|
{renderContent(clean)}
|
||||||
@@ -175,7 +175,7 @@ function MessageRow({
|
|||||||
color: saved ? "#2e7d32" : "#fff",
|
color: saved ? "#2e7d32" : "#fff",
|
||||||
border: saved ? "1px solid #a5d6a7" : "none",
|
border: saved ? "1px solid #a5d6a7" : "none",
|
||||||
fontSize: "0.78rem", fontWeight: 600,
|
fontSize: "0.78rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: saved || saving ? "default" : "pointer",
|
cursor: saved || saving ? "default" : "pointer",
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
opacity: saving ? 0.7 : 1,
|
opacity: saving ? 0.7 : 1,
|
||||||
@@ -186,7 +186,7 @@ function MessageRow({
|
|||||||
{!saved && (
|
{!saved && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: 6, fontSize: "0.72rem", color: "#a09a90",
|
marginTop: 6, fontSize: "0.72rem", color: "#a09a90",
|
||||||
fontFamily: "Outfit, sans-serif", lineHeight: 1.4,
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", lineHeight: 1.4,
|
||||||
}}>
|
}}>
|
||||||
{phase.summary}
|
{phase.summary}
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +218,7 @@ function MessageRow({
|
|||||||
display: "inline-block", padding: "8px 16px", borderRadius: 7,
|
display: "inline-block", padding: "8px 16px", borderRadius: 7,
|
||||||
background: "#1a1a1a", color: "#fff",
|
background: "#1a1a1a", color: "#fff",
|
||||||
fontSize: "0.76rem", fontWeight: 600,
|
fontSize: "0.76rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit, sans-serif", textDecoration: "none",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", textDecoration: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Review architecture →
|
Review architecture →
|
||||||
@@ -234,7 +234,7 @@ function MessageRow({
|
|||||||
style={{
|
style={{
|
||||||
padding: "7px 14px", borderRadius: 6, border: "1px solid #e0dcd4",
|
padding: "7px 14px", borderRadius: 6, border: "1px solid #e0dcd4",
|
||||||
background: "none", fontSize: "0.74rem", color: "#6b6560",
|
background: "none", fontSize: "0.74rem", color: "#6b6560",
|
||||||
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
@@ -256,7 +256,7 @@ function MessageRow({
|
|||||||
padding: "9px 18px", borderRadius: 8, border: "none",
|
padding: "9px 18px", borderRadius: 8, border: "none",
|
||||||
background: archState === "loading" ? "#8a8478" : "#1a1a1a",
|
background: archState === "loading" ? "#8a8478" : "#1a1a1a",
|
||||||
color: "#fff", fontSize: "0.78rem", fontWeight: 600,
|
color: "#fff", fontSize: "0.78rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: archState === "loading" ? "default" : "pointer",
|
cursor: archState === "loading" ? "default" : "pointer",
|
||||||
transition: "background 0.15s",
|
transition: "background 0.15s",
|
||||||
}}
|
}}
|
||||||
@@ -288,7 +288,7 @@ function TypingIndicator() {
|
|||||||
<div style={{
|
<div style={{
|
||||||
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
|
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
|
||||||
background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center",
|
background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: "0.68rem", fontWeight: 700, color: "#fff", fontFamily: "Newsreader, serif",
|
fontSize: "0.68rem", fontWeight: 700, color: "#fff", fontFamily: "var(--font-lora), ui-serif, serif",
|
||||||
}}>A</div>
|
}}>A</div>
|
||||||
<div style={{ display: "flex", gap: 5, paddingTop: 10 }}>
|
<div style={{ display: "flex", gap: 5, paddingTop: 10 }}>
|
||||||
{[0, 1, 2].map(d => (
|
{[0, 1, 2].map(d => (
|
||||||
@@ -425,7 +425,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", flexDirection: "column", height: "100%",
|
display: "flex", flexDirection: "column", height: "100%",
|
||||||
background: "#f6f4f0", fontFamily: "Outfit, sans-serif",
|
background: "#f6f4f0", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
|
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
|
||||||
@@ -443,12 +443,12 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
|||||||
<div style={{
|
<div style={{
|
||||||
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
|
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 500, color: "#fff",
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, color: "#fff",
|
||||||
animation: "breathe 2.5s ease infinite",
|
animation: "breathe 2.5s ease infinite",
|
||||||
}}>A</div>
|
}}>A</div>
|
||||||
<style>{`@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }`}</style>
|
<style>{`@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }`}</style>
|
||||||
<div style={{ textAlign: "center" }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<p style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Atlas</p>
|
<p style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Vibn</p>
|
||||||
<p style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 260, lineHeight: 1.5 }}>
|
<p style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 260, lineHeight: 1.5 }}>
|
||||||
Your product strategist. Let's define what you're building.
|
Your product strategist. Let's define what you're building.
|
||||||
</p>
|
</p>
|
||||||
@@ -466,7 +466,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute", top: 12, right: 16,
|
position: "absolute", top: 12, right: 16,
|
||||||
background: "none", border: "none", cursor: "pointer",
|
background: "none", border: "none", cursor: "pointer",
|
||||||
fontSize: "0.68rem", color: "#d0ccc4", fontFamily: "Outfit, sans-serif",
|
fontSize: "0.68rem", color: "#d0ccc4", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
padding: "3px 7px", borderRadius: 4, transition: "color 0.12s",
|
padding: "3px 7px", borderRadius: 4, transition: "color 0.12s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = "#8a8478")}
|
onMouseEnter={e => (e.currentTarget.style.color = "#8a8478")}
|
||||||
@@ -489,8 +489,44 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Quick-action chips — shown when there's a conversation and AI isn't typing */}
|
||||||
|
{!isEmpty && !isStreaming && (
|
||||||
|
<div style={{ padding: "0 32px 8px", display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
|
{[
|
||||||
|
{ label: "Give me suggestions", prompt: "Can you give me some examples or suggestions to help me think through this?" },
|
||||||
|
{ label: "What's most important?", prompt: "What's the most important thing for me to nail down right now?" },
|
||||||
|
{ label: "Move on", prompt: "That's enough detail for now — let's move to the next phase." },
|
||||||
|
].map(({ label, prompt }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => sendToAtlas(prompt, false)}
|
||||||
|
style={{
|
||||||
|
padding: "5px 12px", borderRadius: 20,
|
||||||
|
border: "1px solid #e0dcd4",
|
||||||
|
background: "#fff", color: "#6b6560",
|
||||||
|
fontSize: "0.73rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
cursor: "pointer", transition: "all 0.1s",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "#f0ece4";
|
||||||
|
(e.currentTarget as HTMLElement).style.borderColor = "#c8c4bc";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "#1a1a1a";
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "#fff";
|
||||||
|
(e.currentTarget as HTMLElement).style.borderColor = "#e0dcd4";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "#6b6560";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input bar */}
|
{/* Input bar */}
|
||||||
<div style={{ padding: "14px 32px max(22px, env(safe-area-inset-bottom))", flexShrink: 0 }}>
|
<div style={{ padding: "6px 32px max(22px, env(safe-area-inset-bottom))", flexShrink: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", gap: 8, padding: "5px 5px 5px 16px",
|
display: "flex", gap: 8, padding: "5px 5px 5px 16px",
|
||||||
background: "#fff", border: "1px solid #e0dcd4", borderRadius: 10,
|
background: "#fff", border: "1px solid #e0dcd4", borderRadius: 10,
|
||||||
@@ -505,7 +541,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
|||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, border: "none", background: "none",
|
flex: 1, border: "none", background: "none",
|
||||||
fontSize: "0.86rem", fontFamily: "Outfit, sans-serif",
|
fontSize: "0.86rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
color: "#1a1a1a", padding: "8px 0",
|
color: "#1a1a1a", padding: "8px 0",
|
||||||
resize: "none", outline: "none",
|
resize: "none", outline: "none",
|
||||||
minHeight: 24, maxHeight: 120,
|
minHeight: 24, maxHeight: 120,
|
||||||
@@ -517,7 +553,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
|||||||
style={{
|
style={{
|
||||||
padding: "9px 16px", borderRadius: 7, border: "none",
|
padding: "9px 16px", borderRadius: 7, border: "none",
|
||||||
background: "#eae6de", color: "#8a8478",
|
background: "#eae6de", color: "#8a8478",
|
||||||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
fontSize: "0.78rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: "pointer", flexShrink: 0,
|
cursor: "pointer", flexShrink: 0,
|
||||||
display: "flex", alignItems: "center", gap: 6,
|
display: "flex", alignItems: "center", gap: 6,
|
||||||
}}
|
}}
|
||||||
@@ -533,7 +569,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
|||||||
padding: "9px 16px", borderRadius: 7, border: "none",
|
padding: "9px 16px", borderRadius: 7, border: "none",
|
||||||
background: input.trim() ? "#1a1a1a" : "#eae6de",
|
background: input.trim() ? "#1a1a1a" : "#eae6de",
|
||||||
color: input.trim() ? "#fff" : "#b5b0a6",
|
color: input.trim() ? "#fff" : "#b5b0a6",
|
||||||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
fontSize: "0.78rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: input.trim() ? "pointer" : "default",
|
cursor: input.trim() ? "pointer" : "default",
|
||||||
flexShrink: 0, transition: "all 0.15s",
|
flexShrink: 0, transition: "all 0.15s",
|
||||||
}}
|
}}
|
||||||
|
|||||||
287
components/layout/coo-chat.tsx
Normal file
287
components/layout/coo-chat.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface CooMessage {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
source?: "atlas" | "coo"; // atlas = discovery history, coo = orchestrator response
|
||||||
|
streaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CooChat({ projectId }: { projectId: string }) {
|
||||||
|
const [messages, setMessages] = useState<CooMessage[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [historyLoaded, setHistoryLoaded] = useState(false);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Scroll to bottom whenever messages change
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Pre-load Atlas discovery history on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/projects/${projectId}/atlas-chat`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((data: { messages?: Array<{ role: "user" | "assistant"; content: string }> }) => {
|
||||||
|
const atlasMessages: CooMessage[] = (data.messages ?? [])
|
||||||
|
.filter(m => m.content?.trim())
|
||||||
|
.map((m, i) => ({
|
||||||
|
id: `atlas_${i}`,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
source: "atlas" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (atlasMessages.length > 0) {
|
||||||
|
// Add a small divider message at the bottom of Atlas history
|
||||||
|
setMessages([
|
||||||
|
...atlasMessages,
|
||||||
|
{
|
||||||
|
id: "coo_divider",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Discovery complete. I'm your product COO — I have the full context above. What do you need?",
|
||||||
|
source: "coo" as const,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// No Atlas history — show default COO welcome
|
||||||
|
setMessages([{
|
||||||
|
id: "welcome",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
|
||||||
|
source: "coo" as const,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
setHistoryLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMessages([{
|
||||||
|
id: "welcome",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
|
||||||
|
source: "coo" as const,
|
||||||
|
}]);
|
||||||
|
setHistoryLoaded(true);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || loading) return;
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text, source: "coo" };
|
||||||
|
const assistantId = (Date.now() + 1).toString();
|
||||||
|
const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", source: "coo", streaming: true };
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Build history from COO messages only (skip atlas history for context to orchestrator)
|
||||||
|
const history = messages
|
||||||
|
.filter(m => m.source === "coo" && m.id !== "coo_divider" && m.content)
|
||||||
|
.map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/advisor`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: text, history }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
setMessages(prev => prev.map(m => m.id === assistantId
|
||||||
|
? { ...m, content: "Something went wrong. Please try again.", streaming: false }
|
||||||
|
: m));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
setMessages(prev => prev.map(m => m.id === assistantId
|
||||||
|
? { ...m, content: m.content + chunk }
|
||||||
|
: m));
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
|
||||||
|
} catch {
|
||||||
|
setMessages(prev => prev.map(m => m.id === assistantId
|
||||||
|
? { ...m, content: "Connection error. Please try again.", streaming: false }
|
||||||
|
: m));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!historyLoaded) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<span key={i} style={{
|
||||||
|
width: 4, height: 4, borderRadius: "50%",
|
||||||
|
background: "#d4cfc6", display: "inline-block",
|
||||||
|
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
|
{/* Messages */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", padding: "12px 14px 8px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{messages.map((msg, idx) => {
|
||||||
|
const isAtlas = msg.source === "atlas";
|
||||||
|
const isUser = msg.role === "user";
|
||||||
|
const isCoo = !isUser && !isAtlas;
|
||||||
|
|
||||||
|
// Separator before the divider message
|
||||||
|
const prevMsg = messages[idx - 1];
|
||||||
|
const showSeparator = msg.id === "coo_divider" && prevMsg?.source === "atlas";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id}>
|
||||||
|
{showSeparator && (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
|
margin: "8px 0 4px", opacity: 0.5,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
|
||||||
|
<span style={{ fontSize: "0.58rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
|
||||||
|
Discovery · COO
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: isUser ? "row-reverse" : "row",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
{/* Avatar */}
|
||||||
|
{!isUser && (
|
||||||
|
<span style={{
|
||||||
|
width: 18, height: 18, borderRadius: 5,
|
||||||
|
background: isAtlas ? "#4a6fa5" : "#1a1a1a",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: isAtlas ? "0.48rem" : "0.48rem",
|
||||||
|
color: "#fff", flexShrink: 0,
|
||||||
|
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "inherit",
|
||||||
|
fontWeight: isAtlas ? 700 : 400,
|
||||||
|
}}>
|
||||||
|
{isAtlas ? "A" : "◈"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
maxWidth: "88%",
|
||||||
|
padding: isUser ? "7px 10px" : "0",
|
||||||
|
background: isUser ? "#f0ece4" : "transparent",
|
||||||
|
borderRadius: isUser ? 10 : 0,
|
||||||
|
fontSize: isAtlas ? "0.75rem" : "0.79rem",
|
||||||
|
color: isAtlas ? "#4a4540" : "#1a1a1a",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
opacity: isAtlas ? 0.85 : 1,
|
||||||
|
}}>
|
||||||
|
{msg.content}
|
||||||
|
{msg.streaming && msg.content === "" && (
|
||||||
|
<span style={{ display: "inline-flex", gap: 3, alignItems: "center", height: "1em" }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<span key={i} style={{
|
||||||
|
width: 4, height: 4, borderRadius: "50%",
|
||||||
|
background: "#b5b0a6", display: "inline-block",
|
||||||
|
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{msg.streaming && msg.content !== "" && (
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block", width: 2, height: "0.85em",
|
||||||
|
background: "#1a1a1a", marginLeft: 1,
|
||||||
|
verticalAlign: "text-bottom",
|
||||||
|
animation: "cooBlink 1s step-end infinite",
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div style={{ flexShrink: 0, borderTop: "1px solid #e8e4dc", padding: "10px 12px 10px", background: "#fff" }}>
|
||||||
|
<div style={{ display: "flex", gap: 7, alignItems: "flex-end" }}>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
|
||||||
|
}}
|
||||||
|
placeholder={loading ? "Thinking…" : "Ask anything…"}
|
||||||
|
disabled={loading}
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
flex: 1, resize: "none",
|
||||||
|
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
padding: "8px 10px", fontSize: "0.79rem",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
color: "#1a1a1a", outline: "none",
|
||||||
|
background: "#faf8f5", lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={send}
|
||||||
|
disabled={!input.trim() || loading}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, flexShrink: 0,
|
||||||
|
border: "none", borderRadius: 8,
|
||||||
|
background: input.trim() && !loading ? "#1a1a1a" : "#e8e4dc",
|
||||||
|
color: input.trim() && !loading ? "#fff" : "#b5b0a6",
|
||||||
|
cursor: input.trim() && !loading ? "pointer" : "default",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>↑</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.6rem", color: "#c5c0b8", marginTop: 5, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
↵ send · Shift+↵ newline
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes cooBounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
@keyframes cooBlink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, Suspense } from "react";
|
||||||
import { VIBNSidebar } from "./vibn-sidebar";
|
import Link from "next/link";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
interface ProjectShellProps {
|
interface ProjectShellProps {
|
||||||
@@ -19,319 +19,141 @@ interface ProjectShellProps {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
featureCount?: number;
|
featureCount?: number;
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = [
|
const SECTIONS = [
|
||||||
{ id: "overview", label: "Atlas", path: "overview" },
|
{ id: "overview", label: "Vibn", path: "overview" },
|
||||||
{ id: "prd", label: "PRD", path: "prd" },
|
{ id: "prd", label: "PRD", path: "prd" },
|
||||||
{ id: "design", label: "Design", path: "design" },
|
|
||||||
{ id: "build", label: "Build", path: "build" },
|
{ id: "build", label: "Build", path: "build" },
|
||||||
{ id: "deployment", label: "Launch", path: "deployment" },
|
{ id: "growth", label: "Growth", path: "growth" },
|
||||||
{ id: "grow", label: "Grow", path: "grow" },
|
{ id: "assist", label: "Assist", path: "assist" },
|
||||||
{ id: "insights", label: "Insights", path: "insights" },
|
{ id: "analytics", label: "Analytics", path: "analytics" },
|
||||||
{ id: "settings", label: "Settings", path: "settings" },
|
] as const;
|
||||||
];
|
|
||||||
|
|
||||||
const DISCOVERY_PHASES = [
|
|
||||||
{ id: "big_picture", label: "Big Picture" },
|
|
||||||
{ id: "users_personas", label: "Users & Personas" },
|
|
||||||
{ id: "features_scope", label: "Features" },
|
|
||||||
{ id: "business_model", label: "Business Model" },
|
|
||||||
{ id: "screens_data", label: "Screens" },
|
|
||||||
{ id: "risks_questions", label: "Risks" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface SavedPhase {
|
function ProjectShellInner({
|
||||||
phase: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
saved_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(dateStr?: string): string {
|
|
||||||
if (!dateStr) return "—";
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (isNaN(date.getTime())) return "—";
|
|
||||||
const diff = (Date.now() - date.getTime()) / 1000;
|
|
||||||
if (diff < 60) return "just now";
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
||||||
const days = Math.floor(diff / 86400);
|
|
||||||
if (days === 1) return "Yesterday";
|
|
||||||
if (days < 7) return `${days}d ago`;
|
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
|
||||||
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusTag({ status }: { status?: string }) {
|
|
||||||
const label = status === "live" ? "Live" : status === "building" ? "Building" : "Defining";
|
|
||||||
const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#9a7b3a";
|
|
||||||
const bg = status === "live" ? "#2e7d3210" : status === "building" ? "#3d5afe10" : "#d4a04a12";
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
|
||||||
padding: "3px 9px", borderRadius: 4,
|
|
||||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
|
||||||
color, background: bg, fontFamily: "Outfit, sans-serif",
|
|
||||||
}}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectShell({
|
|
||||||
children,
|
children,
|
||||||
workspace,
|
workspace,
|
||||||
projectId,
|
projectId,
|
||||||
projectName,
|
projectName,
|
||||||
projectDescription,
|
|
||||||
projectStatus,
|
|
||||||
projectProgress,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
featureCount = 0,
|
|
||||||
}: ProjectShellProps) {
|
}: ProjectShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
|
const { data: session } = useSession();
|
||||||
const progress = projectProgress ?? 0;
|
|
||||||
|
|
||||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
const activeSection =
|
||||||
|
pathname?.includes("/overview") ? "overview" :
|
||||||
|
pathname?.includes("/prd") ? "prd" :
|
||||||
|
pathname?.includes("/build") ? "build" :
|
||||||
|
pathname?.includes("/growth") ? "growth" :
|
||||||
|
pathname?.includes("/assist") ? "assist" :
|
||||||
|
pathname?.includes("/analytics") ? "analytics" :
|
||||||
|
"overview";
|
||||||
|
|
||||||
useEffect(() => {
|
const userInitial = (
|
||||||
fetch(`/api/projects/${projectId}/save-phase`)
|
session?.user?.name?.[0] ?? session?.user?.email?.[0] ?? "?"
|
||||||
.then(r => r.json())
|
).toUpperCase();
|
||||||
.then(d => setSavedPhases(d.phases ?? []))
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
// Refresh every 10s while the user is chatting with Atlas
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetch(`/api/projects/${projectId}/save-phase`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => setSavedPhases(d.phases ?? []))
|
|
||||||
.catch(() => {});
|
|
||||||
}, 10_000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
|
||||||
const firstUnsavedIdx = DISCOVERY_PHASES.findIndex(p => !savedPhaseIds.has(p.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.vibn-left-sidebar { display: none !important; }
|
|
||||||
.vibn-right-panel { display: none !important; }
|
|
||||||
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
||||||
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
|
|
||||||
.vibn-project-header { padding: 12px 16px !important; }
|
|
||||||
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.vibn-tab-bar a { padding: 10px 10px !important; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div style={{ display: "flex", height: "100dvh", background: "#f6f4f0", overflow: "hidden" }}>
|
|
||||||
{/* Left sidebar */}
|
|
||||||
<div className="vibn-left-sidebar" style={{ display: "flex" }}>
|
|
||||||
<VIBNSidebar workspace={workspace} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main column */}
|
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
|
||||||
|
|
||||||
{/* Project header */}
|
|
||||||
<div className="vibn-project-header" style={{
|
|
||||||
padding: "18px 32px",
|
|
||||||
borderBottom: "1px solid #e8e4dc",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
background: "#fff",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 34, height: 34, borderRadius: 9,
|
display: "flex", flexDirection: "column",
|
||||||
background: "#1a1a1a12",
|
height: "100dvh", overflow: "hidden",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
background: "var(--background)",
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontFamily: "Newsreader, serif", fontSize: "1rem", fontWeight: 500, color: "#1a1a1a" }}>
|
|
||||||
{projectName[0]?.toUpperCase() ?? "P"}
|
{/* ── Top bar ── */}
|
||||||
</span>
|
<header style={{
|
||||||
|
height: 48, flexShrink: 0,
|
||||||
|
display: "flex", alignItems: "stretch",
|
||||||
|
background: "var(--card)", borderBottom: "1px solid var(--border)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Logo + project name */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center",
|
||||||
|
padding: "0 16px", gap: 9, flexShrink: 0,
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
}}>
|
||||||
|
<Link
|
||||||
|
href={`/${workspace}/projects`}
|
||||||
|
style={{ display: "flex", alignItems: "center", textDecoration: "none", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 22, height: 22, borderRadius: 6, overflow: "hidden" }}>
|
||||||
|
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</Link>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<span style={{
|
||||||
<h2 style={{
|
fontSize: "0.82rem", fontWeight: 600, color: "var(--foreground)",
|
||||||
fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a",
|
maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||||
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", margin: 0,
|
|
||||||
}}>
|
}}>
|
||||||
{projectName}
|
{projectName}
|
||||||
</h2>
|
</span>
|
||||||
<StatusTag status={projectStatus} />
|
|
||||||
</div>
|
|
||||||
{projectDescription && (
|
|
||||||
<p style={{
|
|
||||||
fontSize: "0.75rem", color: "#a09a90", marginTop: 1,
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
||||||
maxWidth: 400,
|
|
||||||
}}>
|
|
||||||
{projectDescription}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
fontSize: "0.78rem", fontWeight: 500,
|
|
||||||
color: "#1a1a1a", background: "#f6f4f0",
|
|
||||||
padding: "6px 12px", borderRadius: 6,
|
|
||||||
}}>
|
|
||||||
{progress}%
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab nav */}
|
||||||
<div className="vibn-tab-bar" style={{
|
<div style={{ flex: 1, display: "flex", alignItems: "center", padding: "0 12px", gap: 2 }}>
|
||||||
padding: "0 32px",
|
{SECTIONS.map(s => {
|
||||||
borderBottom: "1px solid #e8e4dc",
|
const isActive = activeSection === s.id;
|
||||||
display: "flex",
|
return (
|
||||||
background: "#fff",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={t.id}
|
key={s.id}
|
||||||
href={`/${workspace}/project/${projectId}/${t.path}`}
|
href={`/${workspace}/project/${projectId}/${s.path}`}
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 18px",
|
padding: "5px 12px", borderRadius: 8,
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.8rem",
|
||||||
fontWeight: 500,
|
fontWeight: isActive ? 600 : 440,
|
||||||
color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
|
color: isActive ? "var(--foreground)" : "var(--muted-foreground)",
|
||||||
borderBottom: activeTab === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
|
background: isActive ? "var(--secondary)" : "transparent",
|
||||||
transition: "all 0.12s",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
display: "block",
|
transition: "background 0.1s, color 0.1s",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--muted)"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
>
|
>
|
||||||
{t.label}
|
{s.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Spacer */}
|
||||||
<div className="vibn-page-content" style={{ flex: 1, overflow: "auto" }}>
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/auth" })}
|
||||||
|
title={`${session?.user?.name ?? session?.user?.email ?? "Account"} — Sign out`}
|
||||||
|
style={{
|
||||||
|
width: 28, height: 28, borderRadius: "50%",
|
||||||
|
background: "var(--secondary)", border: "none", cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "0.65rem", fontWeight: 700, color: "var(--muted-foreground)", flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userInitial}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Full-width content ── */}
|
||||||
|
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel — hidden on design tab (design page has its own right panel) */}
|
|
||||||
<div className="vibn-right-panel" style={{
|
|
||||||
width: activeTab === "design" ? 0 : 230,
|
|
||||||
borderLeft: activeTab === "design" ? "none" : "1px solid #e8e4dc",
|
|
||||||
background: "#fff",
|
|
||||||
padding: activeTab === "design" ? 0 : "22px 18px",
|
|
||||||
overflow: "auto",
|
|
||||||
flexShrink: 0,
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
display: activeTab === "design" ? "none" : undefined,
|
|
||||||
}}>
|
|
||||||
{/* Discovery phases */}
|
|
||||||
<SectionLabel>Discovery</SectionLabel>
|
|
||||||
{DISCOVERY_PHASES.map((phase, i) => {
|
|
||||||
const isDone = savedPhaseIds.has(phase.id);
|
|
||||||
const isActive = !isDone && i === firstUnsavedIdx;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={phase.id}
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
|
||||||
padding: "9px 0",
|
|
||||||
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
|
|
||||||
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
fontSize: "0.58rem", fontWeight: 700,
|
|
||||||
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
|
|
||||||
}}>
|
|
||||||
{isDone ? "✓" : isActive ? "→" : i + 1}
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
|
|
||||||
}}>
|
|
||||||
{phase.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
|
||||||
|
|
||||||
{/* Captured data — summaries from saved phases */}
|
|
||||||
<SectionLabel>Captured</SectionLabel>
|
|
||||||
{savedPhases.length > 0 ? (
|
|
||||||
savedPhases.map((p) => (
|
|
||||||
<div key={p.phase} style={{ marginBottom: 14 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.62rem", color: "#2e7d32",
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
|
||||||
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
|
|
||||||
}}>
|
|
||||||
<span>✓</span><span>{p.title}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
|
|
||||||
{p.summary}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
|
|
||||||
Atlas will capture key details here as you chat.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
|
||||||
|
|
||||||
{/* Project info */}
|
|
||||||
<SectionLabel>Project Info</SectionLabel>
|
|
||||||
{[
|
|
||||||
{ k: "Created", v: timeAgo(createdAt) },
|
|
||||||
{ k: "Last active", v: timeAgo(updatedAt) },
|
|
||||||
{ k: "Features", v: featureCount > 0 ? `${featureCount} defined` : "None yet" },
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} style={{ marginBottom: 12 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.62rem", color: "#b5b0a6",
|
|
||||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
|
||||||
marginBottom: 3, fontWeight: 600,
|
|
||||||
}}>
|
|
||||||
{item.k}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.8rem", color: "#4a4640" }}>{item.v}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap in Suspense because useSearchParams requires it
|
||||||
|
export function ProjectShell(props: ProjectShellProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ProjectShellInner {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,43 +5,48 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
interface Project {
|
interface TabItem {
|
||||||
id: string;
|
id: string;
|
||||||
productName: string;
|
label: string;
|
||||||
status?: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VIBNSidebarProps {
|
interface VIBNSidebarProps {
|
||||||
workspace: string;
|
workspace: string;
|
||||||
|
tabs?: TabItem[];
|
||||||
|
activeTab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ status }: { status?: string }) {
|
interface ProjectData {
|
||||||
const color =
|
id: string;
|
||||||
status === "live" ? "#2e7d32"
|
productName?: string;
|
||||||
: status === "building" ? "#3d5afe"
|
name?: string;
|
||||||
: "#d4a04a";
|
status?: string;
|
||||||
const anim = status === "building" ? "vibn-breathe 2.5s ease infinite" : "none";
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
width: 7, height: 7, borderRadius: "50%",
|
|
||||||
background: color, display: "inline-block",
|
|
||||||
flexShrink: 0, animation: anim,
|
|
||||||
}} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Main sidebar ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
|
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
|
||||||
const COLLAPSED_W = 56;
|
const COLLAPSED_W = 52;
|
||||||
const EXPANDED_W = 220;
|
const EXPANDED_W = 216;
|
||||||
|
|
||||||
export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
// Restore collapse state from localStorage
|
// Project-specific data
|
||||||
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
|
// Global projects list (used when NOT inside a project)
|
||||||
|
const [projects, setProjects] = useState<Array<{ id: string; productName: string; status?: string }>>([]);
|
||||||
|
|
||||||
|
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
|
||||||
|
|
||||||
|
// Restore collapse state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem(COLLAPSED_KEY);
|
const stored = localStorage.getItem(COLLAPSED_KEY);
|
||||||
if (stored === "1") setCollapsed(true);
|
if (stored === "1") setCollapsed(true);
|
||||||
@@ -55,14 +60,25 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch global projects list (for non-project pages)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeProjectId) return;
|
||||||
fetch("/api/projects")
|
fetch("/api/projects")
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((d) => setProjects(d.projects ?? []))
|
.then(d => setProjects(d.projects ?? []))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, [activeProjectId]);
|
||||||
|
|
||||||
|
// Fetch project-specific data when inside a project
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) { setProject(null); return; }
|
||||||
|
|
||||||
|
fetch(`/api/projects/${activeProjectId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setProject(d.project ?? null))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [activeProjectId]);
|
||||||
|
|
||||||
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
|
|
||||||
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
|
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
|
||||||
const isActivity = !activeProjectId && pathname?.includes("/activity");
|
const isActivity = !activeProjectId && pathname?.includes("/activity");
|
||||||
const isSettings = !activeProjectId && pathname?.includes("/settings");
|
const isSettings = !activeProjectId && pathname?.includes("/settings");
|
||||||
@@ -78,117 +94,85 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
?? "?";
|
?? "?";
|
||||||
|
|
||||||
const w = collapsed ? COLLAPSED_W : EXPANDED_W;
|
const w = collapsed ? COLLAPSED_W : EXPANDED_W;
|
||||||
|
|
||||||
// Don't animate on initial mount (avoid flash)
|
|
||||||
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
|
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
|
||||||
|
|
||||||
|
const base = `/${workspace}/project/${activeProjectId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{
|
<nav style={{
|
||||||
width: w,
|
width: w, height: "100vh",
|
||||||
height: "100vh",
|
background: "#fff", borderRight: "1px solid #e8e4dc",
|
||||||
background: "#fff",
|
display: "flex", flexDirection: "column",
|
||||||
borderRight: "1px solid #e8e4dc",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
display: "flex",
|
flexShrink: 0, overflow: "hidden",
|
||||||
flexDirection: "column",
|
transition, position: "relative",
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
flexShrink: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
transition,
|
|
||||||
position: "relative",
|
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* Logo + toggle row */}
|
{/* ── Logo + toggle ── */}
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
/* Collapsed: logo centered, toggle below it */
|
|
||||||
<div style={{ flexShrink: 0 }}>
|
<div style={{ flexShrink: 0 }}>
|
||||||
<div style={{ display: "flex", justifyContent: "center", padding: "16px 0 8px" }}>
|
<div style={{ display: "flex", justifyContent: "center", padding: "14px 0 6px" }}>
|
||||||
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
|
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
|
||||||
<div style={{ width: 28, height: 28, borderRadius: 7, overflow: "hidden" }}>
|
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden" }}>
|
||||||
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
|
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
|
||||||
<button
|
<button onClick={toggle} title="Expand sidebar" style={{
|
||||||
onClick={toggle}
|
|
||||||
title="Expand sidebar"
|
|
||||||
style={{
|
|
||||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||||
color: "#6b6560", width: 28, height: 22, borderRadius: 5,
|
color: "#6b6560", width: 26, height: 20, borderRadius: 5,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: "0.82rem", fontWeight: 700, transition: "background 0.12s, color 0.12s",
|
fontSize: "0.8rem", fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
|
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
|
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||||
>
|
>›</button>
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Expanded: logo + name on left, toggle on right */
|
<div style={{ padding: "14px 10px 14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
|
||||||
<div style={{ padding: "16px 12px 16px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
|
|
||||||
<Link href={`/${workspace}/projects`} style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", minWidth: 0 }}>
|
<Link href={`/${workspace}/projects`} style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", minWidth: 0 }}>
|
||||||
<div style={{ width: 28, height: 28, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
|
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
|
||||||
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "Newsreader, serif", whiteSpace: "nowrap" }}>
|
<span style={{ fontSize: "0.92rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "var(--font-lora), ui-serif, serif", whiteSpace: "nowrap" }}>
|
||||||
vibn
|
vibn
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button onClick={toggle} title="Collapse sidebar" style={{
|
||||||
onClick={toggle}
|
|
||||||
title="Collapse sidebar"
|
|
||||||
style={{
|
|
||||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||||
color: "#6b6560", width: 26, height: 24, borderRadius: 5,
|
color: "#6b6560", width: 24, height: 22, borderRadius: 5,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: "0.82rem", fontWeight: 700, flexShrink: 0,
|
fontSize: "0.8rem", fontWeight: 700, flexShrink: 0,
|
||||||
transition: "background 0.12s, color 0.12s",
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
|
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
|
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||||
>
|
>‹</button>
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top nav */}
|
{/* ── Top nav ── */}
|
||||||
<div style={{ padding: collapsed ? "4px 8px" : "4px 10px", flexShrink: 0 }}>
|
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px", flexShrink: 0 }}>
|
||||||
{topNavItems.map((n) => {
|
{topNavItems.map(n => {
|
||||||
const isActive = n.id === "projects" ? isProjects
|
const isActive = n.id === "projects" ? isProjects
|
||||||
: n.id === "activity" ? isActivity
|
: n.id === "activity" ? isActivity
|
||||||
: n.id === "settings" ? isSettings
|
: isSettings;
|
||||||
: false;
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link key={n.id} href={n.href} title={collapsed ? n.label : undefined} style={{
|
||||||
key={n.id}
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
href={n.href}
|
|
||||||
title={collapsed ? n.label : undefined}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
gap: 9,
|
gap: 8, padding: collapsed ? "8px 0" : "7px 10px",
|
||||||
padding: collapsed ? "9px 0" : "8px 10px",
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
background: isActive ? "#f6f4f0" : "transparent",
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
color: isActive ? "#1a1a1a" : "#6b6560",
|
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||||
fontSize: "0.82rem",
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
|
||||||
fontWeight: isActive ? 600 : 500,
|
transition: "background 0.12s", textDecoration: "none",
|
||||||
transition: "all 0.12s",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{ fontSize: collapsed ? "0.95rem" : "0.78rem", opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45, width: collapsed ? "auto" : 16, textAlign: "center" }}>
|
||||||
fontSize: collapsed ? "1rem" : "0.8rem",
|
|
||||||
opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45,
|
|
||||||
width: collapsed ? "auto" : 18,
|
|
||||||
textAlign: "center",
|
|
||||||
transition: "font-size 0.15s",
|
|
||||||
}}>
|
|
||||||
{n.icon}
|
{n.icon}
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && n.label}
|
{!collapsed && n.label}
|
||||||
@@ -197,44 +181,122 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#eae6de", margin: "10px 18px", flexShrink: 0 }} />
|
<div style={{ height: 1, background: "#eae6de", margin: "8px 14px", flexShrink: 0 }} />
|
||||||
|
|
||||||
{/* Projects list */}
|
{/* ── Lower section ── */}
|
||||||
<div style={{ padding: collapsed ? "2px 8px" : "2px 10px", flex: 1, overflow: "auto" }}>
|
<div style={{ flex: 1, overflow: "auto", paddingBottom: 8 }}>
|
||||||
|
|
||||||
|
{activeProjectId && project ? (
|
||||||
|
/* ── PROJECT VIEW: name + status + section tabs ── */
|
||||||
|
<>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div style={{
|
<>
|
||||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
<div style={{ padding: "6px 12px 8px" }}>
|
||||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
<div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
padding: "6px 10px 8px",
|
{project.productName || project.name || "Project"}
|
||||||
}}>
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 6, height: 6, borderRadius: "50%", flexShrink: 0, display: "inline-block",
|
||||||
|
background: project.status === "live" ? "#2e7d32"
|
||||||
|
: project.status === "building" ? "#3d5afe"
|
||||||
|
: "#d4a04a",
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: "0.68rem", color: "#8a8478" }}>
|
||||||
|
{project.status === "live" ? "Live" : project.status === "building" ? "Building" : "Defining"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tabs && tabs.length > 0 && (
|
||||||
|
<div style={{ padding: "2px 8px" }}>
|
||||||
|
{tabs.map(t => {
|
||||||
|
const isActive = activeTab === t.id;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={t.id}
|
||||||
|
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
|
||||||
|
style={{
|
||||||
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
|
padding: "7px 10px", borderRadius: 6,
|
||||||
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
|
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||||
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
|
||||||
|
transition: "background 0.12s", textDecoration: "none",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{collapsed && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 8, gap: 6 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 7, height: 7, borderRadius: "50%", display: "inline-block",
|
||||||
|
background: project.status === "live" ? "#2e7d32"
|
||||||
|
: project.status === "building" ? "#3d5afe"
|
||||||
|
: "#d4a04a",
|
||||||
|
}} title={project.productName || project.name} />
|
||||||
|
{tabs && tabs.map(t => {
|
||||||
|
const isActive = activeTab === t.id;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={t.id}
|
||||||
|
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
|
||||||
|
title={t.label}
|
||||||
|
style={{
|
||||||
|
width: 28, height: 28, borderRadius: 6, display: "flex",
|
||||||
|
alignItems: "center", justifyContent: "center",
|
||||||
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
|
color: isActive ? "#1a1a1a" : "#a09a90",
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, textDecoration: "none",
|
||||||
|
textTransform: "uppercase", letterSpacing: "0.02em",
|
||||||
|
transition: "background 0.12s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
{t.label.slice(0, 2)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* ── GLOBAL VIEW: projects list ── */
|
||||||
|
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px" }}>
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{ fontSize: "0.58rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase", padding: "6px 10px 8px" }}>
|
||||||
Projects
|
Projects
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{projects.map((p) => {
|
{projects.map(p => {
|
||||||
const isActive = activeProjectId === p.id;
|
const isActive = activeProjectId === p.id;
|
||||||
|
const color = p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a";
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link key={p.id} href={`/${workspace}/project/${p.id}/overview`}
|
||||||
key={p.id}
|
|
||||||
href={`/${workspace}/project/${p.id}/overview`}
|
|
||||||
title={collapsed ? p.productName : undefined}
|
title={collapsed ? p.productName : undefined}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
gap: 9,
|
gap: 9, padding: collapsed ? "9px 0" : "7px 10px",
|
||||||
padding: collapsed ? "9px 0" : "7px 10px",
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
background: isActive ? "#f6f4f0" : "transparent",
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a", fontSize: "0.8rem",
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: isActive ? 600 : 450,
|
fontWeight: isActive ? 600 : 450,
|
||||||
transition: "background 0.12s",
|
transition: "background 0.12s", textDecoration: "none", overflow: "hidden",
|
||||||
textDecoration: "none",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
>
|
>
|
||||||
<StatusDot status={p.status} />
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0 }} />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{p.productName}
|
{p.productName}
|
||||||
@@ -244,44 +306,36 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* User footer */}
|
{/* ── User footer ── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: collapsed ? "12px 0" : "14px 18px",
|
padding: collapsed ? "10px 0" : "12px 14px",
|
||||||
borderTop: "1px solid #eae6de",
|
borderTop: "1px solid #eae6de",
|
||||||
display: "flex",
|
display: "flex", alignItems: "center",
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
gap: 9,
|
gap: 9, flexShrink: 0,
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
}}>
|
||||||
<div
|
<div title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
|
||||||
title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
width: 28, height: 28, borderRadius: "50%",
|
width: 26, height: 26, borderRadius: "50%",
|
||||||
background: "#f0ece4", display: "flex", alignItems: "center",
|
background: "#f0ece4", display: "flex", alignItems: "center",
|
||||||
justifyContent: "center", fontSize: "0.72rem", fontWeight: 600,
|
justifyContent: "center", fontSize: "0.7rem", fontWeight: 600,
|
||||||
color: "#8a8478", flexShrink: 0, cursor: "default",
|
color: "#8a8478", flexShrink: 0, cursor: "default",
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{userInitial}
|
{userInitial}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div style={{ fontSize: "0.76rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a",
|
|
||||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
||||||
}}>
|
|
||||||
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
|
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => signOut({ callbackUrl: "/auth" })} style={{
|
||||||
onClick={() => signOut({ callbackUrl: "/auth" })}
|
|
||||||
style={{
|
|
||||||
background: "none", border: "none", padding: 0,
|
background: "none", border: "none", padding: 0,
|
||||||
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,278 +1,6 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
// Re-export the new multi-step creation flow as a drop-in replacement
|
||||||
import { createPortal } from 'react-dom';
|
// for the original 2-step ProjectCreationModal.
|
||||||
import { useRouter } from 'next/navigation';
|
export { CreateProjectFlow as ProjectCreationModal } from "./project-creation/CreateProjectFlow";
|
||||||
import { toast } from 'sonner';
|
export type { CreationMode } from "./project-creation/CreateProjectFlow";
|
||||||
|
|
||||||
interface ProjectCreationModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
workspace: string;
|
|
||||||
initialWorkspacePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROJECT_TYPES = [
|
|
||||||
{ id: 'web-app', label: 'Web App', icon: '⬡', desc: 'SaaS product users log into — dashboards, accounts, core features' },
|
|
||||||
{ id: 'website', label: 'Website', icon: '◎', desc: 'Marketing site, landing page, or content-driven public site' },
|
|
||||||
{ id: 'marketplace', label: 'Marketplace', icon: '⇄', desc: 'Two-sided platform connecting buyers and sellers or providers' },
|
|
||||||
{ id: 'mobile', label: 'Mobile App', icon: '▢', desc: 'iOS and Android app — touch-first, native feel' },
|
|
||||||
{ id: 'internal', label: 'Internal Tool', icon: '◫', desc: 'Admin panel, ops dashboard, or business process tool' },
|
|
||||||
{ id: 'ai-product', label: 'AI Product', icon: '◈', desc: 'AI-native product — copilot, agent, or model-powered workflow' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectCreationModalProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
|
||||||
const [productName, setProductName] = useState('');
|
|
||||||
const [projectType, setProjectType] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setStep(1);
|
|
||||||
setProductName('');
|
|
||||||
setProjectType(null);
|
|
||||||
setLoading(false);
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 80);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); };
|
|
||||||
window.addEventListener('keydown', handler);
|
|
||||||
return () => window.removeEventListener('keydown', handler);
|
|
||||||
}, [open, onOpenChange]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!productName.trim() || !projectType) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/projects/create', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectName: productName.trim(),
|
|
||||||
projectType,
|
|
||||||
slug: productName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
||||||
product: { name: productName.trim(), type: projectType },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
toast.error(err.error || 'Failed to create project');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
onOpenChange(false);
|
|
||||||
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
|
||||||
} catch {
|
|
||||||
toast.error('Something went wrong');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 50,
|
|
||||||
background: 'rgba(26,26,26,0.35)',
|
|
||||||
animation: 'fadeIn 0.15s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 51,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
padding: 24, pointerEvents: 'none',
|
|
||||||
}}>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
background: '#fff', borderRadius: 14,
|
|
||||||
boxShadow: '0 8px 40px rgba(26,26,26,0.14)',
|
|
||||||
padding: '32px 36px',
|
|
||||||
width: '100%', maxWidth: step === 2 ? 560 : 460,
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
pointerEvents: 'all',
|
|
||||||
animation: 'slideUp 0.18s cubic-bezier(0.4,0,0.2,1)',
|
|
||||||
transition: 'max-width 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<style>{`
|
|
||||||
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
|
|
||||||
@keyframes slideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
|
||||||
@keyframes spin { to { transform:rotate(360deg); } }
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
{step === 2 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
color: '#a09a90', fontSize: '1rem', padding: '2px 4px',
|
|
||||||
borderRadius: 4, transition: 'color 0.12s', lineHeight: 1,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = '#1a1a1a')}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = '#a09a90')}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontFamily: 'Newsreader, serif', fontSize: '1.3rem', fontWeight: 400, color: '#1a1a1a', marginBottom: 2 }}>
|
|
||||||
{step === 1 ? 'New project' : `What are you building?`}
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: '0.78rem', color: '#a09a90' }}>
|
|
||||||
{step === 1 ? 'Give your project a name to get started.' : `Choose the type that best fits "${productName}".`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
color: '#b5b0a6', fontSize: '1.1rem', lineHeight: 1,
|
|
||||||
padding: '2px 4px', borderRadius: 4, transition: 'color 0.12s', flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = '#6b6560')}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = '#b5b0a6')}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 1 — Name */}
|
|
||||||
{step === 1 && (
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#6b6560', marginBottom: 7, letterSpacing: '0.02em' }}>
|
|
||||||
Project name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={productName}
|
|
||||||
onChange={e => setProductName(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && productName.trim()) setStep(2); }}
|
|
||||||
placeholder="e.g. Foxglove, Meridian, OpsAI…"
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '11px 14px', marginBottom: 16,
|
|
||||||
borderRadius: 8, border: '1px solid #e0dcd4',
|
|
||||||
background: '#faf8f5', fontSize: '0.9rem',
|
|
||||||
fontFamily: 'Outfit, sans-serif', color: '#1a1a1a',
|
|
||||||
outline: 'none', transition: 'border-color 0.12s',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
|
||||||
onFocus={e => (e.currentTarget.style.borderColor = '#1a1a1a')}
|
|
||||||
onBlur={e => (e.currentTarget.style.borderColor = '#e0dcd4')}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => { if (productName.trim()) setStep(2); }}
|
|
||||||
disabled={!productName.trim()}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '12px',
|
|
||||||
borderRadius: 8, border: 'none',
|
|
||||||
background: productName.trim() ? '#1a1a1a' : '#e0dcd4',
|
|
||||||
color: productName.trim() ? '#fff' : '#b5b0a6',
|
|
||||||
fontSize: '0.88rem', fontWeight: 600,
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
cursor: productName.trim() ? 'pointer' : 'not-allowed',
|
|
||||||
transition: 'opacity 0.15s, background 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (productName.trim()) (e.currentTarget.style.opacity = '0.85'); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
|
|
||||||
>
|
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2 — Project type */}
|
|
||||||
{step === 2 && (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 20 }}>
|
|
||||||
{PROJECT_TYPES.map(type => {
|
|
||||||
const isSelected = projectType === type.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={type.id}
|
|
||||||
onClick={() => setProjectType(type.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
|
||||||
padding: '14px 16px', borderRadius: 10, textAlign: 'left',
|
|
||||||
border: `1px solid ${isSelected ? '#1a1a1a' : '#e8e4dc'}`,
|
|
||||||
background: isSelected ? '#1a1a1a08' : '#fff',
|
|
||||||
boxShadow: isSelected ? '0 0 0 1px #1a1a1a' : '0 1px 2px #1a1a1a04',
|
|
||||||
cursor: 'pointer', transition: 'all 0.12s',
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#d0ccc4'); }}
|
|
||||||
onMouseLeave={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#e8e4dc'); }}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: 30, height: 30, borderRadius: 7, flexShrink: 0,
|
|
||||||
background: isSelected ? '#1a1a1a' : '#f6f4f0',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: '0.95rem', color: isSelected ? '#fff' : '#8a8478',
|
|
||||||
}}>
|
|
||||||
{type.icon}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: '0.84rem', fontWeight: 600, color: '#1a1a1a', marginBottom: 2 }}>
|
|
||||||
{type.label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.71rem', color: '#8a8478', lineHeight: 1.45 }}>
|
|
||||||
{type.desc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={!projectType || loading}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '12px',
|
|
||||||
borderRadius: 8, border: 'none',
|
|
||||||
background: projectType && !loading ? '#1a1a1a' : '#e0dcd4',
|
|
||||||
color: projectType && !loading ? '#fff' : '#b5b0a6',
|
|
||||||
fontSize: '0.88rem', fontWeight: 600,
|
|
||||||
fontFamily: 'Outfit, sans-serif',
|
|
||||||
cursor: projectType && !loading ? 'pointer' : 'not-allowed',
|
|
||||||
transition: 'opacity 0.15s, background 0.15s',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (projectType && !loading) (e.currentTarget.style.opacity = '0.85'); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #fff4', borderTopColor: '#fff', animation: 'spin 0.7s linear infinite', display: 'inline-block' }} />
|
|
||||||
Creating…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`Create ${productName} →`
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
84
components/project-creation/ChatImportSetup.tsx
Normal file
84
components/project-creation/ChatImportSetup.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [chatText, setChatText] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const canCreate = name.trim().length > 0 && chatText.trim().length > 20;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "chat-import",
|
||||||
|
sourceData: { chatText: chatText.trim() },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<SetupHeader
|
||||||
|
icon="⌁" label="Import Chats" tagline="You've been thinking"
|
||||||
|
accent="#2e5a4a" onBack={onBack} onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Project name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="What are you building?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Paste your chat history</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={chatText}
|
||||||
|
onChange={e => setChatText(e.target.value)}
|
||||||
|
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nVibn will extract decisions, ideas, open questions, and architecture notes."}
|
||||||
|
rows={8}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "12px 14px", marginBottom: 20,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.55,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", resize: "vertical", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Extract & analyse →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
components/project-creation/CodeImportSetup.tsx
Normal file
100
components/project-creation/CodeImportSetup.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [repoUrl, setRepoUrl] = useState("");
|
||||||
|
const [pat, setPat] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isValidUrl = repoUrl.trim().startsWith("http");
|
||||||
|
const canCreate = name.trim().length > 0 && isValidUrl;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "code-import",
|
||||||
|
sourceData: { repoUrl: repoUrl.trim(), pat: pat.trim() || undefined },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<SetupHeader
|
||||||
|
icon="⌘" label="Import Code" tagline="Already have a repo"
|
||||||
|
accent="#1a3a5c" onBack={onBack} onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Project name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="What is this project called?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Repository URL</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={setRepoUrl}
|
||||||
|
placeholder="https://github.com/yourorg/your-repo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>
|
||||||
|
Personal Access Token{" "}
|
||||||
|
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(required for private repos)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pat}
|
||||||
|
onChange={e => setPat(e.target.value)}
|
||||||
|
placeholder="ghp_… or similar"
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 20,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
Vibn will clone your repo, read key files, and build a full architecture map — tech stack, routes, database, auth, and third-party integrations. Tokens are used only for cloning and are not stored.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Import & map →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
components/project-creation/CreateProjectFlow.tsx
Normal file
106
components/project-creation/CreateProjectFlow.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { TypeSelector } from "./TypeSelector";
|
||||||
|
import { FreshIdeaSetup } from "./FreshIdeaSetup";
|
||||||
|
import { ChatImportSetup } from "./ChatImportSetup";
|
||||||
|
import { CodeImportSetup } from "./CodeImportSetup";
|
||||||
|
import { MigrateSetup } from "./MigrateSetup";
|
||||||
|
|
||||||
|
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
|
|
||||||
|
interface CreateProjectFlowProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
workspace: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "select-type" | "setup";
|
||||||
|
|
||||||
|
export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProjectFlowProps) {
|
||||||
|
const [step, setStep] = useState<Step>("setup");
|
||||||
|
const [mode, setMode] = useState<CreationMode | null>("fresh");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setStep("setup");
|
||||||
|
setMode("fresh");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onOpenChange(false); };
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleSelectType = (selected: CreationMode) => {
|
||||||
|
setMode(selected);
|
||||||
|
setStep("setup");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep("select-type");
|
||||||
|
setMode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupProps = { workspace, onClose: () => onOpenChange(false), onBack: handleBack };
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@keyframes vibn-fadeIn { from { opacity:0; } to { opacity:1; } }
|
||||||
|
@keyframes vibn-slideUp { from { opacity:0; transform:translateY(14px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
@keyframes vibn-spin { to { transform:rotate(360deg); } }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 50,
|
||||||
|
background: "rgba(26,26,26,0.38)",
|
||||||
|
animation: "vibn-fadeIn 0.15s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal container */}
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 51,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: 24, pointerEvents: "none",
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: "#fff", borderRadius: 16,
|
||||||
|
boxShadow: "0 12px 48px rgba(26,26,26,0.16)",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 520,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
pointerEvents: "all",
|
||||||
|
animation: "vibn-slideUp 0.18s cubic-bezier(0.4,0,0.2,1)",
|
||||||
|
transition: "max-width 0.2s ease",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === "select-type" && (
|
||||||
|
<TypeSelector
|
||||||
|
onSelect={handleSelectType}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === "setup" && mode === "fresh" && <FreshIdeaSetup {...setupProps} />}
|
||||||
|
{step === "setup" && mode === "chat-import" && <ChatImportSetup {...setupProps} />}
|
||||||
|
{step === "setup" && mode === "code-import" && <CodeImportSetup {...setupProps} />}
|
||||||
|
{step === "setup" && mode === "migration" && <MigrateSetup {...setupProps} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
74
components/project-creation/FreshIdeaSetup.tsx
Normal file
74
components/project-creation/FreshIdeaSetup.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const canCreate = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "fresh",
|
||||||
|
sourceData: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 28 }}>
|
||||||
|
<div style={{ fontSize: "1.15rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "var(--font-lora), ui-serif, serif" }}>
|
||||||
|
New project
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "1.1rem", padding: "2px 6px", lineHeight: 1 }}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldLabel>Project name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="e.g. Foxglove, Meridian, OpsAI…"
|
||||||
|
onKeyDown={e => { if (e.key === "Enter" && canCreate) handleCreate(); }}
|
||||||
|
inputRef={nameRef}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Start →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
components/project-creation/MigrateSetup.tsx
Normal file
159
components/project-creation/MigrateSetup.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
|
||||||
|
|
||||||
|
const HOSTING_OPTIONS = [
|
||||||
|
{ value: "", label: "Select hosting provider" },
|
||||||
|
{ value: "vercel", label: "Vercel" },
|
||||||
|
{ value: "aws", label: "AWS (EC2 / ECS / Elastic Beanstalk)" },
|
||||||
|
{ value: "heroku", label: "Heroku" },
|
||||||
|
{ value: "digitalocean", label: "DigitalOcean (Droplet / App Platform)" },
|
||||||
|
{ value: "gcp", label: "Google Cloud Platform" },
|
||||||
|
{ value: "azure", label: "Microsoft Azure" },
|
||||||
|
{ value: "railway", label: "Railway" },
|
||||||
|
{ value: "render", label: "Render" },
|
||||||
|
{ value: "netlify", label: "Netlify" },
|
||||||
|
{ value: "self-hosted", label: "Self-hosted / VPS" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [repoUrl, setRepoUrl] = useState("");
|
||||||
|
const [liveUrl, setLiveUrl] = useState("");
|
||||||
|
const [hosting, setHosting] = useState("");
|
||||||
|
const [pat, setPat] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isValidRepo = repoUrl.trim().startsWith("http");
|
||||||
|
const isValidLive = liveUrl.trim().startsWith("http");
|
||||||
|
const canCreate = name.trim().length > 0 && (isValidRepo || isValidLive);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/projects/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectName: name.trim(),
|
||||||
|
projectType: "web-app",
|
||||||
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||||
|
product: { name: name.trim() },
|
||||||
|
creationMode: "migration",
|
||||||
|
githubRepoUrl: repoUrl.trim() || undefined,
|
||||||
|
githubToken: pat.trim() || undefined,
|
||||||
|
sourceData: {
|
||||||
|
liveUrl: liveUrl.trim() || undefined,
|
||||||
|
hosting: hosting || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to create project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
onClose();
|
||||||
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
<SetupHeader
|
||||||
|
icon="⇢" label="Migrate Product" tagline="Move an existing product"
|
||||||
|
accent="#4a2a5a" onBack={onBack} onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>Product name</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
placeholder="What is this product called?"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>
|
||||||
|
Repository URL{" "}
|
||||||
|
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(recommended)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={setRepoUrl}
|
||||||
|
placeholder="https://github.com/yourorg/your-repo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldLabel>
|
||||||
|
Live URL{" "}
|
||||||
|
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={liveUrl}
|
||||||
|
onChange={setLiveUrl}
|
||||||
|
placeholder="https://yourproduct.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 4 }}>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Hosting provider</FieldLabel>
|
||||||
|
<select
|
||||||
|
value={hosting}
|
||||||
|
onChange={e => setHosting(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.88rem",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90",
|
||||||
|
outline: "none", boxSizing: "border-box", appearance: "none",
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23a09a90' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
|
||||||
|
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{HOSTING_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>
|
||||||
|
PAT{" "}<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(private repos)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pat}
|
||||||
|
onChange={e => setPat(e.target.value)}
|
||||||
|
placeholder="ghp_…"
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Vibn builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
|
||||||
|
Start migration plan →
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/project-creation/TypeSelector.tsx
Normal file
150
components/project-creation/TypeSelector.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CreationMode } from "./CreateProjectFlow";
|
||||||
|
|
||||||
|
interface TypeSelectorProps {
|
||||||
|
onSelect: (mode: CreationMode) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_FLOW_TYPES: {
|
||||||
|
id: CreationMode;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
tagline: string;
|
||||||
|
desc: string;
|
||||||
|
accent: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: "fresh",
|
||||||
|
icon: "✦",
|
||||||
|
label: "Fresh Idea",
|
||||||
|
tagline: "Start from scratch",
|
||||||
|
desc: "Talk through your idea with Vibn. We'll explore it together and shape it into a full product plan.",
|
||||||
|
accent: "#4a3728",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chat-import",
|
||||||
|
icon: "⌁",
|
||||||
|
label: "Import Chats",
|
||||||
|
tagline: "You've been thinking",
|
||||||
|
desc: "Paste conversations from ChatGPT or Claude. Vibn extracts your decisions, ideas, and open questions.",
|
||||||
|
accent: "#2e5a4a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "code-import",
|
||||||
|
icon: "⌘",
|
||||||
|
label: "Import Code",
|
||||||
|
tagline: "Already have a repo",
|
||||||
|
desc: "Point Vibn at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
|
||||||
|
accent: "#1a3a5c",
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "migration",
|
||||||
|
icon: "⇢",
|
||||||
|
label: "Migrate Product",
|
||||||
|
tagline: "Move an existing product",
|
||||||
|
desc: "Bring your live product into the VIBN infrastructure. Vibn builds a safe, phased migration plan.",
|
||||||
|
accent: "#4a2a5a",
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FLOW_TYPES = ALL_FLOW_TYPES.filter(t => !t.hidden);
|
||||||
|
|
||||||
|
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "32px 36px 36px" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.4rem", fontWeight: 400,
|
||||||
|
color: "#1a1a1a", margin: 0, marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
Start a new project
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.78rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
How would you like to begin?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
|
||||||
|
padding: "2px 5px", borderRadius: 4,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type cards */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 10 }}>
|
||||||
|
{FLOW_TYPES.map(type => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => onSelect(type.id)}
|
||||||
|
style={{
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "flex-start",
|
||||||
|
gap: 0, padding: "20px", borderRadius: 12, textAlign: "left",
|
||||||
|
border: "1px solid #e8e4dc",
|
||||||
|
background: "#faf8f5",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.14s",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.borderColor = "#d0ccc4";
|
||||||
|
e.currentTarget.style.background = "#fff";
|
||||||
|
e.currentTarget.style.boxShadow = "0 2px 12px rgba(26,26,26,0.07)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.borderColor = "#e8e4dc";
|
||||||
|
e.currentTarget.style.background = "#faf8f5";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 9, marginBottom: 14,
|
||||||
|
background: `${type.accent}10`,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "1.1rem", color: type.accent,
|
||||||
|
}}>
|
||||||
|
{type.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + tagline */}
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 2 }}>
|
||||||
|
{type.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.68rem", fontWeight: 600, color: type.accent, letterSpacing: "0.03em", marginBottom: 8, textTransform: "uppercase" }}>
|
||||||
|
{type.tagline}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
|
||||||
|
{type.desc}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", right: 16, bottom: 16,
|
||||||
|
fontSize: "0.85rem", color: "#c5c0b8",
|
||||||
|
}}>
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
components/project-creation/setup-shared.tsx
Normal file
153
components/project-creation/setup-shared.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, CSSProperties } from "react";
|
||||||
|
|
||||||
|
export interface SetupProps {
|
||||||
|
workspace: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared modal header
|
||||||
|
export function SetupHeader({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
tagline,
|
||||||
|
accent,
|
||||||
|
onBack,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
tagline: string;
|
||||||
|
accent: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#b5b0a6", fontSize: "1rem", padding: "3px 5px",
|
||||||
|
borderRadius: 4, lineHeight: 1, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400,
|
||||||
|
color: "#1a1a1a", margin: 0, marginBottom: 3,
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.72rem", fontWeight: 600, color: accent, textTransform: "uppercase", letterSpacing: "0.04em", margin: 0 }}>
|
||||||
|
{tagline}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
|
||||||
|
padding: "2px 5px", borderRadius: 4, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
onKeyDown,
|
||||||
|
autoFocus,
|
||||||
|
inputRef,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>;
|
||||||
|
}) {
|
||||||
|
const base: CSSProperties = {
|
||||||
|
width: "100%", padding: "11px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
style={base}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrimaryButton({
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const active = !disabled && !loading;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={!active}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "12px",
|
||||||
|
borderRadius: 8, border: "none",
|
||||||
|
background: active ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: active ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.88rem", fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
cursor: active ? "pointer" : "not-allowed",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (active) e.currentTarget.style.opacity = "0.85"; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.opacity = "1"; }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span style={{ width: 14, height: 14, borderRadius: "50%", border: "2px solid #fff4", borderTopColor: "#fff", animation: "vibn-spin 0.7s linear infinite", display: "inline-block" }} />
|
||||||
|
Creating…
|
||||||
|
</>
|
||||||
|
) : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
components/project-main/ChatImportMain.tsx
Normal file
330
components/project-main/ChatImportMain.tsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
|
interface AnalysisResult {
|
||||||
|
decisions: string[];
|
||||||
|
ideas: string[];
|
||||||
|
openQuestions: string[];
|
||||||
|
architecture: string[];
|
||||||
|
targetUsers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatImportMainProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
sourceData?: { chatText?: string };
|
||||||
|
analysisResult?: AnalysisResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stage = "intake" | "extracting" | "review";
|
||||||
|
|
||||||
|
function EditableList({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
accent,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
items: string[];
|
||||||
|
accent: string;
|
||||||
|
onChange: (items: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const handleEdit = (i: number, value: string) => {
|
||||||
|
const next = [...items];
|
||||||
|
next[i] = value;
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
const handleDelete = (i: number) => {
|
||||||
|
onChange(items.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
const handleAdd = () => {
|
||||||
|
onChange([...items, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: accent, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", margin: "0 0 6px" }}>
|
||||||
|
Nothing captured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 5 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item}
|
||||||
|
onChange={e => handleEdit(i, e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: "7px 10px", borderRadius: 6,
|
||||||
|
border: "1px solid #e0dcd4", background: "#faf8f5",
|
||||||
|
fontSize: "0.81rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
color: "#1a1a1a", outline: "none",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(i)}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: "#c5c0b8", fontSize: "0.85rem", padding: "4px 6px" }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#e53e3e")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "1px dashed #e0dcd4", cursor: "pointer",
|
||||||
|
borderRadius: 6, padding: "5px 10px", fontSize: "0.72rem", color: "#a09a90",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", width: "100%",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatImportMain({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
sourceData,
|
||||||
|
analysisResult: initialResult,
|
||||||
|
}: ChatImportMainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params?.workspace as string;
|
||||||
|
|
||||||
|
const hasChatText = !!sourceData?.chatText;
|
||||||
|
const [stage, setStage] = useState<Stage>(
|
||||||
|
initialResult ? "review" : hasChatText ? "extracting" : "intake"
|
||||||
|
);
|
||||||
|
const [chatText, setChatText] = useState(sourceData?.chatText ?? "");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<AnalysisResult>(
|
||||||
|
initialResult ?? { decisions: [], ideas: [], openQuestions: [], architecture: [], targetUsers: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kick off extraction automatically if chatText is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage === "extracting") {
|
||||||
|
runExtraction();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
const runExtraction = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/analyze-chats`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ chatText }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Extraction failed");
|
||||||
|
setResult(data.analysisResult);
|
||||||
|
setStage("review");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||||
|
setStage("intake");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/prd`);
|
||||||
|
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/build`);
|
||||||
|
|
||||||
|
// ── Stage: intake ─────────────────────────────────────────────────────────
|
||||||
|
if (stage === "intake") {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||||
|
<div style={{ width: "100%", maxWidth: 640, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
Paste your chat history
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
{projectName} — Atlas will extract decisions, ideas, architecture notes, and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={chatText}
|
||||||
|
onChange={e => setChatText(e.target.value)}
|
||||||
|
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nCopy the full conversation — Atlas handles the cleanup."}
|
||||||
|
rows={14}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "14px 16px", marginBottom: 16,
|
||||||
|
borderRadius: 10, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.6,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", resize: "vertical", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (chatText.trim().length > 20) {
|
||||||
|
setStage("extracting");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={chatText.trim().length < 20}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px",
|
||||||
|
borderRadius: 8, border: "none",
|
||||||
|
background: chatText.trim().length > 20 ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: chatText.trim().length > 20 ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
cursor: chatText.trim().length > 20 ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Extract insights →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: extracting ─────────────────────────────────────────────────────
|
||||||
|
if (stage === "extracting") {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, borderRadius: "50%",
|
||||||
|
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
|
||||||
|
animation: "vibn-chat-spin 0.8s linear infinite",
|
||||||
|
margin: "0 auto 20px",
|
||||||
|
}} />
|
||||||
|
<style>{`@keyframes vibn-chat-spin { to { transform:rotate(360deg); } }`}</style>
|
||||||
|
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>
|
||||||
|
Analysing your chats…
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
Atlas is extracting decisions, ideas, and insights
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: review ─────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
What Atlas found
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
Review and edit the extracted insights for <strong>{projectName}</strong>. These will seed your PRD or MVP plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
|
||||||
|
{/* Left column */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Decisions made"
|
||||||
|
items={result.decisions}
|
||||||
|
accent="#1a3a5c"
|
||||||
|
onChange={items => setResult(r => ({ ...r, decisions: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Ideas & features"
|
||||||
|
items={result.ideas}
|
||||||
|
accent="#2e5a4a"
|
||||||
|
onChange={items => setResult(r => ({ ...r, ideas: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Right column */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Open questions"
|
||||||
|
items={result.openQuestions}
|
||||||
|
accent="#9a7b3a"
|
||||||
|
onChange={items => setResult(r => ({ ...r, openQuestions: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Architecture notes"
|
||||||
|
items={result.architecture}
|
||||||
|
accent="#4a3728"
|
||||||
|
onChange={items => setResult(r => ({ ...r, architecture: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||||
|
<EditableList
|
||||||
|
label="Target users"
|
||||||
|
items={result.targetUsers}
|
||||||
|
accent="#4a2a5a"
|
||||||
|
onChange={items => setResult(r => ({ ...r, targetUsers: items }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision buttons */}
|
||||||
|
<div style={{
|
||||||
|
background: "#1a1a1a", borderRadius: 12, padding: "22px 24px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to move forward?</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Choose how you want to proceed with {projectName}.</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={handlePRD}
|
||||||
|
style={{
|
||||||
|
padding: "11px 22px", borderRadius: 8, border: "none",
|
||||||
|
background: "#fff", color: "#1a1a1a",
|
||||||
|
fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
|
>
|
||||||
|
Generate PRD →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMVP}
|
||||||
|
style={{
|
||||||
|
padding: "11px 22px", borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
|
||||||
|
fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
|
||||||
|
>
|
||||||
|
Plan MVP Test →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
components/project-main/CodeImportMain.tsx
Normal file
363
components/project-main/CodeImportMain.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
|
interface ArchRow {
|
||||||
|
category: string;
|
||||||
|
item: string;
|
||||||
|
status: "found" | "partial" | "missing";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisResult {
|
||||||
|
summary: string;
|
||||||
|
rows: ArchRow[];
|
||||||
|
suggestedSurfaces: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeImportMainProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
sourceData?: { repoUrl?: string };
|
||||||
|
analysisResult?: AnalysisResult;
|
||||||
|
creationStage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stage = "input" | "cloning" | "mapping" | "surfaces";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
found: { bg: "#f0fdf4", text: "#15803d", label: "Found" },
|
||||||
|
partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" },
|
||||||
|
missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = [
|
||||||
|
"Tech Stack", "Infrastructure", "Database", "API Surface",
|
||||||
|
"Frontend", "Auth", "Third-party", "Missing / Gaps",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROGRESS_STEPS = [
|
||||||
|
{ key: "cloning", label: "Cloning repository" },
|
||||||
|
{ key: "reading", label: "Reading key files" },
|
||||||
|
{ key: "analyzing", label: "Mapping architecture" },
|
||||||
|
{ key: "done", label: "Analysis complete" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CodeImportMain({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
sourceData,
|
||||||
|
analysisResult: initialResult,
|
||||||
|
creationStage,
|
||||||
|
}: CodeImportMainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params?.workspace as string;
|
||||||
|
|
||||||
|
const hasRepo = !!sourceData?.repoUrl;
|
||||||
|
const getInitialStage = (): Stage => {
|
||||||
|
if (initialResult) return "mapping";
|
||||||
|
if (creationStage === "surfaces") return "surfaces";
|
||||||
|
if (hasRepo) return "cloning";
|
||||||
|
return "input";
|
||||||
|
};
|
||||||
|
|
||||||
|
const [stage, setStage] = useState<Stage>(getInitialStage);
|
||||||
|
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
|
||||||
|
const [progressStep, setProgressStep] = useState<string>("cloning");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<AnalysisResult | null>(initialResult ?? null);
|
||||||
|
const [confirmedSurfaces, setConfirmedSurfaces] = useState<string[]>(
|
||||||
|
initialResult?.suggestedSurfaces ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kick off analysis when in cloning stage
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage !== "cloning") return;
|
||||||
|
startAnalysis();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
// Poll for analysis status when cloning
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage !== "cloning") return;
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
|
||||||
|
const data = await res.json();
|
||||||
|
setProgressStep(data.stage ?? "cloning");
|
||||||
|
if (data.stage === "done" && data.analysisResult) {
|
||||||
|
setResult(data.analysisResult);
|
||||||
|
setConfirmedSurfaces(data.analysisResult.suggestedSurfaces ?? []);
|
||||||
|
clearInterval(interval);
|
||||||
|
setStage("mapping");
|
||||||
|
}
|
||||||
|
} catch { /* keep polling */ }
|
||||||
|
}, 2500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
const startAnalysis = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${projectId}/analyze-repo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ repoUrl }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to start analysis");
|
||||||
|
setStage("input");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSurfaces = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${projectId}/design-surfaces`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ surfaces: confirmedSurfaces }),
|
||||||
|
});
|
||||||
|
router.push(`/${workspace}/project/${projectId}/design`);
|
||||||
|
} catch { /* navigate anyway */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSurface = (s: string) => {
|
||||||
|
setConfirmedSurfaces(prev =>
|
||||||
|
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stage: input ──────────────────────────────────────────────────────────
|
||||||
|
if (stage === "input") {
|
||||||
|
const isValid = repoUrl.trim().startsWith("http");
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||||
|
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
Import your repository
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
{projectName} — paste a clone URL to map your existing stack.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||||
|
Repository URL (HTTPS)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={e => setRepoUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/yourorg/your-repo"
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "12px 14px", marginBottom: 16,
|
||||||
|
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||||
|
background: "#faf8f5", fontSize: "0.9rem",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||||
|
outline: "none", boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
onKeyDown={e => { if (e.key === "Enter" && isValid) setStage("cloning"); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
Atlas will clone and map your stack — tech, database, auth, APIs, and what's missing for a complete go-to-market build.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (isValid) setStage("cloning"); }}
|
||||||
|
disabled={!isValid}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||||
|
background: isValid ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: isValid ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
cursor: isValid ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Map this repo →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: cloning ────────────────────────────────────────────────────────
|
||||||
|
if (stage === "cloning") {
|
||||||
|
const currentIdx = PROGRESS_STEPS.findIndex(s => s.key === progressStep);
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ textAlign: "center", maxWidth: 400 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: "50%",
|
||||||
|
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
|
||||||
|
animation: "vibn-repo-spin 0.85s linear infinite",
|
||||||
|
margin: "0 auto 24px",
|
||||||
|
}} />
|
||||||
|
<style>{`@keyframes vibn-repo-spin { to { transform:rotate(360deg); } }`}</style>
|
||||||
|
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>
|
||||||
|
Mapping your codebase
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>
|
||||||
|
{repoUrl || sourceData?.repoUrl || "Repository"}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
|
||||||
|
{PROGRESS_STEPS.map((step, i) => {
|
||||||
|
const done = i < currentIdx;
|
||||||
|
const active = i === currentIdx;
|
||||||
|
return (
|
||||||
|
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: "50%", flexShrink: 0,
|
||||||
|
background: done ? "#1a1a1a" : active ? "#f6f4f0" : "#f6f4f0",
|
||||||
|
border: active ? "2px solid #1a1a1a" : done ? "none" : "2px solid #e0dcd4",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90",
|
||||||
|
}}>
|
||||||
|
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#1a1a1a", display: "block" }} /> : ""}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: mapping ────────────────────────────────────────────────────────
|
||||||
|
if (stage === "mapping" && result) {
|
||||||
|
const byCategory: Record<string, ArchRow[]> = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const cat = row.category || "Other";
|
||||||
|
if (!byCategory[cat]) byCategory[cat] = [];
|
||||||
|
byCategory[cat].push(row);
|
||||||
|
}
|
||||||
|
const categories = [
|
||||||
|
...CATEGORY_ORDER.filter(c => byCategory[c]),
|
||||||
|
...Object.keys(byCategory).filter(c => !CATEGORY_ORDER.includes(c)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ maxWidth: 800, margin: "0 auto" }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
Architecture map
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 4px" }}>
|
||||||
|
{projectName} — {result.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
|
||||||
|
{categories.map((cat, catIdx) => (
|
||||||
|
<div key={cat}>
|
||||||
|
{catIdx > 0 && <div style={{ height: 1, background: "#f0ece4" }} />}
|
||||||
|
<div style={{ padding: "12px 20px", background: "#faf8f5", fontSize: "0.68rem", fontWeight: 700, color: "#6b6560", letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||||
|
{cat}
|
||||||
|
</div>
|
||||||
|
{byCategory[cat].map((row, i) => {
|
||||||
|
const sc = STATUS_COLORS[row.status];
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 20px", borderTop: "1px solid #f6f4f0" }}>
|
||||||
|
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
|
||||||
|
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
|
||||||
|
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
{sc.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setStage("surfaces")}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||||
|
background: "#1a1a1a", color: "#fff",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
|
>
|
||||||
|
Choose what to build next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: surfaces ───────────────────────────────────────────────────────
|
||||||
|
const SURFACE_OPTIONS = [
|
||||||
|
{ id: "marketing", label: "Marketing Site", icon: "◎", desc: "Landing page, pricing, blog" },
|
||||||
|
{ id: "web-app", label: "Web App", icon: "⬡", desc: "Core SaaS product with auth" },
|
||||||
|
{ id: "admin", label: "Admin Panel", icon: "◫", desc: "Ops dashboard, content management" },
|
||||||
|
{ id: "api", label: "API Layer", icon: "⌁", desc: "REST/GraphQL endpoints" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||||
|
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
What should Atlas build?
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
Based on the gap analysis, Atlas suggests the surfaces below. Confirm or adjust.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 24 }}>
|
||||||
|
{SURFACE_OPTIONS.map(s => {
|
||||||
|
const selected = confirmedSurfaces.includes(s.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => toggleSurface(s.id)}
|
||||||
|
style={{
|
||||||
|
padding: "18px", borderRadius: 10, textAlign: "left",
|
||||||
|
border: `2px solid ${selected ? "#1a1a1a" : "#e8e4dc"}`,
|
||||||
|
background: selected ? "#1a1a1a08" : "#fff",
|
||||||
|
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
transition: "all 0.12s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = "#d0ccc4"; }}
|
||||||
|
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = "#e8e4dc"; }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "1.2rem", marginBottom: 8 }}>{s.icon}</div>
|
||||||
|
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 3 }}>{s.label}</div>
|
||||||
|
<div style={{ fontSize: "0.73rem", color: "#8a8478" }}>{s.desc}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmSurfaces}
|
||||||
|
disabled={confirmedSurfaces.length === 0}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||||
|
background: confirmedSurfaces.length > 0 ? "#1a1a1a" : "#e0dcd4",
|
||||||
|
color: confirmedSurfaces.length > 0 ? "#fff" : "#b5b0a6",
|
||||||
|
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
cursor: confirmedSurfaces.length > 0 ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Design →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
components/project-main/FreshIdeaMain.tsx
Normal file
274
components/project-main/FreshIdeaMain.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AtlasChat } from "@/components/AtlasChat";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const DISCOVERY_PHASES = [
|
||||||
|
"big_picture",
|
||||||
|
"users_personas",
|
||||||
|
"features_scope",
|
||||||
|
"business_model",
|
||||||
|
"screens_data",
|
||||||
|
"risks_questions",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maps discovery phases → the PRD sections they populate
|
||||||
|
const PRD_SECTIONS: { label: string; phase: string | null }[] = [
|
||||||
|
{ label: "Executive Summary", phase: "big_picture" },
|
||||||
|
{ label: "Problem Statement", phase: "big_picture" },
|
||||||
|
{ label: "Vision & Success Metrics", phase: "big_picture" },
|
||||||
|
{ label: "Users & Personas", phase: "users_personas" },
|
||||||
|
{ label: "User Flows", phase: "users_personas" },
|
||||||
|
{ label: "Feature Requirements", phase: "features_scope" },
|
||||||
|
{ label: "Screen Specs", phase: "features_scope" },
|
||||||
|
{ label: "Business Model", phase: "business_model" },
|
||||||
|
{ label: "Integrations & Dependencies", phase: "screens_data" },
|
||||||
|
{ label: "Non-Functional Reqs", phase: "features_scope" },
|
||||||
|
{ label: "Risks & Mitigations", phase: "risks_questions" },
|
||||||
|
{ label: "Open Questions", phase: "risks_questions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FreshIdeaMainProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params?.workspace as string;
|
||||||
|
|
||||||
|
const [savedPhaseIds, setSavedPhaseIds] = useState<Set<string>>(new Set());
|
||||||
|
const [allDone, setAllDone] = useState(false);
|
||||||
|
const [prdLoading, setPrdLoading] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [hasPrd, setHasPrd] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if PRD already exists on the project
|
||||||
|
fetch(`/api/projects/${projectId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (d.project?.prd) setHasPrd(true); })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
const poll = () => {
|
||||||
|
fetch(`/api/projects/${projectId}/save-phase`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const ids = new Set<string>((d.phases ?? []).map((p: { phase: string }) => p.phase));
|
||||||
|
setSavedPhaseIds(ids);
|
||||||
|
const done = DISCOVERY_PHASES.every(id => ids.has(id));
|
||||||
|
setAllDone(done);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 8_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleGeneratePRD = async () => {
|
||||||
|
if (prdLoading) return;
|
||||||
|
setPrdLoading(true);
|
||||||
|
try {
|
||||||
|
router.push(`/${workspace}/project/${projectId}/prd`);
|
||||||
|
} finally {
|
||||||
|
setPrdLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMVP = () => {
|
||||||
|
router.push(`/${workspace}/project/${projectId}/build`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
|
||||||
|
|
||||||
|
const completedSections = PRD_SECTIONS.filter(({ phase }) =>
|
||||||
|
phase === null ? allDone : savedPhaseIds.has(phase)
|
||||||
|
).length;
|
||||||
|
const totalSections = PRD_SECTIONS.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", flexDirection: "row", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* ── Left: Atlas chat ── */}
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
|
||||||
|
|
||||||
|
{/* PRD ready notice — replaces the decision banner once PRD is saved */}
|
||||||
|
{hasPrd && (
|
||||||
|
<div style={{
|
||||||
|
background: "#1a1a1a", padding: "10px 20px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
gap: 16, flexShrink: 0, borderBottom: "1px solid #333",
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: "0.8rem", color: "#e8e4dc", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
✦ PRD saved — you can keep refining here or view the full document.
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${workspace}/project/${projectId}/prd`}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px", borderRadius: 7,
|
||||||
|
background: "#fff", color: "#1a1a1a",
|
||||||
|
fontSize: "0.76rem", fontWeight: 600,
|
||||||
|
textDecoration: "none", flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View PRD →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decision banner — shown when all 6 phases are saved but PRD not yet generated */}
|
||||||
|
{allDone && !dismissed && !hasPrd && (
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)",
|
||||||
|
padding: "14px 20px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
gap: 16, flexShrink: 0, flexWrap: "wrap",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#fff", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 2 }}>
|
||||||
|
✦ Discovery complete — what's next?
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
All 6 phases captured. Generate your PRD or jump into Build.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleGeneratePRD}
|
||||||
|
disabled={prdLoading}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px", borderRadius: 7, border: "none",
|
||||||
|
background: "#fff", color: "#1a1a1a",
|
||||||
|
fontSize: "0.8rem", fontWeight: 700,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
|
transition: "opacity 0.12s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
|
>
|
||||||
|
{prdLoading ? "Navigating…" : "Generate PRD →"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMVP}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px", borderRadius: 7,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "transparent", color: "#fff",
|
||||||
|
fontSize: "0.8rem", fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Plan MVP →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "#888", fontSize: "1rem", padding: "4px 6px",
|
||||||
|
}}
|
||||||
|
title="Dismiss"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AtlasChat projectId={projectId} projectName={projectName} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: PRD section tracker ── */}
|
||||||
|
<div style={{
|
||||||
|
width: 240, flexShrink: 0,
|
||||||
|
background: "#faf8f5",
|
||||||
|
borderLeft: "1px solid #e8e4dc",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: "14px 16px 10px",
|
||||||
|
borderBottom: "1px solid #e8e4dc",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#1a1a1a", letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
|
||||||
|
PRD Sections
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div style={{ height: 3, background: "#e8e4dc", borderRadius: 99, overflow: "hidden" }}>
|
||||||
|
<div style={{
|
||||||
|
height: "100%", borderRadius: 99,
|
||||||
|
background: "#1a1a1a",
|
||||||
|
width: `${Math.round((completedSections / totalSections) * 100)}%`,
|
||||||
|
transition: "width 0.4s ease",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.68rem", color: "#a09a90", marginTop: 5 }}>
|
||||||
|
{completedSections} of {totalSections} sections complete
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section list */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0" }}>
|
||||||
|
{PRD_SECTIONS.map(({ label, phase }) => {
|
||||||
|
const isDone = phase === null
|
||||||
|
? allDone // non-functional reqs generated when all done
|
||||||
|
: savedPhaseIds.has(phase);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
display: "flex", alignItems: "flex-start", gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status dot */}
|
||||||
|
<div style={{
|
||||||
|
width: 8, height: 8, borderRadius: "50%", flexShrink: 0, marginTop: 4,
|
||||||
|
background: isDone ? "#1a1a1a" : "transparent",
|
||||||
|
border: isDone ? "none" : "1.5px solid #c8c4bc",
|
||||||
|
transition: "all 0.3s",
|
||||||
|
}} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.78rem", fontWeight: isDone ? 600 : 400,
|
||||||
|
color: isDone ? "#1a1a1a" : "#6b6560",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{!isDone && (
|
||||||
|
<div style={{ fontSize: "0.65rem", color: "#a09a90", marginTop: 2, lineHeight: 1.3 }}>
|
||||||
|
Complete this phase in Vibn
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer CTA */}
|
||||||
|
{allDone && (
|
||||||
|
<div style={{ padding: "12px 16px", borderTop: "1px solid #e8e4dc", flexShrink: 0 }}>
|
||||||
|
<Link
|
||||||
|
href={`/${workspace}/project/${projectId}/prd`}
|
||||||
|
style={{
|
||||||
|
display: "block", textAlign: "center",
|
||||||
|
padding: "9px 0", borderRadius: 7,
|
||||||
|
background: "#1a1a1a", color: "#fff",
|
||||||
|
fontSize: "0.78rem", fontWeight: 600,
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate PRD →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
components/project-main/MigrateMain.tsx
Normal file
353
components/project-main/MigrateMain.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
|
interface MigrateMainProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
|
||||||
|
analysisResult?: Record<string, unknown>;
|
||||||
|
migrationPlan?: string;
|
||||||
|
creationStage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stage = "input" | "auditing" | "review" | "planning" | "plan";
|
||||||
|
|
||||||
|
const HOSTING_OPTIONS = [
|
||||||
|
{ value: "", label: "Select hosting provider" },
|
||||||
|
{ value: "vercel", label: "Vercel" },
|
||||||
|
{ value: "aws", label: "AWS" },
|
||||||
|
{ value: "heroku", label: "Heroku" },
|
||||||
|
{ value: "digitalocean", label: "DigitalOcean" },
|
||||||
|
{ value: "gcp", label: "Google Cloud Platform" },
|
||||||
|
{ value: "azure", label: "Microsoft Azure" },
|
||||||
|
{ value: "railway", label: "Railway" },
|
||||||
|
{ value: "render", label: "Render" },
|
||||||
|
{ value: "netlify", label: "Netlify" },
|
||||||
|
{ value: "self-hosted", label: "Self-hosted / VPS" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function MarkdownRenderer({ md }: { md: string }) {
|
||||||
|
const lines = md.split('\n');
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem", color: "#1a1a1a", lineHeight: 1.7 }}>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
if (line.startsWith('## ')) return <h2 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, margin: "24px 0 10px", color: "#1a1a1a" }}>{line.slice(3)}</h2>;
|
||||||
|
if (line.startsWith('### ')) return <h3 key={i} style={{ fontSize: "0.88rem", fontWeight: 700, margin: "18px 0 6px", color: "#1a1a1a" }}>{line.slice(4)}</h3>;
|
||||||
|
if (line.startsWith('# ')) return <h1 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.5rem", fontWeight: 400, margin: "0 0 16px", color: "#1a1a1a" }}>{line.slice(2)}</h1>;
|
||||||
|
if (line.match(/^- \[ \] /)) return (
|
||||||
|
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
|
||||||
|
<input type="checkbox" style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
|
||||||
|
<span>{line.slice(6)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (line.match(/^- \[x\] /i)) return (
|
||||||
|
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
|
||||||
|
<input type="checkbox" defaultChecked style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
|
||||||
|
<span style={{ textDecoration: "line-through", color: "#a09a90" }}>{line.slice(6)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} style={{ paddingLeft: 16, marginBottom: 4 }}>• {line.slice(2)}</div>;
|
||||||
|
if (line.startsWith('---')) return <hr key={i} style={{ border: "none", borderTop: "1px solid #e8e4dc", margin: "16px 0" }} />;
|
||||||
|
if (!line.trim()) return <div key={i} style={{ height: "0.6em" }} />;
|
||||||
|
// Bold inline
|
||||||
|
const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) =>
|
||||||
|
seg.startsWith("**") && seg.endsWith("**")
|
||||||
|
? <strong key={j}>{seg.slice(2, -2)}</strong>
|
||||||
|
: <span key={j}>{seg}</span>
|
||||||
|
);
|
||||||
|
return <p key={i} style={{ margin: "0 0 4px" }}>{parts}</p>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MigrateMain({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
sourceData,
|
||||||
|
analysisResult: initialAnalysis,
|
||||||
|
migrationPlan: initialPlan,
|
||||||
|
creationStage,
|
||||||
|
}: MigrateMainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params?.workspace as string;
|
||||||
|
|
||||||
|
const getInitialStage = (): Stage => {
|
||||||
|
if (initialPlan) return "plan";
|
||||||
|
if (creationStage === "planning") return "planning";
|
||||||
|
if (creationStage === "review" || initialAnalysis) return "review";
|
||||||
|
if (sourceData?.repoUrl || sourceData?.liveUrl) return "auditing";
|
||||||
|
return "input";
|
||||||
|
};
|
||||||
|
|
||||||
|
const [stage, setStage] = useState<Stage>(getInitialStage);
|
||||||
|
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
|
||||||
|
const [liveUrl, setLiveUrl] = useState(sourceData?.liveUrl ?? "");
|
||||||
|
const [hosting, setHosting] = useState(sourceData?.hosting ?? "");
|
||||||
|
const [analysisResult, setAnalysisResult] = useState<Record<string, unknown> | null>(initialAnalysis ?? null);
|
||||||
|
const [migrationPlan, setMigrationPlan] = useState<string>(initialPlan ?? "");
|
||||||
|
const [progressStep, setProgressStep] = useState<string>("cloning");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Poll during audit
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage !== "auditing") return;
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
|
||||||
|
const data = await res.json();
|
||||||
|
setProgressStep(data.stage ?? "cloning");
|
||||||
|
if (data.stage === "done" && data.analysisResult) {
|
||||||
|
setAnalysisResult(data.analysisResult);
|
||||||
|
clearInterval(interval);
|
||||||
|
setStage("review");
|
||||||
|
}
|
||||||
|
} catch { /* keep polling */ }
|
||||||
|
}, 2500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
const startAudit = async () => {
|
||||||
|
setError(null);
|
||||||
|
setStage("auditing");
|
||||||
|
if (repoUrl) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${projectId}/analyze-repo`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ repoUrl, liveUrl, hosting }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to start audit");
|
||||||
|
setStage("input");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No repo — just use live URL fingerprinting via generate-migration-plan directly
|
||||||
|
setStage("review");
|
||||||
|
setAnalysisResult({ summary: `Live product at ${liveUrl}`, rows: [], suggestedSurfaces: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPlanning = async () => {
|
||||||
|
setStage("planning");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/generate-migration-plan`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ analysisResult, sourceData: { repoUrl, liveUrl, hosting } }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Planning failed");
|
||||||
|
setMigrationPlan(data.migrationPlan);
|
||||||
|
setStage("plan");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Planning failed");
|
||||||
|
setStage("review");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stage: input ──────────────────────────────────────────────────────────
|
||||||
|
if (stage === "input") {
|
||||||
|
const canProceed = repoUrl.trim().startsWith("http") || liveUrl.trim().startsWith("http");
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||||
|
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||||
|
Tell us about your product
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||||
|
{projectName} — Atlas will audit your current setup and build a safe migration plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||||
|
Repository URL (recommended)
|
||||||
|
</label>
|
||||||
|
<input type="text" value={repoUrl} onChange={e => setRepoUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/yourorg/your-repo"
|
||||||
|
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")} autoFocus
|
||||||
|
/>
|
||||||
|
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||||
|
Live URL (optional)
|
||||||
|
</label>
|
||||||
|
<input type="text" value={liveUrl} onChange={e => setLiveUrl(e.target.value)}
|
||||||
|
placeholder="https://yourproduct.com"
|
||||||
|
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||||
|
/>
|
||||||
|
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||||
|
Current hosting provider
|
||||||
|
</label>
|
||||||
|
<select value={hosting} onChange={e => setHosting(e.target.value)}
|
||||||
|
style={{ width: "100%", padding: "11px 14px", marginBottom: 20, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.88rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90", outline: "none", boxSizing: "border-box", appearance: "none" }}
|
||||||
|
>
|
||||||
|
{HOSTING_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||||
|
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Your existing product stays live throughout. Atlas duplicates, never deletes.
|
||||||
|
</div>
|
||||||
|
<button onClick={startAudit} disabled={!canProceed}
|
||||||
|
style={{ width: "100%", padding: "13px", borderRadius: 8, border: "none", background: canProceed ? "#1a1a1a" : "#e0dcd4", color: canProceed ? "#fff" : "#b5b0a6", fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: canProceed ? "pointer" : "not-allowed" }}
|
||||||
|
>
|
||||||
|
Start audit →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: auditing ───────────────────────────────────────────────────────
|
||||||
|
if (stage === "auditing") {
|
||||||
|
const steps = [
|
||||||
|
{ key: "cloning", label: "Cloning repository" },
|
||||||
|
{ key: "reading", label: "Reading configuration" },
|
||||||
|
{ key: "analyzing", label: "Auditing infrastructure" },
|
||||||
|
{ key: "done", label: "Audit complete" },
|
||||||
|
];
|
||||||
|
const currentIdx = steps.findIndex(s => s.key === progressStep);
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ textAlign: "center", maxWidth: 400 }}>
|
||||||
|
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 24px" }} />
|
||||||
|
<style>{`@keyframes vibn-mig-spin { to { transform:rotate(360deg); } }`}</style>
|
||||||
|
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>Auditing your product</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>This is non-destructive — your live product is untouched</p>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const done = i < currentIdx;
|
||||||
|
const active = i === currentIdx;
|
||||||
|
return (
|
||||||
|
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{ width: 22, height: 22, borderRadius: "50%", flexShrink: 0, background: done ? "#4a2a5a" : "#f6f4f0", border: active ? "2px solid #4a2a5a" : done ? "none" : "2px solid #e0dcd4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90" }}>
|
||||||
|
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4a2a5a", display: "block" }} /> : ""}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>{step.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: review ─────────────────────────────────────────────────────────
|
||||||
|
if (stage === "review") {
|
||||||
|
const rows = (analysisResult?.rows as Array<{ category: string; item: string; status: string; detail?: string }>) ?? [];
|
||||||
|
const summary = (analysisResult?.summary as string) ?? '';
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Audit complete</h2>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{summary || `${projectName} — review your current infrastructure below.`}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const colorMap = { found: { bg: "#f0fdf4", text: "#15803d", label: "Found" }, partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" }, missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" } };
|
||||||
|
const sc = colorMap[row.status as keyof typeof colorMap] ?? colorMap.found;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderTop: i > 0 ? "1px solid #f6f4f0" : "none" }}>
|
||||||
|
<div style={{ fontSize: "0.7rem", color: "#a09a90", width: 110, flexShrink: 0 }}>{row.category}</div>
|
||||||
|
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
|
||||||
|
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
|
||||||
|
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>{sc.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ background: "#1a1a1a", borderRadius: 12, padding: "22px 24px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to build the migration plan?</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Atlas will generate a phased migration doc with Mirror, Validate, Cutover, and Decommission phases.</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={startPlanning}
|
||||||
|
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#fff", color: "#1a1a1a", fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
|
>
|
||||||
|
Generate plan →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: planning ───────────────────────────────────────────────────────
|
||||||
|
if (stage === "planning") {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 20px" }} />
|
||||||
|
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>Generating migration plan…</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>Atlas is designing a safe, phased migration strategy</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: plan ───────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
{/* Non-destructive banner */}
|
||||||
|
<div style={{ background: "#4a2a5a12", borderBottom: "1px solid #4a2a5a30", padding: "12px 32px", display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: "1rem" }}>🛡️</span>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: 700, color: "#4a2a5a" }}>Non-destructive migration — </span>
|
||||||
|
<span style={{ fontSize: "0.8rem", color: "#6b6560" }}>your existing product stays live throughout every phase. Atlas duplicates, never deletes.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "32px 40px" }}>
|
||||||
|
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Migration Plan</h2>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{projectName} — four phased migration with rollback plan</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "28px 32px" }}>
|
||||||
|
<MarkdownRenderer md={migrationPlan} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 20, display: "flex", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/${workspace}/project/${projectId}/design`)}
|
||||||
|
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
Go to Design →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
style={{ padding: "11px 22px", borderRadius: 8, border: "1px solid #e0dcd4", background: "#fff", color: "#6b6560", fontSize: "0.85rem", fontWeight: 500, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
Print / Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,11 +40,11 @@ function TreeNodeItem({ node, level, selectedId, onSelect }: TreeNodeItemProps)
|
|||||||
|
|
||||||
switch (node.status) {
|
switch (node.status) {
|
||||||
case "built":
|
case "built":
|
||||||
return <CheckCircle2 className="h-3 w-3 text-green-600" />;
|
return <CheckCircle2 className="h-3 w-3 text-primary" />;
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
return <Clock className="h-3 w-3 text-blue-600" />;
|
return <Clock className="h-3 w-3 text-muted-foreground" />;
|
||||||
case "missing":
|
case "missing":
|
||||||
return <Circle className="h-3 w-3 text-gray-400" />;
|
return <Circle className="h-3 w-3 text-muted-foreground/50" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ function TreeNodeItem({ node, level, selectedId, onSelect }: TreeNodeItemProps)
|
|||||||
|
|
||||||
switch (node.status) {
|
switch (node.status) {
|
||||||
case "built":
|
case "built":
|
||||||
return "bg-green-50 hover:bg-green-100 border-l-2 border-l-green-500";
|
return "bg-secondary hover:bg-muted border-l-2 border-l-primary";
|
||||||
case "in_progress":
|
case "in_progress":
|
||||||
return "bg-blue-50 hover:bg-blue-100 border-l-2 border-l-blue-500";
|
return "bg-muted/40 hover:bg-muted border-l-2 border-l-border";
|
||||||
case "missing":
|
case "missing":
|
||||||
return "hover:bg-gray-100 border-l-2 border-l-transparent";
|
return "hover:bg-muted/30 border-l-2 border-l-transparent";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,12 +113,12 @@ function TreeNodeItem({ node, level, selectedId, onSelect }: TreeNodeItemProps)
|
|||||||
{node.metadata && (
|
{node.metadata && (
|
||||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{node.metadata.sessionsCount && node.metadata.sessionsCount > 0 && (
|
{node.metadata.sessionsCount && node.metadata.sessionsCount > 0 && (
|
||||||
<span className="text-[10px] text-blue-600 font-medium bg-blue-100 px-1.5 py-0.5 rounded">
|
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||||
{node.metadata.sessionsCount}s
|
{node.metadata.sessionsCount}s
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{node.metadata.commitsCount && node.metadata.commitsCount > 0 && (
|
{node.metadata.commitsCount && node.metadata.commitsCount > 0 && (
|
||||||
<span className="text-[10px] text-green-600 font-medium bg-green-100 px-1.5 py-0.5 rounded">
|
<span className="text-[10px] font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
{node.metadata.commitsCount}c
|
{node.metadata.commitsCount}c
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
1060
components/workspace/WorkspaceKeysPanel.tsx
Normal file
1060
components/workspace/WorkspaceKeysPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,50 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "=== Syncing NextAuth schema ==="
|
# Do NOT run `prisma db push` here. The Prisma schema only lists NextAuth tables; db push
|
||||||
# NOTE: Do NOT use --accept-data-loss — it drops tables not in the Prisma schema,
|
# would try to DROP every other table (fs_*, agent_*, atlas_*, etc.) to match the schema.
|
||||||
# which destroys fs_users, fs_projects etc. Use --skip-generate only.
|
# NextAuth tables are created below with IF NOT EXISTS (same DDL as /api/admin/migrate).
|
||||||
npx prisma db push --skip-generate || echo "Prisma push failed (non-fatal — tables may already be correct)"
|
|
||||||
|
|
||||||
echo "=== Ensuring app tables exist ==="
|
echo "=== Ensuring database tables exist (idempotent SQL) ==="
|
||||||
node -e "
|
node -e "
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
pool.query(\`
|
pool.query(\`
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
email_verified TIMESTAMPTZ,
|
||||||
|
image TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_account_id TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
access_token TEXT,
|
||||||
|
expires_at INTEGER,
|
||||||
|
token_type TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
id_token TEXT,
|
||||||
|
session_state TEXT,
|
||||||
|
UNIQUE (provider, provider_account_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_token TEXT UNIQUE NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS verification_tokens (
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
expires TIMESTAMPTZ NOT NULL,
|
||||||
|
UNIQUE (identifier, token)
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS fs_users (
|
CREATE TABLE IF NOT EXISTS fs_users (
|
||||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
user_id TEXT,
|
user_id TEXT,
|
||||||
@@ -53,6 +86,37 @@ pool.query(\`
|
|||||||
messages JSONB NOT NULL DEFAULT '[]',
|
messages JSONB NOT NULL DEFAULT '[]',
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
app_name TEXT NOT NULL,
|
||||||
|
app_path TEXT NOT NULL,
|
||||||
|
task TEXT NOT NULL,
|
||||||
|
plan JSONB,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
output JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
changed_files JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
error TEXT,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS agent_sessions_project_idx ON agent_sessions (project_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions (status);
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_session_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
seq INT NOT NULL,
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
client_event_id UUID UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(session_id, seq)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx ON agent_session_events (session_id, seq);
|
||||||
\`).then(() => { console.log('App tables ready'); pool.end(); }).catch(e => { console.error('Table init error:', e.message); pool.end(); });
|
\`).then(() => { console.log('App tables ready'); pool.end(); }).catch(e => { console.error('Table init error:', e.message); pool.end(); });
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,85 @@
|
|||||||
import { NextAuthOptions } from "next-auth";
|
import { NextAuthOptions } from "next-auth";
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import GoogleProvider from "next-auth/providers/google";
|
import GoogleProvider from "next-auth/providers/google";
|
||||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { query } from "@/lib/db-postgres";
|
import { query } from "@/lib/db-postgres";
|
||||||
|
import { ensureWorkspaceForUser } from "@/lib/workspaces";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const nextAuthUrl = (process.env.NEXTAUTH_URL ?? "").trim();
|
||||||
|
const isLocalNextAuth =
|
||||||
|
nextAuthUrl.startsWith("http://localhost") ||
|
||||||
|
nextAuthUrl.startsWith("http://127.0.0.1") ||
|
||||||
|
(process.env.NODE_ENV === "development" && !nextAuthUrl);
|
||||||
|
|
||||||
|
/** Set in .env.local (server + client): one email for local dev bypass. */
|
||||||
|
const devLocalEmail = (process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? "").trim();
|
||||||
|
const devLocalSecret = (process.env.DEV_LOCAL_AUTH_SECRET ?? "").trim();
|
||||||
|
const devLocalAuthEnabled =
|
||||||
|
process.env.NODE_ENV === "development" && devLocalEmail.length > 0;
|
||||||
|
|
||||||
|
function isLocalhostHost(host: string): boolean {
|
||||||
|
const h = host.split(":")[0]?.toLowerCase() ?? "";
|
||||||
|
return (
|
||||||
|
h === "localhost" ||
|
||||||
|
h === "127.0.0.1" ||
|
||||||
|
h === "[::1]" ||
|
||||||
|
h === "::1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
|
debug: process.env.NODE_ENV === "development",
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
providers: [
|
providers: [
|
||||||
|
...(devLocalAuthEnabled
|
||||||
|
? [
|
||||||
|
CredentialsProvider({
|
||||||
|
id: "dev-local",
|
||||||
|
name: "Local dev",
|
||||||
|
credentials: {
|
||||||
|
password: { label: "Dev secret", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials, req) {
|
||||||
|
const headers = (req as { headers?: Headers } | undefined)?.headers;
|
||||||
|
const host =
|
||||||
|
headers && typeof headers.get === "function"
|
||||||
|
? (headers.get("host") ?? "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (devLocalSecret) {
|
||||||
|
if ((credentials?.password ?? "") !== devLocalSecret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (!isLocalhostHost(host)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name =
|
||||||
|
(process.env.DEV_LOCAL_AUTH_NAME ?? "").trim() || "Local dev";
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: devLocalEmail },
|
||||||
|
create: {
|
||||||
|
email: devLocalEmail,
|
||||||
|
name,
|
||||||
|
emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
update: { name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
GoogleProvider({
|
GoogleProvider({
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||||
@@ -20,8 +91,8 @@ export const authOptions: NextAuthOptions = {
|
|||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async session({ session, user }) {
|
async session({ session, user }) {
|
||||||
if (session.user) {
|
if (session.user && "id" in user && user.id) {
|
||||||
session.user.id = user.id;
|
(session.user as { id: string }).id = user.id;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
@@ -42,16 +113,34 @@ export const authOptions: NextAuthOptions = {
|
|||||||
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||||
[user.email]
|
[user.email]
|
||||||
);
|
);
|
||||||
|
let fsUserId: string;
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
await query(
|
const inserted = await query<{ id: string }>(
|
||||||
`INSERT INTO fs_users (id, user_id, data) VALUES (gen_random_uuid()::text, $1, $2::jsonb)`,
|
`INSERT INTO fs_users (id, user_id, data)
|
||||||
|
VALUES (gen_random_uuid()::text, $1, $2::jsonb)
|
||||||
|
RETURNING id`,
|
||||||
[user.id, data]
|
[user.id, data]
|
||||||
);
|
);
|
||||||
|
fsUserId = inserted[0].id;
|
||||||
} else {
|
} else {
|
||||||
await query(
|
await query(
|
||||||
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
|
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
|
||||||
[user.id, data, existing[0].id]
|
[user.id, data, existing[0].id]
|
||||||
);
|
);
|
||||||
|
fsUserId = existing[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a Vibn workspace exists for this user. We DO NOT
|
||||||
|
// provision Coolify/Gitea here — that happens lazily on first
|
||||||
|
// project create so signin stays fast and resilient to outages.
|
||||||
|
try {
|
||||||
|
await ensureWorkspaceForUser({
|
||||||
|
userId: fsUserId,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.name ?? null,
|
||||||
|
});
|
||||||
|
} catch (wsErr) {
|
||||||
|
console.error("[signIn] Failed to ensure workspace:", wsErr);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[signIn] Failed to upsert fs_user:", e);
|
console.error("[signIn] Failed to upsert fs_user:", e);
|
||||||
@@ -66,13 +155,14 @@ export const authOptions: NextAuthOptions = {
|
|||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
cookies: {
|
cookies: {
|
||||||
sessionToken: {
|
sessionToken: {
|
||||||
name: `__Secure-next-auth.session-token`,
|
// __Secure- prefix requires Secure; localhost HTTP needs plain name + secure: false
|
||||||
|
name: isLocalNextAuth ? "next-auth.session-token" : "__Secure-next-auth.session-token",
|
||||||
options: {
|
options: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: true,
|
secure: !isLocalNextAuth,
|
||||||
domain: ".vibnai.com", // share across all subdomains (theia.vibnai.com, etc.)
|
...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
46
lib/auth/secret-box.ts
Normal file
46
lib/auth/secret-box.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Tiny AES-256-GCM wrapper for storing secrets (Gitea bot PATs, etc.)
|
||||||
|
* at rest in Postgres. Layout: base64( iv(12) || ciphertext || authTag(16) ).
|
||||||
|
*
|
||||||
|
* The key comes from VIBN_SECRETS_KEY. It must be base64 (32 bytes) OR
|
||||||
|
* any string we hash down to 32 bytes. We hash with SHA-256 so both
|
||||||
|
* forms work — rotating just means generating a new env value and
|
||||||
|
* re-provisioning workspaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const raw = process.env.VIBN_SECRETS_KEY;
|
||||||
|
if (!raw || raw.length < 16) {
|
||||||
|
throw new Error(
|
||||||
|
'VIBN_SECRETS_KEY env var is required (>=16 chars) to encrypt workspace secrets'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Normalize any input into a 32-byte key via SHA-256.
|
||||||
|
return createHash('sha256').update(raw).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSecret(plain: string): string {
|
||||||
|
const key = getKey();
|
||||||
|
const iv = randomBytes(IV_BYTES);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, enc, tag]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSecret(payload: string): string {
|
||||||
|
const buf = Buffer.from(payload, 'base64');
|
||||||
|
if (buf.length < IV_BYTES + 16) throw new Error('secret-box: payload too short');
|
||||||
|
const iv = buf.subarray(0, IV_BYTES);
|
||||||
|
const tag = buf.subarray(buf.length - 16);
|
||||||
|
const ciphertext = buf.subarray(IV_BYTES, buf.length - 16);
|
||||||
|
const key = getKey();
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
return dec.toString('utf8');
|
||||||
|
}
|
||||||
42
lib/auth/session-server.ts
Normal file
42
lib/auth/session-server.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
|
||||||
|
/** True when API routes should accept requests as the dev bypass user (next dev only). */
|
||||||
|
export function isProjectAuthBypassEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
process.env.NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH === "true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Email used for ownership checks when bypass is on; must match fs_users.data->>'email' for your projects. */
|
||||||
|
export function devBypassSessionEmail(): string | null {
|
||||||
|
const email = (
|
||||||
|
process.env.DEV_BYPASS_USER_EMAIL ||
|
||||||
|
process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ||
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
return email || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for getServerSession(authOptions) on API routes.
|
||||||
|
* In development with NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH=true, returns a synthetic session
|
||||||
|
* so you can use the app without Google/cookies when DATABASE_URL works.
|
||||||
|
*/
|
||||||
|
export async function authSession(): Promise<Session | null> {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (session?.user?.email) return session;
|
||||||
|
if (!isProjectAuthBypassEnabled()) return session;
|
||||||
|
const email = devBypassSessionEmail();
|
||||||
|
if (!email) return session;
|
||||||
|
return {
|
||||||
|
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
user: {
|
||||||
|
id: "dev-bypass",
|
||||||
|
email,
|
||||||
|
name: "Dev bypass",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
247
lib/auth/workspace-auth.ts
Normal file
247
lib/auth/workspace-auth.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Workspace-scoped auth.
|
||||||
|
*
|
||||||
|
* Two principal types are accepted on `/api/...` routes:
|
||||||
|
* 1. NextAuth session (browser users) — `authSession()`
|
||||||
|
* 2. Per-workspace bearer API key (`Authorization: Bearer vibn_sk_...`)
|
||||||
|
*
|
||||||
|
* Either way we resolve a `WorkspacePrincipal` that is scoped to one
|
||||||
|
* workspace. Routes that touch Coolify/Gitea/Theia must call
|
||||||
|
* `requireWorkspacePrincipal()` and use `principal.workspace` to fetch
|
||||||
|
* the right Coolify Project / Gitea org.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from '@/lib/auth/session-server';
|
||||||
|
import { query, queryOne } from '@/lib/db-postgres';
|
||||||
|
import {
|
||||||
|
type VibnWorkspace,
|
||||||
|
getWorkspaceById,
|
||||||
|
getWorkspaceBySlug,
|
||||||
|
getWorkspaceByOwner,
|
||||||
|
userHasWorkspaceAccess,
|
||||||
|
} from '@/lib/workspaces';
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'vibn_sk_';
|
||||||
|
const KEY_RANDOM_BYTES = 32; // 256-bit secret
|
||||||
|
|
||||||
|
export interface WorkspacePrincipal {
|
||||||
|
/** "session" = browser user; "api_key" = automated/AI client */
|
||||||
|
source: 'session' | 'api_key';
|
||||||
|
workspace: VibnWorkspace;
|
||||||
|
/** fs_users.id of the human ultimately responsible for this request */
|
||||||
|
userId: string;
|
||||||
|
/** When source = "api_key", which key id was used */
|
||||||
|
apiKeyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the workspace principal from either a NextAuth session or a
|
||||||
|
* `Bearer vibn_sk_...` token. Optional `targetSlug` enforces that the
|
||||||
|
* principal is for the requested workspace.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - principal on success
|
||||||
|
* - NextResponse on failure (401 / 403) — return it directly from the route
|
||||||
|
*/
|
||||||
|
export async function requireWorkspacePrincipal(
|
||||||
|
request: Request,
|
||||||
|
opts: { targetSlug?: string; targetId?: string } = {},
|
||||||
|
): Promise<WorkspacePrincipal | NextResponse> {
|
||||||
|
const apiKey = extractApiKey(request);
|
||||||
|
if (apiKey) {
|
||||||
|
const principal = await resolveApiKey(apiKey);
|
||||||
|
if (!principal) {
|
||||||
|
return NextResponse.json({ error: 'Invalid or revoked API key' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!matchesTarget(principal.workspace, opts)) {
|
||||||
|
return NextResponse.json({ error: 'API key not authorized for this workspace' }, { status: 403 });
|
||||||
|
}
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall through to NextAuth session
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const userRow = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||||
|
[session.user.email]
|
||||||
|
);
|
||||||
|
if (!userRow) {
|
||||||
|
return NextResponse.json({ error: 'No fs_users row for session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace: VibnWorkspace | null = null;
|
||||||
|
if (opts.targetSlug) workspace = await getWorkspaceBySlug(opts.targetSlug);
|
||||||
|
else if (opts.targetId) workspace = await getWorkspaceById(opts.targetId);
|
||||||
|
else workspace = await getWorkspaceByOwner(userRow.id);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await userHasWorkspaceAccess(userRow.id, workspace.id);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: 'session', workspace, userId: userRow.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesTarget(
|
||||||
|
workspace: VibnWorkspace,
|
||||||
|
opts: { targetSlug?: string; targetId?: string }
|
||||||
|
): boolean {
|
||||||
|
if (opts.targetSlug && workspace.slug !== opts.targetSlug) return false;
|
||||||
|
if (opts.targetId && workspace.id !== opts.targetId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiKey(request: Request): string | null {
|
||||||
|
const auth = request.headers.get('authorization');
|
||||||
|
if (!auth) return null;
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const token = m[1].trim();
|
||||||
|
if (!token.startsWith(KEY_PREFIX)) return null;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveApiKey(token: string): Promise<WorkspacePrincipal | null> {
|
||||||
|
const hash = hashKey(token);
|
||||||
|
const row = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
created_by: string;
|
||||||
|
revoked_at: Date | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, workspace_id, created_by, revoked_at
|
||||||
|
FROM vibn_workspace_api_keys
|
||||||
|
WHERE key_hash = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[hash]
|
||||||
|
);
|
||||||
|
if (!row || row.revoked_at) return null;
|
||||||
|
|
||||||
|
const workspace = await getWorkspaceById(row.workspace_id);
|
||||||
|
if (!workspace) return null;
|
||||||
|
|
||||||
|
// Touch last_used_at without blocking
|
||||||
|
void query(
|
||||||
|
`UPDATE vibn_workspace_api_keys SET last_used_at = now() WHERE id = $1`,
|
||||||
|
[row.id]
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'api_key',
|
||||||
|
workspace,
|
||||||
|
userId: row.created_by,
|
||||||
|
apiKeyId: row.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Key minting + hashing
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MintedApiKey {
|
||||||
|
id: string;
|
||||||
|
/** Full plaintext key — shown once at creation, never stored. */
|
||||||
|
token: string;
|
||||||
|
prefix: string;
|
||||||
|
workspace_id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mintWorkspaceApiKey(opts: {
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
scopes?: string[];
|
||||||
|
}): Promise<MintedApiKey> {
|
||||||
|
const random = randomBytes(KEY_RANDOM_BYTES).toString('base64url');
|
||||||
|
const token = `${KEY_PREFIX}${random}`;
|
||||||
|
const hash = hashKey(token);
|
||||||
|
const prefix = token.slice(0, 12); // e.g. "vibn_sk_AbCd"
|
||||||
|
|
||||||
|
const inserted = await query<{ id: string; created_at: Date }>(
|
||||||
|
`INSERT INTO vibn_workspace_api_keys
|
||||||
|
(workspace_id, name, key_prefix, key_hash, scopes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
[
|
||||||
|
opts.workspaceId,
|
||||||
|
opts.name,
|
||||||
|
prefix,
|
||||||
|
hash,
|
||||||
|
JSON.stringify(opts.scopes ?? ['workspace:*']),
|
||||||
|
opts.createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: inserted[0].id,
|
||||||
|
token,
|
||||||
|
prefix,
|
||||||
|
workspace_id: opts.workspaceId,
|
||||||
|
name: opts.name,
|
||||||
|
created_at: inserted[0].created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceApiKeys(workspaceId: string): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
scopes: string[];
|
||||||
|
created_by: string;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
revoked_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
}>> {
|
||||||
|
const rows = await query<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
scopes: string[];
|
||||||
|
created_by: string;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
revoked_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name, key_prefix, scopes, created_by, last_used_at, revoked_at, created_at
|
||||||
|
FROM vibn_workspace_api_keys
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[workspaceId]
|
||||||
|
);
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
prefix: r.key_prefix,
|
||||||
|
scopes: r.scopes,
|
||||||
|
created_by: r.created_by,
|
||||||
|
last_used_at: r.last_used_at,
|
||||||
|
revoked_at: r.revoked_at,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeWorkspaceApiKey(workspaceId: string, keyId: string): Promise<boolean> {
|
||||||
|
const updated = await query<{ id: string }>(
|
||||||
|
`UPDATE vibn_workspace_api_keys
|
||||||
|
SET revoked_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL
|
||||||
|
RETURNING id`,
|
||||||
|
[keyId, workspaceId]
|
||||||
|
);
|
||||||
|
return updated.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashKey(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user