Compare commits
74 Commits
57a4f358d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 24812df89b | |||
| 53b098ce6a | |||
| 69eb3b989c | |||
| 7eaf1ca4f1 | |||
| 5e4cce55de | |||
| 4eff014ae6 |
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}
|
||||||
|
|||||||
133
app/[workspace]/project/[projectId]/analytics/page.tsx
Normal file
133
app/[workspace]/project/[projectId]/analytics/page.tsx
Normal file
@@ -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]/assist/page.tsx
Normal file
133
app/[workspace]/project/[projectId]/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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",
|
||||||
@@ -535,11 +536,20 @@ function SurfaceSection({
|
|||||||
const activeTheme = surface.themes.find(t => t.id === previewId);
|
const activeTheme = surface.themes.find(t => t.id === previewId);
|
||||||
const ScaffoldComponent = previewId ? SCAFFOLD_REGISTRY[surface.id]?.[previewId] : null;
|
const ScaffoldComponent = previewId ? SCAFFOLD_REGISTRY[surface.id]?.[previewId] : null;
|
||||||
|
|
||||||
const availableColorThemes: ThemeColor[] = previewId
|
const allColorThemes: ThemeColor[] = previewId
|
||||||
? (THEME_REGISTRY[surface.id]?.[previewId] ?? [])
|
? (THEME_REGISTRY[surface.id]?.[previewId] ?? [])
|
||||||
: [];
|
: [];
|
||||||
const [selectedColorTheme, setSelectedColorTheme] = useState<ThemeColor | null>(null);
|
const [selectedColorTheme, setSelectedColorTheme] = useState<ThemeColor | null>(null);
|
||||||
const activeColorTheme = selectedColorTheme ?? availableColorThemes[0] ?? null;
|
|
||||||
|
// Filter palettes to match the current mode; untagged themes (themeMode undefined) show in any mode
|
||||||
|
const availableColorThemes = allColorThemes.filter(
|
||||||
|
ct => !ct.themeMode || ct.themeMode === designConfig.mode
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the selected palette is no longer valid for the new mode, clear it so the
|
||||||
|
// first compatible one is auto-selected below
|
||||||
|
const selectedIsCompatible = !selectedColorTheme || availableColorThemes.some(ct => ct.id === selectedColorTheme.id);
|
||||||
|
const activeColorTheme = (selectedIsCompatible ? selectedColorTheme : null) ?? availableColorThemes[0] ?? null;
|
||||||
|
|
||||||
// Design config — per-library style choices (mode, background, nav, header, sections, font)
|
// Design config — per-library style choices (mode, background, nav, header, sections, font)
|
||||||
const defaultForLibrary = previewId ? LIBRARY_STYLE_OPTIONS[previewId]?.defaultConfig : undefined;
|
const defaultForLibrary = previewId ? LIBRARY_STYLE_OPTIONS[previewId]?.defaultConfig : undefined;
|
||||||
@@ -612,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>
|
||||||
)
|
)
|
||||||
@@ -637,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")}
|
||||||
@@ -650,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",
|
||||||
}}
|
}}
|
||||||
@@ -660,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>
|
||||||
@@ -669,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;
|
||||||
@@ -683,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",
|
||||||
@@ -710,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
|
||||||
@@ -730,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>
|
||||||
)}
|
)}
|
||||||
@@ -791,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
|
||||||
@@ -853,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 }}>
|
||||||
@@ -888,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"); }}
|
||||||
@@ -927,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",
|
||||||
}}
|
}}
|
||||||
@@ -937,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>
|
||||||
)}
|
)}
|
||||||
@@ -949,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>>({});
|
||||||
@@ -970,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;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1041,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>
|
||||||
@@ -1057,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" }}>
|
||||||
@@ -1080,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"); }}
|
||||||
@@ -1098,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")}
|
||||||
>
|
>
|
||||||
@@ -1128,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]/growth/page.tsx
Normal file
144
app/[workspace]/project/[projectId]/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
app/[workspace]/project/[projectId]/infrastructure/page.tsx
Normal file
353
app/[workspace]/project/[projectId]/infrastructure/page.tsx
Normal file
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,71 +3,33 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { AtlasChat } from "@/components/AtlasChat";
|
|
||||||
import { OrchestratorChat } from "@/components/OrchestratorChat";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||||
function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) {
|
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||||
const [show, setShow] = useState(false);
|
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||||
const url = typeof window !== "undefined"
|
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||||
? `${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 {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
|
name?: string;
|
||||||
stage?: "discovery" | "architecture" | "building" | "active";
|
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() {
|
export default function ProjectOverviewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const projectId = params.projectId as string;
|
const projectId = params.projectId as string;
|
||||||
const workspace = params.workspace as string;
|
|
||||||
const { status: authStatus } = useSession();
|
const { status: authStatus } = useSession();
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -78,15 +40,15 @@ export default function ProjectOverviewPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch(`/api/projects/${projectId}`)
|
fetch(`/api/projects/${projectId}`)
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((d) => setProject(d.project))
|
.then(d => setProject(d.project))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [authStatus, projectId]);
|
}, [authStatus, projectId]);
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
@@ -94,27 +56,56 @@ export default function ProjectOverviewPage() {
|
|||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
<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.
|
Project not found.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const projectName = project.productName || project.name || "Untitled";
|
||||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
const mode = project.creationMode ?? "fresh";
|
||||||
{/* 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
|
if (mode === "chat-import") {
|
||||||
|
return (
|
||||||
|
<ChatImportMain
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
projectName={project.productName}
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
// Maps each PRD section to the discovery phase that populates it
|
// Maps each PRD section to the discovery phase that populates it
|
||||||
const PRD_SECTIONS = [
|
const PRD_SECTIONS = [
|
||||||
@@ -14,7 +14,7 @@ const PRD_SECTIONS = [
|
|||||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
|
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||||
];
|
];
|
||||||
@@ -47,7 +47,7 @@ function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
|||||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||||
background: "none", border: "none", cursor: "pointer",
|
background: "none", border: "none", cursor: "pointer",
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||||
@@ -78,12 +78,120 @@ function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function PRDPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const projectId = params.projectId as string;
|
const projectId = params.projectId as string;
|
||||||
|
const workspace = params.workspace as string;
|
||||||
const [prd, setPrd] = useState<string | null>(null);
|
const [prd, setPrd] = useState<string | null>(null);
|
||||||
|
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -91,11 +199,30 @@ export default function PRDPage() {
|
|||||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||||
]).then(([projectData, phaseData]) => {
|
]).then(([projectData, phaseData]) => {
|
||||||
setPrd(projectData?.project?.prd ?? null);
|
setPrd(projectData?.project?.prd ?? null);
|
||||||
|
setArchitecture(projectData?.project?.architecture ?? null);
|
||||||
setSavedPhases(phaseData?.phases ?? []);
|
setSavedPhases(phaseData?.phases ?? []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [projectId]);
|
}, [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 phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||||
|
|
||||||
@@ -110,19 +237,102 @@ export default function PRDPage() {
|
|||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "prd" as const, label: "PRD", available: true },
|
||||||
|
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
{prd ? (
|
|
||||||
/* ── Finalized PRD view ── */
|
{/* 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={{ maxWidth: 760 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
<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 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||||
Product Requirements
|
Product Requirements
|
||||||
</h3>
|
</h3>
|
||||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||||
@@ -133,12 +343,15 @@ export default function PRDPage() {
|
|||||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||||
padding: "28px 32px", lineHeight: 1.8,
|
padding: "28px 32px", lineHeight: 1.8,
|
||||||
fontSize: "0.88rem", color: "#2a2824",
|
fontSize: "0.88rem", color: "#2a2824",
|
||||||
whiteSpace: "pre-wrap", fontFamily: "Outfit, sans-serif",
|
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
{prd}
|
{prd}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||||
|
{activeTab === "prd" && !prd && (
|
||||||
/* ── Section progress view ── */
|
/* ── Section progress view ── */
|
||||||
<div style={{ maxWidth: 680 }}>
|
<div style={{ maxWidth: 680 }}>
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
@@ -227,7 +440,7 @@ export default function PRDPage() {
|
|||||||
{!s.isDone && (
|
{!s.isDone && (
|
||||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||||
{s.phaseId
|
{s.phaseId
|
||||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Atlas`
|
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||||
: "Will be generated when PRD is finalized"}
|
: "Will be generated when PRD is finalized"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -236,7 +449,7 @@ export default function PRDPage() {
|
|||||||
|
|
||||||
{doneCount === 0 && (
|
{doneCount === 0 && (
|
||||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||||
Continue chatting with Atlas — saved phases will appear here automatically.
|
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||||
</p>
|
</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 }}>
|
||||||
|
|||||||
@@ -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 ?? '');
|
||||||
|
setLoading(false);
|
||||||
const loadSettings = async () => {
|
}, [session, status]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
211
app/api/admin/migrate/route.ts
Normal file
211
app/api/admin/migrate/route.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* 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)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,6 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from '@/lib/auth/authOptions';
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
async function verifyOwnership(projectId: string, email: string): Promise<boolean> {
|
|
||||||
const rows = await query<{ id: string }>(
|
|
||||||
`SELECT p.id 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]
|
|
||||||
);
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET — returns surfaces[] and surfaceThemes{} for the project.
|
* GET — returns surfaces[] and surfaceThemes{} for the project.
|
||||||
*/
|
*/
|
||||||
@@ -29,7 +18,7 @@ export async function GET(
|
|||||||
const rows = await query<{ data: Record<string, unknown> }>(
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
`SELECT p.data FROM fs_projects p
|
`SELECT p.data FROM fs_projects p
|
||||||
JOIN fs_users u ON u.id = p.user_id
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
WHERE p.id = $1 AND u.data->>'email' = $2
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[projectId, session.user.email]
|
[projectId, session.user.email]
|
||||||
);
|
);
|
||||||
@@ -63,36 +52,54 @@ export async function PATCH(
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const owned = await verifyOwnership(projectId, session.user.email);
|
// Step 1: read current data — explicit ::text casts on every param
|
||||||
if (!owned) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
let rows: { data: Record<string, unknown> }[];
|
||||||
|
try {
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
} catch (selErr) {
|
||||||
|
console.error('[design-surfaces PATCH] SELECT failed:', selErr);
|
||||||
|
return NextResponse.json({ error: 'Internal error (select)' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const current = rows[0].data ?? {};
|
||||||
const body = await req.json() as
|
const body = await req.json() as
|
||||||
| { surfaces: string[] }
|
| { surfaces: string[] }
|
||||||
| { surface: string; theme: string };
|
| { surface: string; theme: string };
|
||||||
|
|
||||||
|
let updated: Record<string, unknown>;
|
||||||
|
|
||||||
if ('surfaces' in body) {
|
if ('surfaces' in body) {
|
||||||
await query(
|
updated = { ...current, surfaces: body.surfaces, updatedAt: new Date().toISOString() };
|
||||||
`UPDATE fs_projects
|
|
||||||
SET data = data || jsonb_build_object('surfaces', $2::jsonb),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1`,
|
|
||||||
[projectId, JSON.stringify(body.surfaces)]
|
|
||||||
);
|
|
||||||
} else if ('surface' in body && 'theme' in body) {
|
} else if ('surface' in body && 'theme' in body) {
|
||||||
await query(
|
const existing = (current.surfaceThemes ?? {}) as Record<string, string>;
|
||||||
`UPDATE fs_projects
|
updated = {
|
||||||
SET data = data || jsonb_build_object(
|
...current,
|
||||||
'surfaceThemes',
|
surfaceThemes: { ...existing, [body.surface]: body.theme },
|
||||||
COALESCE(data->'surfaceThemes', '{}'::jsonb) || jsonb_build_object($2, $3)
|
updatedAt: new Date().toISOString(),
|
||||||
),
|
};
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1`,
|
|
||||||
[projectId, body.surface, body.theme]
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: write back — explicit ::text cast on id param, ::jsonb on data param
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $1::jsonb WHERE id = $2::text`,
|
||||||
|
[JSON.stringify(updated), projectId]
|
||||||
|
);
|
||||||
|
} catch (updErr) {
|
||||||
|
console.error('[design-surfaces PATCH] UPDATE failed:', updErr);
|
||||||
|
return NextResponse.json({ error: 'Internal error (update)' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[design-surfaces PATCH]', err);
|
console.error('[design-surfaces PATCH]', err);
|
||||||
|
|||||||
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.
|
||||||
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 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,32 +182,29 @@ 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}`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const app of provisionedApps) {
|
if (giteaCloneUrl && coolifyProjectUuid) {
|
||||||
try {
|
for (const app of provisionedApps) {
|
||||||
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
|
try {
|
||||||
const service = await createMonorepoAppService({
|
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
|
||||||
projectUuid: coolifyProject.uuid,
|
const service = await createMonorepoAppService({
|
||||||
appName: app.name,
|
projectUuid: coolifyProjectUuid,
|
||||||
gitRepo: giteaCloneUrl,
|
appName: `${slug}-${app.name}`, // unique within the workspace's Coolify Project
|
||||||
domain,
|
gitRepo: giteaCloneUrl,
|
||||||
});
|
domain,
|
||||||
app.coolifyServiceUuid = service.uuid;
|
});
|
||||||
app.domain = domain;
|
app.coolifyServiceUuid = service.uuid;
|
||||||
console.log(`[API] Coolify service created: ${app.name} → ${domain}`);
|
app.domain = domain;
|
||||||
} catch (appErr) {
|
console.log(`[API] Coolify service created: ${app.name} → ${domain}`);
|
||||||
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
|
} catch (appErr) {
|
||||||
}
|
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
|
||||||
}
|
}
|
||||||
} catch (coolifyErr) {
|
|
||||||
console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr);
|
|
||||||
}
|
}
|
||||||
|
} else if (!coolifyProjectUuid) {
|
||||||
|
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);
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
33
app/api/workspaces/[slug]/route.ts
Normal file
33
app/api/workspaces/[slug]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
provisionStatus: w.provision_status,
|
||||||
|
provisionError: w.provision_error,
|
||||||
|
createdAt: w.created_at,
|
||||||
|
updatedAt: w.updated_at,
|
||||||
|
principal: {
|
||||||
|
source: principal.source,
|
||||||
|
apiKeyId: principal.apiKeyId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
67
app/api/workspaces/route.ts
Normal file
67
app/api/workspaces/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -515,16 +515,28 @@ export function MarketingAceternity({ themeColor, config }: { themeColor?: Theme
|
|||||||
const border= isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.1)";
|
const border= isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.1)";
|
||||||
|
|
||||||
const BgLayer = () => {
|
const BgLayer = () => {
|
||||||
if (bgStyle === "gradient") return (
|
if (bgStyle === "gradient") {
|
||||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0, background: "rgb(8,0,20)" }}>
|
if (!isDark) return (
|
||||||
<div style={{ position: "absolute", inset: 0, filter: "blur(55px)", mixBlendMode: "hard-light" }}>
|
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0, background: "rgb(248,247,255)" }}>
|
||||||
<div style={{ position: "absolute", width: "70%", height: "70%", top: "5%", left: "10%", borderRadius: "50%", background: `radial-gradient(circle, rgba(18,113,255,0.85) 0%, transparent 65%)`, animation: "ace-blob1 22s ease infinite" }} />
|
<div style={{ position: "absolute", inset: 0, filter: "blur(70px)", opacity: 0.55 }}>
|
||||||
<div style={{ position: "absolute", width: "65%", height: "65%", top: "-10%", left: "-5%", borderRadius: "50%", background: `radial-gradient(circle, ${p}cc 0%, transparent 65%)`, animation: "ace-blob2 18s reverse infinite" }} />
|
<div style={{ position: "absolute", width: "70%", height: "70%", top: "5%", left: "10%", borderRadius: "50%", background: `radial-gradient(circle, rgba(196,181,253,0.7) 0%, transparent 65%)`, animation: "ace-blob1 22s ease infinite" }} />
|
||||||
<div style={{ position: "absolute", width: "55%", height: "55%", bottom: "10%", right: "10%", borderRadius: "50%", background: `radial-gradient(circle, rgba(100,220,255,0.7) 0%, transparent 65%)`, animation: "ace-blob3 28s linear infinite" }} />
|
<div style={{ position: "absolute", width: "65%", height: "65%", top: "-10%", left: "-5%", borderRadius: "50%", background: `radial-gradient(circle, rgba(147,197,253,0.6) 0%, transparent 65%)`, animation: "ace-blob2 18s reverse infinite" }} />
|
||||||
<div style={{ position: "absolute", width: "50%", height: "50%", bottom: "-10%", left: "30%", borderRadius: "50%", background: `radial-gradient(circle, rgba(200,50,50,0.65) 0%, transparent 65%)`, animation: "ace-blob4 24s ease infinite" }} />
|
<div style={{ position: "absolute", width: "55%", height: "55%", bottom: "10%", right: "10%", borderRadius: "50%", background: `radial-gradient(circle, rgba(216,180,254,0.5) 0%, transparent 65%)`, animation: "ace-blob3 28s linear infinite" }} />
|
||||||
|
<div style={{ position: "absolute", width: "50%", height: "50%", bottom: "-10%", left: "30%", borderRadius: "50%", background: `radial-gradient(circle, ${p}40 0%, transparent 65%)`, animation: "ace-blob4 24s ease infinite" }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
return (
|
||||||
|
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0, background: "rgb(8,0,20)" }}>
|
||||||
|
<div style={{ position: "absolute", inset: 0, filter: "blur(55px)", mixBlendMode: "hard-light" }}>
|
||||||
|
<div style={{ position: "absolute", width: "70%", height: "70%", top: "5%", left: "10%", borderRadius: "50%", background: `radial-gradient(circle, rgba(18,113,255,0.85) 0%, transparent 65%)`, animation: "ace-blob1 22s ease infinite" }} />
|
||||||
|
<div style={{ position: "absolute", width: "65%", height: "65%", top: "-10%", left: "-5%", borderRadius: "50%", background: `radial-gradient(circle, ${p}cc 0%, transparent 65%)`, animation: "ace-blob2 18s reverse infinite" }} />
|
||||||
|
<div style={{ position: "absolute", width: "55%", height: "55%", bottom: "10%", right: "10%", borderRadius: "50%", background: `radial-gradient(circle, rgba(100,220,255,0.7) 0%, transparent 65%)`, animation: "ace-blob3 28s linear infinite" }} />
|
||||||
|
<div style={{ position: "absolute", width: "50%", height: "50%", bottom: "-10%", left: "30%", borderRadius: "50%", background: `radial-gradient(circle, rgba(200,50,50,0.65) 0%, transparent 65%)`, animation: "ace-blob4 24s ease infinite" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (bgStyle === "shader") return (
|
if (bgStyle === "shader") return (
|
||||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0 }}>
|
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0 }}>
|
||||||
<div style={{ position: "absolute", inset: 0, background: "linear-gradient(135deg, #7c3aed 0%, #db2777 35%, #ea580c 65%, #ca8a04 100%)", opacity: 0.92 }} />
|
<div style={{ position: "absolute", inset: 0, background: "linear-gradient(135deg, #7c3aed 0%, #db2777 35%, #ea580c 65%, #ca8a04 100%)", opacity: 0.92 }} />
|
||||||
@@ -534,21 +546,19 @@ export function MarketingAceternity({ themeColor, config }: { themeColor?: Theme
|
|||||||
);
|
);
|
||||||
if (bgStyle === "beams") return (
|
if (bgStyle === "beams") return (
|
||||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0 }}>
|
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0 }}>
|
||||||
|
{/* CSS radial glow — no SVG gradient ID reference (avoids black-fill fallback) */}
|
||||||
|
<div style={{ position: "absolute", top: 0, left: "50%", transform: "translateX(-50%)", width: "80%", height: "60%", background: `radial-gradient(ellipse at 50% 0%, ${p}${isDark ? "30" : "18"}, transparent 70%)`, pointerEvents: "none" }} />
|
||||||
<svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} viewBox="0 0 400 700" preserveAspectRatio="xMidYMid slice">
|
<svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} viewBox="0 0 400 700" preserveAspectRatio="xMidYMid slice">
|
||||||
<defs>
|
|
||||||
<radialGradient id="bm-glow" cx="50%" cy="0%" r="75%">
|
|
||||||
<stop offset="0%" stopColor={p} stopOpacity="0.18" />
|
|
||||||
<stop offset="100%" stopColor={p} stopOpacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="400" height="700" fill="url(#bm-glow)" />
|
|
||||||
{Array.from({ length: 14 }).map((_, i) => {
|
{Array.from({ length: 14 }).map((_, i) => {
|
||||||
const x = 200 + (i - 7) * 32;
|
const x = 200 + (i - 7) * 32;
|
||||||
|
const lineColor = isDark
|
||||||
|
? (i % 3 === 0 ? p : i % 3 === 1 ? "#3b82f6" : "#06b6d4")
|
||||||
|
: (i % 3 === 0 ? p : i % 3 === 1 ? "#6366f1" : "#0891b2");
|
||||||
return (
|
return (
|
||||||
<line key={i} x1={200} y1={0} x2={x} y2={700}
|
<line key={i} x1={200} y1={0} x2={x} y2={700}
|
||||||
stroke={i % 3 === 0 ? p : i % 3 === 1 ? "#3b82f6" : "#06b6d4"}
|
stroke={lineColor}
|
||||||
strokeWidth={i % 4 === 0 ? 0.7 : 0.35}
|
strokeWidth={i % 4 === 0 ? 0.7 : 0.35}
|
||||||
strokeOpacity={0.12 + (i % 3) * 0.05}
|
strokeOpacity={isDark ? (0.12 + (i % 3) * 0.05) : (0.18 + (i % 3) * 0.06)}
|
||||||
style={{ animation: `ace-beam-pulse ${3 + i * 0.4}s ease-in-out ${i * 0.22}s infinite` }}
|
style={{ animation: `ace-beam-pulse ${3 + i * 0.4}s ease-in-out ${i * 0.22}s infinite` }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -558,14 +568,16 @@ export function MarketingAceternity({ themeColor, config }: { themeColor?: Theme
|
|||||||
);
|
);
|
||||||
if (bgStyle === "meteors") return (
|
if (bgStyle === "meteors") return (
|
||||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0 }}>
|
<div style={{ position: "absolute", inset: 0, overflow: "hidden", zIndex: 0 }}>
|
||||||
<div style={{ position: "absolute", inset: 0, background: `radial-gradient(ellipse 55% 35% at 50% 0%, ${p}15, transparent)` }} />
|
<div style={{ position: "absolute", inset: 0, background: `radial-gradient(ellipse 55% 35% at 50% 0%, ${p}${isDark ? "20" : "12"}, transparent)` }} />
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: `${(i * 31 + 5) % 65}%`,
|
top: `${(i * 31 + 5) % 65}%`,
|
||||||
right: `${(i * 47) % 90}%`,
|
right: `${(i * 47) % 90}%`,
|
||||||
width: `${36 + i * 8}px`, height: "1.5px",
|
width: `${36 + i * 8}px`, height: "1.5px",
|
||||||
background: `linear-gradient(90deg, ${p}ee, rgba(255,255,255,0.8), transparent)`,
|
background: isDark
|
||||||
|
? `linear-gradient(90deg, ${p}ee, rgba(255,255,255,0.8), transparent)`
|
||||||
|
: `linear-gradient(90deg, ${p}cc, rgba(0,0,0,0.15), transparent)`,
|
||||||
transform: "rotate(-35deg)",
|
transform: "rotate(-35deg)",
|
||||||
animation: `ace-meteor ${1.2 + (i % 5) * 0.5}s linear ${i * 0.55}s infinite`,
|
animation: `ace-meteor ${1.2 + (i % 5) * 0.5}s linear ${i * 0.55}s infinite`,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
@@ -602,14 +614,8 @@ export function MarketingAceternity({ themeColor, config }: { themeColor?: Theme
|
|||||||
if (bgStyle === "wavy") return (
|
if (bgStyle === "wavy") return (
|
||||||
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: "55%", zIndex: 0, overflow: "hidden" }}>
|
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: "55%", zIndex: 0, overflow: "hidden" }}>
|
||||||
<svg viewBox="0 0 400 120" preserveAspectRatio="none" style={{ width: "100%", height: "100%" }}>
|
<svg viewBox="0 0 400 120" preserveAspectRatio="none" style={{ width: "100%", height: "100%" }}>
|
||||||
<defs>
|
<path d="M0,40 Q50,10 100,40 T200,40 T300,40 T400,40 L400,120 L0,120 Z" fill={`${p}${isDark ? "20" : "14"}`} />
|
||||||
<linearGradient id="wg1" x1="0%" y1="0%" x2="100%" y2="0%">
|
<path d="M0,65 Q70,35 140,65 T280,65 T400,65 L400,120 L0,120 Z" fill={`${p}${isDark ? "0e" : "07"}`} />
|
||||||
<stop offset="0%" stopColor={p} stopOpacity="0.14" />
|
|
||||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.08" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d="M0,40 Q50,10 100,40 T200,40 T300,40 T400,40 L400,120 L0,120 Z" fill="url(#wg1)" />
|
|
||||||
<path d="M0,65 Q70,35 140,65 T280,65 T400,65 L400,120 L0,120 Z" fill={`${p}07`} />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ThemeColor {
|
|||||||
textColor?: string;
|
textColor?: string;
|
||||||
borderColor?: string;
|
borderColor?: string;
|
||||||
mutedText?: string;
|
mutedText?: string;
|
||||||
|
/** If set, only show this palette when the user's mode matches. Unset = show for any mode. */
|
||||||
|
themeMode?: "dark" | "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -93,24 +95,26 @@ export const TREMOR_THEMES: ThemeColor[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const DAISY_THEMES: ThemeColor[] = [
|
export const DAISY_THEMES: ThemeColor[] = [
|
||||||
{ id: "dark", label: "Dark", primary: "#793ef9", primaryFg: "#fff", activeBg: "rgba(121,62,249,0.2)", activeFg: "#a78bfa", ring: "#4c1d95", bg: "#1d232a", cardBg: "#191e24", textColor: "#a6adba", borderColor: "#2a323c", mutedText: "#6b7280" },
|
// — dark backgrounds —
|
||||||
{ id: "light", label: "Light", primary: "#570df8", primaryFg: "#fff", activeBg: "#f3f0ff", activeFg: "#4c1d95", ring: "#ddd6fe", bg: "#fff", cardBg: "#fff", textColor: "#1f2937", borderColor: "#e5e7eb", mutedText: "#6b7280" },
|
{ id: "dark", label: "Dark", themeMode: "dark", primary: "#793ef9", primaryFg: "#fff", activeBg: "rgba(121,62,249,0.2)", activeFg: "#a78bfa", ring: "#4c1d95", bg: "#1d232a", cardBg: "#191e24", textColor: "#a6adba", borderColor: "#2a323c", mutedText: "#6b7280" },
|
||||||
{ id: "cupcake", label: "Cupcake", primary: "#65c3c8", primaryFg: "#291334", activeBg: "#d9f5f6", activeFg: "#0e6b70", ring: "#a7eaec", bg: "#faf7f5", cardBg: "#fff", textColor: "#291334", borderColor: "#e9e3df", mutedText: "#9ca3af" },
|
{ id: "synthwave", label: "Synthwave", themeMode: "dark", primary: "#e779c1", primaryFg: "#2d1b69", activeBg: "rgba(231,121,193,0.2)", activeFg: "#f0abdc", ring: "#701a75", bg: "#1a103c", cardBg: "#221551", textColor: "#e2e8f0", borderColor: "#4c3585", mutedText: "#a78bfa" },
|
||||||
{ id: "synthwave", label: "Synthwave", primary: "#e779c1", primaryFg: "#2d1b69", activeBg: "rgba(231,121,193,0.2)", activeFg: "#f0abdc", ring: "#701a75", bg: "#1a103c", cardBg: "#221551", textColor: "#e2e8f0", borderColor: "#4c3585", mutedText: "#a78bfa" },
|
{ id: "halloween", label: "Halloween", themeMode: "dark", primary: "#f28c18", primaryFg: "#fff", activeBg: "rgba(242,140,24,0.15)", activeFg: "#f28c18", ring: "rgba(242,140,24,0.4)", bg: "#212121", cardBg: "#2a2a2a", textColor: "#f4f4f4", borderColor: "#383838", mutedText: "#888" },
|
||||||
{ id: "cyberpunk", label: "Cyberpunk", primary: "#ff7598", primaryFg: "#1a103d", activeBg: "rgba(255,117,152,0.2)", activeFg: "#ff7598", ring: "rgba(255,117,152,0.5)", bg: "#ffff00", cardBg: "#ffef00", textColor: "#1a103d", borderColor: "#ff7598", mutedText: "rgba(26,16,61,0.55)" },
|
{ id: "aqua", label: "Aqua", themeMode: "dark", primary: "#09ecf3", primaryFg: "#1a1a1a", activeBg: "rgba(9,236,243,0.15)", activeFg: "#09ecf3", ring: "rgba(9,236,243,0.4)", bg: "#0f172a", cardBg: "#1e293b", textColor: "#e2e8f0", borderColor: "#334155", mutedText: "#94a3b8" },
|
||||||
{ id: "halloween", label: "Halloween", primary: "#f28c18", primaryFg: "#fff", activeBg: "rgba(242,140,24,0.15)", activeFg: "#f28c18", ring: "rgba(242,140,24,0.4)", bg: "#212121", cardBg: "#2a2a2a", textColor: "#f4f4f4", borderColor: "#383838", mutedText: "#888" },
|
{ id: "luxury", label: "Luxury", themeMode: "dark", primary: "#dca54c", primaryFg: "#09090b", activeBg: "rgba(220,165,76,0.15)", activeFg: "#dca54c", ring: "rgba(220,165,76,0.4)", bg: "#09090b", cardBg: "#171717", textColor: "#f5f5f4", borderColor: "#262626", mutedText: "#737373" },
|
||||||
{ id: "valentine", label: "Valentine", primary: "#e96d7b", primaryFg: "#fff", activeBg: "rgba(233,109,123,0.1)", activeFg: "#e96d7b", ring: "rgba(233,109,123,0.3)", bg: "#fae6eb", cardBg: "#fff5f7", textColor: "#632935", borderColor: "#f4c2cb", mutedText: "#9a5468" },
|
{ id: "night", label: "Night", themeMode: "dark", primary: "#38bdf8", primaryFg: "#0c1a2a", activeBg: "rgba(56,189,248,0.15)", activeFg: "#38bdf8", ring: "rgba(56,189,248,0.4)", bg: "#0f1923", cardBg: "#1a2535", textColor: "#cbd5e1", borderColor: "#1e3a5f", mutedText: "#64748b" },
|
||||||
{ id: "aqua", label: "Aqua", primary: "#09ecf3", primaryFg: "#1a1a1a", activeBg: "rgba(9,236,243,0.15)", activeFg: "#09ecf3", ring: "rgba(9,236,243,0.4)", bg: "#0f172a", cardBg: "#1e293b", textColor: "#e2e8f0", borderColor: "#334155", mutedText: "#94a3b8" },
|
{ id: "coffee", label: "Coffee", themeMode: "dark", primary: "#db924b", primaryFg: "#fff", activeBg: "rgba(219,146,75,0.15)", activeFg: "#db924b", ring: "rgba(219,146,75,0.4)", bg: "#20100e", cardBg: "#2c1810", textColor: "#e8d5c4", borderColor: "#3d2319", mutedText: "#8b6355" },
|
||||||
{ id: "luxury", label: "Luxury", primary: "#dca54c", primaryFg: "#09090b", activeBg: "rgba(220,165,76,0.15)", activeFg: "#dca54c", ring: "rgba(220,165,76,0.4)", bg: "#09090b", cardBg: "#171717", textColor: "#f5f5f4", borderColor: "#262626", mutedText: "#737373" },
|
{ id: "dracula", label: "Dracula", themeMode: "dark", primary: "#ff79c6", primaryFg: "#282a36", activeBg: "rgba(255,121,198,0.15)", activeFg: "#ff79c6", ring: "#bd93f9", bg: "#282a36", cardBg: "#343746", textColor: "#f8f8f2", borderColor: "#44475a", mutedText: "#6272a4" },
|
||||||
{ id: "night", label: "Night", primary: "#38bdf8", primaryFg: "#0c1a2a", activeBg: "rgba(56,189,248,0.15)", activeFg: "#38bdf8", ring: "rgba(56,189,248,0.4)", bg: "#0f1923", cardBg: "#1a2535", textColor: "#cbd5e1", borderColor: "#1e3a5f", mutedText: "#64748b" },
|
{ id: "forest", label: "Forest", themeMode: "dark", primary: "#1eb854", primaryFg: "#fff", activeBg: "rgba(30,184,84,0.15)", activeFg: "#1eb854", ring: "#15803d", bg: "#171212", cardBg: "#1d1d1d", textColor: "#d1d5db", borderColor: "#292929", mutedText: "#4b5563" },
|
||||||
{ id: "coffee", label: "Coffee", primary: "#db924b", primaryFg: "#fff", activeBg: "rgba(219,146,75,0.15)", activeFg: "#db924b", ring: "rgba(219,146,75,0.4)", bg: "#20100e", cardBg: "#2c1810", textColor: "#e8d5c4", borderColor: "#3d2319", mutedText: "#8b6355" },
|
{ id: "nord", label: "Nord", themeMode: "dark", primary: "#5e81ac", primaryFg: "#fff", activeBg: "rgba(94,129,172,0.15)", activeFg: "#88c0d0", ring: "rgba(94,129,172,0.4)", bg: "#2e3440", cardBg: "#3b4252", textColor: "#eceff4", borderColor: "#434c5e", mutedText: "#9198a1" },
|
||||||
{ id: "dracula", label: "Dracula", primary: "#ff79c6", primaryFg: "#282a36", activeBg: "rgba(255,121,198,0.15)", activeFg: "#ff79c6", ring: "#bd93f9", bg: "#282a36", cardBg: "#343746", textColor: "#f8f8f2", borderColor: "#44475a", mutedText: "#6272a4" },
|
{ id: "dim", label: "Dim", themeMode: "dark", primary: "#9fb8d8", primaryFg: "#1c2638", activeBg: "rgba(159,184,216,0.15)", activeFg: "#9fb8d8", ring: "rgba(159,184,216,0.4)", bg: "#2a303c", cardBg: "#242933", textColor: "#c6cdd8", borderColor: "#3d4451", mutedText: "#717d8a" },
|
||||||
{ id: "forest", label: "Forest", primary: "#1eb854", primaryFg: "#fff", activeBg: "rgba(30,184,84,0.15)", activeFg: "#1eb854", ring: "#15803d", bg: "#171212", cardBg: "#1d1d1d", textColor: "#d1d5db", borderColor: "#292929", mutedText: "#4b5563" },
|
{ id: "sunset", label: "Sunset", themeMode: "dark", primary: "#ff865b", primaryFg: "#fff", activeBg: "rgba(255,134,91,0.15)", activeFg: "#ff865b", ring: "rgba(255,134,91,0.4)", bg: "#1a0a00", cardBg: "#270f00", textColor: "#f4c09a", borderColor: "#3d1a00", mutedText: "#8b5a3a" },
|
||||||
{ id: "retro", label: "Retro", primary: "#ef9995", primaryFg: "#282425", activeBg: "#fde8e7", activeFg: "#7f1d1d", ring: "#fca5a5", bg: "#e4d8b4", cardBg: "#f7f0d8", textColor: "#282425", borderColor: "#d4b483", mutedText: "#6b5745" },
|
// — light backgrounds —
|
||||||
{ id: "nord", label: "Nord", primary: "#5e81ac", primaryFg: "#fff", activeBg: "rgba(94,129,172,0.15)", activeFg: "#88c0d0", ring: "rgba(94,129,172,0.4)", bg: "#2e3440", cardBg: "#3b4252", textColor: "#eceff4", borderColor: "#434c5e", mutedText: "#9198a1" },
|
{ id: "light", label: "Light", themeMode: "light", primary: "#570df8", primaryFg: "#fff", activeBg: "#f3f0ff", activeFg: "#4c1d95", ring: "#ddd6fe", bg: "#fff", cardBg: "#fff", textColor: "#1f2937", borderColor: "#e5e7eb", mutedText: "#6b7280" },
|
||||||
{ id: "dim", label: "Dim", primary: "#9fb8d8", primaryFg: "#1c2638", activeBg: "rgba(159,184,216,0.15)", activeFg: "#9fb8d8", ring: "rgba(159,184,216,0.4)", bg: "#2a303c", cardBg: "#242933", textColor: "#c6cdd8", borderColor: "#3d4451", mutedText: "#717d8a" },
|
{ id: "cupcake", label: "Cupcake", themeMode: "light", primary: "#65c3c8", primaryFg: "#291334", activeBg: "#d9f5f6", activeFg: "#0e6b70", ring: "#a7eaec", bg: "#faf7f5", cardBg: "#fff", textColor: "#291334", borderColor: "#e9e3df", mutedText: "#9ca3af" },
|
||||||
{ id: "winter", label: "Winter", primary: "#047aed", primaryFg: "#fff", activeBg: "#e0f0ff", activeFg: "#0369a1", ring: "#bae6fd", bg: "#fff", cardBg: "#f0f9ff", textColor: "#1e3a5f", borderColor: "#bae6fd", mutedText: "#64748b" },
|
{ id: "valentine", label: "Valentine", themeMode: "light", primary: "#e96d7b", primaryFg: "#fff", activeBg: "rgba(233,109,123,0.1)", activeFg: "#e96d7b", ring: "rgba(233,109,123,0.3)", bg: "#fae6eb", cardBg: "#fff5f7", textColor: "#632935", borderColor: "#f4c2cb", mutedText: "#9a5468" },
|
||||||
{ id: "sunset", label: "Sunset", primary: "#ff865b", primaryFg: "#fff", activeBg: "rgba(255,134,91,0.15)", activeFg: "#ff865b", ring: "rgba(255,134,91,0.4)", bg: "#1a0a00", cardBg: "#270f00", textColor: "#f4c09a", borderColor: "#3d1a00", mutedText: "#8b5a3a" },
|
{ id: "cyberpunk", label: "Cyberpunk", themeMode: "light", primary: "#ff7598", primaryFg: "#1a103d", activeBg: "rgba(255,117,152,0.2)", activeFg: "#ff7598", ring: "rgba(255,117,152,0.5)", bg: "#ffff00", cardBg: "#ffef00", textColor: "#1a103d", borderColor: "#ff7598", mutedText: "rgba(26,16,61,0.55)" },
|
||||||
|
{ id: "retro", label: "Retro", themeMode: "light", primary: "#ef9995", primaryFg: "#282425", activeBg: "#fde8e7", activeFg: "#7f1d1d", ring: "#fca5a5", bg: "#e4d8b4", cardBg: "#f7f0d8", textColor: "#282425", borderColor: "#d4b483", mutedText: "#6b5745" },
|
||||||
|
{ id: "winter", label: "Winter", themeMode: "light", primary: "#047aed", primaryFg: "#fff", activeBg: "#e0f0ff", activeFg: "#0369a1", ring: "#bae6fd", bg: "#fff", cardBg: "#f0f9ff", textColor: "#1e3a5f", borderColor: "#bae6fd", mutedText: "#64748b" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Aceternity UI accent colour palettes
|
// Aceternity UI accent colour palettes
|
||||||
@@ -123,9 +127,9 @@ export const ACETERNITY_THEMES: ThemeColor[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const HEROUI_MARKETING_THEMES: ThemeColor[] = [
|
export const HEROUI_MARKETING_THEMES: ThemeColor[] = [
|
||||||
{ id: "purple", label: "Purple", primary: "#7c3aed", primaryFg: "#fff", activeBg: "rgba(124,58,237,0.08)", activeFg: "#7c3aed", ring: "rgba(124,58,237,0.15)", bg: "#fff" },
|
{ id: "purple", label: "Purple", themeMode: "light", primary: "#7c3aed", primaryFg: "#fff", activeBg: "rgba(124,58,237,0.08)", activeFg: "#7c3aed", ring: "rgba(124,58,237,0.15)", bg: "#fff" },
|
||||||
{ id: "blue", label: "Blue", primary: "#2563eb", primaryFg: "#fff", activeBg: "rgba(37,99,235,0.08)", activeFg: "#2563eb", ring: "rgba(37,99,235,0.15)", bg: "#fff" },
|
{ id: "blue", label: "Blue", themeMode: "light", primary: "#2563eb", primaryFg: "#fff", activeBg: "rgba(37,99,235,0.08)", activeFg: "#2563eb", ring: "rgba(37,99,235,0.15)", bg: "#fff" },
|
||||||
{ id: "teal", label: "Teal", primary: "#0d9488", primaryFg: "#fff", activeBg: "rgba(13,148,136,0.08)", activeFg: "#0d9488", ring: "rgba(13,148,136,0.15)", bg: "#fff" },
|
{ id: "teal", label: "Teal", themeMode: "light", primary: "#0d9488", primaryFg: "#fff", activeBg: "rgba(13,148,136,0.08)", activeFg: "#0d9488", ring: "rgba(13,148,136,0.15)", bg: "#fff" },
|
||||||
{ id: "dark", label: "Dark", primary: "#7c3aed", primaryFg: "#fff", activeBg: "rgba(124,58,237,0.2)", activeFg: "#c084fc", ring: "rgba(124,58,237,0.3)", bg: "#09090b", cardBg: "#18181b", textColor: "#f4f4f5", borderColor: "#27272a", mutedText: "#71717a" },
|
{ id: "dark", label: "Dark", themeMode: "dark", primary: "#7c3aed", primaryFg: "#fff", activeBg: "rgba(124,58,237,0.2)", activeFg: "#c084fc", ring: "rgba(124,58,237,0.3)", bg: "#09090b", cardBg: "#18181b", textColor: "#f4f4f5", borderColor: "#27272a", mutedText: "#71717a" },
|
||||||
{ id: "modern", label: "Modern", primary: "#06b6d4", primaryFg: "#fff", activeBg: "rgba(6,182,212,0.08)", activeFg: "#06b6d4", ring: "rgba(6,182,212,0.15)", bg: "#fff" },
|
{ id: "modern", label: "Modern", themeMode: "light", primary: "#06b6d4", primaryFg: "#fff", activeBg: "rgba(6,182,212,0.08)", activeFg: "#06b6d4", ring: "rgba(6,182,212,0.15)", bg: "#fff" },
|
||||||
];
|
];
|
||||||
|
|||||||
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: "growth", label: "Growth", path: "growth" },
|
||||||
{ id: "deployment", label: "Launch", path: "deployment" },
|
{ id: "assist", label: "Assist", path: "assist" },
|
||||||
{ id: "grow", label: "Grow", path: "grow" },
|
{ id: "analytics", label: "Analytics", path: "analytics" },
|
||||||
{ id: "insights", label: "Insights", path: "insights" },
|
] as const;
|
||||||
{ id: "settings", label: "Settings", path: "settings" },
|
|
||||||
];
|
|
||||||
|
|
||||||
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>{`
|
<div style={{
|
||||||
@media (max-width: 768px) {
|
display: "flex", flexDirection: "column",
|
||||||
.vibn-left-sidebar { display: none !important; }
|
height: "100dvh", overflow: "hidden",
|
||||||
.vibn-right-panel { display: none !important; }
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
background: "var(--background)",
|
||||||
.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 */}
|
{/* ── Top bar ── */}
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<header style={{
|
||||||
|
height: 48, flexShrink: 0,
|
||||||
{/* Project header */}
|
display: "flex", alignItems: "stretch",
|
||||||
<div className="vibn-project-header" style={{
|
background: "var(--card)", borderBottom: "1px solid var(--border)",
|
||||||
padding: "18px 32px",
|
zIndex: 10,
|
||||||
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={{
|
|
||||||
width: 34, height: 34, borderRadius: 9,
|
|
||||||
background: "#1a1a1a12",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
}}>
|
|
||||||
<span style={{ fontFamily: "Newsreader, serif", fontSize: "1rem", fontWeight: 500, color: "#1a1a1a" }}>
|
|
||||||
{projectName[0]?.toUpperCase() ?? "P"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<h2 style={{
|
|
||||||
fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a",
|
|
||||||
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", margin: 0,
|
|
||||||
}}>
|
|
||||||
{projectName}
|
|
||||||
</h2>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className="vibn-tab-bar" style={{
|
|
||||||
padding: "0 32px",
|
|
||||||
borderBottom: "1px solid #e8e4dc",
|
|
||||||
display: "flex",
|
|
||||||
background: "#fff",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<Link
|
|
||||||
key={t.id}
|
|
||||||
href={`/${workspace}/project/${projectId}/${t.path}`}
|
|
||||||
style={{
|
|
||||||
padding: "12px 18px",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
|
|
||||||
borderBottom: activeTab === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
|
|
||||||
transition: "all 0.12s",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
textDecoration: "none",
|
|
||||||
display: "block",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page content */}
|
|
||||||
<div className="vibn-page-content" style={{ flex: 1, overflow: "auto" }}>
|
|
||||||
{children}
|
|
||||||
</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>
|
{/* Logo + project name */}
|
||||||
{DISCOVERY_PHASES.map((phase, i) => {
|
<div style={{
|
||||||
const isDone = savedPhaseIds.has(phase.id);
|
display: "flex", alignItems: "center",
|
||||||
const isActive = !isDone && i === firstUnsavedIdx;
|
padding: "0 16px", gap: 9, flexShrink: 0,
|
||||||
return (
|
borderRight: "1px solid var(--border)",
|
||||||
<div
|
}}>
|
||||||
key={phase.id}
|
<Link
|
||||||
style={{
|
href={`/${workspace}/projects`}
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
style={{ display: "flex", alignItems: "center", textDecoration: "none", flexShrink: 0 }}
|
||||||
padding: "9px 0",
|
>
|
||||||
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
|
<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 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>
|
||||||
);
|
</Link>
|
||||||
})}
|
<span style={{
|
||||||
|
fontSize: "0.82rem", fontWeight: 600, color: "var(--foreground)",
|
||||||
|
maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
{projectName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
|
{/* Tab nav */}
|
||||||
|
<div style={{ flex: 1, display: "flex", alignItems: "center", padding: "0 12px", gap: 2 }}>
|
||||||
|
{SECTIONS.map(s => {
|
||||||
|
const isActive = activeSection === s.id;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={s.id}
|
||||||
|
href={`/${workspace}/project/${projectId}/${s.path}`}
|
||||||
|
style={{
|
||||||
|
padding: "5px 12px", borderRadius: 8,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: isActive ? 600 : 440,
|
||||||
|
color: isActive ? "var(--foreground)" : "var(--muted-foreground)",
|
||||||
|
background: isActive ? "var(--secondary)" : "transparent",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "background 0.1s, color 0.1s",
|
||||||
|
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"; }}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Captured data — summaries from saved phases */}
|
{/* Spacer */}
|
||||||
<SectionLabel>Captured</SectionLabel>
|
<div style={{ flex: 1 }} />
|
||||||
{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" }} />
|
{/* 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>
|
||||||
|
|
||||||
{/* Project info */}
|
{/* ── Full-width content ── */}
|
||||||
<SectionLabel>Project Info</SectionLabel>
|
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||||
{[
|
{children}
|
||||||
{ 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>
|
||||||
</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}
|
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||||
title="Expand sidebar"
|
color: "#6b6560", width: 26, height: 20, borderRadius: 5,
|
||||||
style={{
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
fontSize: "0.8rem", fontWeight: 700,
|
||||||
color: "#6b6560", width: 28, height: 22, borderRadius: 5,
|
}}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||||
fontSize: "0.82rem", fontWeight: 700, transition: "background 0.12s, color 0.12s",
|
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||||
}}
|
>›</button>
|
||||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</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}
|
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||||
title="Collapse sidebar"
|
color: "#6b6560", width: 24, height: 22, borderRadius: 5,
|
||||||
style={{
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
fontSize: "0.8rem", fontWeight: 700, flexShrink: 0,
|
||||||
color: "#6b6560", width: 26, height: 24, borderRadius: 5,
|
}}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||||
fontSize: "0.82rem", fontWeight: 700, flexShrink: 0,
|
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||||
transition: "background 0.12s, color 0.12s",
|
>‹</button>
|
||||||
}}
|
|
||||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</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}
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
title={collapsed ? n.label : undefined}
|
gap: 8, padding: collapsed ? "8px 0" : "7px 10px",
|
||||||
style={{
|
borderRadius: 6,
|
||||||
width: "100%",
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
display: "flex",
|
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||||
alignItems: "center",
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
transition: "background 0.12s", textDecoration: "none",
|
||||||
gap: 9,
|
}}
|
||||||
padding: collapsed ? "9px 0" : "8px 10px",
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
borderRadius: 6,
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
background: isActive ? "#f6f4f0" : "transparent",
|
|
||||||
color: isActive ? "#1a1a1a" : "#6b6560",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: isActive ? 600 : 500,
|
|
||||||
transition: "all 0.12s",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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,91 +181,161 @@ 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 }}>
|
||||||
{!collapsed && (
|
|
||||||
<div style={{
|
{activeProjectId && project ? (
|
||||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
/* ── PROJECT VIEW: name + status + section tabs ── */
|
||||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
<>
|
||||||
padding: "6px 10px 8px",
|
{!collapsed && (
|
||||||
}}>
|
<>
|
||||||
Projects
|
<div style={{ padding: "6px 12px 8px" }}>
|
||||||
|
<div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{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
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{projects.map(p => {
|
||||||
|
const isActive = activeProjectId === p.id;
|
||||||
|
const color = p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a";
|
||||||
|
return (
|
||||||
|
<Link key={p.id} href={`/${workspace}/project/${p.id}/overview`}
|
||||||
|
title={collapsed ? p.productName : undefined}
|
||||||
|
style={{
|
||||||
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
|
gap: 9, padding: collapsed ? "9px 0" : "7px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isActive ? "#f6f4f0" : "transparent",
|
||||||
|
color: "#1a1a1a", fontSize: "0.8rem",
|
||||||
|
fontWeight: isActive ? 600 : 450,
|
||||||
|
transition: "background 0.12s", 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"; }}
|
||||||
|
>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0 }} />
|
||||||
|
{!collapsed && (
|
||||||
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{p.productName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{projects.map((p) => {
|
|
||||||
const isActive = activeProjectId === p.id;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={p.id}
|
|
||||||
href={`/${workspace}/project/${p.id}/overview`}
|
|
||||||
title={collapsed ? p.productName : undefined}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
|
||||||
gap: 9,
|
|
||||||
padding: collapsed ? "9px 0" : "7px 10px",
|
|
||||||
borderRadius: 6,
|
|
||||||
background: isActive ? "#f6f4f0" : "transparent",
|
|
||||||
color: "#1a1a1a",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: isActive ? 600 : 450,
|
|
||||||
transition: "background 0.12s",
|
|
||||||
textDecoration: "none",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StatusDot status={p.status} />
|
|
||||||
{!collapsed && (
|
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
||||||
{p.productName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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" })}
|
background: "none", border: "none", padding: 0,
|
||||||
style={{
|
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
||||||
background: "none", border: "none", padding: 0,
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
}}>
|
||||||
fontFamily: "Outfit, 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
825
components/workspace/WorkspaceKeysPanel.tsx
Normal file
825
components/workspace/WorkspaceKeysPanel.tsx
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace settings panel: shows workspace identity + API keys
|
||||||
|
* (mint / list / revoke), and renders ready-to-paste config for
|
||||||
|
* Cursor and other VS Code-style IDEs so an AI agent can act on
|
||||||
|
* behalf of this workspace.
|
||||||
|
*
|
||||||
|
* The full plaintext token is shown ONCE in the post-create modal
|
||||||
|
* and never again — `key_hash` is all we persist server-side.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Copy, Download, KeyRound, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface WorkspaceSummary {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
coolifyProjectUuid: string | null;
|
||||||
|
coolifyTeamId: number | null;
|
||||||
|
giteaOrg: string | null;
|
||||||
|
provisionStatus: "pending" | "partial" | "ready" | "error";
|
||||||
|
provisionError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
scopes: string[];
|
||||||
|
created_by: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MintedKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_BASE =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : "https://vibnai.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL param (e.g. `/mark-account/settings`) is treated as a hint —
|
||||||
|
* the actual workspace is resolved via `GET /api/workspaces`, which is
|
||||||
|
* keyed off the signed-in user. That endpoint also lazy-creates the
|
||||||
|
* workspace row for users who signed in before the workspace hook
|
||||||
|
* landed, so this panel never gets stuck on a missing row.
|
||||||
|
*/
|
||||||
|
export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug?: string }) {
|
||||||
|
const [workspace, setWorkspace] = useState<WorkspaceSummary | null>(null);
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [provisioning, setProvisioning] = useState(false);
|
||||||
|
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const [minted, setMinted] = useState<MintedKey | null>(null);
|
||||||
|
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
|
||||||
|
const [revoking, setRevoking] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const listRes = await fetch(`/api/workspaces`, { credentials: "include" });
|
||||||
|
if (!listRes.ok) {
|
||||||
|
const body = listRes.status === 401
|
||||||
|
? "Sign in to view your workspace."
|
||||||
|
: `Couldn't load workspace (HTTP ${listRes.status}).`;
|
||||||
|
setErrorMsg(body);
|
||||||
|
setWorkspace(null);
|
||||||
|
setKeys([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = (await listRes.json()) as { workspaces: WorkspaceSummary[] };
|
||||||
|
const ws = list.workspaces?.[0] ?? null;
|
||||||
|
setWorkspace(ws);
|
||||||
|
if (!ws) {
|
||||||
|
setKeys([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keysRes = await fetch(`/api/workspaces/${ws.slug}/keys`, { credentials: "include" });
|
||||||
|
if (keysRes.ok) {
|
||||||
|
const j = (await keysRes.json()) as { keys: ApiKey[] };
|
||||||
|
setKeys(j.keys ?? []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[workspace-keys] refresh failed", err);
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const provision = useCallback(async () => {
|
||||||
|
if (!workspace) return;
|
||||||
|
setProvisioning(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/workspaces/${workspace.slug}/provision`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
toast.success("Provisioning re-run");
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setProvisioning(false);
|
||||||
|
}
|
||||||
|
}, [workspace, refresh]);
|
||||||
|
|
||||||
|
const createKey = useCallback(async () => {
|
||||||
|
if (!workspace) return;
|
||||||
|
if (!newName.trim()) {
|
||||||
|
toast.error("Give the key a name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/workspaces/${workspace.slug}/keys`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newName.trim() }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) throw new Error(j.error ?? "Failed");
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewName("");
|
||||||
|
setMinted(j as MintedKey);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Could not mint key: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}, [workspace, newName, refresh]);
|
||||||
|
|
||||||
|
const revokeKey = useCallback(async () => {
|
||||||
|
if (!workspace || !keyToRevoke) return;
|
||||||
|
setRevoking(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/workspaces/${workspace.slug}/keys/${keyToRevoke.id}`,
|
||||||
|
{ method: "DELETE", credentials: "include" }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
toast.success("Key revoked");
|
||||||
|
setKeyToRevoke(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Revoke failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setRevoking(false);
|
||||||
|
}
|
||||||
|
}, [workspace, keyToRevoke, refresh]);
|
||||||
|
|
||||||
|
if (loading && !workspace) {
|
||||||
|
return (
|
||||||
|
<section style={cardStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--muted)", fontSize: 13 }}>
|
||||||
|
<Loader2 className="animate-spin" size={14} /> Loading workspace…
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return (
|
||||||
|
<section style={cardStyle}>
|
||||||
|
<header style={cardHeaderStyle}>
|
||||||
|
<div>
|
||||||
|
<h2 style={cardTitleStyle}>Workspace</h2>
|
||||||
|
<p style={cardSubtitleStyle}>
|
||||||
|
{errorMsg ?? "No workspace yet — this usually means you signed in before AI access was rolled out."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="animate-spin" size={14} /> : <RefreshCw size={14} />}
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<p style={{ fontSize: 12.5, color: "var(--muted)", margin: 0 }}>
|
||||||
|
Try signing out and back in, then refresh this page. If the problem
|
||||||
|
persists, the API may be down or your account may need to be
|
||||||
|
provisioned manually.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
||||||
|
<WorkspaceIdentityCard workspace={workspace} onProvision={provision} provisioning={provisioning} />
|
||||||
|
|
||||||
|
<KeysCard
|
||||||
|
workspace={workspace}
|
||||||
|
keys={keys}
|
||||||
|
onCreateClick={() => setShowCreate(true)}
|
||||||
|
onRevokeClick={k => setKeyToRevoke(k)}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CursorIntegrationCard workspace={workspace} />
|
||||||
|
|
||||||
|
{/* ── Create key modal ─────────────────────────────────────── */}
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create workspace API key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Used by AI agents (Cursor, Claude, scripts) to act on
|
||||||
|
behalf of <code>{workspace.slug}</code>. The token is shown
|
||||||
|
once — save it somewhere safe.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div style={{ display: "grid", gap: 8 }}>
|
||||||
|
<Label htmlFor="key-name">Key name</Label>
|
||||||
|
<Input
|
||||||
|
id="key-name"
|
||||||
|
placeholder="e.g. Cursor on my MacBook"
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === "Enter") createKey();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowCreate(false)} disabled={creating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={createKey} disabled={creating || !newName.trim()}>
|
||||||
|
{creating && <Loader2 className="animate-spin" size={14} />}
|
||||||
|
Mint key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Show minted secret ONCE ──────────────────────────────── */}
|
||||||
|
<Dialog open={!!minted} onOpenChange={open => !open && setMinted(null)}>
|
||||||
|
<DialogContent style={{ maxWidth: 640 }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save your API key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This is the only time the full key is shown. Store it in a
|
||||||
|
password manager or paste it into the Cursor config below.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{minted && <MintedKeyView workspace={workspace} minted={minted} />}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setMinted(null)}>I've saved it</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Revoke confirm ───────────────────────────────────────── */}
|
||||||
|
<AlertDialog open={!!keyToRevoke} onOpenChange={open => !open && setKeyToRevoke(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Revoke this key?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Any agent using <code>{keyToRevoke?.prefix}…</code> will
|
||||||
|
immediately lose access to {workspace.slug}. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={revoking}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={revokeKey} disabled={revoking}>
|
||||||
|
{revoking && <Loader2 className="animate-spin" size={14} />}
|
||||||
|
Revoke
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Sub-components
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WorkspaceIdentityCard({
|
||||||
|
workspace,
|
||||||
|
onProvision,
|
||||||
|
provisioning,
|
||||||
|
}: {
|
||||||
|
workspace: WorkspaceSummary;
|
||||||
|
onProvision: () => void;
|
||||||
|
provisioning: boolean;
|
||||||
|
}) {
|
||||||
|
const status = workspace.provisionStatus;
|
||||||
|
const statusColor =
|
||||||
|
status === "ready" ? "#10b981" : status === "partial" ? "#f59e0b" : status === "error" ? "#ef4444" : "#9ca3af";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={cardStyle}>
|
||||||
|
<header style={cardHeaderStyle}>
|
||||||
|
<div>
|
||||||
|
<h2 style={cardTitleStyle}>Workspace</h2>
|
||||||
|
<p style={cardSubtitleStyle}>
|
||||||
|
Your tenant boundary on Coolify and Gitea. All AI access is scoped to this workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onProvision}
|
||||||
|
disabled={provisioning}
|
||||||
|
>
|
||||||
|
{provisioning ? <Loader2 className="animate-spin" size={14} /> : <RefreshCw size={14} />}
|
||||||
|
Re-provision
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<dl style={kvGrid}>
|
||||||
|
<Kv label="Slug" value={<code>{workspace.slug}</code>} />
|
||||||
|
<Kv
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor }} />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Kv
|
||||||
|
label="Coolify Project"
|
||||||
|
value={
|
||||||
|
workspace.coolifyProjectUuid ? (
|
||||||
|
<code style={{ fontSize: 11 }}>{workspace.coolifyProjectUuid}</code>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--muted)" }}>not provisioned</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Kv
|
||||||
|
label="Gitea Org"
|
||||||
|
value={
|
||||||
|
workspace.giteaOrg ? (
|
||||||
|
<code>{workspace.giteaOrg}</code>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--muted)" }}>not provisioned</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
{workspace.provisionError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "#fef2f2",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 8,
|
||||||
|
color: "#991b1b",
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workspace.provisionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeysCard({
|
||||||
|
workspace,
|
||||||
|
keys,
|
||||||
|
onCreateClick,
|
||||||
|
onRevokeClick,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
workspace: WorkspaceSummary;
|
||||||
|
keys: ApiKey[];
|
||||||
|
onCreateClick: () => void;
|
||||||
|
onRevokeClick: (k: ApiKey) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const active = useMemo(() => keys.filter(k => !k.revoked_at), [keys]);
|
||||||
|
const revoked = useMemo(() => keys.filter(k => k.revoked_at), [keys]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={cardStyle}>
|
||||||
|
<header style={cardHeaderStyle}>
|
||||||
|
<div>
|
||||||
|
<h2 style={cardTitleStyle}>API keys</h2>
|
||||||
|
<p style={cardSubtitleStyle}>
|
||||||
|
Tokens scoped to <code>{workspace.slug}</code>. Use them in Cursor,
|
||||||
|
Claude Code, the CLI, or any HTTP client.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onRefresh}>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onCreateClick}>
|
||||||
|
<Plus size={14} />
|
||||||
|
New key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<EmptyKeysState onCreateClick={onCreateClick} />
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{active.map(k => (
|
||||||
|
<KeyRow key={k.id} k={k} onRevoke={() => onRevokeClick(k)} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{revoked.length > 0 && (
|
||||||
|
<details style={{ marginTop: 16 }}>
|
||||||
|
<summary style={{ fontSize: 12, color: "var(--muted)", cursor: "pointer" }}>
|
||||||
|
{revoked.length} revoked
|
||||||
|
</summary>
|
||||||
|
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0, display: "flex", flexDirection: "column", gap: 6, opacity: 0.6 }}>
|
||||||
|
{revoked.map(k => (
|
||||||
|
<KeyRow key={k.id} k={k} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyRow({ k, onRevoke }: { k: ApiKey; onRevoke?: () => void }) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid var(--border, #e5e7eb)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound size={16} style={{ color: "var(--muted)" }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>{k.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "monospace" }}>
|
||||||
|
{k.prefix}…
|
||||||
|
{k.last_used_at
|
||||||
|
? ` · last used ${new Date(k.last_used_at).toLocaleString()}`
|
||||||
|
: " · never used"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onRevoke && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onRevoke} aria-label="Revoke">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyKeysState({ onCreateClick }: { onCreateClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
background: "#f9fafb",
|
||||||
|
border: "1px dashed var(--border, #e5e7eb)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound size={20} style={{ margin: "0 auto 8px", color: "var(--muted)" }} />
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>No API keys yet</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 4 }}>
|
||||||
|
Mint one to let Cursor or any AI agent push code and deploy on your behalf.
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={onCreateClick} style={{ marginTop: 12 }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
Create first key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Cursor / VS Code integration block
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CursorIntegrationCard({ workspace }: { workspace: WorkspaceSummary }) {
|
||||||
|
const cursorRule = buildCursorRule(workspace);
|
||||||
|
const mcpJson = buildMcpJson(workspace, "<paste-your-vibn_sk_-token>");
|
||||||
|
const envSnippet = buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={cardStyle}>
|
||||||
|
<header style={cardHeaderStyle}>
|
||||||
|
<div>
|
||||||
|
<h2 style={cardTitleStyle}>Connect Cursor</h2>
|
||||||
|
<p style={cardSubtitleStyle}>
|
||||||
|
Drop these into your repo (or <code>~/.cursor/</code>) so any
|
||||||
|
agent inside Cursor knows how to talk to this workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FileBlock
|
||||||
|
title=".cursor/rules/vibn-workspace.mdc"
|
||||||
|
description="Tells the agent it can use the Vibn API and which workspace it's bound to."
|
||||||
|
filename="vibn-workspace.mdc"
|
||||||
|
contents={cursorRule}
|
||||||
|
language="markdown"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileBlock
|
||||||
|
title="~/.cursor/mcp.json"
|
||||||
|
description="Registers Vibn as an MCP server so the agent can call workspace endpoints natively. Paste your minted key in place of the placeholder."
|
||||||
|
filename="mcp.json"
|
||||||
|
contents={mcpJson}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileBlock
|
||||||
|
title=".env.local (for shell / scripts)"
|
||||||
|
description="If the agent shells out (curl, gh, scripts), expose the key as an env var."
|
||||||
|
filename="vibn.env"
|
||||||
|
contents={envSnippet}
|
||||||
|
language="bash"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileBlock({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
filename,
|
||||||
|
contents,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
filename: string;
|
||||||
|
contents: string;
|
||||||
|
language: string;
|
||||||
|
}) {
|
||||||
|
const copy = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(contents).then(
|
||||||
|
() => toast.success(`Copied ${title}`),
|
||||||
|
() => toast.error("Copy failed")
|
||||||
|
);
|
||||||
|
}, [contents, title]);
|
||||||
|
|
||||||
|
const download = useCallback(() => {
|
||||||
|
const blob = new Blob([contents], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [contents, filename]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 14, border: "1px solid var(--border, #e5e7eb)", borderRadius: 8, overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "#f9fafb",
|
||||||
|
borderBottom: "1px solid var(--border, #e5e7eb)",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12.5, fontWeight: 600, color: "var(--ink)", fontFamily: "monospace" }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 2 }}>{description}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={copy} aria-label={`Copy ${filename}`}>
|
||||||
|
<Copy size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={download} aria-label={`Download ${filename}`}>
|
||||||
|
<Download size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 12,
|
||||||
|
background: "#0f172a",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
overflowX: "auto",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code data-language={language}>{contents}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; minted: MintedKey }) {
|
||||||
|
const cursorRule = buildCursorRule(workspace);
|
||||||
|
const mcpJson = buildMcpJson(workspace, minted.token);
|
||||||
|
const envSnippet = buildEnvSnippet(workspace, minted.token);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
|
<FileBlock
|
||||||
|
title="Your key"
|
||||||
|
description="Copy this now — the full value is never shown again."
|
||||||
|
filename={`${workspace.slug}-${minted.name.replace(/\s+/g, "-")}.txt`}
|
||||||
|
contents={minted.token}
|
||||||
|
language="text"
|
||||||
|
/>
|
||||||
|
<FileBlock
|
||||||
|
title=".cursor/rules/vibn-workspace.mdc"
|
||||||
|
description="Drop into your repo so Cursor agents know about the Vibn API."
|
||||||
|
filename="vibn-workspace.mdc"
|
||||||
|
contents={cursorRule}
|
||||||
|
language="markdown"
|
||||||
|
/>
|
||||||
|
<FileBlock
|
||||||
|
title="~/.cursor/mcp.json (key embedded)"
|
||||||
|
description="Paste into Cursor's MCP config to register Vibn as a tool source."
|
||||||
|
filename="mcp.json"
|
||||||
|
contents={mcpJson}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
<FileBlock
|
||||||
|
title=".env.local"
|
||||||
|
description="For shell / script use."
|
||||||
|
filename="vibn.env"
|
||||||
|
contents={envSnippet}
|
||||||
|
language="bash"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// File generators
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildCursorRule(w: WorkspaceSummary): string {
|
||||||
|
return `---
|
||||||
|
description: Vibn workspace "${w.slug}" — REST API for git/deploy via Coolify + Gitea
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vibn workspace: ${w.slug}
|
||||||
|
|
||||||
|
You have access to the Vibn workspace **${w.slug}** through the REST API
|
||||||
|
at \`${APP_BASE}\`. The API is scoped to this workspace only — the bearer
|
||||||
|
token cannot reach any other tenant.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
All requests must include:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
Authorization: Bearer $VIBN_API_KEY
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The token lives in \`.env.local\` as \`VIBN_API_KEY\`. Never print it.
|
||||||
|
|
||||||
|
## Workspace identity
|
||||||
|
|
||||||
|
- Coolify Project UUID: \`${w.coolifyProjectUuid ?? "(not provisioned)"}\`
|
||||||
|
- Gitea organization: \`${w.giteaOrg ?? "(not provisioned)"}\`
|
||||||
|
- Provision status: \`${w.provisionStatus}\`
|
||||||
|
|
||||||
|
All git operations should target the \`${w.giteaOrg ?? "(unset)"}\` org on
|
||||||
|
\`git.vibnai.com\`. All deployments live under the Coolify Project above.
|
||||||
|
|
||||||
|
## Useful endpoints (all under ${APP_BASE})
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|-------:|--------------------------------------------|--------------------------|
|
||||||
|
| GET | /api/workspaces/${w.slug} | Workspace details |
|
||||||
|
| GET | /api/workspaces/${w.slug}/keys | List API keys |
|
||||||
|
| POST | /api/workspaces/${w.slug}/provision | Re-run Coolify+Gitea provisioning |
|
||||||
|
| GET | /api/projects | Projects in this workspace |
|
||||||
|
| POST | /api/projects/create | Create a new project (provisions repo + Coolify apps) |
|
||||||
|
| GET | /api/projects/{projectId} | Project details |
|
||||||
|
| GET | /api/projects/{projectId}/preview-url | Live deploy URLs |
|
||||||
|
| GET | /api/projects/{projectId}/file?path=... | Read a file from the repo |
|
||||||
|
|
||||||
|
## House rules for AI agents
|
||||||
|
|
||||||
|
1. Confirm with the user before creating new projects, repos, or deployments.
|
||||||
|
2. Use \`git.vibnai.com/${w.giteaOrg ?? "<org>"}/<repo>\` for git remotes.
|
||||||
|
3. Prefer issuing PRs over force-pushing to \`main\`.
|
||||||
|
4. If a request is rejected with 403, re-check that the key is for
|
||||||
|
workspace \`${w.slug}\` (it cannot act on others).
|
||||||
|
5. Treat \`VIBN_API_KEY\` like a password — never echo, log, or commit it.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMcpJson(w: WorkspaceSummary, token: string): string {
|
||||||
|
const config = {
|
||||||
|
mcpServers: {
|
||||||
|
[`vibn-${w.slug}`]: {
|
||||||
|
url: `${APP_BASE}/api/mcp`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return JSON.stringify(config, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvSnippet(w: WorkspaceSummary, token: string): string {
|
||||||
|
return `# Vibn workspace: ${w.slug}
|
||||||
|
# Generated ${new Date().toISOString()}
|
||||||
|
VIBN_API_BASE=${APP_BASE}
|
||||||
|
VIBN_WORKSPACE=${w.slug}
|
||||||
|
VIBN_API_KEY=${token}
|
||||||
|
|
||||||
|
# Quick smoke test:
|
||||||
|
# curl -H "Authorization: Bearer $VIBN_API_KEY" $VIBN_API_BASE/api/workspaces/$VIBN_WORKSPACE
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Inline styles (matches the rest of the dashboard's plain-CSS look)
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: "var(--card-bg, #fff)",
|
||||||
|
border: "1px solid var(--border, #e5e7eb)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardHeaderStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardTitleStyle: React.CSSProperties = {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--ink)",
|
||||||
|
margin: 0,
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardSubtitleStyle: React.CSSProperties = {
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: "var(--muted)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
maxWidth: 520,
|
||||||
|
};
|
||||||
|
|
||||||
|
const kvGrid: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
margin: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Kv({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)" }}>
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd style={{ margin: "4px 0 0", fontSize: 13, color: "var(--ink)", wordBreak: "break-all" }}>{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
@@ -128,7 +128,7 @@ export async function createApplication(opts: {
|
|||||||
const {
|
const {
|
||||||
projectUuid, name, gitRepo,
|
projectUuid, name, gitRepo,
|
||||||
gitBranch = 'main',
|
gitBranch = 'main',
|
||||||
serverUuid = '0',
|
serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc',
|
||||||
environmentName = 'production',
|
environmentName = 'production',
|
||||||
buildPack = 'nixpacks',
|
buildPack = 'nixpacks',
|
||||||
ports = '3000',
|
ports = '3000',
|
||||||
@@ -166,7 +166,7 @@ export async function createMonorepoAppService(opts: {
|
|||||||
projectUuid, appName, gitRepo,
|
projectUuid, appName, gitRepo,
|
||||||
gitBranch = 'main',
|
gitBranch = 'main',
|
||||||
domain,
|
domain,
|
||||||
serverUuid = '0',
|
serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc',
|
||||||
environmentName = 'production',
|
environmentName = 'production',
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
@@ -188,6 +188,10 @@ export async function createMonorepoAppService(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listApplications(): Promise<CoolifyApplication[]> {
|
||||||
|
return coolifyFetch('/applications');
|
||||||
|
}
|
||||||
|
|
||||||
export async function deployApplication(uuid: string): Promise<{ deployment_uuid: string }> {
|
export async function deployApplication(uuid: string): Promise<{ deployment_uuid: string }> {
|
||||||
return coolifyFetch(`/applications/${uuid}/deploy`, { method: 'POST' });
|
return coolifyFetch(`/applications/${uuid}/deploy`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|||||||
83
lib/gitea.ts
83
lib/gitea.ts
@@ -54,7 +54,10 @@ async function giteaFetch(path: string, options: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new repo under the admin user (or a specified owner).
|
* Create a new repo. By default creates under the admin user.
|
||||||
|
* Pass `owner` to create under a specific user OR org — when the owner
|
||||||
|
* is an org (or any user other than the token holder), Gitea requires
|
||||||
|
* the org-scoped endpoint `/orgs/{owner}/repos`.
|
||||||
*/
|
*/
|
||||||
export async function createRepo(
|
export async function createRepo(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -62,18 +65,86 @@ export async function createRepo(
|
|||||||
): Promise<GiteaRepo> {
|
): Promise<GiteaRepo> {
|
||||||
const { description = '', private: isPrivate = true, owner = GITEA_ADMIN_USER, auto_init = true } = opts;
|
const { description = '', private: isPrivate = true, owner = GITEA_ADMIN_USER, auto_init = true } = opts;
|
||||||
|
|
||||||
return giteaFetch(`/user/repos`, {
|
const body = JSON.stringify({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
private: isPrivate,
|
||||||
|
auto_init,
|
||||||
|
default_branch: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token-owner repos use /user/repos; everything else (orgs, other users)
|
||||||
|
// must go through /orgs/{owner}/repos.
|
||||||
|
const path = owner === GITEA_ADMIN_USER ? `/user/repos` : `/orgs/${owner}/repos`;
|
||||||
|
return giteaFetch(path, { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Organizations (per-workspace tenancy)
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GiteaOrg {
|
||||||
|
id: number;
|
||||||
|
username: string; // org name (Gitea uses "username" for orgs too)
|
||||||
|
full_name: string;
|
||||||
|
description?: string;
|
||||||
|
visibility: 'public' | 'private' | 'limited';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Gitea organization. Requires the admin token to have
|
||||||
|
* permission to create orgs.
|
||||||
|
*/
|
||||||
|
export async function createOrg(opts: {
|
||||||
|
name: string;
|
||||||
|
fullName?: string;
|
||||||
|
description?: string;
|
||||||
|
visibility?: 'public' | 'private' | 'limited';
|
||||||
|
}): Promise<GiteaOrg> {
|
||||||
|
const { name, fullName = name, description = '', visibility = 'private' } = opts;
|
||||||
|
return giteaFetch(`/orgs`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
username: name,
|
||||||
|
full_name: fullName,
|
||||||
description,
|
description,
|
||||||
private: isPrivate,
|
visibility,
|
||||||
auto_init,
|
repo_admin_change_team_access: true,
|
||||||
default_branch: 'main',
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOrg(name: string): Promise<GiteaOrg | null> {
|
||||||
|
try {
|
||||||
|
return await giteaFetch(`/orgs/${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('404')) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a Gitea user to an org's "Owners" team (full access to the org).
|
||||||
|
* Falls back to the org's default team when "Owners" cannot be located.
|
||||||
|
*/
|
||||||
|
export async function addOrgOwner(orgName: string, username: string): Promise<void> {
|
||||||
|
const teams = (await giteaFetch(`/orgs/${orgName}/teams`)) as Array<{ id: number; name: string }>;
|
||||||
|
const owners = teams.find(t => t.name.toLowerCase() === 'owners') ?? teams[0];
|
||||||
|
if (!owners) throw new Error(`No teams found for org ${orgName}`);
|
||||||
|
await giteaFetch(`/teams/${owners.id}/members/${username}`, { method: 'PUT' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(username: string): Promise<{ id: number; login: string } | null> {
|
||||||
|
try {
|
||||||
|
return await giteaFetch(`/users/${username}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('404')) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an existing repo.
|
* Get an existing repo.
|
||||||
*/
|
*/
|
||||||
|
|||||||
270
lib/workspaces.ts
Normal file
270
lib/workspaces.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Vibn workspaces — logical multi-tenancy on top of Coolify + Gitea.
|
||||||
|
*
|
||||||
|
* Each Vibn user gets one workspace. The workspace owns:
|
||||||
|
* - a Coolify Project UUID (the team/namespace boundary inside Coolify)
|
||||||
|
* - a Gitea org (which contains all repos for the workspace)
|
||||||
|
*
|
||||||
|
* All Vibn projects, apps, deployments, and AI access keys are
|
||||||
|
* scoped to a single workspace. Code that touches Coolify or Gitea
|
||||||
|
* MUST resolve a workspace first and use its IDs (never the legacy
|
||||||
|
* hardcoded admin user / project UUID).
|
||||||
|
*
|
||||||
|
* Coolify cannot create real Teams via its public API today — see
|
||||||
|
* Coolify changelog notes about scoping queries to current team and
|
||||||
|
* the lack of POST /teams. We treat one Coolify *Project* as our
|
||||||
|
* tenant boundary instead, and stamp `coolify_team_id` later if/when
|
||||||
|
* Coolify exposes team creation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from '@/lib/db-postgres';
|
||||||
|
import { createProject as createCoolifyProject } from '@/lib/coolify';
|
||||||
|
import { createOrg, getOrg, getUser, addOrgOwner } from '@/lib/gitea';
|
||||||
|
|
||||||
|
export interface VibnWorkspace {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
coolify_project_uuid: string | null;
|
||||||
|
coolify_team_id: number | null;
|
||||||
|
gitea_org: string | null;
|
||||||
|
provision_status: 'pending' | 'partial' | 'ready' | 'error';
|
||||||
|
provision_error: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VibnWorkspaceMember {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: 'owner' | 'admin' | 'member';
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Slug helpers
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SAFE_SLUG = /[^a-z0-9]+/g;
|
||||||
|
|
||||||
|
export function workspaceSlugFromEmail(email: string): string {
|
||||||
|
const local = email.split('@')[0]?.toLowerCase() ?? 'user';
|
||||||
|
return local.replace(SAFE_SLUG, '-').replace(/^-+|-+$/g, '') || 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coolify Project name we use for a workspace. Prefixed to avoid collisions. */
|
||||||
|
export function coolifyProjectNameFor(slug: string): string {
|
||||||
|
return `vibn-ws-${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gitea org name we use for a workspace. Same prefix for consistency. */
|
||||||
|
export function giteaOrgNameFor(slug: string): string {
|
||||||
|
return `vibn-${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// CRUD
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getWorkspaceById(id: string): Promise<VibnWorkspace | null> {
|
||||||
|
return queryOne<VibnWorkspace>(`SELECT * FROM vibn_workspaces WHERE id = $1`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkspaceBySlug(slug: string): Promise<VibnWorkspace | null> {
|
||||||
|
return queryOne<VibnWorkspace>(`SELECT * FROM vibn_workspaces WHERE slug = $1`, [slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkspaceByOwner(userId: string): Promise<VibnWorkspace | null> {
|
||||||
|
return queryOne<VibnWorkspace>(
|
||||||
|
`SELECT * FROM vibn_workspaces WHERE owner_user_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspacesForUser(userId: string): Promise<VibnWorkspace[]> {
|
||||||
|
return query<VibnWorkspace>(
|
||||||
|
`SELECT w.* FROM vibn_workspaces w
|
||||||
|
LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id
|
||||||
|
WHERE w.owner_user_id = $1 OR m.user_id = $1
|
||||||
|
GROUP BY w.id
|
||||||
|
ORDER BY w.created_at ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userHasWorkspaceAccess(userId: string, workspaceId: string): Promise<boolean> {
|
||||||
|
const row = await queryOne<{ id: string }>(
|
||||||
|
`SELECT w.id FROM vibn_workspaces w
|
||||||
|
LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id AND m.user_id = $1
|
||||||
|
WHERE w.id = $2 AND (w.owner_user_id = $1 OR m.user_id = $1)
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId, workspaceId]
|
||||||
|
);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Get-or-create + provision
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently ensures a workspace row exists for the user. Does NOT
|
||||||
|
* provision Coolify/Gitea — call ensureWorkspaceProvisioned() for that.
|
||||||
|
*
|
||||||
|
* Suitable to call from the NextAuth signIn callback (cheap, single insert).
|
||||||
|
*/
|
||||||
|
export async function ensureWorkspaceForUser(opts: {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
}): Promise<VibnWorkspace> {
|
||||||
|
const existing = await getWorkspaceByOwner(opts.userId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const slug = await pickAvailableSlug(workspaceSlugFromEmail(opts.email));
|
||||||
|
const name = opts.displayName?.trim() || opts.email.split('@')[0];
|
||||||
|
|
||||||
|
const inserted = await query<VibnWorkspace>(
|
||||||
|
`INSERT INTO vibn_workspaces (slug, name, owner_user_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[slug, name, opts.userId]
|
||||||
|
);
|
||||||
|
const workspace = inserted[0];
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO vibn_workspace_members (workspace_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
ON CONFLICT (workspace_id, user_id) DO NOTHING`,
|
||||||
|
[workspace.id, opts.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisions Coolify Project + Gitea org for a workspace if not yet done.
|
||||||
|
* Idempotent. Failures are recorded on the row but do not throw — callers
|
||||||
|
* can retry by calling again. Returns the up-to-date workspace row.
|
||||||
|
*/
|
||||||
|
export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Promise<VibnWorkspace> {
|
||||||
|
if (workspace.provision_status === 'ready') return workspace;
|
||||||
|
|
||||||
|
let coolifyUuid = workspace.coolify_project_uuid;
|
||||||
|
let giteaOrg = workspace.gitea_org;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// ── Coolify Project ────────────────────────────────────────────────
|
||||||
|
if (!coolifyUuid) {
|
||||||
|
try {
|
||||||
|
const project = await createCoolifyProject(
|
||||||
|
coolifyProjectNameFor(workspace.slug),
|
||||||
|
`Vibn workspace ${workspace.slug}`
|
||||||
|
);
|
||||||
|
coolifyUuid = project.uuid;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
// Coolify returns 400/409 if the name collides — fall through; the
|
||||||
|
// workspace can still be patched manually with the right UUID.
|
||||||
|
errors.push(`coolify: ${msg}`);
|
||||||
|
console.error('[workspaces] Coolify provisioning failed', workspace.slug, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gitea Org ──────────────────────────────────────────────────────
|
||||||
|
if (!giteaOrg) {
|
||||||
|
const wantOrg = giteaOrgNameFor(workspace.slug);
|
||||||
|
try {
|
||||||
|
const existingOrg = await getOrg(wantOrg);
|
||||||
|
if (existingOrg) {
|
||||||
|
giteaOrg = existingOrg.username;
|
||||||
|
} else {
|
||||||
|
const created = await createOrg({
|
||||||
|
name: wantOrg,
|
||||||
|
fullName: workspace.name,
|
||||||
|
description: `Vibn workspace for ${workspace.slug}`,
|
||||||
|
visibility: 'private',
|
||||||
|
});
|
||||||
|
giteaOrg = created.username;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push(`gitea: ${msg}`);
|
||||||
|
console.error('[workspaces] Gitea org provisioning failed', workspace.slug, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add the workspace owner to the Gitea org if they have a Gitea account.
|
||||||
|
// Best-effort: most Vibn users won't have a Gitea login, so a 404 is fine.
|
||||||
|
if (giteaOrg) {
|
||||||
|
try {
|
||||||
|
const ownerEmail = await queryOne<{ email: string }>(
|
||||||
|
`SELECT data->>'email' AS email FROM fs_users WHERE id = $1`,
|
||||||
|
[workspace.owner_user_id]
|
||||||
|
);
|
||||||
|
const candidateLogin = ownerEmail?.email
|
||||||
|
? workspaceSlugFromEmail(ownerEmail.email)
|
||||||
|
: null;
|
||||||
|
if (candidateLogin) {
|
||||||
|
const giteaUser = await getUser(candidateLogin);
|
||||||
|
if (giteaUser) {
|
||||||
|
await addOrgOwner(giteaOrg, giteaUser.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Membership add is best-effort
|
||||||
|
console.warn('[workspaces] Skipping Gitea owner add', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: VibnWorkspace['provision_status'] =
|
||||||
|
coolifyUuid && giteaOrg ? 'ready' : errors.length > 0 ? 'partial' : 'pending';
|
||||||
|
|
||||||
|
const updated = await query<VibnWorkspace>(
|
||||||
|
`UPDATE vibn_workspaces
|
||||||
|
SET coolify_project_uuid = COALESCE($2, coolify_project_uuid),
|
||||||
|
gitea_org = COALESCE($3, gitea_org),
|
||||||
|
provision_status = $4,
|
||||||
|
provision_error = $5,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[workspace.id, coolifyUuid, giteaOrg, status, errors.length ? errors.join('; ') : null]
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: get-or-create + provision in one call. Used by the
|
||||||
|
* project-create flow so the first project in a fresh account always
|
||||||
|
* has somewhere to land.
|
||||||
|
*/
|
||||||
|
export async function getOrCreateProvisionedWorkspace(opts: {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
}): Promise<VibnWorkspace> {
|
||||||
|
const ws = await ensureWorkspaceForUser(opts);
|
||||||
|
return ensureWorkspaceProvisioned(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Slug uniqueness
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pickAvailableSlug(base: string): Promise<string> {
|
||||||
|
// Try base, then base-2, base-3, … up to base-99.
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const candidate = i === 0 ? base : `${base}-${i + 1}`;
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM vibn_workspaces WHERE slug = $1 LIMIT 1`,
|
||||||
|
[candidate]
|
||||||
|
);
|
||||||
|
if (!existing) return candidate;
|
||||||
|
}
|
||||||
|
// Extremely unlikely fallback
|
||||||
|
return `${base}-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ export function CTA() {
|
|||||||
return (
|
return (
|
||||||
<section className="w-full py-16 md:py-24">
|
<section className="w-full py-16 md:py-24">
|
||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-6 rounded-2xl border bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-950/20 dark:to-purple-950/20 p-12 md:p-16">
|
<div className="vibn-cta-surface mx-auto flex max-w-[980px] flex-col items-center gap-6 rounded-2xl border border-border p-12 md:p-16">
|
||||||
<h2 className="text-2xl font-bold leading-tight tracking-tight md:text-4xl lg:text-5xl text-center max-w-[800px]">
|
<h2 className="font-serif text-2xl font-semibold leading-tight tracking-tight md:text-4xl lg:text-5xl text-center max-w-[800px]">
|
||||||
{homepage.finalCTA.title}
|
{homepage.finalCTA.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ export function EmotionalHook() {
|
|||||||
<section className="w-full py-16 md:py-24">
|
<section className="w-full py-16 md:py-24">
|
||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="mx-auto max-w-[800px] text-center space-y-4">
|
<div className="mx-auto max-w-[800px] text-center space-y-4">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-5xl">
|
<h2 className="font-serif text-3xl font-semibold tracking-tight md:text-5xl">
|
||||||
{homepage.emotionalHook.title}
|
{homepage.emotionalHook.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-4xl font-bold tracking-tight md:text-6xl bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
<p className="vibn-gradient-text font-serif text-4xl font-semibold tracking-tight md:text-6xl">
|
||||||
{homepage.emotionalHook.subtitle}
|
{homepage.emotionalHook.subtitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg text-muted-foreground md:text-xl pt-4 leading-relaxed">
|
<p className="text-lg text-muted-foreground md:text-xl pt-4 leading-relaxed">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function Features() {
|
|||||||
<section id="features" className="w-full py-16 md:py-24">
|
<section id="features" className="w-full py-16 md:py-24">
|
||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4 mb-12">
|
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4 mb-12">
|
||||||
<h2 className="text-3xl font-bold leading-tight tracking-tighter md:text-5xl text-center">
|
<h2 className="font-serif text-3xl font-semibold leading-tight tracking-tight md:text-5xl text-center">
|
||||||
{homepage.features.title}
|
{homepage.features.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-[750px] text-center text-lg text-muted-foreground md:text-xl">
|
<p className="max-w-[750px] text-center text-lg text-muted-foreground md:text-xl">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function Footer() {
|
|||||||
<div className="col-span-2 md:col-span-1">
|
<div className="col-span-2 md:col-span-1">
|
||||||
<Link href="/" className="flex items-center gap-2 mb-4">
|
<Link href="/" className="flex items-center gap-2 mb-4">
|
||||||
<img src="/vibn-black-circle-logo.png" alt="Vib'n" className="h-8 w-8" />
|
<img src="/vibn-black-circle-logo.png" alt="Vib'n" className="h-8 w-8" />
|
||||||
<span className="text-lg font-bold">Vib'n</span>
|
<span className="font-serif text-lg font-bold tracking-tight">Vib'n</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-[220px]">
|
<p className="text-sm text-muted-foreground leading-relaxed max-w-[220px]">
|
||||||
AI-powered development platform for vibe coders. From idea to market.
|
AI-powered development platform for vibe coders. From idea to market.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function Hero() {
|
|||||||
<div className="flex flex-col items-center gap-6 pb-16 pt-16 md:py-24 lg:py-32">
|
<div className="flex flex-col items-center gap-6 pb-16 pt-16 md:py-24 lg:py-32">
|
||||||
<div className="flex max-w-[980px] flex-col items-center gap-6 text-center">
|
<div className="flex max-w-[980px] flex-col items-center gap-6 text-center">
|
||||||
{/* Main title */}
|
{/* Main title */}
|
||||||
<h1 className="text-3xl font-extrabold leading-tight tracking-tighter md:text-5xl lg:text-6xl lg:leading-[1.1]">
|
<h1 className="font-serif text-3xl font-bold leading-tight tracking-tight md:text-5xl md:font-semibold lg:text-6xl lg:leading-[1.1]">
|
||||||
{homepage.hero.title}
|
{homepage.hero.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function HowItWorks() {
|
|||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="mx-auto max-w-[900px] space-y-12">
|
<div className="mx-auto max-w-[900px] space-y-12">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-5xl">
|
<h2 className="font-serif text-3xl font-semibold tracking-tight md:text-5xl">
|
||||||
{homepage.howItWorks.title}
|
{homepage.howItWorks.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-muted-foreground md:text-xl">
|
<p className="text-lg text-muted-foreground md:text-xl">
|
||||||
@@ -18,7 +18,7 @@ export function HowItWorks() {
|
|||||||
{homepage.howItWorks.steps.map((step) => (
|
{homepage.howItWorks.steps.map((step) => (
|
||||||
<div key={step.number} className="flex gap-6">
|
<div key={step.number} className="flex gap-6">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-blue-600 to-purple-600 text-white font-bold text-xl">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground font-bold text-xl">
|
||||||
{step.number}
|
{step.number}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
marketing/components/justine/JustineFooter.tsx
Normal file
32
marketing/components/justine/JustineFooter.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/** Footer from justine/01_homepage.html */
|
||||||
|
export function JustineFooter() {
|
||||||
|
return (
|
||||||
|
<footer>
|
||||||
|
<div>
|
||||||
|
<span className="f" style={{ fontSize: 16, fontWeight: 700, color: "var(--ink)" }}>
|
||||||
|
vibn
|
||||||
|
</span>
|
||||||
|
<span className="footer-tagline">The fastest way from idea to product.</span>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<Link href="/#how-it-works" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
|
||||||
|
How it works
|
||||||
|
</Link>
|
||||||
|
<Link href="/pricing" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
<Link href="/privacy" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
<Link href="/terms" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
|
||||||
|
Terms
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12.5, color: "var(--muted)", textAlign: "right", display: "block" }}>
|
||||||
|
© {new Date().getFullYear()} vibn
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
518
marketing/components/justine/JustineHomePage.tsx
Normal file
518
marketing/components/justine/JustineHomePage.tsx
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body sections from justine/01_homepage.html — inline styles + classes match the source file.
|
||||||
|
* Lives under [data-justine]; tokens are --ink, --mid, --muted, --border, --white (see 01-homepage.css).
|
||||||
|
*/
|
||||||
|
export function JustineHomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className="hero-section"
|
||||||
|
style={{ maxWidth: 980, margin: "0 auto", padding: "88px 52px 72px" }}
|
||||||
|
>
|
||||||
|
<div className="hero-grid">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.13em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginBottom: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
For non-technical founders
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="f hero-h1"
|
||||||
|
style={{
|
||||||
|
fontSize: 58,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--ink)",
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
lineHeight: 1.06,
|
||||||
|
marginBottom: 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
You have the idea.
|
||||||
|
<br />
|
||||||
|
We handle
|
||||||
|
<br />
|
||||||
|
<em className="gradient-em">everything else.</em>
|
||||||
|
</h1>
|
||||||
|
<p className="hero-sub" style={{ fontSize: 17, color: "var(--mid)", lineHeight: 1.75 }}>
|
||||||
|
You describe it. Vibn builds it, launches it, and markets it. From idea to{" "}
|
||||||
|
<strong style={{ color: "var(--ink)" }}>live</strong> product in{" "}
|
||||||
|
<strong style={{ color: "var(--ink)" }}>72 hours</strong> — no code, no agencies, no
|
||||||
|
waiting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--white)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 20px 60px rgba(30,27,75,0.05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 26px 20px",
|
||||||
|
background: "#FCFCFF",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your idea
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="f"
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontStyle: "italic",
|
||||||
|
color: "var(--ink)",
|
||||||
|
lineHeight: 1.65,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
"I want to build a booking tool for independent personal trainers."
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--muted)",
|
||||||
|
background: "var(--white)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: "3px 9px",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↵ Enter
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "20px 26px 24px", background: "var(--white)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
vibn generated
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||||
|
{[
|
||||||
|
["Pages", "Landing, Dashboard, Booking, Payments"],
|
||||||
|
["Stack", "Auth, database, payments — handled"],
|
||||||
|
["Revenue", "Subscription · $29 / mo"],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div
|
||||||
|
key={k}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
padding: "10px 0",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--muted)", fontWeight: 500 }}>{k}</span>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--ink)", fontWeight: 600 }}>{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
padding: "10px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--muted)", fontWeight: 500 }}>Status</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#6366F1" }}>
|
||||||
|
⬤ Ready to build
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 52,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link href="/auth">
|
||||||
|
<button type="button" className="btn-ink-lg">
|
||||||
|
Start free — no code needed
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 13.5, color: "#818CF8" }}>★★★★★</span>
|
||||||
|
<span style={{ fontSize: 13.5, color: "var(--stone)" }}>
|
||||||
|
280 founders launched
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: "#9CA3AF" }}>No credit card required · Free forever plan</p>
|
||||||
|
<Link
|
||||||
|
href="/#how-it-works"
|
||||||
|
style={{
|
||||||
|
fontSize: 13.5,
|
||||||
|
color: "#6366F1",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See how it works →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="empathy-section"
|
||||||
|
style={{ borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)", padding: "80px 52px" }}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||||
|
<div className="empathy-grid">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.13em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginBottom: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sound familiar?
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="f"
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#1A1A1A",
|
||||||
|
lineHeight: 1.18,
|
||||||
|
marginBottom: 24,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
The idea is the hard part.{" "}
|
||||||
|
<span className="gradient-text">Everything else shouldn't be.</span>
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 15, color: "var(--mid)", lineHeight: 1.82, marginBottom: 20 }}>
|
||||||
|
You know exactly what you want to build and who it's for. But the moment you
|
||||||
|
think about servers, databases, deployment pipelines, SEO — the whole thing stalls.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 15, color: "var(--mid)", lineHeight: 1.82 }}>
|
||||||
|
vibn exists to remove all of that. Not abstract it —{" "}
|
||||||
|
<em className="f" style={{ fontStyle: "italic" }}>
|
||||||
|
remove it entirely.
|
||||||
|
</em>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
t: `No more "I need to hire a developer first"`,
|
||||||
|
d: "vibn is your developer. Start building the moment you have an idea.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "No more staring at a blank marketing calendar",
|
||||||
|
d: "AI generates and publishes your content every single week.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: `No more "I'll launch when it's ready"`,
|
||||||
|
d: "Most founders ship their first version in under 72 hours.",
|
||||||
|
},
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.t} className="empathy-card">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "1.5px solid rgba(99,102,241,0.4)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#6366F1",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="f" style={{ fontSize: 14, fontWeight: 600, color: "#1A1A1A", marginBottom: 4 }}>
|
||||||
|
{row.t}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--mid)", lineHeight: 1.7 }}>{row.d}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="how-it-works" className="how-section" style={{ maxWidth: 980, margin: "0 auto", padding: "84px 52px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.13em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
How it works
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="f"
|
||||||
|
style={{
|
||||||
|
fontSize: 42,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#1A1A1A",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
marginBottom: 54,
|
||||||
|
maxWidth: 480,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Four phases. One <span className="gradient-text">complete</span> product.
|
||||||
|
</h2>
|
||||||
|
<div className="phase-grid">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
k: "01 — Discover",
|
||||||
|
t: "Define your idea",
|
||||||
|
p: "Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon.",
|
||||||
|
style: {
|
||||||
|
padding: "40px 44px",
|
||||||
|
background: "var(--white)",
|
||||||
|
borderRight: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
borderBottom: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
k: "02 — Design",
|
||||||
|
t: "Choose your style",
|
||||||
|
p: "Pick a visual style and see your exact site and emails live before a single line of code is written.",
|
||||||
|
style: {
|
||||||
|
padding: "40px 44px",
|
||||||
|
background: "var(--white)",
|
||||||
|
borderBottom: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
k: "03 — Build",
|
||||||
|
t: "Your app, live",
|
||||||
|
p: "AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English.",
|
||||||
|
style: {
|
||||||
|
padding: "40px 44px",
|
||||||
|
background: "var(--white)",
|
||||||
|
borderRight: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
k: "04 — Grow",
|
||||||
|
t: "Market & automate",
|
||||||
|
p: "AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on users.",
|
||||||
|
style: { padding: "40px 44px", background: "var(--white)" } satisfies CSSProperties,
|
||||||
|
},
|
||||||
|
].map((cell) => (
|
||||||
|
<div key={cell.k} style={cell.style}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "rgba(99,102,241,0.6)",
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell.k}
|
||||||
|
</div>
|
||||||
|
<div className="f" style={{ fontSize: 22, fontWeight: 700, color: "#1A1A1A", marginBottom: 10 }}>
|
||||||
|
{cell.t}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.72 }}>{cell.p}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: "var(--white)", borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<div className="wyg-grid wyg-section" style={{ maxWidth: 980, margin: "0 auto", padding: "0 52px" }}>
|
||||||
|
<div style={{ padding: "44px 40px 44px 0", borderRight: "1px solid var(--border)" }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#6366F1", marginBottom: 12, textAlign: "center" }}>✦</div>
|
||||||
|
<div className="f" style={{ fontSize: 17, fontWeight: 700, color: "#1A1A1A", marginBottom: 8, textAlign: "center" }}>
|
||||||
|
A live, working product
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.7, textAlign: "center" }}>
|
||||||
|
Not a prototype. Real auth, real payments, real database — on your own URL from day one.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, textAlign: "center", marginTop: 10 }}>
|
||||||
|
Runs on your own servers — your data, your infrastructure, no lock-in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "44px 40px", borderRight: "1px solid var(--border)" }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#6366F1", marginBottom: 12, textAlign: "center" }}>✦</div>
|
||||||
|
<div className="f" style={{ fontSize: 17, fontWeight: 700, color: "#1A1A1A", marginBottom: 8, textAlign: "center" }}>
|
||||||
|
A full marketing engine
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.7, textAlign: "center" }}>
|
||||||
|
Blog posts, onboarding emails, and social content — written and published automatically every week.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "44px 0 44px 40px" }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#6366F1", marginBottom: 12, textAlign: "center" }}>✦</div>
|
||||||
|
<div className="f" style={{ fontSize: 17, fontWeight: 700, color: "#1A1A1A", marginBottom: 8, textAlign: "center" }}>
|
||||||
|
A product that evolves
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.7, textAlign: "center" }}>
|
||||||
|
Describe changes in plain English. Vibn handles the code so your product grows as fast as your ideas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="quote-section" style={{ background: "#1A1A1A", padding: "32px 52px 28px" }}>
|
||||||
|
<div style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||||
|
<div className="quote-grid">
|
||||||
|
<div className="quote-side" style={{ display: "flex", gap: 14, opacity: 0.85 }}>
|
||||||
|
<div style={{ width: 2, background: "#6366F1", borderRadius: 2, flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<p className="f" style={{ fontSize: 12.5, color: "#FFFFFF", lineHeight: 1.65, fontStyle: "italic", marginBottom: 8 }}>
|
||||||
|
"I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing."
|
||||||
|
</p>
|
||||||
|
<span style={{ fontSize: 10.5, color: "var(--muted)", fontWeight: 600 }}>— Alex K., founder of Taskly</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "rgba(255,255,255,0.05)", borderRadius: 12, padding: "22px 26px" }}>
|
||||||
|
<div style={{ width: 3, height: 16, background: "#6366F1", borderRadius: 2, marginBottom: 12, opacity: 0.7 }} />
|
||||||
|
<p className="f" style={{ fontSize: 16, color: "#FFFFFF", lineHeight: 1.7, fontStyle: "italic", marginBottom: 12 }}>
|
||||||
|
"I have zero coding experience. Three weeks in, I have 300 paying users. That's entirely because of vibn."
|
||||||
|
</p>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--muted)", fontWeight: 600 }}>— Marcus L., founder of Flowmatic</span>
|
||||||
|
</div>
|
||||||
|
<div className="quote-side" style={{ display: "flex", gap: 14, opacity: 0.85 }}>
|
||||||
|
<div style={{ width: 2, background: "#6366F1", borderRadius: 2, flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<p className="f" style={{ fontSize: 12.5, color: "#FFFFFF", lineHeight: 1.65, fontStyle: "italic", marginBottom: 8 }}>
|
||||||
|
"The marketing autopilot saved me ten hours a week. My blog runs itself. I just focus on product."
|
||||||
|
</p>
|
||||||
|
<span style={{ fontSize: 10.5, color: "var(--muted)", fontWeight: 600 }}>— Sara R., founder of Nudge</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", gap: 7 }}>
|
||||||
|
<div style={{ width: 5, height: 5, borderRadius: "50%", background: "rgba(255,255,255,0.3)" }} />
|
||||||
|
<div style={{ width: 16, height: 5, borderRadius: 3, background: "#FFFFFF" }} />
|
||||||
|
<div style={{ width: 5, height: 5, borderRadius: "50%", background: "rgba(255,255,255,0.3)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: "var(--white)", borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<div className="stats-grid stats-section" style={{ maxWidth: 980, margin: "0 auto", padding: "0 52px" }}>
|
||||||
|
{[
|
||||||
|
{ num: "280+", label: "founders launched", pl: 0, pr: true },
|
||||||
|
{ num: "72h", label: "average time to first version", pl: 36, pr: true },
|
||||||
|
{ num: "4.9★", label: "average rating", pl: 36, pr: true },
|
||||||
|
{ num: "3×", label: "faster than hiring a developer", pl: 36, pr: false },
|
||||||
|
].map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
style={{
|
||||||
|
padding: row.pl ? `40px 0 40px ${row.pl}px` : "40px 0",
|
||||||
|
borderRight: row.pr ? "1px solid var(--border)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="f gradient-num" style={{ fontSize: 40, fontWeight: 700, letterSpacing: "-0.03em", marginBottom: 6 }}>
|
||||||
|
{row.num}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "var(--muted)" }}>{row.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="cta-section" style={{ padding: "80px 52px", textAlign: "center" }}>
|
||||||
|
<div
|
||||||
|
className="cta-card"
|
||||||
|
style={{
|
||||||
|
maxWidth: 680,
|
||||||
|
margin: "0 auto",
|
||||||
|
background: "#FFFFFF",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "64px 52px",
|
||||||
|
boxShadow: "0 0 0 1px rgba(99,102,241,0.15),0 20px 60px rgba(30,27,75,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="f"
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--ink)",
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your idea deserves to exist.
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 16, color: "var(--mid)", lineHeight: 1.75, marginBottom: 38 }}>
|
||||||
|
Thousands of ideas never make it past a spreadsheet. Yours doesn't have to be one of them.
|
||||||
|
</p>
|
||||||
|
<Link href="/auth">
|
||||||
|
<button type="button" className="btn-ink-lg" style={{ marginBottom: 16 }}>
|
||||||
|
Build my product — free
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
<div style={{ fontSize: 12.5, color: "var(--muted)" }}>Joins 280+ non-technical founders already live</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
marketing/components/justine/JustineNav.tsx
Normal file
115
marketing/components/justine/JustineNav.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Nav from justine/01_homepage.html — classes defined in app/styles/justine/01-homepage.css */
|
||||||
|
export function JustineNav() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setOpen((o) => {
|
||||||
|
const next = !o;
|
||||||
|
document.body.style.overflow = next ? "hidden" : "";
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav>
|
||||||
|
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none" }}>
|
||||||
|
<div
|
||||||
|
className="logo-box"
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
background: "linear-gradient(135deg,#2E2A5E,#4338CA)",
|
||||||
|
borderRadius: 7,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="f" style={{ fontSize: 15, fontWeight: 700, color: "#FFFFFF" }}>
|
||||||
|
V
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="f" style={{ fontSize: 19, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.02em" }}>
|
||||||
|
vibn
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="nav-links">
|
||||||
|
<Link href="/#how-it-works" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
|
||||||
|
How it works
|
||||||
|
</Link>
|
||||||
|
<Link href="/pricing" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
<Link href="/features" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
|
||||||
|
Stories
|
||||||
|
</Link>
|
||||||
|
<span style={{ fontSize: 14, color: "var(--muted)" }}>Blog</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nav-right-btns" style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<Link href="/auth" style={{ fontSize: 14, color: "#6366F1", fontWeight: 600, textDecoration: "none" }}>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link href="/auth">
|
||||||
|
<button type="button" className="btn-ink">
|
||||||
|
Get started free
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`hamburger ${open ? "open" : ""}`}
|
||||||
|
aria-label={open ? "Close menu" : "Open menu"}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={`mobile-menu ${open ? "open" : ""}`}>
|
||||||
|
<Link href="/#how-it-works" onClick={close}>
|
||||||
|
How it works
|
||||||
|
</Link>
|
||||||
|
<Link href="/pricing" onClick={close}>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
<Link href="/features" onClick={close}>
|
||||||
|
Stories
|
||||||
|
</Link>
|
||||||
|
<Link href="#" onClick={(e) => { e.preventDefault(); close(); }}>
|
||||||
|
Blog
|
||||||
|
</Link>
|
||||||
|
<Link href="/auth" style={{ color: "#6366F1", fontWeight: 600 }} onClick={close}>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<div className="mobile-menu-cta">
|
||||||
|
<Link href="/auth" onClick={close} style={{ display: "block", width: "100%" }}>
|
||||||
|
<button type="button" className="btn-ink-lg" style={{ width: "100%" }}>
|
||||||
|
Get started free
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
marketing/components/justine/index.ts
Normal file
3
marketing/components/justine/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { JustineNav } from "./JustineNav";
|
||||||
|
export { JustineFooter } from "./JustineFooter";
|
||||||
|
export { JustineHomePage } from "./JustineHomePage";
|
||||||
@@ -10,7 +10,7 @@ export function Pricing() {
|
|||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="mx-auto max-w-[980px] space-y-12">
|
<div className="mx-auto max-w-[980px] space-y-12">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-5xl">
|
<h2 className="font-serif text-3xl font-semibold tracking-tight md:text-5xl">
|
||||||
{homepage.pricing.title}
|
{homepage.pricing.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-muted-foreground md:text-xl">
|
<p className="text-lg text-muted-foreground md:text-xl">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function Transformation() {
|
|||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="mx-auto max-w-[900px] space-y-12">
|
<div className="mx-auto max-w-[900px] space-y-12">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-5xl">
|
<h2 className="font-serif text-3xl font-semibold tracking-tight md:text-5xl">
|
||||||
{homepage.transformation.title}
|
{homepage.transformation.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-muted-foreground md:text-xl pt-4 leading-relaxed">
|
<p className="text-lg text-muted-foreground md:text-xl pt-4 leading-relaxed">
|
||||||
@@ -21,8 +21,8 @@ export function Transformation() {
|
|||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-4 rounded-lg border bg-background p-6 shadow-sm"
|
className="flex items-start gap-4 rounded-lg border bg-background p-6 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="rounded-full bg-gradient-to-br from-blue-500 to-purple-600 p-2 mt-1">
|
<div className="mt-1 rounded-full bg-primary p-2 text-primary-foreground">
|
||||||
<Sparkles className="h-5 w-5 text-white" />
|
<Sparkles className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg leading-relaxed font-medium">{outcome}</p>
|
<p className="text-lg leading-relaxed font-medium">{outcome}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function WhoItsFor() {
|
|||||||
<div className="container mx-auto px-6">
|
<div className="container mx-auto px-6">
|
||||||
<div className="mx-auto max-w-[900px] space-y-12">
|
<div className="mx-auto max-w-[900px] space-y-12">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-3xl font-bold tracking-tight md:text-5xl">
|
<h2 className="font-serif text-3xl font-semibold tracking-tight md:text-5xl">
|
||||||
{homepage.whoItsFor.title}
|
{homepage.whoItsFor.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-2xl font-semibold text-muted-foreground md:text-3xl">
|
<p className="text-2xl font-semibold text-muted-foreground md:text-3xl">
|
||||||
|
|||||||
159
scripts/migrate-fs-tables.sql
Normal file
159
scripts/migrate-fs-tables.sql
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- VIBN fs_* tables + agent_sessions migration
|
||||||
|
-- Run once against the production Coolify Postgres database.
|
||||||
|
--
|
||||||
|
-- These tables back the live app (fs_ prefix = "Firestore-shaped" flexible
|
||||||
|
-- JSONB rows that replaced the original Firebase collections).
|
||||||
|
--
|
||||||
|
-- Safe to re-run — all statements use IF NOT EXISTS / ON CONFLICT.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Enable uuid support (safe no-op if already enabled)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- fs_users (mirrors Firebase Auth + Firestore user docs)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS fs_users (
|
||||||
|
id TEXT PRIMARY KEY, -- gen_random_uuid()::text at insert time
|
||||||
|
user_id TEXT, -- NextAuth User.id (cuid)
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- fs_projects (Firestore projects collection)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS fs_projects (
|
||||||
|
id TEXT PRIMARY KEY, -- randomUUID() at insert time
|
||||||
|
user_id TEXT NOT NULL, -- FK → fs_users.id
|
||||||
|
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 INDEX IF NOT EXISTS fs_projects_slug_idx
|
||||||
|
ON fs_projects (slug);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- fs_sessions (AI coding session logs)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
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 (vibn-agent-runner execution records)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id TEXT NOT NULL, -- fs_projects.id (TEXT)
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- agent_session_events (append-only timeline for SSE + replay)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
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 (required by PrismaAdapter + strategy:"database")
|
||||||
|
-- Only created if not already present from a prisma migrate run.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Done
|
||||||
|
SELECT 'Migration complete' AS status;
|
||||||
Reference in New Issue
Block a user