Compare commits
62 Commits
24812df89b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets.
|
||||
|
||||
# --- Postgres (Coolify internal service DNS, same stack as this app) ---
|
||||
# Example: postgresql://USER:PASS@<coolify-service-uuid>:5432/vibn
|
||||
DATABASE_URL=
|
||||
POSTGRES_URL=
|
||||
|
||||
# --- Public URL of this Next app (OAuth callbacks, runner callbacks) ---
|
||||
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=
|
||||
|
||||
# --- Google OAuth / Gemini (see .google.env locally) ---
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# 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/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 --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function FeaturesPage() {
|
||||
return (
|
||||
<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">
|
||||
<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
|
||||
</h1>
|
||||
<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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Code2 className="h-12 w-12 text-blue-600" />
|
||||
<Code2 className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Automatic Session Tracking</CardTitle>
|
||||
<CardDescription>
|
||||
Every coding session is automatically captured with zero configuration.
|
||||
@@ -48,7 +48,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Brain className="h-12 w-12 text-purple-600" />
|
||||
<Brain className="h-12 w-12 text-primary" />
|
||||
<CardTitle>AI Usage Analytics</CardTitle>
|
||||
<CardDescription>
|
||||
Deep insights into how you and your team use AI tools.
|
||||
@@ -66,7 +66,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<DollarSign className="h-12 w-12 text-green-600" />
|
||||
<DollarSign className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Cost Tracking</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time cost monitoring for all your AI services.
|
||||
@@ -84,7 +84,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Clock className="h-12 w-12 text-orange-600" />
|
||||
<Clock className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Productivity Metrics</CardTitle>
|
||||
<CardDescription>
|
||||
Track your velocity and identify productivity patterns.
|
||||
@@ -102,7 +102,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Github className="h-12 w-12 text-gray-600" />
|
||||
<Github className="h-12 w-12 text-primary" />
|
||||
<CardTitle>GitHub Integration</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your repositories for comprehensive code analysis.
|
||||
@@ -120,7 +120,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Sparkles className="h-12 w-12 text-pink-600" />
|
||||
<Sparkles className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Smart Summaries</CardTitle>
|
||||
<CardDescription>
|
||||
AI-powered summaries of your work and progress.
|
||||
@@ -138,7 +138,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Users className="h-12 w-12 text-cyan-600" />
|
||||
<Users className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Team Collaboration</CardTitle>
|
||||
<CardDescription>
|
||||
Built for teams working with AI tools together.
|
||||
@@ -156,7 +156,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<FileCode className="h-12 w-12 text-indigo-600" />
|
||||
<FileCode className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Code Quality Tracking</CardTitle>
|
||||
<CardDescription>
|
||||
Monitor code quality and AI-generated code effectiveness.
|
||||
@@ -174,7 +174,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<TrendingUp className="h-12 w-12 text-emerald-600" />
|
||||
<TrendingUp className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Trend Analysis</CardTitle>
|
||||
<CardDescription>
|
||||
Understand long-term patterns in your development process.
|
||||
@@ -192,7 +192,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Shield className="h-12 w-12 text-red-600" />
|
||||
<Shield className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Privacy & Security</CardTitle>
|
||||
<CardDescription>
|
||||
Your code and data stay private and secure.
|
||||
@@ -210,7 +210,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Zap className="h-12 w-12 text-yellow-600" />
|
||||
<Zap className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Real-Time Insights</CardTitle>
|
||||
<CardDescription>
|
||||
Get instant feedback as you code.
|
||||
@@ -228,7 +228,7 @@ export default function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<BarChart3 className="h-12 w-12 text-violet-600" />
|
||||
<BarChart3 className="h-12 w-12 text-primary" />
|
||||
<CardTitle>Custom Reports</CardTitle>
|
||||
<CardDescription>
|
||||
Create custom reports tailored to your needs.
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function MarketingLayout({
|
||||
alt="Vib'n"
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<span className="text-xl font-bold">Vib'n</span>
|
||||
<span className="font-serif text-xl font-bold tracking-tight">Vib'n</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
|
||||
@@ -32,7 +32,7 @@ function typeColor(t: string) {
|
||||
|
||||
const FILTERS = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "atlas", label: "Atlas" },
|
||||
{ id: "atlas", label: "Vibn" },
|
||||
{ id: "build", label: "Builds" },
|
||||
{ id: "deploy", label: "Deploys" },
|
||||
{ id: "user", label: "You" },
|
||||
@@ -58,10 +58,10 @@ export default function ActivityPage() {
|
||||
return (
|
||||
<div
|
||||
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={{
|
||||
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,
|
||||
}}>
|
||||
Activity
|
||||
@@ -81,7 +81,7 @@ export default function ActivityPage() {
|
||||
background: filter === f.id ? "#1a1a1a" : "#fff",
|
||||
color: filter === f.id ? "#fff" : "#6b6560",
|
||||
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}
|
||||
|
||||
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) {
|
||||
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…
|
||||
</div>
|
||||
);
|
||||
@@ -77,11 +77,11 @@ export default function DeploymentPage() {
|
||||
return (
|
||||
<div
|
||||
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 }}>
|
||||
<h3 style={{
|
||||
fontFamily: "Newsreader, serif", fontSize: "1.2rem",
|
||||
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
|
||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
||||
}}>
|
||||
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>
|
||||
<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 ↗
|
||||
</a>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export default function DeploymentPage() {
|
||||
</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>
|
||||
<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 ↗
|
||||
</a>
|
||||
</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>
|
||||
<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 ↗
|
||||
</a>
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@ export default function DeploymentPage() {
|
||||
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
||||
{!hasPRD
|
||||
? "Complete your PRD with Atlas first, then build and deploy."
|
||||
? "Complete your PRD with Vibn first, then build and deploy."
|
||||
: !hasRepo
|
||||
? "No repository yet — the Architect agent will scaffold one from your PRD."
|
||||
: "No deployment yet — kick off a build to get a live URL."}
|
||||
@@ -166,7 +166,7 @@ export default function DeploymentPage() {
|
||||
<button
|
||||
onClick={handleConnectDomain}
|
||||
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"}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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 {
|
||||
SCAFFOLD_REGISTRY, THEME_REGISTRY,
|
||||
@@ -360,7 +361,7 @@ const LIBRARY_STYLE_OPTIONS: Record<string, LibraryStyleOptions> = {
|
||||
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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" }}>
|
||||
{children}
|
||||
</div>
|
||||
@@ -382,7 +383,7 @@ function OptionChip({
|
||||
display: "flex", alignItems: "center", gap: 5,
|
||||
padding: multi ? "4px 9px" : "4px 11px",
|
||||
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",
|
||||
borderColor: active ? "#1a1a1a" : "#e0dcd4",
|
||||
background: active ? "#1a1a1a" : "#fff",
|
||||
@@ -410,7 +411,7 @@ function ModeToggle({ value, onChange }: { value: string; onChange: (v: "dark" |
|
||||
key={m}
|
||||
onClick={() => onChange(id)}
|
||||
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,
|
||||
background: active ? "#1a1a1a" : "transparent",
|
||||
color: active ? "#fff" : "#8a8478",
|
||||
@@ -621,7 +622,7 @@ function SurfaceSection({
|
||||
{ScaffoldComponent
|
||||
? <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
|
||||
</div>
|
||||
)
|
||||
@@ -646,7 +647,7 @@ function SurfaceSection({
|
||||
style={{
|
||||
flex: 1, padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
|
||||
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")}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||
@@ -659,7 +660,7 @@ function SurfaceSection({
|
||||
flex: 1, padding: "7px 14px", borderRadius: 7, border: `1px solid ${previewId && !saving ? "#1a1a1a" : "#e0dcd4"}`,
|
||||
background: previewId && !saving ? "#1a1a1a" : "#e0dcd4",
|
||||
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",
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
@@ -669,7 +670,7 @@ function SurfaceSection({
|
||||
)}
|
||||
{activeTheme && (
|
||||
<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")}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||
>Docs ↗</a>
|
||||
@@ -678,7 +679,7 @@ function SurfaceSection({
|
||||
|
||||
{/* 2. Library */}
|
||||
<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" }}>
|
||||
{surface.themes.map(theme => {
|
||||
const isActive = theme.id === previewId;
|
||||
@@ -692,7 +693,7 @@ function SurfaceSection({
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
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,
|
||||
borderColor: isActive ? "#1a1a1a" : "#e0dcd4",
|
||||
background: isActive ? "#1a1a1a" : "#fff",
|
||||
@@ -719,7 +720,7 @@ function SurfaceSection({
|
||||
{/* 4. Colour */}
|
||||
{availableColorThemes.length > 0 && (
|
||||
<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" }}>
|
||||
{availableColorThemes.map(ct => (
|
||||
<button
|
||||
@@ -739,7 +740,7 @@ function SurfaceSection({
|
||||
))}
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
@@ -800,7 +801,7 @@ function SurfaceSection({
|
||||
{/* Colour swatches when locked (read-only) */}
|
||||
{isLocked && availableColorThemes.length > 0 && (
|
||||
<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" }}>
|
||||
{availableColorThemes.map(ct => (
|
||||
<button key={ct.id} title={ct.label} disabled
|
||||
@@ -862,8 +863,8 @@ function SurfacePicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 32px", fontFamily: "Outfit, sans-serif" }}>
|
||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
<div style={{ padding: "28px 32px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Design surfaces
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
|
||||
@@ -897,7 +898,7 @@ function SurfacePicker({
|
||||
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
|
||||
background: isSelected ? "#1a1a1a08" : "#fff",
|
||||
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",
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
|
||||
@@ -936,7 +937,7 @@ function SurfacePicker({
|
||||
padding: "9px 20px", borderRadius: 7, border: "none",
|
||||
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
|
||||
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",
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
@@ -946,7 +947,7 @@ function SurfacePicker({
|
||||
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`} →
|
||||
</button>
|
||||
{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
|
||||
</p>
|
||||
)}
|
||||
@@ -958,8 +959,9 @@ function SurfacePicker({
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
|
||||
const { projectId } = use(params);
|
||||
function DesignPageInner({ projectId }: { projectId: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const requestedSurface = searchParams.get("surface");
|
||||
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
|
||||
@@ -979,7 +981,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
||||
setSurfaces(loaded);
|
||||
setSurfaceThemes(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;
|
||||
});
|
||||
|
||||
@@ -1050,7 +1056,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
||||
|
||||
if (loading) {
|
||||
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" }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
@@ -1066,7 +1072,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
||||
const lockedCount = Object.keys(surfaceThemes).length;
|
||||
|
||||
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 */}
|
||||
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
|
||||
@@ -1089,7 +1095,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
||||
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
|
||||
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"); }}
|
||||
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
|
||||
@@ -1107,11 +1113,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
||||
|
||||
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||
{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
|
||||
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")}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
|
||||
>
|
||||
@@ -1137,3 +1143,12 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
||||
</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 (
|
||||
<div
|
||||
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 }}>
|
||||
<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
|
||||
</h3>
|
||||
<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 (
|
||||
<div
|
||||
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 }}>
|
||||
<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
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ProjectData {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
featureCount?: number;
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
}
|
||||
|
||||
async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||
@@ -31,6 +32,7 @@ async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||
createdAt: created_at,
|
||||
updatedAt: updated_at,
|
||||
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
||||
creationMode: data?.creationMode ?? "fresh",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -62,6 +64,7 @@ export default async function ProjectLayout({
|
||||
createdAt={project.createdAt}
|
||||
updatedAt={project.updatedAt}
|
||||
featureCount={project.featureCount}
|
||||
creationMode={project.creationMode}
|
||||
>
|
||||
{children}
|
||||
</ProjectShell>
|
||||
|
||||
@@ -3,71 +3,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { AtlasChat } from "@/components/AtlasChat";
|
||||
import { OrchestratorChat } from "@/components/OrchestratorChat";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
const url = typeof window !== "undefined"
|
||||
? `${window.location.origin}/${workspace}/project/${projectId}/overview`
|
||||
: "";
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}&bgcolor=f6f4f0&color=1a1a1a&margin=2`;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setShow(s => !s)}
|
||||
title="Open on your phone"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
padding: "6px 12px", borderRadius: 7,
|
||||
background: "none", border: "1px solid #e0dcd4",
|
||||
fontSize: "0.72rem", fontFamily: "Outfit, sans-serif",
|
||||
color: "#8a8478", cursor: "pointer",
|
||||
transition: "border-color 0.12s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
||||
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||
>
|
||||
📱 Open on phone
|
||||
</button>
|
||||
{show && (
|
||||
<div style={{
|
||||
position: "absolute", top: "calc(100% + 8px)", right: 0,
|
||||
background: "#fff", borderRadius: 12,
|
||||
border: "1px solid #e8e4dc",
|
||||
boxShadow: "0 8px 24px #1a1a1a12",
|
||||
padding: "16px", zIndex: 50,
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
||||
minWidth: 220,
|
||||
}}>
|
||||
<img src={qrSrc} alt="QR code" width={180} height={180} style={{ borderRadius: 8 }} />
|
||||
<p style={{ fontSize: "0.72rem", color: "#8a8478", textAlign: "center", margin: 0, fontFamily: "Outfit, sans-serif" }}>
|
||||
Scan to open Atlas on your phone
|
||||
</p>
|
||||
<p style={{ fontSize: "0.65rem", color: "#b5b0a6", textAlign: "center", margin: 0, fontFamily: "IBM Plex Mono, monospace", wordBreak: "break-all" }}>
|
||||
{url}
|
||||
</p>
|
||||
<button onClick={() => setShow(false)} style={{ fontSize: "0.68rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer" }}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
name?: string;
|
||||
stage?: "discovery" | "architecture" | "building" | "active";
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
creationStage?: string;
|
||||
sourceData?: {
|
||||
chatText?: string;
|
||||
repoUrl?: string;
|
||||
liveUrl?: string;
|
||||
hosting?: string;
|
||||
description?: string;
|
||||
};
|
||||
analysisResult?: Record<string, unknown>;
|
||||
migrationPlan?: string;
|
||||
}
|
||||
|
||||
export default function ProjectOverviewPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const { status: authStatus } = useSession();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -78,15 +40,15 @@ export default function ProjectOverviewPage() {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProject(d.project))
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [authStatus, projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
@@ -94,27 +56,56 @@ export default function ProjectOverviewPage() {
|
||||
|
||||
if (!project) {
|
||||
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.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* Desktop-only: Open on phone button */}
|
||||
<style>{`@media (max-width: 768px) { .vibn-phone-btn { display: none !important; } }`}</style>
|
||||
<div className="vibn-phone-btn" style={{
|
||||
position: "absolute", top: 14, right: 248,
|
||||
zIndex: 20,
|
||||
}}>
|
||||
<MobileQRButton projectId={projectId} workspace={workspace} />
|
||||
</div>
|
||||
const projectName = project.productName || project.name || "Untitled";
|
||||
const mode = project.creationMode ?? "fresh";
|
||||
|
||||
<AtlasChat
|
||||
if (mode === "chat-import") {
|
||||
return (
|
||||
<ChatImportMain
|
||||
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";
|
||||
|
||||
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
|
||||
const PRD_SECTIONS = [
|
||||
@@ -14,7 +14,7 @@ const PRD_SECTIONS = [
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||
];
|
||||
@@ -47,7 +47,7 @@ function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
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 }}>
|
||||
@@ -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() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const [prd, setPrd] = useState<string | null>(null);
|
||||
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
||||
const [archGenerating, setArchGenerating] = useState(false);
|
||||
const [archError, setArchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -91,11 +199,30 @@ export default function PRDPage() {
|
||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||
]).then(([projectData, phaseData]) => {
|
||||
setPrd(projectData?.project?.prd ?? null);
|
||||
setArchitecture(projectData?.project?.architecture ?? null);
|
||||
setSavedPhases(phaseData?.phases ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleGenerateArchitecture = async () => {
|
||||
setArchGenerating(true);
|
||||
setArchError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||
setArchitecture(data.architecture);
|
||||
setActiveTab("architecture");
|
||||
} catch (e) {
|
||||
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
||||
} finally {
|
||||
setArchGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||
|
||||
@@ -110,19 +237,102 @@ export default function PRDPage() {
|
||||
|
||||
if (loading) {
|
||||
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…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "prd" as const, label: "PRD", available: true },
|
||||
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}>
|
||||
{prd ? (
|
||||
/* ── Finalized PRD view ── */
|
||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
|
||||
{/* Tab bar — only when at least one doc exists */}
|
||||
{(prd || architecture) && (
|
||||
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
|
||||
{tabs.map(t => {
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => t.available && setActiveTab(t.id)}
|
||||
disabled={!t.available}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
|
||||
background: isActive ? "#1a1a1a" : "transparent",
|
||||
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
|
||||
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}>—</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next step banner — PRD done but no architecture yet */}
|
||||
{prd && !architecture && activeTab === "prd" && (
|
||||
<div style={{
|
||||
marginBottom: 24, padding: "18px 22px",
|
||||
background: "#1a1a1a", borderRadius: 10,
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||
Next: Generate technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||
</div>
|
||||
{archError && (
|
||||
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateArchitecture}
|
||||
disabled={archGenerating}
|
||||
style={{
|
||||
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||
background: archGenerating ? "#4a4640" : "#fff",
|
||||
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||
fontSize: "0.82rem", fontWeight: 700,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: archGenerating ? "default" : "pointer",
|
||||
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
{archGenerating && (
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||
}} />
|
||||
)}
|
||||
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture tab */}
|
||||
{activeTab === "architecture" && architecture && (
|
||||
<ArchitectureView arch={architecture} />
|
||||
)}
|
||||
|
||||
{/* PRD tab — finalized */}
|
||||
{activeTab === "prd" && prd && (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||
<h3 style={{ fontFamily: "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
|
||||
</h3>
|
||||
<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",
|
||||
padding: "28px 32px", lineHeight: 1.8,
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||
{activeTab === "prd" && !prd && (
|
||||
/* ── Section progress view ── */
|
||||
<div style={{ maxWidth: 680 }}>
|
||||
{/* Progress bar */}
|
||||
@@ -227,7 +440,7 @@ export default function PRDPage() {
|
||||
{!s.isDone && (
|
||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||
{s.phaseId
|
||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Atlas`
|
||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||
: "Will be generated when PRD is finalized"}
|
||||
</div>
|
||||
)}
|
||||
@@ -236,7 +449,7 @@ export default function PRDPage() {
|
||||
|
||||
{doneCount === 0 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function ProjectSettingsPage() {
|
||||
|
||||
if (loading) {
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
@@ -131,10 +131,10 @@ export default function ProjectSettingsPage() {
|
||||
return (
|
||||
<div
|
||||
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 }}>
|
||||
<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
|
||||
</h3>
|
||||
<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,
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
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}
|
||||
</span>
|
||||
@@ -76,6 +76,7 @@ export default function ProjectsPage() {
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -134,13 +135,13 @@ export default function ProjectsPage() {
|
||||
return (
|
||||
<div
|
||||
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 */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
|
||||
<div>
|
||||
<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",
|
||||
lineHeight: 1.15, marginBottom: 4,
|
||||
}}>
|
||||
@@ -158,7 +159,7 @@ export default function ProjectsPage() {
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
border: "1px solid #1a1a1a",
|
||||
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>
|
||||
@@ -188,15 +189,17 @@ export default function ProjectsPage() {
|
||||
width: "100%", display: "flex", alignItems: "center",
|
||||
padding: "18px 22px", borderRadius: 10,
|
||||
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",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
setHoveredId(p.id);
|
||||
e.currentTarget.style.borderColor = "#d0ccc4";
|
||||
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
setHoveredId(null);
|
||||
e.currentTarget.style.borderColor = "#e8e4dc";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
|
||||
}}
|
||||
@@ -209,7 +212,7 @@ export default function ProjectsPage() {
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "Newsreader, serif",
|
||||
fontFamily: "var(--font-lora), ui-serif, serif",
|
||||
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
|
||||
}}>
|
||||
{p.productName[0]?.toUpperCase() ?? "P"}
|
||||
@@ -247,19 +250,19 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete (hover) */}
|
||||
{/* Delete (visible on row hover) */}
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setProjectToDelete(p); }}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
|
||||
style={{
|
||||
marginLeft: 16, padding: "5px 8px", borderRadius: 6,
|
||||
marginLeft: 16, padding: "6px 8px", borderRadius: 6,
|
||||
border: "none", background: "transparent",
|
||||
color: "#b5b0a6", cursor: "pointer",
|
||||
opacity: 0, transition: "opacity 0.15s",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
color: "#c0bab2", cursor: "pointer",
|
||||
opacity: hoveredId === p.id ? 1 : 0,
|
||||
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"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = "#b5b0a6"}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 style={{ width: 14, height: 14 }} />
|
||||
@@ -275,7 +278,7 @@ export default function ProjectsPage() {
|
||||
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: "22px", borderRadius: 10,
|
||||
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,
|
||||
transition: "all 0.15s",
|
||||
animationDelay: `${projects.length * 0.05}s`,
|
||||
@@ -292,11 +295,11 @@ export default function ProjectsPage() {
|
||||
{/* Empty state */}
|
||||
{!loading && projects.length === 0 && (
|
||||
<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
|
||||
</h3>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setShowNew(true)}
|
||||
@@ -304,7 +307,7 @@ export default function ProjectsPage() {
|
||||
padding: "10px 22px", borderRadius: 7,
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
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
|
||||
|
||||
163
app/api/admin/migrate/route.ts
Normal file
163
app/api/admin/migrate/route.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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)
|
||||
)`,
|
||||
];
|
||||
|
||||
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 }[] = [];
|
||||
|
||||
if (giteaRepo) {
|
||||
// First: try the standard turborepo apps/ directory
|
||||
try {
|
||||
const contents: Array<{ name: string; path: string; type: string }> =
|
||||
await giteaGet(`/repos/${giteaRepo}/contents/apps`);
|
||||
@@ -55,11 +56,64 @@ export async function GET(
|
||||
.filter((item) => item.type === 'dir')
|
||||
.map(({ name, path }) => ({ name, path }));
|
||||
} 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({
|
||||
// For init, send the greeting prompt but don't store it as a user message
|
||||
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,
|
||||
session_id: sessionId,
|
||||
history,
|
||||
@@ -146,7 +146,7 @@ export async function POST(
|
||||
const text = await res.text();
|
||||
console.error("[atlas-chat] Agent runner error:", text);
|
||||
return NextResponse.json(
|
||||
{ error: "Atlas is unavailable. Please try again." },
|
||||
{ error: "Vibn is unavailable. Please try again." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
108
app/api/projects/[projectId]/file/route.ts
Normal file
108
app/api/projects/[projectId]/file/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* GET /api/projects/[projectId]/file?path=apps/admin
|
||||
*
|
||||
* Returns directory listing or file content from the project's Gitea repo.
|
||||
* Response for directory: { type: "dir", items: [{ name, path, type }] }
|
||||
* Response for file: { type: "file", content: string, encoding: "utf8" | "base64" }
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
|
||||
|
||||
async function giteaGet(path: string) {
|
||||
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
|
||||
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
|
||||
next: { revalidate: 10 },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico',
|
||||
'woff', 'woff2', 'ttf', 'eot',
|
||||
'zip', 'tar', 'gz', 'pdf',
|
||||
]);
|
||||
|
||||
function isBinary(name: string): boolean {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const filePath = searchParams.get('path') ?? '';
|
||||
|
||||
// Verify ownership + get giteaRepo
|
||||
const rows = await query<{ data: Record<string, unknown> }>(
|
||||
`SELECT p.data FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const giteaRepo = rows[0].data?.giteaRepo as string | undefined;
|
||||
if (!giteaRepo) {
|
||||
return NextResponse.json({ error: 'No Gitea repo connected' }, { status: 404 });
|
||||
}
|
||||
|
||||
const encodedPath = filePath ? encodeURIComponent(filePath).replace(/%2F/g, '/') : '';
|
||||
const apiPath = `/repos/${giteaRepo}/contents/${encodedPath}`;
|
||||
const data = await giteaGet(apiPath);
|
||||
|
||||
// Directory listing
|
||||
if (Array.isArray(data)) {
|
||||
const items = data
|
||||
.map((item: { name: string; path: string; type: string; size?: number }) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: item.type, // "file" | "dir" | "symlink"
|
||||
size: item.size,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Dirs first
|
||||
if (a.type === 'dir' && b.type !== 'dir') return -1;
|
||||
if (a.type !== 'dir' && b.type === 'dir') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return NextResponse.json({ type: 'dir', items });
|
||||
}
|
||||
|
||||
// Single file
|
||||
const item = data as { name: string; content?: string; encoding?: string; size?: number };
|
||||
if (isBinary(item.name)) {
|
||||
return NextResponse.json({ type: 'file', content: '(binary file)', encoding: 'utf8' });
|
||||
}
|
||||
|
||||
// Gitea returns base64-encoded content
|
||||
const raw = item.content ?? '';
|
||||
let content: string;
|
||||
try {
|
||||
content = Buffer.from(raw.replace(/\n/g, ''), 'base64').toString('utf8');
|
||||
} catch {
|
||||
content = raw;
|
||||
}
|
||||
|
||||
return NextResponse.json({ type: 'file', content, encoding: 'utf8', name: item.name });
|
||||
} catch (err) {
|
||||
console.error('[file API]', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
139
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
139
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||
|
||||
async function callGemini(prompt: string): Promise<string> {
|
||||
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
generationConfig: { temperature: 0.3, maxOutputTokens: 8000 },
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params;
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json() as {
|
||||
analysisResult?: Record<string, unknown>;
|
||||
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
|
||||
};
|
||||
|
||||
// Verify ownership
|
||||
const rows = await query<{ data: Record<string, unknown> }>(
|
||||
`SELECT p.data FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const current = rows[0].data ?? {};
|
||||
const projectName = (current.productName as string) || (current.name as string) || 'the product';
|
||||
const { analysisResult, sourceData } = body;
|
||||
|
||||
const prompt = `You are a senior DevOps and platform migration architect. Generate a comprehensive, phased migration plan in Markdown for migrating an existing product into a new infrastructure (VIBN — a self-hosted PaaS).
|
||||
|
||||
Product: ${projectName}
|
||||
Repo: ${sourceData?.repoUrl || 'Not provided'}
|
||||
Live URL: ${sourceData?.liveUrl || 'Not provided'}
|
||||
Current hosting: ${sourceData?.hosting || 'Unknown'}
|
||||
|
||||
Architecture audit summary:
|
||||
${analysisResult?.summary || 'No audit data provided.'}
|
||||
|
||||
Detected components:
|
||||
${JSON.stringify(analysisResult?.rows || [], null, 2).slice(0, 3000)}
|
||||
|
||||
Generate a complete migration plan with exactly these 4 phases:
|
||||
|
||||
# ${projectName} — Migration Plan
|
||||
|
||||
## Overview
|
||||
Brief 2-3 sentence description of the migration approach and guiding principle (non-destructive duplication).
|
||||
|
||||
## Phase 1: Mirror
|
||||
Set up parallel infrastructure on VIBN without touching production.
|
||||
- [ ] Clone repository to VIBN Gitea
|
||||
- [ ] Configure Coolify application
|
||||
- [ ] Set up identical database schema
|
||||
- [ ] Configure environment variables
|
||||
- [ ] Verify build passes
|
||||
|
||||
## Phase 2: Validate
|
||||
Run both systems in parallel and compare outputs.
|
||||
- [ ] Route 5% of traffic to new infrastructure (or test internally)
|
||||
- [ ] Compare API responses between old and new
|
||||
- [ ] Run full end-to-end test suite
|
||||
- [ ] Validate data sync between databases
|
||||
- [ ] Sign off on performance benchmarks
|
||||
|
||||
## Phase 3: Cutover
|
||||
Redirect production traffic to the new infrastructure.
|
||||
- [ ] Update DNS records to point to VIBN load balancer
|
||||
- [ ] Monitor error rates and latency for 24h
|
||||
- [ ] Validate all integrations (auth, payments, third-party APIs)
|
||||
- [ ] Keep old infrastructure on standby for 7 days
|
||||
|
||||
## Phase 4: Decommission
|
||||
Remove old infrastructure after successful validation period.
|
||||
- [ ] Confirm all data has been migrated
|
||||
- [ ] Archive old repository access
|
||||
- [ ] Terminate old hosting resources
|
||||
- [ ] Update all internal documentation
|
||||
|
||||
## Risk Register
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Database migration failure | Medium | High | Full backup before any migration step |
|
||||
| DNS propagation delay | Low | Medium | Use low TTL before cutover |
|
||||
| Third-party integration breakage | Medium | High | Test all webhooks and OAuth in Phase 2 |
|
||||
|
||||
## Rollback Plan
|
||||
At any phase, revert by: pointing DNS back to original infrastructure. Data written during parallel run must be synced back manually. Old infrastructure MUST remain live until Phase 4 completes.
|
||||
|
||||
---
|
||||
|
||||
Write a thorough, specific plan. Use real details from the audit where available. Every checklist item should be actionable. Return only the Markdown document.`;
|
||||
|
||||
const migrationPlan = await callGemini(prompt);
|
||||
|
||||
// Save to project
|
||||
const updated = {
|
||||
...current,
|
||||
migrationPlan,
|
||||
creationStage: 'plan',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await query(
|
||||
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||
[projectId, JSON.stringify(updated)]
|
||||
);
|
||||
|
||||
return NextResponse.json({ migrationPlan });
|
||||
} catch (err) {
|
||||
console.error('[generate-migration-plan]', err);
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
95
app/api/projects/[projectId]/preview-url/route.ts
Normal file
95
app/api/projects/[projectId]/preview-url/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { listApplications, CoolifyApplication } from '@/lib/coolify';
|
||||
|
||||
const GITEA_BASE = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||
|
||||
export interface PreviewApp {
|
||||
name: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
coolifyUuid: string | null;
|
||||
gitRepo: string | null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const { projectId } = await params;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 1. Load project — get the Gitea repo name
|
||||
const rows = await query<{ data: Record<string, unknown> }>(
|
||||
`SELECT p.data FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = rows[0].data ?? {};
|
||||
const giteaRepo = data.giteaRepo as string | undefined; // e.g. "mark/scout-ai-engine"
|
||||
|
||||
if (!giteaRepo) {
|
||||
return NextResponse.json({ apps: [], message: 'No Gitea repo linked to this project' });
|
||||
}
|
||||
|
||||
// 2. Build the possible Gitea remote URLs for this repo (with and without .git)
|
||||
const repoBase = `${GITEA_BASE}/${giteaRepo}`;
|
||||
const repoUrls = new Set([repoBase, `${repoBase}.git`]);
|
||||
|
||||
// 3. Fetch all Coolify applications and match by git_repository
|
||||
let coolifyApps: CoolifyApplication[] = [];
|
||||
try {
|
||||
coolifyApps = await listApplications();
|
||||
} catch (err) {
|
||||
console.error('[preview-url] Coolify fetch failed:', err);
|
||||
// Fall back to stored data
|
||||
}
|
||||
|
||||
const matched = coolifyApps.filter(app =>
|
||||
app.git_repository && repoUrls.has(app.git_repository.replace(/\/$/, ''))
|
||||
);
|
||||
|
||||
if (matched.length > 0) {
|
||||
const apps: PreviewApp[] = matched.map(app => ({
|
||||
name: app.name,
|
||||
url: app.fqdn
|
||||
? (app.fqdn.startsWith('http') ? app.fqdn : `https://${app.fqdn}`)
|
||||
: null,
|
||||
status: app.status ?? 'unknown',
|
||||
coolifyUuid: app.uuid,
|
||||
gitRepo: app.git_repository ?? null,
|
||||
}));
|
||||
return NextResponse.json({ apps, source: 'coolify' });
|
||||
}
|
||||
|
||||
// 4. Fallback: use whatever URL was stored by the Coolify webhook
|
||||
const lastDeployment = (data as any).contextSnapshot?.lastDeployment ?? null;
|
||||
if (lastDeployment?.url) {
|
||||
const apps: PreviewApp[] = [{
|
||||
name: giteaRepo.split('/').pop() ?? 'app',
|
||||
url: lastDeployment.url.startsWith('http') ? lastDeployment.url : `https://${lastDeployment.url}`,
|
||||
status: lastDeployment.status === 'finished' ? 'running' : lastDeployment.status ?? 'unknown',
|
||||
coolifyUuid: lastDeployment.applicationUuid ?? null,
|
||||
gitRepo: giteaRepo,
|
||||
}];
|
||||
return NextResponse.json({ apps, source: 'webhook' });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
apps: [],
|
||||
message: `No Coolify app found for repo: ${giteaRepo}`,
|
||||
giteaRepo,
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export async function POST(request: Request) {
|
||||
githubRepoId,
|
||||
githubRepoUrl,
|
||||
githubDefaultBranch,
|
||||
githubToken,
|
||||
} = body;
|
||||
|
||||
// Check slug uniqueness
|
||||
@@ -115,9 +116,29 @@ export async function POST(request: Request) {
|
||||
giteaCloneUrl = repo.clone_url;
|
||||
giteaSshUrl = repo.ssh_url;
|
||||
|
||||
// Push Turborepo monorepo scaffold as initial commit
|
||||
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
|
||||
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
||||
// If a GitHub repo was provided, mirror it as-is.
|
||||
// Otherwise push the default Turborepo scaffold.
|
||||
if (githubRepoUrl) {
|
||||
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||
const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
github_url: githubRepoUrl,
|
||||
gitea_repo: `${GITEA_ADMIN_USER}/${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(GITEA_ADMIN_USER, repoName, slug, projectName);
|
||||
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
||||
}
|
||||
|
||||
// Register webhook — skip if one already points to this project
|
||||
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
|
||||
@@ -147,12 +168,15 @@ export async function POST(request: Request) {
|
||||
name: string; path: string; coolifyServiceUuid: string | null; domain: string | null;
|
||||
}> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null }));
|
||||
|
||||
let coolifyProjectUuid: string | null = null;
|
||||
|
||||
if (giteaCloneUrl) {
|
||||
try {
|
||||
const coolifyProject = await createCoolifyProject(
|
||||
projectName,
|
||||
`Vibn project: ${projectName}`
|
||||
`Vibn project for ${projectName}`
|
||||
);
|
||||
coolifyProjectUuid = coolifyProject.uuid;
|
||||
|
||||
for (const app of provisionedApps) {
|
||||
try {
|
||||
@@ -236,9 +260,15 @@ export async function POST(request: Request) {
|
||||
theiaError,
|
||||
// Context snapshot (kept fresh by webhooks)
|
||||
contextSnapshot: null,
|
||||
// Coolify project — one per VIBN project, scopes all app services + DBs
|
||||
coolifyProjectUuid,
|
||||
// Turborepo monorepo apps — each gets its own Coolify service
|
||||
turboVersion: '2.3.3',
|
||||
apps: provisionedApps,
|
||||
// Import metadata
|
||||
isImport: !!githubRepoUrl,
|
||||
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
|
||||
importAnalysisJobId: null as string | null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -262,7 +292,40 @@ export async function POST(request: Request) {
|
||||
`, [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({
|
||||
success: true,
|
||||
@@ -275,6 +338,8 @@ export async function POST(request: Request) {
|
||||
giteaError: giteaError ?? undefined,
|
||||
theiaWorkspaceUrl,
|
||||
theiaError: theiaError ?? undefined,
|
||||
isImport: !!githubRepoUrl,
|
||||
analysisJobId: analysisJobId ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[POST /api/projects/create] Error:', error);
|
||||
|
||||
@@ -5,22 +5,27 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, Suspense } from "react";
|
||||
import NextAuthComponent from "@/app/components/NextAuthComponent";
|
||||
|
||||
function deriveWorkspace(email: string): string {
|
||||
return email.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-account";
|
||||
}
|
||||
|
||||
function AuthPageInner() {
|
||||
const { status } = useSession();
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
if (status === "authenticated" && session?.user?.email) {
|
||||
const callbackUrl = searchParams.get("callbackUrl");
|
||||
// Only follow external callbackUrls we control (Theia subdomain)
|
||||
if (callbackUrl && callbackUrl.startsWith("https://theia.vibnai.com")) {
|
||||
window.location.href = callbackUrl;
|
||||
} 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") {
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function NextAuthComponent() {
|
||||
try {
|
||||
// Sign in with Google using NextAuth
|
||||
await signIn("google", {
|
||||
callbackUrl: "/marks-account/projects",
|
||||
callbackUrl: "/auth",
|
||||
});
|
||||
} catch (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)} }
|
||||
.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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-outfit);
|
||||
--font-serif: var(--font-newsreader);
|
||||
--font-sans: var(--font-inter);
|
||||
--font-serif: var(--font-lora);
|
||||
--font-mono: var(--font-ibm-plex-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
@@ -51,38 +62,50 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
/* Stackless warm beige palette */
|
||||
--background: #f6f4f0;
|
||||
--foreground: #1a1a1a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1a1a1a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1a1a1a;
|
||||
--primary: #1a1a1a;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f0ece4;
|
||||
--secondary-foreground: #1a1a1a;
|
||||
--muted: #f0ece4;
|
||||
--muted-foreground: #a09a90;
|
||||
--accent: #eae6de;
|
||||
--accent-foreground: #1a1a1a;
|
||||
--destructive: #d32f2f;
|
||||
--border: #e8e4dc;
|
||||
--input: #e0dcd4;
|
||||
--ring: #d0ccc4;
|
||||
/* Justine UX pack — ink & parchment (aligned with master-ai/justine/00_design-tokens.css) */
|
||||
--vibn-ink: #1a1510;
|
||||
--vibn-ink2: #2c2c2a;
|
||||
--vibn-ink3: #444441;
|
||||
--vibn-mid: #5f5e5a;
|
||||
--vibn-muted: #888780;
|
||||
--vibn-stone: #b4b2a9;
|
||||
--vibn-parch: #d3d1c7;
|
||||
--vibn-cream: #f1efe8;
|
||||
--vibn-paper: #f7f4ee;
|
||||
--vibn-white: #fdfcfa;
|
||||
--vibn-border: #e8e2d9;
|
||||
|
||||
--background: var(--vibn-paper);
|
||||
--foreground: var(--vibn-ink);
|
||||
--card: var(--vibn-white);
|
||||
--card-foreground: var(--vibn-ink);
|
||||
--popover: var(--vibn-white);
|
||||
--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-2: oklch(0.70 0.12 210);
|
||||
--chart-3: oklch(0.55 0.10 220);
|
||||
--chart-4: oklch(0.40 0.08 230);
|
||||
--chart-5: oklch(0.75 0.15 70);
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #1a1a1a;
|
||||
--sidebar-primary: #1a1a1a;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #f6f4f0;
|
||||
--sidebar-accent-foreground: #1a1a1a;
|
||||
--sidebar-border: #e8e4dc;
|
||||
--sidebar-ring: #d0ccc4;
|
||||
--sidebar: var(--vibn-white);
|
||||
--sidebar-foreground: var(--vibn-ink);
|
||||
--sidebar-primary: var(--vibn-ink);
|
||||
--sidebar-primary-foreground: var(--vibn-paper);
|
||||
--sidebar-accent: var(--vibn-paper);
|
||||
--sidebar-accent-foreground: var(--vibn-ink);
|
||||
--sidebar-border: var(--vibn-border);
|
||||
--sidebar-ring: var(--vibn-stone);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -111,8 +134,8 @@
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.85 0.02 85);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.02 60);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
@@ -125,21 +148,24 @@
|
||||
}
|
||||
body {
|
||||
@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 {
|
||||
font-family: var(--font-outfit), 'Outfit', sans-serif;
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
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 {
|
||||
color: #b5b0a6;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
::selection {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
@@ -149,7 +175,7 @@
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d0ccc4;
|
||||
background: var(--vibn-stone);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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 { Toaster } from "@/components/ui/sonner";
|
||||
import { Providers } from "@/app/components/Providers";
|
||||
|
||||
const outfit = Outfit({
|
||||
variable: "--font-outfit",
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const newsreader = Newsreader({
|
||||
variable: "--font-newsreader",
|
||||
const lora = Lora({
|
||||
variable: "--font-lora",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
@@ -24,8 +24,8 @@ const ibmPlexMono = IBM_Plex_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "VIBN — Build with Atlas",
|
||||
description: "Chat with Atlas to define your product, then let AI build it.",
|
||||
title: "VIBN — Build with Vibn",
|
||||
description: "Chat with Vibn to define your product, then let AI build it.",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
other: {
|
||||
"mobile-web-app-capable": "yes",
|
||||
"msapplication-TileColor": "#1a1a1a",
|
||||
"msapplication-TileColor": "#1a1510",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,12 +47,12 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<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="manifest" href="/manifest.json" />
|
||||
</head>
|
||||
<body
|
||||
className={`${outfit.variable} ${newsreader.variable} ${ibmPlexMono.variable} antialiased`}
|
||||
className={`${inter.variable} ${lora.variable} ${ibmPlexMono.variable} antialiased`}
|
||||
>
|
||||
<Providers>
|
||||
{children}
|
||||
@@ -69,4 +69,3 @@ export default function RootLayout({
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ function MessageRow({
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.68rem", fontWeight: 700,
|
||||
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}
|
||||
</div>
|
||||
@@ -149,14 +149,14 @@ function MessageRow({
|
||||
<div style={{
|
||||
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
|
||||
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>
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
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",
|
||||
}}>
|
||||
{renderContent(clean)}
|
||||
@@ -175,7 +175,7 @@ function MessageRow({
|
||||
color: saved ? "#2e7d32" : "#fff",
|
||||
border: saved ? "1px solid #a5d6a7" : "none",
|
||||
fontSize: "0.78rem", fontWeight: 600,
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: saved || saving ? "default" : "pointer",
|
||||
transition: "all 0.15s",
|
||||
opacity: saving ? 0.7 : 1,
|
||||
@@ -186,7 +186,7 @@ function MessageRow({
|
||||
{!saved && (
|
||||
<div style={{
|
||||
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}
|
||||
</div>
|
||||
@@ -218,7 +218,7 @@ function MessageRow({
|
||||
display: "inline-block", padding: "8px 16px", borderRadius: 7,
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
fontSize: "0.76rem", fontWeight: 600,
|
||||
fontFamily: "Outfit, sans-serif", textDecoration: "none",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Review architecture →
|
||||
@@ -234,7 +234,7 @@ function MessageRow({
|
||||
style={{
|
||||
padding: "7px 14px", borderRadius: 6, border: "1px solid #e0dcd4",
|
||||
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
|
||||
@@ -256,7 +256,7 @@ function MessageRow({
|
||||
padding: "9px 18px", borderRadius: 8, border: "none",
|
||||
background: archState === "loading" ? "#8a8478" : "#1a1a1a",
|
||||
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",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
@@ -288,7 +288,7 @@ function TypingIndicator() {
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
|
||||
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>
|
||||
<div style={{ display: "flex", gap: 5, paddingTop: 10 }}>
|
||||
{[0, 1, 2].map(d => (
|
||||
@@ -425,7 +425,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", height: "100%",
|
||||
background: "#f6f4f0", fontFamily: "Outfit, sans-serif",
|
||||
background: "#f6f4f0", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
|
||||
@@ -443,12 +443,12 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
|
||||
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",
|
||||
}}>A</div>
|
||||
<style>{`@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }`}</style>
|
||||
<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 }}>
|
||||
Your product strategist. Let's define what you're building.
|
||||
</p>
|
||||
@@ -466,7 +466,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
style={{
|
||||
position: "absolute", top: 12, right: 16,
|
||||
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",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = "#8a8478")}
|
||||
@@ -489,8 +489,44 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
</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 */}
|
||||
<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={{
|
||||
display: "flex", gap: 8, padding: "5px 5px 5px 16px",
|
||||
background: "#fff", border: "1px solid #e0dcd4", borderRadius: 10,
|
||||
@@ -505,7 +541,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
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",
|
||||
resize: "none", outline: "none",
|
||||
minHeight: 24, maxHeight: 120,
|
||||
@@ -517,7 +553,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
style={{
|
||||
padding: "9px 16px", borderRadius: 7, border: "none",
|
||||
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,
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
}}
|
||||
@@ -533,7 +569,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
padding: "9px 16px", borderRadius: 7, border: "none",
|
||||
background: input.trim() ? "#1a1a1a" : "#eae6de",
|
||||
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",
|
||||
flexShrink: 0, transition: "all 0.15s",
|
||||
}}
|
||||
|
||||
287
components/layout/coo-chat.tsx
Normal file
287
components/layout/coo-chat.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface CooMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
source?: "atlas" | "coo"; // atlas = discovery history, coo = orchestrator response
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export function CooChat({ projectId }: { projectId: string }) {
|
||||
const [messages, setMessages] = useState<CooMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Scroll to bottom whenever messages change
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Pre-load Atlas discovery history on mount
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/atlas-chat`)
|
||||
.then(r => r.json())
|
||||
.then((data: { messages?: Array<{ role: "user" | "assistant"; content: string }> }) => {
|
||||
const atlasMessages: CooMessage[] = (data.messages ?? [])
|
||||
.filter(m => m.content?.trim())
|
||||
.map((m, i) => ({
|
||||
id: `atlas_${i}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
source: "atlas" as const,
|
||||
}));
|
||||
|
||||
if (atlasMessages.length > 0) {
|
||||
// Add a small divider message at the bottom of Atlas history
|
||||
setMessages([
|
||||
...atlasMessages,
|
||||
{
|
||||
id: "coo_divider",
|
||||
role: "assistant",
|
||||
content: "Discovery complete. I'm your product COO — I have the full context above. What do you need?",
|
||||
source: "coo" as const,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// No Atlas history — show default COO welcome
|
||||
setMessages([{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
|
||||
source: "coo" as const,
|
||||
}]);
|
||||
}
|
||||
setHistoryLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setMessages([{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
|
||||
source: "coo" as const,
|
||||
}]);
|
||||
setHistoryLoaded(true);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
setInput("");
|
||||
|
||||
const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text, source: "coo" };
|
||||
const assistantId = (Date.now() + 1).toString();
|
||||
const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", source: "coo", streaming: true };
|
||||
|
||||
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
||||
setLoading(true);
|
||||
|
||||
// Build history from COO messages only (skip atlas history for context to orchestrator)
|
||||
const history = messages
|
||||
.filter(m => m.source === "coo" && m.id !== "coo_divider" && m.content)
|
||||
.map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/advisor`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text, history }),
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: "Something went wrong. Please try again.", streaming: false }
|
||||
: m));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: m.content + chunk }
|
||||
: m));
|
||||
}
|
||||
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
|
||||
} catch {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: "Connection error. Please try again.", streaming: false }
|
||||
: m));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (!historyLoaded) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 4, height: 4, borderRadius: "50%",
|
||||
background: "#d4cfc6", display: "inline-block",
|
||||
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "12px 14px 8px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{messages.map((msg, idx) => {
|
||||
const isAtlas = msg.source === "atlas";
|
||||
const isUser = msg.role === "user";
|
||||
const isCoo = !isUser && !isAtlas;
|
||||
|
||||
// Separator before the divider message
|
||||
const prevMsg = messages[idx - 1];
|
||||
const showSeparator = msg.id === "coo_divider" && prevMsg?.source === "atlas";
|
||||
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{showSeparator && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
margin: "8px 0 4px", opacity: 0.5,
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
|
||||
<span style={{ fontSize: "0.58rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
|
||||
Discovery · COO
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: isUser ? "row-reverse" : "row",
|
||||
alignItems: "flex-end",
|
||||
gap: 6,
|
||||
}}>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 5,
|
||||
background: isAtlas ? "#4a6fa5" : "#1a1a1a",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: isAtlas ? "0.48rem" : "0.48rem",
|
||||
color: "#fff", flexShrink: 0,
|
||||
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "inherit",
|
||||
fontWeight: isAtlas ? 700 : 400,
|
||||
}}>
|
||||
{isAtlas ? "A" : "◈"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
maxWidth: "88%",
|
||||
padding: isUser ? "7px 10px" : "0",
|
||||
background: isUser ? "#f0ece4" : "transparent",
|
||||
borderRadius: isUser ? 10 : 0,
|
||||
fontSize: isAtlas ? "0.75rem" : "0.79rem",
|
||||
color: isAtlas ? "#4a4540" : "#1a1a1a",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
opacity: isAtlas ? 0.85 : 1,
|
||||
}}>
|
||||
{msg.content}
|
||||
{msg.streaming && msg.content === "" && (
|
||||
<span style={{ display: "inline-flex", gap: 3, alignItems: "center", height: "1em" }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 4, height: 4, borderRadius: "50%",
|
||||
background: "#b5b0a6", display: "inline-block",
|
||||
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
|
||||
}} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{msg.streaming && msg.content !== "" && (
|
||||
<span style={{
|
||||
display: "inline-block", width: 2, height: "0.85em",
|
||||
background: "#1a1a1a", marginLeft: 1,
|
||||
verticalAlign: "text-bottom",
|
||||
animation: "cooBlink 1s step-end infinite",
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{ flexShrink: 0, borderTop: "1px solid #e8e4dc", padding: "10px 12px 10px", background: "#fff" }}>
|
||||
<div style={{ display: "flex", gap: 7, alignItems: "flex-end" }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
}}
|
||||
placeholder={loading ? "Thinking…" : "Ask anything…"}
|
||||
disabled={loading}
|
||||
rows={2}
|
||||
style={{
|
||||
flex: 1, resize: "none",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "8px 10px", fontSize: "0.79rem",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
color: "#1a1a1a", outline: "none",
|
||||
background: "#faf8f5", lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!input.trim() || loading}
|
||||
style={{
|
||||
width: 32, height: 32, flexShrink: 0,
|
||||
border: "none", borderRadius: 8,
|
||||
background: input.trim() && !loading ? "#1a1a1a" : "#e8e4dc",
|
||||
color: input.trim() && !loading ? "#fff" : "#b5b0a6",
|
||||
cursor: input.trim() && !loading ? "pointer" : "default",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>↑</button>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.6rem", color: "#c5c0b8", marginTop: 5, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
↵ send · Shift+↵ newline
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes cooBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-4px); }
|
||||
}
|
||||
@keyframes cooBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { VIBNSidebar } from "./vibn-sidebar";
|
||||
import { ReactNode, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
interface ProjectShellProps {
|
||||
@@ -19,319 +19,141 @@ interface ProjectShellProps {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
featureCount?: number;
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Atlas", path: "overview" },
|
||||
{ id: "prd", label: "PRD", path: "prd" },
|
||||
{ id: "design", label: "Design", path: "design" },
|
||||
{ id: "build", label: "Build", path: "build" },
|
||||
{ id: "deployment", label: "Launch", path: "deployment" },
|
||||
{ id: "grow", label: "Grow", path: "grow" },
|
||||
{ id: "insights", label: "Insights", path: "insights" },
|
||||
{ id: "settings", label: "Settings", path: "settings" },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: "overview", label: "Vibn", path: "overview" },
|
||||
{ id: "prd", label: "PRD", path: "prd" },
|
||||
{ id: "build", label: "Build", path: "build" },
|
||||
{ id: "growth", label: "Growth", path: "growth" },
|
||||
{ id: "assist", label: "Assist", path: "assist" },
|
||||
{ id: "analytics", label: "Analytics", path: "analytics" },
|
||||
] as const;
|
||||
|
||||
const DISCOVERY_PHASES = [
|
||||
{ id: "big_picture", label: "Big Picture" },
|
||||
{ id: "users_personas", label: "Users & Personas" },
|
||||
{ id: "features_scope", label: "Features" },
|
||||
{ id: "business_model", label: "Business Model" },
|
||||
{ id: "screens_data", label: "Screens" },
|
||||
{ id: "risks_questions", label: "Risks" },
|
||||
];
|
||||
|
||||
interface SavedPhase {
|
||||
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({
|
||||
function ProjectShellInner({
|
||||
children,
|
||||
workspace,
|
||||
projectId,
|
||||
projectName,
|
||||
projectDescription,
|
||||
projectStatus,
|
||||
projectProgress,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
featureCount = 0,
|
||||
}: ProjectShellProps) {
|
||||
const pathname = usePathname();
|
||||
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
|
||||
const progress = projectProgress ?? 0;
|
||||
const { data: session } = useSession();
|
||||
|
||||
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(() => {
|
||||
fetch(`/api/projects/${projectId}/save-phase`)
|
||||
.then(r => r.json())
|
||||
.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));
|
||||
const userInitial = (
|
||||
session?.user?.name?.[0] ?? session?.user?.email?.[0] ?? "?"
|
||||
).toUpperCase();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media (max-width: 768px) {
|
||||
.vibn-left-sidebar { display: none !important; }
|
||||
.vibn-right-panel { display: none !important; }
|
||||
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
|
||||
.vibn-project-header { padding: 12px 16px !important; }
|
||||
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.vibn-tab-bar a { padding: 10px 10px !important; }
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ display: "flex", height: "100dvh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
{/* Left sidebar */}
|
||||
<div className="vibn-left-sidebar" style={{ display: "flex" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column",
|
||||
height: "100dvh", overflow: "hidden",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
background: "var(--background)",
|
||||
}}>
|
||||
|
||||
{/* Main column */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="vibn-project-header" style={{
|
||||
padding: "18px 32px",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
background: "#fff",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div style={{
|
||||
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,
|
||||
{/* ── Top bar ── */}
|
||||
<header style={{
|
||||
height: 48, flexShrink: 0,
|
||||
display: "flex", alignItems: "stretch",
|
||||
background: "var(--card)", borderBottom: "1px solid var(--border)",
|
||||
zIndex: 10,
|
||||
}}>
|
||||
{/* Discovery phases */}
|
||||
<SectionLabel>Discovery</SectionLabel>
|
||||
{DISCOVERY_PHASES.map((phase, i) => {
|
||||
const isDone = savedPhaseIds.has(phase.id);
|
||||
const isActive = !isDone && i === firstUnsavedIdx;
|
||||
return (
|
||||
<div
|
||||
key={phase.id}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "9px 0",
|
||||
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
|
||||
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.58rem", fontWeight: 700,
|
||||
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
|
||||
}}>
|
||||
{isDone ? "✓" : isActive ? "→" : i + 1}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
|
||||
}}>
|
||||
{phase.label}
|
||||
</span>
|
||||
|
||||
{/* Logo + project name */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center",
|
||||
padding: "0 16px", gap: 9, flexShrink: 0,
|
||||
borderRight: "1px solid var(--border)",
|
||||
}}>
|
||||
<Link
|
||||
href={`/${workspace}/projects`}
|
||||
style={{ display: "flex", alignItems: "center", textDecoration: "none", flexShrink: 0 }}
|
||||
>
|
||||
<div style={{ width: 22, height: 22, borderRadius: 6, overflow: "hidden" }}>
|
||||
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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 */}
|
||||
<SectionLabel>Captured</SectionLabel>
|
||||
{savedPhases.length > 0 ? (
|
||||
savedPhases.map((p) => (
|
||||
<div key={p.phase} style={{ marginBottom: 14 }}>
|
||||
<div style={{
|
||||
fontSize: "0.62rem", color: "#2e7d32",
|
||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
||||
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
|
||||
}}>
|
||||
<span>✓</span><span>{p.title}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||
{p.summary}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
|
||||
Atlas will capture key details here as you chat.
|
||||
</p>
|
||||
)}
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<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 */}
|
||||
<SectionLabel>Project Info</SectionLabel>
|
||||
{[
|
||||
{ k: "Created", v: timeAgo(createdAt) },
|
||||
{ k: "Last active", v: timeAgo(updatedAt) },
|
||||
{ k: "Features", v: featureCount > 0 ? `${featureCount} defined` : "None yet" },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ marginBottom: 12 }}>
|
||||
<div style={{
|
||||
fontSize: "0.62rem", color: "#b5b0a6",
|
||||
textTransform: "uppercase", letterSpacing: "0.05em",
|
||||
marginBottom: 3, fontWeight: 600,
|
||||
}}>
|
||||
{item.k}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.8rem", color: "#4a4640" }}>{item.v}</div>
|
||||
</div>
|
||||
))}
|
||||
{/* ── Full-width content ── */}
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 { signOut, useSession } from "next-auth/react";
|
||||
|
||||
interface Project {
|
||||
interface TabItem {
|
||||
id: string;
|
||||
productName: string;
|
||||
status?: string;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface VIBNSidebarProps {
|
||||
workspace: string;
|
||||
tabs?: TabItem[];
|
||||
activeTab?: string;
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status?: string }) {
|
||||
const color =
|
||||
status === "live" ? "#2e7d32"
|
||||
: status === "building" ? "#3d5afe"
|
||||
: "#d4a04a";
|
||||
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,
|
||||
}} />
|
||||
);
|
||||
interface ProjectData {
|
||||
id: string;
|
||||
productName?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
|
||||
// ── Main sidebar ─────────────────────────────────────────────────────────────
|
||||
|
||||
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
|
||||
const COLLAPSED_W = 56;
|
||||
const EXPANDED_W = 220;
|
||||
const COLLAPSED_W = 52;
|
||||
const EXPANDED_W = 216;
|
||||
|
||||
export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
||||
export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { data: session } = useSession();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
const [collapsed, setCollapsed] = 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(() => {
|
||||
const stored = localStorage.getItem(COLLAPSED_KEY);
|
||||
if (stored === "1") setCollapsed(true);
|
||||
@@ -55,14 +60,25 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch global projects list (for non-project pages)
|
||||
useEffect(() => {
|
||||
if (activeProjectId) return;
|
||||
fetch("/api/projects")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProjects(d.projects ?? []))
|
||||
.then(r => r.json())
|
||||
.then(d => setProjects(d.projects ?? []))
|
||||
.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 isActivity = !activeProjectId && pathname?.includes("/activity");
|
||||
const isSettings = !activeProjectId && pathname?.includes("/settings");
|
||||
@@ -78,117 +94,85 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
||||
?? "?";
|
||||
|
||||
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 base = `/${workspace}/project/${activeProjectId}`;
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
width: w,
|
||||
height: "100vh",
|
||||
background: "#fff",
|
||||
borderRight: "1px solid #e8e4dc",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
transition,
|
||||
position: "relative",
|
||||
width: w, height: "100vh",
|
||||
background: "#fff", borderRight: "1px solid #e8e4dc",
|
||||
display: "flex", flexDirection: "column",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
flexShrink: 0, overflow: "hidden",
|
||||
transition, position: "relative",
|
||||
}}>
|
||||
|
||||
{/* Logo + toggle row */}
|
||||
{/* ── Logo + toggle ── */}
|
||||
{collapsed ? (
|
||||
/* Collapsed: logo centered, toggle below it */
|
||||
<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" }}>
|
||||
<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" }} />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
title="Expand sidebar"
|
||||
style={{
|
||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||
color: "#6b6560", width: 28, height: 22, borderRadius: 5,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.82rem", fontWeight: 700, transition: "background 0.12s, color 0.12s",
|
||||
}}
|
||||
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>
|
||||
<button onClick={toggle} title="Expand sidebar" style={{
|
||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||
color: "#6b6560", width: 26, height: 20, borderRadius: 5,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.8rem", fontWeight: 700,
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||
>›</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Expanded: logo + name on left, toggle on right */
|
||||
<div style={{ padding: "16px 12px 16px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
|
||||
<div style={{ padding: "14px 10px 14px 16px", 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 }}>
|
||||
<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" }} />
|
||||
</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
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggle}
|
||||
title="Collapse sidebar"
|
||||
style={{
|
||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||
color: "#6b6560", width: 26, height: 24, borderRadius: 5,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.82rem", fontWeight: 700, flexShrink: 0,
|
||||
transition: "background 0.12s, color 0.12s",
|
||||
}}
|
||||
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>
|
||||
<button onClick={toggle} title="Collapse sidebar" style={{
|
||||
background: "#f0ece4", border: "none", cursor: "pointer",
|
||||
color: "#6b6560", width: 24, height: 22, borderRadius: 5,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.8rem", fontWeight: 700, flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
|
||||
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
|
||||
>‹</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top nav */}
|
||||
<div style={{ padding: collapsed ? "4px 8px" : "4px 10px", flexShrink: 0 }}>
|
||||
{topNavItems.map((n) => {
|
||||
{/* ── Top nav ── */}
|
||||
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px", flexShrink: 0 }}>
|
||||
{topNavItems.map(n => {
|
||||
const isActive = n.id === "projects" ? isProjects
|
||||
: n.id === "activity" ? isActivity
|
||||
: n.id === "settings" ? isSettings
|
||||
: false;
|
||||
: isSettings;
|
||||
return (
|
||||
<Link
|
||||
key={n.id}
|
||||
href={n.href}
|
||||
title={collapsed ? n.label : undefined}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: 9,
|
||||
padding: collapsed ? "9px 0" : "8px 10px",
|
||||
borderRadius: 6,
|
||||
background: isActive ? "#f6f4f0" : "transparent",
|
||||
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
transition: "all 0.12s",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
<Link key={n.id} href={n.href} title={collapsed ? n.label : undefined} style={{
|
||||
width: "100%", display: "flex", alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: 8, padding: collapsed ? "8px 0" : "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"; }}
|
||||
>
|
||||
<span style={{
|
||||
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",
|
||||
}}>
|
||||
<span style={{ fontSize: collapsed ? "0.95rem" : "0.78rem", opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45, width: collapsed ? "auto" : 16, textAlign: "center" }}>
|
||||
{n.icon}
|
||||
</span>
|
||||
{!collapsed && n.label}
|
||||
@@ -197,91 +181,161 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
||||
})}
|
||||
</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 */}
|
||||
<div style={{ padding: collapsed ? "2px 8px" : "2px 10px", flex: 1, overflow: "auto" }}>
|
||||
{!collapsed && (
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
padding: "6px 10px 8px",
|
||||
}}>
|
||||
Projects
|
||||
{/* ── Lower section ── */}
|
||||
<div style={{ flex: 1, overflow: "auto", paddingBottom: 8 }}>
|
||||
|
||||
{activeProjectId && project ? (
|
||||
/* ── PROJECT VIEW: name + status + section tabs ── */
|
||||
<>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* User footer */}
|
||||
{/* ── User footer ── */}
|
||||
<div style={{
|
||||
padding: collapsed ? "12px 0" : "14px 18px",
|
||||
padding: collapsed ? "10px 0" : "12px 14px",
|
||||
borderTop: "1px solid #eae6de",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
display: "flex", alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: 9,
|
||||
flexShrink: 0,
|
||||
gap: 9, flexShrink: 0,
|
||||
}}>
|
||||
<div
|
||||
title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
|
||||
<div title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: "50%",
|
||||
width: 26, height: 26, borderRadius: "50%",
|
||||
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",
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{userInitial}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a",
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}}>
|
||||
<div style={{ fontSize: "0.76rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/auth" })}
|
||||
style={{
|
||||
background: "none", border: "none", padding: 0,
|
||||
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
}}
|
||||
>
|
||||
<button onClick={() => signOut({ callbackUrl: "/auth" })} style={{
|
||||
background: "none", border: "none", padding: 0,
|
||||
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,278 +1,6 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
// Re-export the new multi-step creation flow as a drop-in replacement
|
||||
// for the original 2-step ProjectCreationModal.
|
||||
export { CreateProjectFlow as ProjectCreationModal } from "./project-creation/CreateProjectFlow";
|
||||
export type { CreationMode } from "./project-creation/CreateProjectFlow";
|
||||
|
||||
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) {
|
||||
case "built":
|
||||
return <CheckCircle2 className="h-3 w-3 text-green-600" />;
|
||||
return <CheckCircle2 className="h-3 w-3 text-primary" />;
|
||||
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":
|
||||
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) {
|
||||
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":
|
||||
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":
|
||||
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 && (
|
||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "=== Syncing NextAuth schema ==="
|
||||
# NOTE: Do NOT use --accept-data-loss — it drops tables not in the Prisma schema,
|
||||
# which destroys fs_users, fs_projects etc. Use --skip-generate only.
|
||||
npx prisma db push --skip-generate || echo "Prisma push failed (non-fatal — tables may already be correct)"
|
||||
# Do NOT run `prisma db push` here. The Prisma schema only lists NextAuth tables; db push
|
||||
# would try to DROP every other table (fs_*, agent_*, atlas_*, etc.) to match the schema.
|
||||
# NextAuth tables are created below with IF NOT EXISTS (same DDL as /api/admin/migrate).
|
||||
|
||||
echo "=== Ensuring app tables exist ==="
|
||||
echo "=== Ensuring database tables exist (idempotent SQL) ==="
|
||||
node -e "
|
||||
const { Pool } = require('pg');
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
pool.query(\`
|
||||
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 (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT,
|
||||
@@ -53,6 +86,37 @@ pool.query(\`
|
||||
messages JSONB NOT NULL DEFAULT '[]',
|
||||
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(); });
|
||||
"
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function createApplication(opts: {
|
||||
const {
|
||||
projectUuid, name, gitRepo,
|
||||
gitBranch = 'main',
|
||||
serverUuid = '0',
|
||||
serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc',
|
||||
environmentName = 'production',
|
||||
buildPack = 'nixpacks',
|
||||
ports = '3000',
|
||||
@@ -166,7 +166,7 @@ export async function createMonorepoAppService(opts: {
|
||||
projectUuid, appName, gitRepo,
|
||||
gitBranch = 'main',
|
||||
domain,
|
||||
serverUuid = '0',
|
||||
serverUuid = process.env.COOLIFY_SERVER_UUID ?? 'jws4g4cgssss4cw48s488woc',
|
||||
environmentName = 'production',
|
||||
} = 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 }> {
|
||||
return coolifyFetch(`/applications/${uuid}/deploy`, { method: 'POST' });
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ export function CTA() {
|
||||
return (
|
||||
<section className="w-full py-16 md:py-24">
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-tight md:text-4xl lg:text-5xl text-center max-w-[800px]">
|
||||
<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="font-serif text-2xl font-semibold leading-tight tracking-tight md:text-4xl lg:text-5xl text-center max-w-[800px]">
|
||||
{homepage.finalCTA.title}
|
||||
</h2>
|
||||
<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">
|
||||
<div className="container mx-auto px-6">
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
<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">
|
||||
<div className="container mx-auto px-6">
|
||||
<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}
|
||||
</h2>
|
||||
<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">
|
||||
<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" />
|
||||
<span className="text-lg font-bold">Vib'n</span>
|
||||
<span className="font-serif text-lg font-bold tracking-tight">Vib'n</span>
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-[220px]">
|
||||
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 max-w-[980px] flex-col items-center gap-6 text-center">
|
||||
{/* 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}
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export function HowItWorks() {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mx-auto max-w-[900px] space-y-12">
|
||||
<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}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground md:text-xl">
|
||||
@@ -18,7 +18,7 @@ export function HowItWorks() {
|
||||
{homepage.howItWorks.steps.map((step) => (
|
||||
<div key={step.number} className="flex gap-6">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function Pricing() {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mx-auto max-w-[980px] space-y-12">
|
||||
<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}
|
||||
</h2>
|
||||
<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="mx-auto max-w-[900px] space-y-12">
|
||||
<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}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground md:text-xl pt-4 leading-relaxed">
|
||||
@@ -21,8 +21,8 @@ export function Transformation() {
|
||||
key={index}
|
||||
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">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
<div className="mt-1 rounded-full bg-primary p-2 text-primary-foreground">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-lg leading-relaxed font-medium">{outcome}</p>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function WhoItsFor() {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="mx-auto max-w-[900px] space-y-12">
|
||||
<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}
|
||||
</h2>
|
||||
<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