Compare commits
146 Commits
97df21883b
...
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 | |||
| 24812df89b | |||
| 53b098ce6a | |||
| 69eb3b989c | |||
| 7eaf1ca4f1 | |||
| 5e4cce55de | |||
| 4eff014ae6 | |||
| 57a4f358d1 | |||
| a1b605febf | |||
| ef9f5a6ad3 | |||
| eff75a1ab5 | |||
| 0a237e1e8f | |||
| e95761cc61 | |||
| e79c2fe5c5 | |||
| b020f73ca7 | |||
| 2d8fbbbd81 | |||
| 9c8e1a5f34 | |||
| a980354da6 | |||
| 57c283796f | |||
| d30af447da | |||
| a3aa5e4208 | |||
| bedd7d3470 | |||
| 156232062d | |||
| 9e4450e400 | |||
| 3896eb671c | |||
| 585343968e | |||
| 5bfbe86541 | |||
| c8d8deb2cc | |||
| 7732b5fbea | |||
| 33ec7b787f | |||
| ecdeee9f1a | |||
| db21737f50 | |||
| 7602d81120 | |||
| 1ce4ad4c8b | |||
| 3e0be782c4 | |||
| 11d6f14645 | |||
| d3a5655948 | |||
| 0146ae7df6 | |||
| 9fc643f9b6 | |||
| 7f452c0420 | |||
| d60d300a64 | |||
| 59c8ec2e02 | |||
| 9858a7fa15 | |||
| 94bb9dbeb4 | |||
| aaa3f51592 | |||
| 7ba3b9563e | |||
| 16766f587d | |||
| 817fe3a1a4 | |||
| b3462a31a7 | |||
| 086047d177 | |||
| 54248887f1 | |||
| 7cf4f2ef78 | |||
| ea54440be7 | |||
| 7be66f60b7 | |||
| 62731af91f | |||
| 287bc96fac | |||
| c842a4b75b | |||
| a2bde95222 | |||
| 86504c4c55 | |||
| 9bec2e9b17 | |||
| 296324f424 | |||
| 26a11412b5 | |||
| 35675b7d86 | |||
| 8c3486dd58 | |||
| a893d95387 | |||
| b2b3424b05 | |||
| fe89087cc5 | |||
| 8d95a74cc6 | |||
| c9ef2379ec | |||
| ef7a88e913 | |||
| 3ce10dc45b | |||
| 0625943cc1 | |||
| cb0ece541f | |||
| d8ead667d0 | |||
| 17056ea00c | |||
| 8e6406232d | |||
| 6eaa6d64ac | |||
| 91f579dbc5 | |||
| 8587644a62 | |||
| e22f5e379f | |||
| 1154592ab8 | |||
| b42edbe681 | |||
| f4ab70822c | |||
| aeedc76a18 | |||
| 1ff58049c0 |
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
|
||||
|
||||
@@ -8,7 +8,7 @@ FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps --ignore-scripts
|
||||
RUN npm install --legacy-peer-deps --ignore-scripts
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
@@ -41,8 +41,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
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">
|
||||
|
||||
20
app/[workspace]/activity/layout.tsx
Normal file
20
app/[workspace]/activity/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function ActivityLayout({ children }: { children: ReactNode }) {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
<main style={{ flex: 1, overflow: "auto" }}>{children}</main>
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
156
app/[workspace]/activity/page.tsx
Normal file
156
app/[workspace]/activity/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
action: string;
|
||||
type: "atlas" | "build" | "deploy" | "user";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
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 date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function typeColor(t: string) {
|
||||
return t === "atlas" ? "#1a1a1a" : t === "build" ? "#3d5afe" : t === "deploy" ? "#2e7d32" : "#8a8478";
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "atlas", label: "Vibn" },
|
||||
{ id: "build", label: "Builds" },
|
||||
{ id: "deploy", label: "Deploys" },
|
||||
{ id: "user", label: "You" },
|
||||
];
|
||||
|
||||
export default function ActivityPage() {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [items, setItems] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/activity")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setItems(d.items ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = filter === "all" ? items : items.filter((a) => a.type === filter);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<h1 style={{
|
||||
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
|
||||
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4,
|
||||
}}>
|
||||
Activity
|
||||
</h1>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 28 }}>
|
||||
Everything happening across your projects
|
||||
</p>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 6, border: "none",
|
||||
background: filter === f.id ? "#1a1a1a" : "#fff",
|
||||
color: filter === f.id ? "#fff" : "#6b6560",
|
||||
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
|
||||
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p style={{ fontSize: "0.82rem", color: "#b5b0a6" }}>Loading…</p>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p style={{ fontSize: "0.82rem", color: "#b5b0a6" }}>No activity yet.</p>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div style={{ position: "relative", paddingLeft: 24 }}>
|
||||
{/* Vertical line */}
|
||||
<div style={{
|
||||
position: "absolute", left: 8, top: 8, bottom: 8,
|
||||
width: 1, background: "#e8e4dc",
|
||||
}} />
|
||||
|
||||
{filtered.map((item, i) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="vibn-enter"
|
||||
style={{
|
||||
display: "flex", gap: 14, marginBottom: 4,
|
||||
padding: "12px 16px", borderRadius: 8,
|
||||
transition: "background 0.12s", position: "relative",
|
||||
animationDelay: `${i * 0.03}s`,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "#fff")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div style={{
|
||||
position: "absolute", left: -20, top: 18,
|
||||
width: 9, height: 9, borderRadius: "50%",
|
||||
background: typeColor(item.type),
|
||||
border: "2px solid #f6f4f0",
|
||||
}} />
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 3 }}>
|
||||
<Link
|
||||
href={`/${workspace}/project/${item.projectId}/overview`}
|
||||
style={{
|
||||
fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = "underline")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")}
|
||||
>
|
||||
{item.projectName}
|
||||
</Link>
|
||||
<span style={{ fontSize: "0.68rem", color: "#b5b0a6" }}>·</span>
|
||||
<span style={{ fontSize: "0.72rem", color: "#b5b0a6" }}>{timeAgo(item.createdAt)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.5 }}>
|
||||
{item.action}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function ConnectionsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState<string>("connections");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Github, CheckCircle2, Download, Copy, Check, Eye, EyeOff } from "lucide-react";
|
||||
import { CursorIcon } from "@/components/icons/custom-icons";
|
||||
import { toast } from "sonner";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import type { User } from "firebase/auth";
|
||||
import { MCPConnectionCard } from "@/components/mcp-connection-card";
|
||||
import { ChatGPTImportCard } from "@/components/chatgpt-import-card";
|
||||
|
||||
export default function ConnectionsPage() {
|
||||
const [githubConnected, setGithubConnected] = useState(false);
|
||||
const [extensionInstalled] = useState(false); // Future use: track extension installation
|
||||
const [copiedApiKey, setCopiedApiKey] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [loadingApiKey, setLoadingApiKey] = useState(true);
|
||||
const [apiUrl, setApiUrl] = useState('https://vibnai.com');
|
||||
|
||||
// Set API URL on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setApiUrl(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch API key on mount
|
||||
useEffect(() => {
|
||||
async function fetchApiKey(user: User) {
|
||||
try {
|
||||
console.log('[Client] Getting ID token for user:', user.uid);
|
||||
const token = await user.getIdToken();
|
||||
console.log('[Client] Token received, length:', token.length);
|
||||
|
||||
const response = await fetch('/api/user/api-key', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Client] Response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('[Client] API key received');
|
||||
setApiKey(data.apiKey);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error('[Client] Failed to fetch API key:', response.status, errorData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Client] Error fetching API key:', error);
|
||||
} finally {
|
||||
setLoadingApiKey(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for auth state changes
|
||||
const unsubscribe = auth.onAuthStateChanged((user) => {
|
||||
if (user) {
|
||||
fetchApiKey(user);
|
||||
} else {
|
||||
setLoadingApiKey(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const handleConnectGitHub = async () => {
|
||||
// TODO: Implement GitHub OAuth flow
|
||||
toast.success("GitHub connected successfully!");
|
||||
setGithubConnected(true);
|
||||
};
|
||||
|
||||
const handleInstallExtension = () => {
|
||||
// Link to Cursor Monitor extension (update with actual marketplace URL when published)
|
||||
window.open("https://marketplace.visualstudio.com/items?itemName=cursor-monitor", "_blank");
|
||||
};
|
||||
|
||||
const handleCopyApiKey = () => {
|
||||
if (apiKey) {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
setCopiedApiKey(true);
|
||||
toast.success("API key copied to clipboard!");
|
||||
setTimeout(() => setCopiedApiKey(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Connect Your Tools</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Set up your development tools to unlock the full power of Vib'n
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection Cards */}
|
||||
<div className="space-y-6">
|
||||
{/* Cursor Extension */}
|
||||
<Card className={extensionInstalled ? "border-green-500/50 bg-green-500/5" : ""}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<CursorIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>Cursor Monitor Extension</CardTitle>
|
||||
{extensionInstalled && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Automatically track your coding sessions, AI usage, and costs
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{!extensionInstalled ? (
|
||||
<Button onClick={handleInstallExtension}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Get Extension
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" disabled>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Installed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">What it does:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Tracks your coding sessions in real-time</li>
|
||||
<li>Monitors AI model usage and token consumption</li>
|
||||
<li>Logs file changes and conversation history</li>
|
||||
<li>Calculates costs automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{!extensionInstalled && (
|
||||
<>
|
||||
<div className="rounded-lg bg-muted p-4 space-y-2">
|
||||
<p className="text-sm font-medium">Installation Steps:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground ml-2">
|
||||
<li>Install the Cursor Monitor extension from the marketplace</li>
|
||||
<li>Restart Cursor to activate the extension</li>
|
||||
<li>Configure your API key (see instructions below)</li>
|
||||
<li>Start coding - sessions will be tracked automatically!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-primary/10 border border-primary/20 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Your API Key</p>
|
||||
{!loadingApiKey && apiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyApiKey}
|
||||
>
|
||||
{copiedApiKey ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingApiKey ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : apiKey ? (
|
||||
<>
|
||||
<Input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add this key to your extension settings to connect it to your Vibn account.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sign in to generate your API key
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 space-y-2">
|
||||
<p className="text-sm font-medium">Configure Cursor Monitor Extension:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground ml-2">
|
||||
<li>Open Cursor Settings (Cmd/Ctrl + ,)</li>
|
||||
<li>Search for "Cursor Monitor"</li>
|
||||
<li>Find "Cursor Monitor: Vibn Api Key"</li>
|
||||
<li>Paste your API key (from above)</li>
|
||||
<li>Verify "Cursor Monitor: Vibn Api Url" is set to: <code className="text-xs bg-background px-1 py-0.5 rounded">{apiUrl}/api</code></li>
|
||||
<li>Make sure "Cursor Monitor: Vibn Enabled" is checked</li>
|
||||
<li>Reload Cursor to start tracking</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{extensionInstalled && (
|
||||
<div className="rounded-lg bg-green-500/10 border border-green-500/20 p-4">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
✓ Extension is installed and tracking your sessions
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GitHub Connection */}
|
||||
<Card className={githubConnected ? "border-green-500/50 bg-green-500/5" : ""}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Github className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>GitHub</CardTitle>
|
||||
{githubConnected && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Connect your repositories for automatic analysis
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{!githubConnected ? (
|
||||
<Button onClick={handleConnectGitHub}>
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
Connect GitHub
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" disabled>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Connected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">What we'll access:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Read your repository code and structure</li>
|
||||
<li>Access repository metadata and commit history</li>
|
||||
<li>Analyze tech stack and dependencies</li>
|
||||
<li>Identify project architecture patterns</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{!githubConnected && (
|
||||
<div className="rounded-lg bg-muted p-4 space-y-2">
|
||||
<p className="text-sm font-medium">Why connect GitHub?</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Our AI will analyze your codebase to understand your tech stack,
|
||||
architecture, and features. This helps generate better documentation
|
||||
and provides smarter insights.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubConnected && (
|
||||
<div className="rounded-lg bg-green-500/10 border border-green-500/20 p-4">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
✓ GitHub connected - Your repositories are ready for analysis
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ChatGPT (MCP) Connection */}
|
||||
<MCPConnectionCard />
|
||||
|
||||
{/* ChatGPT Import */}
|
||||
<ChatGPTImportCard />
|
||||
</div>
|
||||
|
||||
{/* Status Summary */}
|
||||
{(githubConnected || extensionInstalled) && (
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Connection Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<CursorIcon className="h-4 w-4" />
|
||||
Cursor Extension
|
||||
</span>
|
||||
{extensionInstalled ? (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not installed</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Github className="h-4 w-4" />
|
||||
GitHub
|
||||
</span>
|
||||
{githubConnected ? (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not connected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function CostsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState<string>("costs");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { toast } from 'sonner';
|
||||
import { DollarSign, TrendingUp, TrendingDown, Calendar } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
interface CostData {
|
||||
total: number;
|
||||
thisMonth: number;
|
||||
lastMonth: number;
|
||||
byProject: Array<{
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
cost: number;
|
||||
}>;
|
||||
byDate: Array<{
|
||||
date: string;
|
||||
cost: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function CostsPage() {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const [costs, setCosts] = useState<CostData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadCosts();
|
||||
}, []);
|
||||
|
||||
const loadCosts = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch('/api/costs', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCosts(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading costs:', error);
|
||||
toast.error('Failed to load cost data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const percentageChange = costs && costs.lastMonth > 0
|
||||
? ((costs.thisMonth - costs.lastMonth) / costs.lastMonth) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Costs</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Track your AI usage costs across all projects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">Loading cost data...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Costs</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${costs?.total.toFixed(2) || '0.00'}</div>
|
||||
<p className="text-xs text-muted-foreground">All time</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">This Month</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${costs?.thisMonth.toFixed(2) || '0.00'}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">vs Last Month</CardTitle>
|
||||
{percentageChange >= 0 ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{percentageChange >= 0 ? '+' : ''}{percentageChange.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last month: ${costs?.lastMonth.toFixed(2) || '0.00'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Costs by Project */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Costs by Project</CardTitle>
|
||||
<CardDescription>Your spending broken down by project</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{costs?.byProject && costs.byProject.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{costs.byProject.map((project) => (
|
||||
<div key={project.projectId} className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div>
|
||||
<p className="font-medium">{project.projectName}</p>
|
||||
<p className="text-sm text-muted-foreground">Project ID: {project.projectId}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">${project.cost.toFixed(2)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{((project.cost / (costs.total || 1)) * 100).toFixed(1)}% of total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">No project costs yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="border-blue-500/20 bg-blue-500/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">About Cost Tracking</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>📊 Automatic Tracking:</strong> All AI API costs are automatically tracked when you use Vibn features.
|
||||
</p>
|
||||
<p>
|
||||
<strong>💰 Your Keys, Your Costs:</strong> Costs reflect usage of your own API keys - Vibn doesn't add any markup.
|
||||
</p>
|
||||
<p>
|
||||
<strong>📈 Project Attribution:</strong> Costs are attributed to projects based on session metadata.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth, db } from '@/lib/firebase/config';
|
||||
import { collection, query, where, getDocs } from 'firebase/firestore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
interface ProjectDebugInfo {
|
||||
id: string;
|
||||
productName: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
userId: string;
|
||||
workspacePath?: string;
|
||||
createdAt: any;
|
||||
updatedAt: any;
|
||||
}
|
||||
|
||||
export default function DebugProjectsPage() {
|
||||
const [projects, setProjects] = useState<ProjectDebugInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string>('');
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
setError('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(user.uid);
|
||||
|
||||
const projectsRef = collection(db, 'projects');
|
||||
const projectsQuery = query(
|
||||
projectsRef,
|
||||
where('userId', '==', user.uid)
|
||||
);
|
||||
const snapshot = await getDocs(projectsQuery);
|
||||
|
||||
const projectsData = snapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
productName: data.productName || 'N/A',
|
||||
name: data.name || 'N/A',
|
||||
slug: data.slug || 'N/A',
|
||||
userId: data.userId || 'N/A',
|
||||
workspacePath: data.workspacePath,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('DEBUG: All projects from Firebase:', projectsData);
|
||||
setProjects(projectsData);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading projects:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = auth.onAuthStateChanged((user) => {
|
||||
if (user) {
|
||||
loadProjects();
|
||||
} else {
|
||||
setError('Please sign in');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8 bg-background">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">🔍 Projects Debug Page</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
View all your projects and their unique IDs from Firebase
|
||||
</p>
|
||||
{userId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
User ID: <code className="bg-muted px-2 py-1 rounded">{userId}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={loadProjects} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-red-500">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-600">Error: {error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">Loading projects from Firebase...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Projects</p>
|
||||
<p className="text-2xl font-bold">{projects.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Unique IDs</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{new Set(projects.map(p => p.id)).size}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duplicate IDs?</p>
|
||||
<p className={`text-2xl font-bold ${projects.length !== new Set(projects.map(p => p.id)).size ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{projects.length !== new Set(projects.map(p => p.id)).size ? 'YES ⚠️' : 'NO ✓'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">All Projects</h2>
|
||||
{projects.map((project, index) => (
|
||||
<Card key={project.id + index}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<span>#{index + 1}: {project.productName}</span>
|
||||
<a
|
||||
href={`/marks-account/project/${project.id}/overview`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Open Overview →
|
||||
</a>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Project ID</p>
|
||||
<code className="block bg-muted px-2 py-1 rounded mt-1 break-all">
|
||||
{project.id}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Slug</p>
|
||||
<code className="block bg-muted px-2 py-1 rounded mt-1 break-all">
|
||||
{project.slug}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Product Name</p>
|
||||
<p className="font-medium mt-1">{project.productName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Internal Name</p>
|
||||
<p className="font-medium mt-1">{project.name}</p>
|
||||
</div>
|
||||
{project.workspacePath && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Workspace Path</p>
|
||||
<code className="block bg-muted px-2 py-1 rounded mt-1 break-all text-xs">
|
||||
{project.workspacePath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(project.id);
|
||||
alert('Project ID copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
Copy ID
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const url = `/marks-account/project/${project.id}/v_ai_chat`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
>
|
||||
Open Chat
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
console.log('Full project data:', project);
|
||||
alert('Check browser console for full data');
|
||||
}}
|
||||
>
|
||||
Log to Console
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { auth, db } from '@/lib/firebase/config';
|
||||
import { collection, query, where, getDocs, orderBy, limit } from 'firebase/firestore';
|
||||
import { RefreshCw, CheckCircle2, AlertCircle, Link as LinkIcon } from 'lucide-react';
|
||||
|
||||
interface SessionDebugInfo {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
workspacePath?: string;
|
||||
workspaceName?: string;
|
||||
needsProjectAssociation: boolean;
|
||||
model?: string;
|
||||
tokensUsed?: number;
|
||||
cost?: number;
|
||||
createdAt: any;
|
||||
}
|
||||
|
||||
export default function DebugSessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionDebugInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string>('');
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
setError('Not authenticated');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(user.uid);
|
||||
|
||||
const sessionsRef = collection(db, 'sessions');
|
||||
// Remove orderBy to avoid index issues - just get recent sessions
|
||||
const sessionsQuery = query(
|
||||
sessionsRef,
|
||||
where('userId', '==', user.uid),
|
||||
limit(50)
|
||||
);
|
||||
const snapshot = await getDocs(sessionsQuery);
|
||||
|
||||
const sessionsData = snapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
projectId: data.projectId || null,
|
||||
workspacePath: data.workspacePath || null,
|
||||
workspaceName: data.workspaceName || null,
|
||||
needsProjectAssociation: data.needsProjectAssociation || false,
|
||||
model: data.model,
|
||||
tokensUsed: data.tokensUsed,
|
||||
cost: data.cost,
|
||||
createdAt: data.createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('DEBUG: All sessions from Firebase:', sessionsData);
|
||||
setSessions(sessionsData);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading sessions:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const unsubscribe = auth.onAuthStateChanged((user) => {
|
||||
if (!mounted) return;
|
||||
|
||||
if (user) {
|
||||
loadSessions();
|
||||
} else {
|
||||
setError('Please sign in');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [loadSessions]);
|
||||
|
||||
const unassociatedSessions = sessions.filter(s => s.needsProjectAssociation);
|
||||
const associatedSessions = sessions.filter(s => !s.needsProjectAssociation);
|
||||
|
||||
// Group unassociated sessions by workspace path
|
||||
const sessionsByWorkspace = unassociatedSessions.reduce((acc, session) => {
|
||||
const path = session.workspacePath || 'No workspace path';
|
||||
if (!acc[path]) acc[path] = [];
|
||||
acc[path].push(session);
|
||||
return acc;
|
||||
}, {} as Record<string, SessionDebugInfo[]>);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8 bg-background">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">🔍 Sessions Debug Page</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
View all your chat sessions and their workspace paths
|
||||
</p>
|
||||
{userId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
User ID: <code className="bg-muted px-2 py-1 rounded">{userId}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={loadSessions} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-red-500">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-red-600">Error: {error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">Loading sessions...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Sessions</p>
|
||||
<p className="text-2xl font-bold">{sessions.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Linked to Projects</p>
|
||||
<p className="text-2xl font-bold text-green-600">{associatedSessions.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Unassociated (Available)</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{unassociatedSessions.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unassociated Sessions by Workspace */}
|
||||
{unassociatedSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600" />
|
||||
Unassociated Sessions (Available to Link)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Object.entries(sessionsByWorkspace).map(([path, workspaceSessions]) => {
|
||||
const folderName = path !== 'No workspace path' ? path.split('/').pop() : null;
|
||||
return (
|
||||
<div key={path} className="border rounded-lg p-4">
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">📁 {folderName || 'Unknown folder'}</p>
|
||||
<code className="text-xs text-muted-foreground break-all">{path}</code>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">{workspaceSessions.length}</p>
|
||||
<p className="text-xs text-muted-foreground">sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{workspaceSessions.slice(0, 3).map((session) => (
|
||||
<div key={session.id} className="text-xs bg-muted/50 p-2 rounded">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-mono">{session.id.substring(0, 12)}...</span>
|
||||
<span>{session.model || 'unknown'}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{session.tokensUsed?.toLocaleString()} tokens • ${session.cost?.toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{workspaceSessions.length > 3 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+ {workspaceSessions.length - 3} more sessions...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded text-sm">
|
||||
<p className="text-blue-600 dark:text-blue-400 font-medium mb-1">
|
||||
💡 To link these sessions:
|
||||
</p>
|
||||
<ol className="text-xs text-muted-foreground space-y-1 ml-4 list-decimal">
|
||||
<li>Create a project with workspace path: <code className="bg-muted px-1 rounded">{path}</code></li>
|
||||
<li>OR connect GitHub to a project that already has this workspace path set</li>
|
||||
</ol>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Folder name: <code className="bg-muted px-1 rounded">{folderName}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Associated Sessions */}
|
||||
{associatedSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
Linked Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
These sessions are already linked to projects
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{associatedSessions.slice(0, 5).map((session) => (
|
||||
<div key={session.id} className="flex items-center justify-between p-2 border rounded text-sm">
|
||||
<div>
|
||||
<code className="text-xs">{session.id.substring(0, 12)}...</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{session.workspaceName || 'No workspace'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<LinkIcon className="h-4 w-4 text-green-600" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Project: {session.projectId?.substring(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-muted-foreground">No sessions found. Start coding with Cursor to track sessions!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function KeysLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState<string>("keys");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { toast } from 'sonner';
|
||||
import { Key, Plus, Trash2, Eye, EyeOff, ExternalLink, Save } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
service: string;
|
||||
name: string;
|
||||
createdAt: any;
|
||||
lastUsed: any;
|
||||
}
|
||||
|
||||
const SUPPORTED_SERVICES = [
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
description: 'For ChatGPT imports and AI features',
|
||||
placeholder: 'sk-...',
|
||||
helpUrl: 'https://platform.openai.com/api-keys',
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
description: 'Personal access token for repository access',
|
||||
placeholder: 'ghp_...',
|
||||
helpUrl: 'https://github.com/settings/tokens',
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic (Claude)',
|
||||
description: 'For Claude AI integrations',
|
||||
placeholder: 'sk-ant-...',
|
||||
helpUrl: 'https://console.anthropic.com/settings/keys',
|
||||
},
|
||||
];
|
||||
|
||||
export default function KeysPage() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [selectedService, setSelectedService] = useState('');
|
||||
const [keyValue, setKeyValue] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, []);
|
||||
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch('/api/keys', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setKeys(data.keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading keys:', error);
|
||||
toast.error('Failed to load API keys');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddKey = async () => {
|
||||
if (!selectedService || !keyValue) {
|
||||
toast.error('Please select a service and enter a key');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const service = SUPPORTED_SERVICES.find(s => s.id === selectedService);
|
||||
|
||||
const response = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
service: selectedService,
|
||||
name: service?.name,
|
||||
keyValue,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(`${service?.name} key saved successfully`);
|
||||
setShowAddDialog(false);
|
||||
setSelectedService('');
|
||||
setKeyValue('');
|
||||
loadKeys();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to save key');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving key:', error);
|
||||
toast.error('Failed to save key');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (service: string, name: string) => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch('/api/keys', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ service }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(`${name} key deleted`);
|
||||
loadKeys();
|
||||
} else {
|
||||
toast.error('Failed to delete key');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting key:', error);
|
||||
toast.error('Failed to delete key');
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceConfig = (serviceId: string) => {
|
||||
return SUPPORTED_SERVICES.find(s => s.id === serviceId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">API Keys</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Manage your third-party API keys for Vibn integrations
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a third-party API key for Vibn to use on your behalf
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="service">Service</Label>
|
||||
<Select value={selectedService} onValueChange={setSelectedService}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_SERVICES.map(service => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedService && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getServiceConfig(selectedService)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="key"
|
||||
type={showKey ? 'text' : 'password'}
|
||||
placeholder={getServiceConfig(selectedService)?.placeholder || 'Enter API key'}
|
||||
value={keyValue}
|
||||
onChange={(e) => setKeyValue(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{selectedService && (
|
||||
<a
|
||||
href={getServiceConfig(selectedService)?.helpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your API key <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>🔐 Secure Storage:</strong> Your API key will be encrypted and stored securely.
|
||||
Vibn will only use it when you explicitly request actions that require it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddKey} disabled={saving || !selectedService || !keyValue}>
|
||||
{saving ? 'Saving...' : 'Save Key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Keys List */}
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">Loading your API keys...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : keys.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Key className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">No API keys yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add your third-party API keys to enable Vibn features like ChatGPT imports and AI analysis
|
||||
</p>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Key
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{keys.map((key) => {
|
||||
const serviceConfig = getServiceConfig(key.service);
|
||||
return (
|
||||
<Card key={key.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Key className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{key.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{serviceConfig?.description || key.service}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete API Key?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove your {key.name} API key. Features using this key will stop working until you add a new one.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDeleteKey(key.service, key.name)}>
|
||||
Delete Key
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground">
|
||||
Added: {key.createdAt ? new Date(key.createdAt._seconds * 1000).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
{key.lastUsed && (
|
||||
<p className="text-muted-foreground">
|
||||
Last used: {new Date(key.lastUsed._seconds * 1000).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{serviceConfig && (
|
||||
<a
|
||||
href={serviceConfig.helpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Manage on {serviceConfig.name} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="border-blue-500/20 bg-blue-500/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">How API Keys Work</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>🔐 Encrypted Storage:</strong> All API keys are encrypted before being stored in the database.
|
||||
</p>
|
||||
<p>
|
||||
<strong>🎯 Automatic Usage:</strong> When you use Vibn features (like ChatGPT import), we'll automatically use your stored keys instead of asking each time.
|
||||
</p>
|
||||
<p>
|
||||
<strong>🔄 Easy Updates:</strong> Add a new key with the same service name to replace an existing one.
|
||||
</p>
|
||||
<p>
|
||||
<strong>🗑️ Full Control:</strong> Delete keys anytime - you can always add them back later.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* MCP Integration Page
|
||||
*
|
||||
* Test and demonstrate Vibn's Model Context Protocol capabilities
|
||||
*/
|
||||
|
||||
import { MCPPlayground } from '@/components/mcp-playground';
|
||||
|
||||
export const metadata = {
|
||||
title: 'MCP Integration | Vibn',
|
||||
description: 'Connect AI assistants to your Vibn projects using the Model Context Protocol',
|
||||
};
|
||||
|
||||
export default function MCPPage() {
|
||||
return (
|
||||
<div className="container max-w-6xl py-8">
|
||||
<MCPPlayground />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export default function NewProjectLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ArrowLeft, ArrowRight, Check, Sparkles, Code2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ProjectType = "scratch" | "existing" | null;
|
||||
|
||||
export default function NewProjectPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [step, setStep] = useState(1);
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [projectType, setProjectType] = useState<ProjectType>(null);
|
||||
const [workspacePath, setWorkspacePath] = useState<string | null>(null);
|
||||
|
||||
// Product vision (can skip)
|
||||
const [productVision, setProductVision] = useState("");
|
||||
|
||||
// Product details
|
||||
const [productName, setProductName] = useState("");
|
||||
const [isForClient, setIsForClient] = useState<boolean | null>(null);
|
||||
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
|
||||
const [hasDomain, setHasDomain] = useState<boolean | null>(null);
|
||||
const [hasWebsite, setHasWebsite] = useState<boolean | null>(null);
|
||||
const [hasGithub, setHasGithub] = useState<boolean | null>(null);
|
||||
const [hasChatGPT, setHasChatGPT] = useState<boolean | null>(null);
|
||||
|
||||
const [isCheckingSlug, setIsCheckingSlug] = useState(false);
|
||||
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
||||
|
||||
// Check for workspacePath query parameter
|
||||
useEffect(() => {
|
||||
const path = searchParams.get('workspacePath');
|
||||
if (path) {
|
||||
setWorkspacePath(path);
|
||||
// Auto-fill project name from workspace path
|
||||
const folderName = path.split('/').pop();
|
||||
if (folderName && !projectName) {
|
||||
setProjectName(folderName.replace(/-/g, ' ').replace(/_/g, ' '));
|
||||
}
|
||||
}
|
||||
}, [searchParams, projectName]);
|
||||
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
|
||||
const checkSlugAvailability = async (name: string) => {
|
||||
const slug = generateSlug(name);
|
||||
if (!slug) return;
|
||||
|
||||
setIsCheckingSlug(true);
|
||||
// TODO: Replace with actual API call
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Mock check - in reality, check against database
|
||||
const isAvailable = !["test", "demo", "admin"].includes(slug);
|
||||
setSlugAvailable(isAvailable);
|
||||
setIsCheckingSlug(false);
|
||||
};
|
||||
|
||||
const handleProductNameChange = (value: string) => {
|
||||
setProductName(value);
|
||||
setSlugAvailable(null);
|
||||
if (value.length > 2) {
|
||||
checkSlugAvailability(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && projectName && projectType) {
|
||||
setStep(2);
|
||||
} else if (step === 2) {
|
||||
// Can skip questions
|
||||
setStep(3);
|
||||
} else if (step === 3 && productName && slugAvailable) {
|
||||
handleCreateProject();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) setStep(step - 1);
|
||||
};
|
||||
|
||||
const handleSkipQuestions = () => {
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const slug = generateSlug(productName);
|
||||
|
||||
const projectData = {
|
||||
projectName,
|
||||
projectType,
|
||||
slug,
|
||||
vision: productVision,
|
||||
product: {
|
||||
name: productName,
|
||||
isForClient,
|
||||
hasLogo,
|
||||
hasDomain,
|
||||
hasWebsite,
|
||||
hasGithub,
|
||||
hasChatGPT,
|
||||
},
|
||||
workspacePath,
|
||||
};
|
||||
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('You must be signed in to create a project');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
const response = await fetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast.success('Project created successfully!');
|
||||
// Redirect to AI chat to start with vision questions
|
||||
router.push(`/${data.workspace}/project/${data.projectId}/v_ai_chat`);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to create project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
toast.error('An error occurred while creating project');
|
||||
}
|
||||
};
|
||||
|
||||
const canProceedStep1 = projectName.trim() && projectType;
|
||||
const canProceedStep3 = productName.trim() && slugAvailable;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/projects")}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">Create New Project</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Step {step} of 3
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="flex gap-2 mb-8">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-2 flex-1 rounded-full transition-colors ${
|
||||
s <= step ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Project Setup */}
|
||||
{step === 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Setup</CardTitle>
|
||||
<CardDescription>
|
||||
Give your project a name and choose how you want to start
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectName">Project Name</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
placeholder="My Awesome Project"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Starting Point</Label>
|
||||
<div className="grid gap-3">
|
||||
<button
|
||||
onClick={() => setProjectType("scratch")}
|
||||
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||
projectType === "scratch"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 mt-0.5 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">Start from scratch</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Build a new project with AI assistance
|
||||
</div>
|
||||
</div>
|
||||
{projectType === "scratch" && (
|
||||
<Check className="h-5 w-5 ml-auto text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setProjectType("existing")}
|
||||
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||
projectType === "existing"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Code2 className="h-5 w-5 mt-0.5 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">Existing project</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Import and enhance an existing codebase
|
||||
</div>
|
||||
</div>
|
||||
{projectType === "existing" && (
|
||||
<Check className="h-5 w-5 ml-auto text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Product Vision */}
|
||||
{step === 2 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Describe your product vision</CardTitle>
|
||||
<CardDescription>
|
||||
Help us understand your project (you can skip this)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="Describe who you're building for, what problem they have, and how you plan to solve it..."
|
||||
value={productVision}
|
||||
onChange={(e) => setProductVision(e.target.value)}
|
||||
rows={8}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={handleSkipQuestions}
|
||||
>
|
||||
Skip this step
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Product Details */}
|
||||
{step === 3 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Details</CardTitle>
|
||||
<CardDescription>
|
||||
Tell us about your product
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productName">Product Name *</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
placeholder="Taskify"
|
||||
value={productName}
|
||||
onChange={(e) => handleProductNameChange(e.target.value)}
|
||||
/>
|
||||
{productName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isCheckingSlug ? (
|
||||
<span>Checking availability...</span>
|
||||
) : slugAvailable === true ? (
|
||||
<span className="text-green-600">
|
||||
✓ URL available: vibn.app/{generateSlug(productName)}
|
||||
</span>
|
||||
) : slugAvailable === false ? (
|
||||
<span className="text-red-600">
|
||||
✗ This name is already taken
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Client or Self */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Is this for a client or yourself?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isForClient === true ? "default" : "outline"}
|
||||
onClick={() => setIsForClient(true)}
|
||||
size="sm"
|
||||
className="w-20 h-8"
|
||||
>
|
||||
Client
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isForClient === false ? "default" : "outline"}
|
||||
onClick={() => setIsForClient(false)}
|
||||
size="sm"
|
||||
className="w-20 h-8"
|
||||
>
|
||||
Myself
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Does it have a logo?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasLogo === true ? "default" : "outline"}
|
||||
onClick={() => setHasLogo(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasLogo === false ? "default" : "outline"}
|
||||
onClick={() => setHasLogo(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Does it have a domain?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasDomain === true ? "default" : "outline"}
|
||||
onClick={() => setHasDomain(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasDomain === false ? "default" : "outline"}
|
||||
onClick={() => setHasDomain(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Does it have a website?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasWebsite === true ? "default" : "outline"}
|
||||
onClick={() => setHasWebsite(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasWebsite === false ? "default" : "outline"}
|
||||
onClick={() => setHasWebsite(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Do you have a GitHub repository?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasGithub === true ? "default" : "outline"}
|
||||
onClick={() => setHasGithub(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasGithub === false ? "default" : "outline"}
|
||||
onClick={() => setHasGithub(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ChatGPT */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Do you have your ideas in a ChatGPT project?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasChatGPT === true ? "default" : "outline"}
|
||||
onClick={() => setHasChatGPT(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasChatGPT === false ? "default" : "outline"}
|
||||
onClick={() => setHasChatGPT(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={handleNext}
|
||||
disabled={
|
||||
(step === 1 && !canProceedStep1) ||
|
||||
(step === 3 && !canProceedStep3) ||
|
||||
isCheckingSlug
|
||||
}
|
||||
>
|
||||
{step === 3 ? "Create Project" : "Next"}
|
||||
{step < 3 && <ArrowRight className="h-4 w-4 ml-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,179 +1,133 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart3, DollarSign, TrendingUp, Zap } from "lucide-react";
|
||||
"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 });
|
||||
|
||||
export default async function AnalyticsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page Header */}
|
||||
<div className="border-b bg-card/50 px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cost analysis, token usage, and performance metrics
|
||||
</p>
|
||||
</div>
|
||||
<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 className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$12.50</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<TrendingUp className="mr-1 inline h-3 w-3" />
|
||||
+8% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Tokens Used</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">2.5M</div>
|
||||
<p className="text-xs text-muted-foreground">Across all sessions</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Cost/Session</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$0.30</div>
|
||||
<p className="text-xs text-muted-foreground">Per coding session</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cost/Feature</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$1.56</div>
|
||||
<p className="text-xs text-muted-foreground">Average per feature</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
|
||||
{/* Detailed Analytics */}
|
||||
<Tabs defaultValue="costs" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="costs">Costs</TabsTrigger>
|
||||
<TabsTrigger value="tokens">Tokens</TabsTrigger>
|
||||
<TabsTrigger value="performance">Performance</TabsTrigger>
|
||||
</TabsList>
|
||||
<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>
|
||||
|
||||
<TabsContent value="costs" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
AI usage costs over time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cost chart visualization coming soon
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost by Model</CardTitle>
|
||||
<CardDescription>
|
||||
Breakdown by AI model used
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ model: "Claude Sonnet 4", cost: "$8.20", percentage: 66 },
|
||||
{ model: "GPT-4", cost: "$3.10", percentage: 25 },
|
||||
{ model: "Gemini Pro", cost: "$1.20", percentage: 9 },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{item.model}</span>
|
||||
<span className="text-muted-foreground">{item.cost}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Token Usage</CardTitle>
|
||||
<CardDescription>
|
||||
Token consumption over time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Token usage chart coming soon
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="performance" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Development Velocity</CardTitle>
|
||||
<CardDescription>
|
||||
Features completed over time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[300px] items-center justify-center border-2 border-dashed rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Velocity metrics coming soon
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Map } from "lucide-react";
|
||||
|
||||
export default async function ApiMapPage({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page Header */}
|
||||
<div className="border-b bg-card/50 px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">API Map</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Auto-generated API endpoint documentation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Endpoints</CardTitle>
|
||||
<CardDescription>
|
||||
Automatically detected from your codebase
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Example endpoints */}
|
||||
{[
|
||||
{ method: "GET", path: "/api/sessions", desc: "List all sessions" },
|
||||
{ method: "POST", path: "/api/sessions", desc: "Create new session" },
|
||||
{ method: "GET", path: "/api/features", desc: "List features" },
|
||||
].map((endpoint, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge
|
||||
variant={endpoint.method === "GET" ? "outline" : "default"}
|
||||
className="font-mono"
|
||||
>
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<div>
|
||||
<code className="text-sm font-mono">{endpoint.path}</code>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{endpoint.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { FileCode } from "lucide-react";
|
||||
|
||||
export default async function ArchitecturePage({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page Header */}
|
||||
<div className="border-b bg-card/50 px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Architecture</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Living architecture documentation and ADRs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="decisions">Decisions (ADRs)</TabsTrigger>
|
||||
<TabsTrigger value="tech-stack">Tech Stack</TabsTrigger>
|
||||
<TabsTrigger value="data-model">Data Model</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Architecture Overview</CardTitle>
|
||||
<CardDescription>
|
||||
High-level system architecture
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose max-w-none">
|
||||
<p className="text-muted-foreground">
|
||||
Architecture documentation will be automatically generated
|
||||
from your code and conversations.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decisions" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Architectural Decision Records</CardTitle>
|
||||
<CardDescription>
|
||||
Key architectural choices and their context
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="mb-4 rounded-full bg-muted p-3">
|
||||
<FileCode className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-center text-muted-foreground max-w-sm">
|
||||
ADRs will be automatically detected from your AI conversations
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tech-stack" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Technology Stack</CardTitle>
|
||||
<CardDescription>
|
||||
Approved technologies and frameworks
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Frontend</h4>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li>• Next.js 15</li>
|
||||
<li>• React 19</li>
|
||||
<li>• Tailwind CSS</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Backend</h4>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li>• Node.js</li>
|
||||
<li>• Express</li>
|
||||
<li>• PostgreSQL</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="data-model" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data Model</CardTitle>
|
||||
<CardDescription>
|
||||
Database schema and relationships
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Database schema documentation coming soon
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { auth, db } from '@/lib/firebase/config';
|
||||
import { doc, getDoc } from 'firebase/firestore';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Link as LinkIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
githubRepo?: string;
|
||||
workspacePath?: string;
|
||||
}
|
||||
|
||||
export default function AssociateSessionsPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [associating, setAssociating] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProject();
|
||||
}, [projectId]);
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const projectDoc = await getDoc(doc(db, 'projects', projectId));
|
||||
if (projectDoc.exists()) {
|
||||
setProject({ id: projectDoc.id, ...projectDoc.data() } as Project);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading project:', error);
|
||||
toast.error('Failed to load project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssociateSessions = async () => {
|
||||
if (!project?.githubRepo) {
|
||||
toast.error('Project does not have a GitHub repository connected');
|
||||
return;
|
||||
}
|
||||
|
||||
setAssociating(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch(`/api/projects/${projectId}/associate-github-sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
githubRepo: project.githubRepo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
|
||||
if (data.sessionsAssociated > 0) {
|
||||
toast.success(`Success!`, {
|
||||
description: `Linked ${data.sessionsAssociated} existing chat sessions to this project`,
|
||||
});
|
||||
} else {
|
||||
toast.info('No unassociated sessions found for this repository');
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to associate sessions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
toast.error('An error occurred');
|
||||
} finally {
|
||||
setAssociating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Associate Existing Sessions</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Find and link chat sessions from this GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
<CardDescription>Current project configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Product Name</p>
|
||||
<p className="font-medium">{project?.productName}</p>
|
||||
</div>
|
||||
|
||||
{project?.githubRepo && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">GitHub Repository</p>
|
||||
<p className="font-medium font-mono text-sm">{project.githubRepo}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project?.workspacePath && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Workspace Path</p>
|
||||
<p className="font-medium font-mono text-sm">{project.workspacePath}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Find Matching Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Search your database for chat sessions that match this project's GitHub repository
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-lg space-y-2 text-sm">
|
||||
<p><strong>How it works:</strong></p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Searches for sessions with matching GitHub repository</li>
|
||||
<li>Also checks sessions from matching workspace paths</li>
|
||||
<li>Only links sessions that aren't already assigned to a project</li>
|
||||
<li>Updates all matched sessions to link to this project</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAssociateSessions}
|
||||
disabled={!project?.githubRepo || associating}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{associating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
Find and Link Sessions
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!project?.githubRepo && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Connect a GitHub repository first to use this feature
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card className="border-green-500/50 bg-green-50/50 dark:bg-green-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
Results
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Sessions Linked</p>
|
||||
<p className="text-2xl font-bold">{result.sessionsAssociated}</p>
|
||||
</div>
|
||||
|
||||
{result.details && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Exact GitHub Matches</p>
|
||||
<p className="text-2xl font-bold">{result.details.exactMatches}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Path Matches</p>
|
||||
<p className="text-2xl font-bold">{result.details.pathMatches}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
'use client';
|
||||
|
||||
export default function AuditTestPage() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold">Audit Test Page</h1>
|
||||
<p className="mt-4">If you can see this, routing is working!</p>
|
||||
<button
|
||||
onClick={() => alert('Button works!')}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
|
||||
>
|
||||
Test Button
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,956 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Loader2, FileText, TrendingUp, DollarSign, Code, Calendar, Clock } from 'lucide-react';
|
||||
|
||||
interface AuditReport {
|
||||
projectId: string;
|
||||
generatedAt: string;
|
||||
timeline: {
|
||||
firstActivity: string | null;
|
||||
lastActivity: string | null;
|
||||
totalDays: number;
|
||||
activeDays: number;
|
||||
totalSessions: number;
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: number;
|
||||
messageCount: number;
|
||||
userMessages: number;
|
||||
aiMessages: number;
|
||||
topics: string[];
|
||||
filesWorkedOn: string[];
|
||||
}>;
|
||||
velocity: {
|
||||
messagesPerDay: number;
|
||||
averageSessionLength: number;
|
||||
peakProductivityHours: number[];
|
||||
};
|
||||
};
|
||||
costs: {
|
||||
messageStats: {
|
||||
totalMessages: number;
|
||||
userMessages: number;
|
||||
aiMessages: number;
|
||||
avgMessageLength: number;
|
||||
};
|
||||
estimatedTokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
};
|
||||
costs: {
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
totalCost: number;
|
||||
currency: string;
|
||||
};
|
||||
model: string;
|
||||
pricing: {
|
||||
inputPer1M: number;
|
||||
outputPer1M: number;
|
||||
};
|
||||
};
|
||||
features: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
pages: string[];
|
||||
apis: string[];
|
||||
status: string;
|
||||
}>;
|
||||
techStack: {
|
||||
frontend: Record<string, string>;
|
||||
backend: Record<string, string>;
|
||||
integrations: string[];
|
||||
};
|
||||
extensionActivity: {
|
||||
totalSessions: number;
|
||||
uniqueFilesEdited: number;
|
||||
topFiles: Array<{ file: string; editCount: number }>;
|
||||
earliestActivity: string | null;
|
||||
latestActivity: string | null;
|
||||
} | null;
|
||||
gitHistory: {
|
||||
totalCommits: number;
|
||||
firstCommit: string | null;
|
||||
lastCommit: string | null;
|
||||
totalFilesChanged: number;
|
||||
totalInsertions: number;
|
||||
totalDeletions: number;
|
||||
commits: Array<{
|
||||
hash: string;
|
||||
date: string;
|
||||
author: string;
|
||||
message: string;
|
||||
filesChanged: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}>;
|
||||
topFiles: Array<{ filePath: string; changeCount: number }>;
|
||||
commitsByDay: Record<string, number>;
|
||||
authors: Array<{ name: string; commitCount: number }>;
|
||||
} | null;
|
||||
unifiedTimeline: {
|
||||
projectId: string;
|
||||
dateRange: {
|
||||
earliest: string;
|
||||
latest: string;
|
||||
totalDays: number;
|
||||
};
|
||||
days: Array<{
|
||||
date: string;
|
||||
dayOfWeek: string;
|
||||
gitCommits: any[];
|
||||
extensionSessions: any[];
|
||||
cursorMessages: any[];
|
||||
summary: {
|
||||
totalGitCommits: number;
|
||||
totalExtensionSessions: number;
|
||||
totalCursorMessages: number;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
uniqueFilesModified: number;
|
||||
};
|
||||
}>;
|
||||
dataSources: {
|
||||
git: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
|
||||
extension: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
|
||||
cursor: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
|
||||
};
|
||||
} | null;
|
||||
summary: {
|
||||
totalConversations: number;
|
||||
totalMessages: number;
|
||||
developmentPeriod: number;
|
||||
estimatedCost: number;
|
||||
extensionSessions: number;
|
||||
filesEdited: number;
|
||||
gitCommits: number;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
timelineDays: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProjectAuditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = use(params);
|
||||
const [report, setReport] = useState<AuditReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generateReport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/audit/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to generate report');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setReport(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Project Audit Report</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Comprehensive analysis of development history, costs, and architecture
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={generateReport} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Generate Report
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{error}</p>
|
||||
{error.includes('No conversations found') && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Import Cursor conversations first to generate an audit report.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!report && !loading && !error && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ready to Generate</CardTitle>
|
||||
<CardDescription>
|
||||
Click the button above to analyze your project's development history,
|
||||
calculate costs, and document your architecture.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Calendar className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold">Timeline Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">Work sessions & velocity</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<DollarSign className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold">Cost Estimation</p>
|
||||
<p className="text-sm text-muted-foreground">AI & developer costs</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Code className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold">Architecture</p>
|
||||
<p className="text-sm text-muted-foreground">Features & tech stack</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Section */}
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Messages
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatNumber(report.summary.totalMessages)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{report.summary.totalConversations} conversations
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Development Period
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{report.summary.developmentPeriod} days</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{report.timeline.activeDays} active days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Work Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{report.timeline.totalSessions}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Avg {report.timeline.velocity.averageSessionLength} min
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
AI Cost
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatCurrency(report.summary.estimatedCost)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{report.costs.model}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Git Commits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatNumber(report.summary.gitCommits)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Code changes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Lines Changed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-lg font-bold">
|
||||
<span className="text-green-600">+{formatNumber(report.summary.linesAdded)}</span>
|
||||
{' / '}
|
||||
<span className="text-red-600">-{formatNumber(report.summary.linesRemoved)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Total modifications
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Unified Timeline Section */}
|
||||
{report.unifiedTimeline && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Calendar className="mr-2 h-5 w-5" />
|
||||
Complete Project Timeline
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Day-by-day history combining Git commits, Extension activity, and Cursor messages
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Data Source Overview */}
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-6">
|
||||
<div className={`border rounded-lg p-3 ${report.unifiedTimeline.dataSources.git.available ? 'bg-green-50 border-green-200' : 'bg-gray-50'}`}>
|
||||
<p className="text-sm font-medium mb-1">📊 Git Commits</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{report.unifiedTimeline.dataSources.git.available ? (
|
||||
<>
|
||||
{report.unifiedTimeline.dataSources.git.totalRecords} commits<br/>
|
||||
{formatDate(report.unifiedTimeline.dataSources.git.firstDate)} to {formatDate(report.unifiedTimeline.dataSources.git.lastDate)}
|
||||
</>
|
||||
) : 'No data'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`border rounded-lg p-3 ${report.unifiedTimeline.dataSources.extension.available ? 'bg-blue-50 border-blue-200' : 'bg-gray-50'}`}>
|
||||
<p className="text-sm font-medium mb-1">💻 Extension Activity</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{report.unifiedTimeline.dataSources.extension.available ? (
|
||||
<>
|
||||
{report.unifiedTimeline.dataSources.extension.totalRecords} sessions<br/>
|
||||
{formatDate(report.unifiedTimeline.dataSources.extension.firstDate)} to {formatDate(report.unifiedTimeline.dataSources.extension.lastDate)}
|
||||
</>
|
||||
) : 'No data'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`border rounded-lg p-3 ${report.unifiedTimeline.dataSources.cursor.available ? 'bg-purple-50 border-purple-200' : 'bg-gray-50'}`}>
|
||||
<p className="text-sm font-medium mb-1">🤖 Cursor Messages</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{report.unifiedTimeline.dataSources.cursor.available ? (
|
||||
<>
|
||||
{report.unifiedTimeline.dataSources.cursor.totalRecords} messages<br/>
|
||||
{formatDate(report.unifiedTimeline.dataSources.cursor.firstDate)} to {formatDate(report.unifiedTimeline.dataSources.cursor.lastDate)}
|
||||
</>
|
||||
) : 'No data'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timeline Days */}
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||
{report.unifiedTimeline.days.filter(day =>
|
||||
day.summary.totalGitCommits > 0 ||
|
||||
day.summary.totalExtensionSessions > 0 ||
|
||||
day.summary.totalCursorMessages > 0
|
||||
).reverse().map((day, index) => (
|
||||
<div key={index} className="border-l-4 border-primary/30 pl-4 py-3 hover:bg-accent/50 rounded-r-lg transition-colors">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="font-semibold">{formatDate(day.date)}</h4>
|
||||
<p className="text-xs text-muted-foreground">{day.dayOfWeek}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
{day.summary.totalGitCommits > 0 && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 rounded">
|
||||
📊 {day.summary.totalGitCommits}
|
||||
</span>
|
||||
)}
|
||||
{day.summary.totalExtensionSessions > 0 && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded">
|
||||
💻 {day.summary.totalExtensionSessions}
|
||||
</span>
|
||||
)}
|
||||
{day.summary.totalCursorMessages > 0 && (
|
||||
<span className="px-2 py-1 bg-purple-100 text-purple-800 rounded">
|
||||
🤖 {day.summary.totalCursorMessages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{/* Git Commits */}
|
||||
{day.gitCommits.length > 0 && (
|
||||
<div className="bg-green-50 rounded p-2">
|
||||
<p className="text-xs font-medium text-green-900 mb-1">Git Commits:</p>
|
||||
{day.gitCommits.map((commit: any, idx: number) => (
|
||||
<div key={idx} className="text-xs text-green-800 ml-2">
|
||||
• {commit.message}
|
||||
<span className="text-green-600 ml-1">
|
||||
(+{commit.insertions}/-{commit.deletions})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extension Sessions */}
|
||||
{day.extensionSessions.length > 0 && (
|
||||
<div className="bg-blue-50 rounded p-2">
|
||||
<p className="text-xs font-medium text-blue-900 mb-1">
|
||||
Extension Sessions: {day.summary.totalExtensionSessions}
|
||||
({day.summary.uniqueFilesModified} files modified)
|
||||
</p>
|
||||
{day.extensionSessions.slice(0, 3).map((session: any, idx: number) => (
|
||||
<div key={idx} className="text-xs text-blue-800 ml-2">
|
||||
• {session.duration} min session
|
||||
{session.conversationSummary && (
|
||||
<span className="ml-1">- {session.conversationSummary.substring(0, 50)}...</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{day.extensionSessions.length > 3 && (
|
||||
<p className="text-xs text-blue-600 ml-2 mt-1">
|
||||
+{day.extensionSessions.length - 3} more sessions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cursor Messages */}
|
||||
{day.cursorMessages.length > 0 && (
|
||||
<div className="bg-purple-50 rounded p-2">
|
||||
<p className="text-xs font-medium text-purple-900 mb-1">
|
||||
AI Conversations: {day.summary.totalCursorMessages} messages
|
||||
</p>
|
||||
<div className="text-xs text-purple-800 ml-2">
|
||||
• Active in: {[...new Set(day.cursorMessages.map((m: any) => m.conversationName))].join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day Summary */}
|
||||
{(day.summary.linesAdded > 0 || day.summary.linesRemoved > 0) && (
|
||||
<div className="mt-2 pt-2 border-t text-xs text-muted-foreground">
|
||||
Total changes: <span className="text-green-600">+{day.summary.linesAdded}</span> /
|
||||
<span className="text-red-600"> -{day.summary.linesRemoved}</span> lines
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Timeline Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Calendar className="mr-2 h-5 w-5" />
|
||||
Development Timeline
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Work sessions and development velocity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Development Period</p>
|
||||
<p className="text-2xl font-bold">{formatDate(report.timeline.firstActivity)}</p>
|
||||
<p className="text-sm text-muted-foreground">to {formatDate(report.timeline.lastActivity)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Peak Productivity Hours</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{report.timeline.velocity.peakProductivityHours.map(h => `${h}:00`).join(', ')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Most active times</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Velocity Metrics</p>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Messages per day:</span>
|
||||
<span className="font-mono">{report.timeline.velocity.messagesPerDay.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Average session length:</span>
|
||||
<span className="font-mono">{report.timeline.velocity.averageSessionLength} minutes</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total sessions:</span>
|
||||
<span className="font-mono">{report.timeline.totalSessions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Recent Sessions</p>
|
||||
<div className="space-y-2">
|
||||
{report.timeline.sessions.slice(-5).reverse().map((session) => (
|
||||
<div key={session.sessionId} className="border rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{formatDate(session.date)}</span>
|
||||
<span className="text-muted-foreground font-mono">
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
{session.duration} min
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{session.messageCount} messages • {session.topics.slice(0, 2).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Extension Activity Section */}
|
||||
{report.extensionActivity && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Code className="mr-2 h-5 w-5" />
|
||||
File Edit Activity
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Files you've edited tracked by the Cursor Monitor extension
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Extension Sessions</p>
|
||||
<p className="text-2xl font-bold">{report.extensionActivity.totalSessions}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Work sessions logged</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Files Edited</p>
|
||||
<p className="text-2xl font-bold">{report.extensionActivity.uniqueFilesEdited}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique files modified</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Activity Period</p>
|
||||
<p className="text-sm font-bold">
|
||||
{report.extensionActivity.earliestActivity
|
||||
? formatDate(report.extensionActivity.earliestActivity)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
to {report.extensionActivity.latestActivity
|
||||
? formatDate(report.extensionActivity.latestActivity)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Most Edited Files (Top 20)</p>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{report.extensionActivity.topFiles.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between border-b pb-2">
|
||||
<span className="text-sm font-mono truncate flex-1" title={item.file}>
|
||||
{item.file.split('/').pop()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{item.editCount} {item.editCount === 1 ? 'edit' : 'edits'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Git Commit History Section */}
|
||||
{report.gitHistory && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="mr-2 h-5 w-5" />
|
||||
Git Commit History
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Complete development history from Git repository
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Total Commits</p>
|
||||
<p className="text-2xl font-bold">{report.gitHistory.totalCommits}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Code changes tracked</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Lines of Code</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
+{formatNumber(report.gitHistory.totalInsertions)}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
-{formatNumber(report.gitHistory.totalDeletions)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Repository Period</p>
|
||||
<p className="text-sm font-bold">
|
||||
{report.gitHistory.firstCommit
|
||||
? formatDate(report.gitHistory.firstCommit)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
to {report.gitHistory.lastCommit
|
||||
? formatDate(report.gitHistory.lastCommit)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Authors */}
|
||||
{report.gitHistory.authors.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Contributors</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{report.gitHistory.authors.map((author, index) => (
|
||||
<span key={index} className="text-xs px-3 py-1 bg-secondary rounded-full">
|
||||
{author.name} ({author.commitCount} {author.commitCount === 1 ? 'commit' : 'commits'})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Top Files */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Most Changed Files (Top 20)</p>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{report.gitHistory.topFiles.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between border-b pb-2">
|
||||
<span className="text-sm font-mono truncate flex-1" title={item.filePath}>
|
||||
{item.filePath.split('/').pop()}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{item.changeCount} {item.changeCount === 1 ? 'change' : 'changes'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Recent Commits */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Recent Commits (Last 20)</p>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{report.gitHistory.commits.slice(0, 20).map((commit, index) => (
|
||||
<div key={index} className="border-l-2 border-primary/20 pl-3 py-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{commit.message}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{commit.author} • {formatDate(commit.date)} •
|
||||
<span className="font-mono ml-1">{commit.hash}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
<span className="text-green-600">+{commit.insertions}</span> /
|
||||
<span className="text-red-600">-{commit.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cost Analysis Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<DollarSign className="mr-2 h-5 w-5" />
|
||||
AI Cost Analysis
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Estimated costs based on {report.costs.model} usage
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Message Statistics</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total messages:</span>
|
||||
<span className="font-mono">{formatNumber(report.costs.messageStats.totalMessages)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">User messages:</span>
|
||||
<span className="font-mono">{formatNumber(report.costs.messageStats.userMessages)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">AI messages:</span>
|
||||
<span className="font-mono">{formatNumber(report.costs.messageStats.aiMessages)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Avg length:</span>
|
||||
<span className="font-mono">{report.costs.messageStats.avgMessageLength} chars</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Token Usage</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Input tokens:</span>
|
||||
<span className="font-mono">{formatNumber(report.costs.estimatedTokens.input)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Output tokens:</span>
|
||||
<span className="font-mono">{formatNumber(report.costs.estimatedTokens.output)}</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total tokens:</span>
|
||||
<span className="font-mono">{formatNumber(report.costs.estimatedTokens.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Cost Breakdown</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Input cost ({formatCurrency(report.costs.pricing.inputPer1M)}/1M tokens):
|
||||
</span>
|
||||
<span className="font-mono">{formatCurrency(report.costs.costs.inputCost)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Output cost ({formatCurrency(report.costs.pricing.outputPer1M)}/1M tokens):
|
||||
</span>
|
||||
<span className="font-mono">{formatCurrency(report.costs.costs.outputCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="bg-primary/5 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Total AI Cost</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{report.costs.model}</p>
|
||||
</div>
|
||||
<div className="text-3xl font-bold">{formatCurrency(report.costs.costs.totalCost)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>* Token estimation: ~4 characters per token</p>
|
||||
<p className="mt-1">* Costs are estimates based on message content length</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Code className="mr-2 h-5 w-5" />
|
||||
Features Implemented
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Current project capabilities and status
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{report.features.map((feature, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold">{feature.name}</h3>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
feature.status === 'complete' ? 'bg-green-100 text-green-800' :
|
||||
feature.status === 'in-progress' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{feature.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">{feature.description}</p>
|
||||
<div className="grid gap-2 text-xs">
|
||||
{feature.pages.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">Pages:</span>{' '}
|
||||
<span className="text-muted-foreground">{feature.pages.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{feature.apis.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">APIs:</span>{' '}
|
||||
<span className="text-muted-foreground font-mono">{feature.apis.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tech Stack Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<TrendingUp className="mr-2 h-5 w-5" />
|
||||
Technology Stack
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Frameworks, libraries, and integrations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Frontend</p>
|
||||
<div className="grid gap-2 text-sm">
|
||||
{Object.entries(report.techStack.frontend).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-muted-foreground capitalize">{key.replace(/([A-Z])/g, ' $1')}:</span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Backend</p>
|
||||
<div className="grid gap-2 text-sm">
|
||||
{Object.entries(report.techStack.backend).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-muted-foreground capitalize">{key}:</span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Integrations</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{report.techStack.integrations.map((integration) => (
|
||||
<span key={integration} className="text-xs px-2 py-1 bg-secondary rounded-md">
|
||||
{integration}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Report generated at {new Date(report.generatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Zap } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
|
||||
// Mock project data
|
||||
const MOCK_PROJECT = {
|
||||
id: "1",
|
||||
name: "AI Proxy",
|
||||
emoji: "🤖",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}
|
||||
|
||||
export default async function AutomationPage({ params }: PageProps) {
|
||||
const { projectId } = await params;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
projectId={projectId}
|
||||
projectName={MOCK_PROJECT.name}
|
||||
projectEmoji={MOCK_PROJECT.emoji}
|
||||
pageName="Automation"
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="container max-w-7xl py-6 space-y-6">
|
||||
{/* Hero Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Zap className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Automation</CardTitle>
|
||||
<CardDescription>
|
||||
Create workflows, set up triggers, and automate repetitive tasks
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="mb-3 rounded-full bg-muted p-4">
|
||||
<Zap className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-medium text-lg mb-2">Coming Soon</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Build custom workflows to automate testing, deployment, notifications,
|
||||
and other development tasks to accelerate your workflow.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
1499
app/[workspace]/project/[projectId]/build/page.tsx
Normal file
1499
app/[workspace]/project/[projectId]/build/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,447 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Code2,
|
||||
FolderOpen,
|
||||
File,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Loader2,
|
||||
Github,
|
||||
RefreshCw,
|
||||
FileCode
|
||||
} from "lucide-react";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { db } from "@/lib/firebase/config";
|
||||
import { doc, getDoc } from "firebase/firestore";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Project {
|
||||
githubRepo?: string;
|
||||
githubRepoUrl?: string;
|
||||
githubDefaultBranch?: string;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'file' | 'folder';
|
||||
children?: FileNode[];
|
||||
size?: number;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
interface GitHubFile {
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default function CodePage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [fileTree, setFileTree] = useState<FileNode[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['/']));
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchProject();
|
||||
}, [projectId]);
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const projectRef = doc(db, "projects", projectId);
|
||||
const projectSnap = await getDoc(projectRef);
|
||||
|
||||
if (projectSnap.exists()) {
|
||||
const projectData = projectSnap.data() as Project;
|
||||
setProject(projectData);
|
||||
|
||||
// Auto-load files if GitHub is connected
|
||||
if (projectData.githubRepo) {
|
||||
await fetchFileTree(projectData.githubRepo, projectData.githubDefaultBranch);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching project:", error);
|
||||
toast.error("Failed to load project");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFileTree = async (repoFullName: string, branch = 'main') => {
|
||||
setLoadingFiles(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const [owner, repo] = repoFullName.split('/');
|
||||
|
||||
const response = await fetch(
|
||||
`/api/github/repo-tree?owner=${owner}&repo=${repo}&branch=${branch}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch repository files");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const tree = buildFileTree(data.files);
|
||||
setFileTree(tree);
|
||||
|
||||
toast.success(`Loaded ${data.totalFiles} files from ${repoFullName}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching file tree:", error);
|
||||
toast.error("Failed to load repository files");
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildFileTree = (files: GitHubFile[]): FileNode[] => {
|
||||
const root: FileNode = {
|
||||
path: '/',
|
||||
name: '/',
|
||||
type: 'folder',
|
||||
children: [],
|
||||
};
|
||||
|
||||
files.forEach((file) => {
|
||||
const parts = file.path.split('/');
|
||||
let currentNode = root;
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isFile = index === parts.length - 1;
|
||||
const fullPath = parts.slice(0, index + 1).join('/');
|
||||
|
||||
if (!currentNode.children) {
|
||||
currentNode.children = [];
|
||||
}
|
||||
|
||||
let childNode = currentNode.children.find(child => child.name === part);
|
||||
|
||||
if (!childNode) {
|
||||
childNode = {
|
||||
path: fullPath,
|
||||
name: part,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
...(isFile && { size: file.size, sha: file.sha }),
|
||||
...(!isFile && { children: [] }),
|
||||
};
|
||||
currentNode.children.push(childNode);
|
||||
}
|
||||
|
||||
if (!isFile) {
|
||||
currentNode = childNode;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort children recursively
|
||||
const sortNodes = (nodes: FileNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
nodes.forEach(node => {
|
||||
if (node.children) {
|
||||
sortNodes(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (root.children) {
|
||||
sortNodes(root.children);
|
||||
}
|
||||
|
||||
return root.children || [];
|
||||
};
|
||||
|
||||
const fetchFileContent = async (filePath: string) => {
|
||||
if (!project?.githubRepo) return;
|
||||
|
||||
setLoadingContent(true);
|
||||
setSelectedFile(filePath);
|
||||
setFileContent(null);
|
||||
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const [owner, repo] = project.githubRepo.split('/');
|
||||
const branch = project.githubDefaultBranch || 'main';
|
||||
|
||||
console.log('[Code Page] Fetching file:', filePath);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/github/file-content?owner=${owner}&repo=${repo}&path=${encodeURIComponent(filePath)}&branch=${branch}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('[Code Page] Failed to fetch file:', errorData);
|
||||
throw new Error(errorData.error || "Failed to fetch file content");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[Code Page] File loaded:', data.name, `(${data.size} bytes)`);
|
||||
setFileContent(data.content);
|
||||
} catch (error) {
|
||||
console.error("Error fetching file content:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to load file content");
|
||||
setFileContent(`// Error loading file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setLoadingContent(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
const renderFileTree = (nodes: FileNode[], level = 0): JSX.Element[] => {
|
||||
return nodes
|
||||
.filter(node => {
|
||||
if (!searchQuery) return true;
|
||||
return node.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.map((node) => (
|
||||
<div key={node.path}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (node.type === 'folder') {
|
||||
toggleFolder(node.path);
|
||||
} else {
|
||||
fetchFileContent(node.path);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors",
|
||||
selectedFile === node.path && "bg-muted"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
||||
>
|
||||
{node.type === 'folder' ? (
|
||||
<>
|
||||
{expandedFolders.has(node.path) ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-4" />
|
||||
<FileCode className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.size && (
|
||||
<span className="ml-auto text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(node.size)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{node.type === 'folder' && expandedFolders.has(node.path) && node.children && (
|
||||
renderFileTree(node.children, level + 1)
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project?.githubRepo) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center gap-2 px-6">
|
||||
<Code2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Code</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<Card className="max-w-2xl mx-auto p-8 text-center">
|
||||
<div className="mb-4 rounded-full bg-muted p-4 w-fit mx-auto">
|
||||
<Github className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">No Repository Connected</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Connect a GitHub repository in the Context section to view your code here
|
||||
</p>
|
||||
<Button onClick={() => window.location.href = `/${params.workspace}/project/${projectId}/context`}>
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
Connect Repository
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center gap-2 px-6">
|
||||
<Code2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Code</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<a
|
||||
href={project.githubRepoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
{project.githubRepo}
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fetchFileTree(project.githubRepo!, project.githubDefaultBranch)}
|
||||
disabled={loadingFiles}
|
||||
>
|
||||
{loadingFiles ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* File Tree Sidebar */}
|
||||
<div className="w-80 border-r flex flex-col bg-background">
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{loadingFiles ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : fileTree.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No files found
|
||||
</div>
|
||||
) : (
|
||||
renderFileTree(fileTree)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Viewer */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="px-4 py-2 border-b bg-background flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-mono">{selectedFile}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-background">
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<div className="flex">
|
||||
{/* Line Numbers */}
|
||||
<div className="select-none border-r bg-muted/30 px-4 py-4 text-right text-sm font-mono text-muted-foreground">
|
||||
{fileContent.split('\n').map((_, i) => (
|
||||
<div key={i} className="leading-relaxed">
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Code Content */}
|
||||
<pre className="flex-1 p-4 text-sm font-mono leading-relaxed overflow-x-auto">
|
||||
<code>{fileContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p className="text-sm">Failed to load file content</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Code2 className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Select a file to view its contents</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FolderOpen, Plus, Github, Zap, FileText, Trash2, CheckCircle2, Upload } from "lucide-react";
|
||||
import { CursorIcon } from "@/components/icons/custom-icons";
|
||||
import { db } from "@/lib/firebase/config";
|
||||
import { collection, doc, getDoc, addDoc, deleteDoc, query, where, getDocs, updateDoc } from "firebase/firestore";
|
||||
import { toast } from "sonner";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { GitHubRepoPicker } from "@/components/ai/github-repo-picker";
|
||||
|
||||
interface ContextSource {
|
||||
id: string;
|
||||
type: "github" | "extension" | "chat" | "file" | "document";
|
||||
name: string;
|
||||
content?: string;
|
||||
url?: string;
|
||||
summary?: string;
|
||||
connectedAt: Date;
|
||||
metadata?: any;
|
||||
chunkCount?: number;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
githubRepo?: string;
|
||||
githubRepoUrl?: string;
|
||||
}
|
||||
|
||||
export default function ContextPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const [sources, setSources] = useState<ContextSource[]>([]);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [chatTitle, setChatTitle] = useState("");
|
||||
const [chatContent, setChatContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadMode, setUploadMode] = useState<"text" | "file">("text");
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isGithubDialogOpen, setIsGithubDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
// Fetch project details
|
||||
const projectRef = doc(db, "projects", projectId);
|
||||
const projectSnap = await getDoc(projectRef);
|
||||
|
||||
if (projectSnap.exists()) {
|
||||
setProject(projectSnap.data() as Project);
|
||||
}
|
||||
|
||||
// Fetch context sources
|
||||
const contextRef = collection(db, "projects", projectId, "contextSources");
|
||||
const contextSnap = await getDocs(contextRef);
|
||||
|
||||
const fetchedSources: ContextSource[] = contextSnap.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
connectedAt: doc.data().connectedAt?.toDate() || new Date()
|
||||
} as ContextSource));
|
||||
|
||||
setSources(fetchedSources);
|
||||
} catch (error) {
|
||||
console.error("Error fetching context data:", error);
|
||||
toast.error("Failed to load context sources");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [projectId]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setSelectedFiles(Array.from(e.target.files));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChatContent = async () => {
|
||||
if (!chatTitle.trim() || !chatContent.trim()) {
|
||||
toast.error("Please provide both a title and content");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Generate AI summary
|
||||
toast.info("Generating summary...");
|
||||
const summaryResponse = await fetch("/api/context/summarize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: chatContent, title: chatTitle })
|
||||
});
|
||||
|
||||
let summary = "";
|
||||
if (summaryResponse.ok) {
|
||||
const data = await summaryResponse.json();
|
||||
summary = data.summary;
|
||||
} else {
|
||||
console.error("Failed to generate summary");
|
||||
summary = `${chatContent.substring(0, 100)}...`;
|
||||
}
|
||||
|
||||
// Also create a knowledge_item so it's included in extraction and checklist
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const importResponse = await fetch(`/api/projects/${projectId}/knowledge/import-ai-chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: chatTitle,
|
||||
transcript: chatContent, // API expects 'transcript' not 'content'
|
||||
provider: 'other',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!importResponse.ok) {
|
||||
throw new Error("Failed to save content as knowledge item");
|
||||
}
|
||||
|
||||
const contextRef = collection(db, "projects", projectId, "contextSources");
|
||||
const newSource = {
|
||||
type: "chat",
|
||||
name: chatTitle,
|
||||
content: chatContent,
|
||||
summary: summary,
|
||||
connectedAt: new Date(),
|
||||
metadata: {
|
||||
length: chatContent.length,
|
||||
addedManually: true
|
||||
}
|
||||
};
|
||||
|
||||
const docRef = await addDoc(contextRef, newSource);
|
||||
|
||||
setSources([...sources, {
|
||||
id: docRef.id,
|
||||
...newSource,
|
||||
connectedAt: new Date()
|
||||
} as ContextSource]);
|
||||
|
||||
toast.success("Chat content added successfully");
|
||||
setIsAddModalOpen(false);
|
||||
setChatTitle("");
|
||||
setChatContent("");
|
||||
} catch (error) {
|
||||
console.error("Error adding chat content:", error);
|
||||
toast.error("Failed to add chat content");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadDocuments = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
toast.error("Please select at least one file");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in to upload documents");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
toast.info(`Uploading ${file.name}...`);
|
||||
|
||||
// Create FormData to send file as multipart/form-data
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('projectId', projectId);
|
||||
|
||||
// Upload to endpoint that handles file storage + chunking
|
||||
const response = await fetch(`/api/projects/${projectId}/knowledge/upload-document`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload ${file.name}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success(`${file.name} uploaded: ${result.chunkCount} chunks created`);
|
||||
}
|
||||
|
||||
// Reload sources
|
||||
const contextRef = collection(db, "projects", projectId, "contextSources");
|
||||
const contextSnap = await getDocs(contextRef);
|
||||
|
||||
const fetchedSources: ContextSource[] = contextSnap.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
connectedAt: doc.data().connectedAt?.toDate() || new Date()
|
||||
} as ContextSource));
|
||||
|
||||
setSources(fetchedSources);
|
||||
|
||||
setIsAddModalOpen(false);
|
||||
setSelectedFiles([]);
|
||||
toast.success("All documents uploaded successfully");
|
||||
} catch (error) {
|
||||
console.error("Error uploading documents:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to upload documents");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSource = async (sourceId: string) => {
|
||||
try {
|
||||
const sourceRef = doc(db, "projects", projectId, "contextSources", sourceId);
|
||||
await deleteDoc(sourceRef);
|
||||
|
||||
setSources(sources.filter(s => s.id !== sourceId));
|
||||
toast.success("Context source removed");
|
||||
} catch (error) {
|
||||
console.error("Error deleting source:", error);
|
||||
toast.error("Failed to remove source");
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "github":
|
||||
return <Github className="h-5 w-5" />;
|
||||
case "extension":
|
||||
return <CursorIcon className="h-5 w-5" />;
|
||||
case "chat":
|
||||
return <FileText className="h-5 w-5" />;
|
||||
case "file":
|
||||
return <FileText className="h-5 w-5" />;
|
||||
case "document":
|
||||
return <FileText className="h-5 w-5" />;
|
||||
default:
|
||||
return <FolderOpen className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceLabel = (source: ContextSource) => {
|
||||
switch (source.type) {
|
||||
case "github":
|
||||
return `Connected GitHub: ${source.name}`;
|
||||
case "extension":
|
||||
return "Installed Vibn Extension";
|
||||
case "chat":
|
||||
return source.name;
|
||||
case "file":
|
||||
return source.name;
|
||||
default:
|
||||
return source.name;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">Loading context sources...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build sources list with auto-detected connections
|
||||
// Note: GitHub is now shown in its own section via GitHubRepoPicker component
|
||||
const allSources: ContextSource[] = [...sources];
|
||||
|
||||
// Check if extension is installed (placeholder for now)
|
||||
const extensionInstalled = true; // TODO: Detect extension
|
||||
if (extensionInstalled && !sources.find(s => s.type === "extension")) {
|
||||
allSources.unshift({
|
||||
id: "extension-auto",
|
||||
type: "extension",
|
||||
name: "Cursor Extension",
|
||||
connectedAt: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center gap-2 px-6">
|
||||
<FolderOpen className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Context Sources</h1>
|
||||
<div className="ml-auto">
|
||||
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Context
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Context</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload documents or paste text to give the AI more context about your project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Mode Selector */}
|
||||
<div className="flex gap-2 p-1 bg-muted rounded-lg">
|
||||
<Button
|
||||
variant={uploadMode === "file" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setUploadMode("file")}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload Files
|
||||
</Button>
|
||||
<Button
|
||||
variant={uploadMode === "text" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setUploadMode("text")}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Paste Text
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{uploadMode === "file" ? (
|
||||
/* File Upload Mode */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file-upload">Select Documents</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.md,.pdf,.doc,.docx,.json,.csv,.xml"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Selected: {selectedFiles.map(f => f.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Documents will be stored for the Extractor AI to review and process.
|
||||
Supported formats: TXT, MD, PDF, DOC, JSON, CSV, XML
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Text Paste Mode */
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g., Planning discussion with Sarah"
|
||||
value={chatTitle}
|
||||
onChange={(e) => setChatTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">Content</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="Paste your chat conversation or notes here..."
|
||||
value={chatContent}
|
||||
onChange={(e) => setChatContent(e.target.value)}
|
||||
className="min-h-[300px] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{uploadMode === "file" ? (
|
||||
<Button onClick={handleUploadDocuments} disabled={isProcessing || selectedFiles.length === 0}>
|
||||
{isProcessing ? "Processing..." : `Upload ${selectedFiles.length} File${selectedFiles.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleAddChatContent} disabled={saving}>
|
||||
{saving ? "Saving..." : "Add Context"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
{/* GitHub Repository Connection */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
GitHub Repository
|
||||
</h2>
|
||||
{project?.githubRepo ? (
|
||||
// Show connected repo
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<Github className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-sm">Connected: {project.githubRepo}</h3>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Repository connected and ready for AI access
|
||||
</p>
|
||||
{project.githubRepoUrl && (
|
||||
<a
|
||||
href={project.githubRepoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline inline-block"
|
||||
>
|
||||
View on GitHub →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsGithubDialogOpen(true)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
// Show connect button
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<Github className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm mb-1">Connect GitHub Repository</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Give the AI access to your codebase for better context
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsGithubDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* GitHub Connection Dialog */}
|
||||
<Dialog open={isGithubDialogOpen} onOpenChange={setIsGithubDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect a GitHub repository to give the AI access to your codebase
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto">
|
||||
<GitHubRepoPicker
|
||||
projectId={projectId}
|
||||
onRepoSelected={(repo) => {
|
||||
toast.success(`Repository ${repo.full_name} connected!`);
|
||||
setIsGithubDialogOpen(false);
|
||||
// Reload project data to show the connected repo
|
||||
const fetchProject = async () => {
|
||||
const projectRef = doc(db, "projects", projectId);
|
||||
const projectSnap = await getDoc(projectRef);
|
||||
if (projectSnap.exists()) {
|
||||
setProject(projectSnap.data() as Project);
|
||||
}
|
||||
};
|
||||
fetchProject();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Other Context Sources */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Additional Context
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{allSources.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<FolderOpen className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Context Sources Yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add context sources to help the AI understand your project better
|
||||
</p>
|
||||
<Button onClick={() => setIsAddModalOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Context
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{allSources.map((source) => (
|
||||
<Card key={source.id} className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
{getSourceIcon(source.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-sm">{getSourceLabel(source)}</h3>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connected {source.connectedAt.toLocaleDateString()}
|
||||
</p>
|
||||
{source.summary && (
|
||||
<p className="text-sm text-foreground/80 mt-2 leading-relaxed">
|
||||
{source.summary}
|
||||
</p>
|
||||
)}
|
||||
{source.url && (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline mt-1 inline-block"
|
||||
>
|
||||
{source.type === 'github' ? 'View on GitHub →' :
|
||||
source.type === 'document' ? 'Download File →' :
|
||||
'View Source →'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{!source.id.includes("auto") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteSource(source.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,204 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Server } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
"use client";
|
||||
|
||||
// Mock project data
|
||||
const MOCK_PROJECT = {
|
||||
id: "1",
|
||||
name: "AI Proxy",
|
||||
emoji: "🤖",
|
||||
};
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ projectId: string }>;
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
status?: string;
|
||||
giteaRepoUrl?: string;
|
||||
giteaRepo?: string;
|
||||
theiaWorkspaceUrl?: string;
|
||||
coolifyDeployUrl?: string;
|
||||
customDomain?: string;
|
||||
prd?: string;
|
||||
}
|
||||
|
||||
export default async function DeploymentPage({ params }: PageProps) {
|
||||
const { projectId } = await params;
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
projectId={projectId}
|
||||
projectName={MOCK_PROJECT.name}
|
||||
projectEmoji={MOCK_PROJECT.emoji}
|
||||
pageName="Deployment"
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="container max-w-7xl py-6 space-y-6">
|
||||
{/* Hero Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Server className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Deployment</CardTitle>
|
||||
<CardDescription>
|
||||
Manage deployments, monitor environments, and track releases
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="mb-3 rounded-full bg-muted p-4">
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-medium text-lg mb-2">Coming Soon</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Connect your hosting platforms to manage deployments, view logs,
|
||||
and monitor your application's health across all environments.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
boxShadow: "0 1px 2px #1a1a1a05", marginBottom: 12, ...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeploymentPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [customDomainInput, setCustomDomainInput] = useState("");
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProject(d.project))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
const handleConnectDomain = async () => {
|
||||
if (!customDomainInput.trim()) return;
|
||||
setConnecting(true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
toast.info("Domain connection coming soon — we'll hook this to Coolify.");
|
||||
setConnecting(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl);
|
||||
const hasRepo = Boolean(project?.giteaRepoUrl);
|
||||
const hasPRD = Boolean(project?.prd);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h3 style={{
|
||||
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
|
||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
||||
}}>
|
||||
Deployment
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
||||
Links, environments, and hosting for {project?.productName ?? "this project"}
|
||||
</p>
|
||||
|
||||
{/* Project URLs */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Project URLs</SectionLabel>
|
||||
{hasDeploy ? (
|
||||
<>
|
||||
{project?.coolifyDeployUrl && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}>▲</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Staging</div>
|
||||
<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: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{project?.customDomain && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#2e7d3210", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#2e7d32" }}>●</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Production</div>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#2e7d32", fontWeight: 500 }}>{project.customDomain}</div>
|
||||
</div>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
|
||||
<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: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{project?.giteaRepoUrl && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}>⚙</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Build repo</div>
|
||||
<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: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
View ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
||||
{!hasPRD
|
||||
? "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."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Custom domain */}
|
||||
{hasDeploy && !project?.customDomain && (
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Custom Domain</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.6, marginBottom: 14 }}>
|
||||
Point your own domain to this project. SSL certificates are handled automatically.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
placeholder="app.yourdomain.com"
|
||||
value={customDomainInput}
|
||||
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||
style={{ flex: 1, padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a" }}
|
||||
/>
|
||||
<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: "var(--font-inter), ui-sans-serif, sans-serif", opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{/* Environment variables */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Environment Variables</SectionLabel>
|
||||
{hasDeploy ? (
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
|
||||
Manage environment variables in Coolify for your deployed services.
|
||||
{project?.coolifyDeployUrl && (
|
||||
<> <a href="http://34.19.250.135:8000" target="_blank" rel="noopener noreferrer" style={{ color: "#3d5afe", textDecoration: "none" }}>Open Coolify ↗</a></>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>Available after first build completes.</p>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Deploy history */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Deploy History</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
|
||||
{project?.status === "live"
|
||||
? "Deploy history will appear here."
|
||||
: "No deploys yet."}
|
||||
</p>
|
||||
</InfoCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Eye, MessageSquare, Copy, Share2, Sparkles, History, Loader2, Send, MousePointer2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Mock data for page variations
|
||||
const mockPageData: Record<string, any> = {
|
||||
"landing-hero": {
|
||||
name: "Landing Page Hero",
|
||||
emoji: "✨",
|
||||
style: "modern",
|
||||
prompt: "Create a modern landing page hero section with gradient background",
|
||||
v0Url: "https://v0.dev/chat/abc123",
|
||||
variations: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Version 1 - Blue Gradient",
|
||||
thumbnail: "https://placehold.co/800x600/1e40af/ffffff?text=Hero+V1",
|
||||
createdAt: "2025-11-11",
|
||||
views: 45,
|
||||
comments: 3,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Version 2 - Purple Gradient",
|
||||
thumbnail: "https://placehold.co/800x600/7c3aed/ffffff?text=Hero+V2",
|
||||
createdAt: "2025-11-10",
|
||||
views: 32,
|
||||
comments: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Version 3 - Minimal",
|
||||
thumbnail: "https://placehold.co/800x600/6b7280/ffffff?text=Hero+V3",
|
||||
createdAt: "2025-11-09",
|
||||
views: 28,
|
||||
comments: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
"dashboard": {
|
||||
name: "Dashboard Layout",
|
||||
emoji: "📊",
|
||||
style: "minimal",
|
||||
prompt: "Design a clean dashboard with sidebar, metrics cards, and charts",
|
||||
v0Url: "https://v0.dev/chat/def456",
|
||||
variations: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Version 1 - Default",
|
||||
thumbnail: "https://placehold.co/800x600/7c3aed/ffffff?text=Dashboard+V1",
|
||||
createdAt: "2025-11-10",
|
||||
views: 78,
|
||||
comments: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
"pricing": {
|
||||
name: "Pricing Cards",
|
||||
emoji: "💳",
|
||||
style: "colorful",
|
||||
prompt: "Three-tier pricing cards with features, hover effects, and CTA buttons",
|
||||
v0Url: "https://v0.dev/chat/ghi789",
|
||||
variations: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Version 1 - Standard",
|
||||
thumbnail: "https://placehold.co/800x600/059669/ffffff?text=Pricing+V1",
|
||||
createdAt: "2025-11-09",
|
||||
views: 102,
|
||||
comments: 12,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Version 2 - Compact",
|
||||
thumbnail: "https://placehold.co/800x600/0891b2/ffffff?text=Pricing+V2",
|
||||
createdAt: "2025-11-08",
|
||||
views: 67,
|
||||
comments: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
"user-profile": {
|
||||
name: "User Profile",
|
||||
emoji: "👤",
|
||||
style: "modern",
|
||||
prompt: "User profile page with avatar, bio, stats, and activity feed",
|
||||
v0Url: "https://v0.dev/chat/jkl012",
|
||||
variations: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Version 1 - Default",
|
||||
thumbnail: "https://placehold.co/800x600/dc2626/ffffff?text=Profile+V1",
|
||||
createdAt: "2025-11-08",
|
||||
views: 56,
|
||||
comments: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function DesignPageView({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string; pageSlug: string }>;
|
||||
}) {
|
||||
const { projectId, pageSlug } = use(params);
|
||||
const pageData = mockPageData[pageSlug] || mockPageData["landing-hero"];
|
||||
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState(pageData.variations[0]);
|
||||
const [versionsModalOpen, setVersionsModalOpen] = useState(false);
|
||||
const [commentsModalOpen, setCommentsModalOpen] = useState(false);
|
||||
const [chatMessage, setChatMessage] = useState("");
|
||||
const [pageName, setPageName] = useState(pageData.name);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [designModeActive, setDesignModeActive] = useState(false);
|
||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
|
||||
const handleIterate = async () => {
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("Please enter a prompt to iterate");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Call v0 API to generate update
|
||||
const response = await fetch('/api/v0/iterate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: pageData.v0Url.split('/').pop(),
|
||||
message: editPrompt,
|
||||
projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to iterate');
|
||||
}
|
||||
|
||||
toast.success("Design updated!", {
|
||||
description: "Your changes have been generated",
|
||||
});
|
||||
|
||||
// Refresh or update the current version
|
||||
setEditPrompt("");
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error iterating:', error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to iterate design");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushToCursor = () => {
|
||||
toast.success("Code will be pushed to Cursor", {
|
||||
description: "This feature will send the component code to your IDE",
|
||||
});
|
||||
// TODO: Implement actual push to Cursor IDE
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b bg-card/50 px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
value={pageName}
|
||||
onChange={(e) => setPageName(e.target.value)}
|
||||
onBlur={() => {
|
||||
setIsEditingName(false);
|
||||
toast.success("Page name updated");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsEditingName(false);
|
||||
toast.success("Page name updated");
|
||||
}
|
||||
}}
|
||||
className="text-lg font-semibold bg-transparent border-b border-primary outline-none px-1 min-w-[200px]"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h1
|
||||
className="text-lg font-semibold cursor-pointer hover:text-primary transition-colors"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
>
|
||||
{pageName}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setVersionsModalOpen(true)}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
Versions
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCommentsModalOpen(true)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Comments
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePushToCursor}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Push to Cursor
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="flex-1 overflow-auto bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 relative">
|
||||
<div className="w-full h-full p-8">
|
||||
{/* Sample SaaS Dashboard Component */}
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
{/* Page Header */}
|
||||
<div
|
||||
data-element="page-header"
|
||||
className={cn(
|
||||
"flex items-center justify-between transition-all p-2 rounded-lg",
|
||||
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
|
||||
selectedElement === "page-header" && "ring-2 ring-primary ring-inset"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement("page-header");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
data-element="page-title"
|
||||
className={cn(
|
||||
"text-3xl font-bold transition-all rounded px-1",
|
||||
designModeActive && "hover:ring-2 hover:ring-primary/50 hover:ring-inset",
|
||||
selectedElement === "page-title" && "ring-2 ring-primary/50 ring-inset"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement("page-title");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Dashboard Overview
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here's what's happening today.</p>
|
||||
</div>
|
||||
<Button
|
||||
data-element="primary-action-button"
|
||||
className={cn(
|
||||
"transition-all",
|
||||
designModeActive && "hover:ring-2 hover:ring-yellow-400 hover:ring-inset",
|
||||
selectedElement === "primary-action-button" && "ring-2 ring-yellow-400 ring-inset"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement("primary-action-button");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create New Project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div
|
||||
data-element="stats-grid"
|
||||
className={cn(
|
||||
"grid md:grid-cols-4 gap-4 transition-all rounded-xl",
|
||||
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
|
||||
selectedElement === "stats-grid" && "ring-2 ring-primary ring-inset"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement("stats-grid");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: "Total Users", value: "2,847", change: "+12.3%", trend: "up" },
|
||||
{ label: "Revenue", value: "$45,231", change: "+8.1%", trend: "up" },
|
||||
{ label: "Active Projects", value: "127", change: "-2.4%", trend: "down" },
|
||||
{ label: "Conversion Rate", value: "3.24%", change: "+0.8%", trend: "up" },
|
||||
].map((stat, i) => (
|
||||
<Card
|
||||
key={i}
|
||||
data-element={`stat-card-${i}`}
|
||||
className={cn(
|
||||
"transition-all",
|
||||
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
|
||||
selectedElement === `stat-card-${i}` && "ring-2 ring-primary ring-inset"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement(`stat-card-${i}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="text-xs">{stat.label}</CardDescription>
|
||||
<CardTitle className="text-2xl">{stat.value}</CardTitle>
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
stat.trend === "up" ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
{stat.change}
|
||||
</span>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<Card
|
||||
data-element="data-table"
|
||||
className={cn(
|
||||
"transition-all",
|
||||
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset",
|
||||
selectedElement === "data-table" && "ring-2 ring-primary ring-inset"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement("data-table");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Recent Projects</CardTitle>
|
||||
<CardDescription>Your team's latest work</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-element="table-action-button"
|
||||
className={cn(
|
||||
"transition-all",
|
||||
designModeActive && "hover:ring-2 hover:ring-yellow-400 hover:ring-inset",
|
||||
selectedElement === "table-action-button" && "ring-2 ring-yellow-400 ring-inset"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement("table-action-button");
|
||||
}
|
||||
}}
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ name: "Mobile App Redesign", status: "In Progress", team: "Design Team", updated: "2 hours ago" },
|
||||
{ name: "API Documentation", status: "Review", team: "Engineering", updated: "5 hours ago" },
|
||||
{ name: "Marketing Website", status: "Completed", team: "Marketing", updated: "1 day ago" },
|
||||
{ name: "User Dashboard v2", status: "Planning", team: "Product", updated: "3 days ago" },
|
||||
].map((project, i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-element={`table-row-${i}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border transition-all",
|
||||
designModeActive && "cursor-pointer hover:ring-2 hover:ring-primary hover:ring-inset hover:bg-accent",
|
||||
selectedElement === `table-row-${i}` && "ring-2 ring-primary ring-inset bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (designModeActive) {
|
||||
e.stopPropagation();
|
||||
setSelectedElement(`table-row-${i}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{project.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{project.team}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={cn(
|
||||
"text-xs font-medium px-2 py-1 rounded-full",
|
||||
project.status === "Completed" && "bg-green-100 text-green-700",
|
||||
project.status === "In Progress" && "bg-blue-100 text-blue-700",
|
||||
project.status === "Review" && "bg-yellow-100 text-yellow-700",
|
||||
project.status === "Planning" && "bg-gray-100 text-gray-700"
|
||||
)}>
|
||||
{project.status}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground w-24 text-right">{project.updated}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Chat Interface - v0 Style */}
|
||||
<div
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 w-full max-w-3xl px-6"
|
||||
>
|
||||
<div className="bg-background/95 backdrop-blur-lg border border-border rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Input Area */}
|
||||
<div className="p-3 relative">
|
||||
<Textarea
|
||||
placeholder="e.g., 'Make the hero section more vibrant', 'Add a call-to-action button', 'Change the color scheme to dark mode'"
|
||||
value={chatMessage}
|
||||
onChange={(e) => setChatMessage(e.target.value)}
|
||||
className="min-h-[60px] resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 text-sm px-1"
|
||||
disabled={isGenerating}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className="px-4 pb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={designModeActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDesignModeActive(!designModeActive);
|
||||
setSelectedElement(null);
|
||||
}}
|
||||
>
|
||||
<MousePointer2 className="h-4 w-4 mr-2" />
|
||||
Design Mode
|
||||
</Button>
|
||||
{selectedElement && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">
|
||||
<MousePointer2 className="h-3 w-3" />
|
||||
<span className="font-medium">{selectedElement.replace(/-/g, ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isGenerating}
|
||||
onClick={() => {
|
||||
toast.info("Creating variation...");
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Variation
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const contextualPrompt = selectedElement
|
||||
? `[Targeting: ${selectedElement.replace(/-/g, ' ')}] ${chatMessage}`
|
||||
: chatMessage;
|
||||
setEditPrompt(contextualPrompt);
|
||||
handleIterate();
|
||||
}}
|
||||
disabled={isGenerating || !chatMessage.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Generating
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{selectedElement ? 'Modify Selected' : 'Generate'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Versions Modal */}
|
||||
<Dialog open={versionsModalOpen} onOpenChange={setVersionsModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Version History</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and switch between different versions of this design
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<div className="space-y-3">
|
||||
{pageData.variations.map((variation: any) => (
|
||||
<button
|
||||
key={variation.id}
|
||||
onClick={() => {
|
||||
setCurrentVersion(variation);
|
||||
setVersionsModalOpen(false);
|
||||
toast.success(`Switched to ${variation.name}`);
|
||||
}}
|
||||
className={`w-full text-left rounded-lg border p-4 transition-colors hover:bg-accent ${
|
||||
currentVersion.id === variation.id ? 'border-primary bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<img
|
||||
src={variation.thumbnail}
|
||||
alt={variation.name}
|
||||
className="w-32 h-20 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-base">{variation.name}</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{variation.createdAt}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-4 w-4" />
|
||||
{variation.views} views
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{variation.comments} comments
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Comments Modal */}
|
||||
<Dialog open={commentsModalOpen} onOpenChange={setCommentsModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Comments & Feedback</DialogTitle>
|
||||
<DialogDescription>
|
||||
Discuss this design with your team
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[50vh] pr-4">
|
||||
<div className="space-y-4">
|
||||
{/* Mock comments */}
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium">
|
||||
JD
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium">Jane Doe</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">2h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Love the gradient! Could we try a darker variant?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-green-500/10 flex items-center justify-center text-sm font-medium">
|
||||
MS
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium">Mike Smith</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">5h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The layout looks perfect. Spacing is on point 👍
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add comment */}
|
||||
<div className="pt-4 border-t space-y-3">
|
||||
<Textarea
|
||||
placeholder="Add a comment..."
|
||||
className="min-h-[100px] resize-none"
|
||||
/>
|
||||
<Button className="w-full">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Post Comment
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Sparkles, ChevronRight, ChevronDown, Folder, FileText, Palette, LayoutGrid, Workflow, Github, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
PageTemplate,
|
||||
PageSection,
|
||||
PageCard as TemplateCard,
|
||||
} from "@/components/layout/page-template";
|
||||
|
||||
// Mock tree structure - Core Product screens
|
||||
const coreProductTree = [
|
||||
{
|
||||
id: "dashboard",
|
||||
name: "Dashboard",
|
||||
type: "folder",
|
||||
children: [
|
||||
{ id: "overview", name: "Overview", type: "page", route: "/dashboard", variations: 2 },
|
||||
{ id: "analytics", name: "Analytics", type: "page", route: "/dashboard/analytics", variations: 1 },
|
||||
{ id: "projects", name: "Projects", type: "page", route: "/dashboard/projects", variations: 2 },
|
||||
{ id: "activity", name: "Activity", type: "page", route: "/dashboard/activity", variations: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
name: "Profile & Settings",
|
||||
type: "folder",
|
||||
children: [
|
||||
{ id: "user-profile", name: "User Profile", type: "page", route: "/profile", variations: 2 },
|
||||
{ id: "edit-profile", name: "Edit Profile", type: "page", route: "/profile/edit", variations: 1 },
|
||||
{ id: "account", name: "Account Settings", type: "page", route: "/settings/account", variations: 1 },
|
||||
{ id: "billing", name: "Billing", type: "page", route: "/settings/billing", variations: 2 },
|
||||
{ id: "notifications", name: "Notifications", type: "page", route: "/settings/notifications", variations: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// AI-suggested screens for Core Product
|
||||
const suggestedCoreScreens = [
|
||||
{
|
||||
id: "team-management",
|
||||
name: "Team Management",
|
||||
reason: "Collaborate with team members and manage permissions",
|
||||
version: "V1",
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
name: "Reports & Insights",
|
||||
reason: "Data-driven decision making with comprehensive reports",
|
||||
version: "V2",
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
name: "Integrations",
|
||||
reason: "Connect with external tools and services",
|
||||
version: "V2",
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
name: "Global Search",
|
||||
reason: "Quick access to any content across the platform",
|
||||
version: "V2",
|
||||
},
|
||||
{
|
||||
id: "empty-states",
|
||||
name: "Empty States",
|
||||
reason: "Guide users when no data is available",
|
||||
version: "V1",
|
||||
},
|
||||
];
|
||||
|
||||
// Mock tree structure - User Flows
|
||||
const userFlowsTree = [
|
||||
{
|
||||
id: "authentication",
|
||||
name: "Authentication",
|
||||
type: "folder",
|
||||
children: [
|
||||
{ id: "signup", name: "Sign Up", type: "page", route: "/signup", variations: 3 },
|
||||
{ id: "login", name: "Login", type: "page", route: "/login", variations: 2 },
|
||||
{ id: "forgot-password", name: "Forgot Password", type: "page", route: "/forgot-password", variations: 1 },
|
||||
{ id: "verify-email", name: "Verify Email", type: "page", route: "/verify-email", variations: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "onboarding",
|
||||
name: "Onboarding",
|
||||
type: "folder",
|
||||
children: [
|
||||
{ id: "welcome", name: "Welcome", type: "page", route: "/onboarding/welcome", variations: 2 },
|
||||
{ id: "setup-profile", name: "Setup Profile", type: "page", route: "/onboarding/profile", variations: 2 },
|
||||
{ id: "preferences", name: "Preferences", type: "page", route: "/onboarding/preferences", variations: 1 },
|
||||
{ id: "complete", name: "Complete", type: "page", route: "/onboarding/complete", variations: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// AI-suggested flows/screens
|
||||
const suggestedFlows = [
|
||||
{
|
||||
id: "password-reset",
|
||||
name: "Password Reset Flow",
|
||||
reason: "Users need a complete password reset journey",
|
||||
version: "V1",
|
||||
screens: [
|
||||
{ name: "Reset Request" },
|
||||
{ name: "Check Email" },
|
||||
{ name: "New Password" },
|
||||
{ name: "Success" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "email-verification",
|
||||
name: "Email Verification Flow",
|
||||
reason: "Enhance security with multi-step verification",
|
||||
version: "V2",
|
||||
screens: [
|
||||
{ name: "Verification Sent" },
|
||||
{ name: "Enter Code" },
|
||||
{ name: "Verified" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "two-factor-setup",
|
||||
name: "Two-Factor Auth Setup",
|
||||
reason: "Add additional security layer for users",
|
||||
version: "V2",
|
||||
screens: [
|
||||
{ name: "Enable 2FA" },
|
||||
{ name: "Setup Authenticator" },
|
||||
{ name: "Verify Code" },
|
||||
{ name: "Backup Codes" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DESIGN_NAV_ITEMS = [
|
||||
{ title: "Core Screens", icon: LayoutGrid, href: "#screens" },
|
||||
{ title: "User Flows", icon: Workflow, href: "#flows" },
|
||||
{ title: "Style Guide", icon: Palette, href: "#style-guide" },
|
||||
];
|
||||
|
||||
export default function UIUXPage({ params }: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = use(params);
|
||||
const pathname = usePathname();
|
||||
const workspace = pathname.split('/')[1]; // quick hack to get workspace
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// GitHub connection state
|
||||
const [isGithubConnected, setIsGithubConnected] = useState(false);
|
||||
const [githubRepo, setGithubRepo] = useState<string | null>(null);
|
||||
const [lastSyncTime, setLastSyncTime] = useState<string | null>(null);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
// Tree view state
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["authentication", "dashboard"]));
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(folderId)) {
|
||||
newExpanded.delete(folderId);
|
||||
} else {
|
||||
newExpanded.add(folderId);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
const handleConnectGithub = async () => {
|
||||
toast.info("Opening GitHub OAuth...");
|
||||
setTimeout(() => {
|
||||
setIsGithubConnected(true);
|
||||
setGithubRepo("username/repo-name");
|
||||
toast.success("GitHub connected!", {
|
||||
description: "Click Sync to scan your repository",
|
||||
});
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleSyncRepository = async () => {
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
toast.info("Syncing repository...", {
|
||||
description: "AI is analyzing your codebase",
|
||||
});
|
||||
|
||||
const response = await fetch('/api/github/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
repo: githubRepo,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to sync repository');
|
||||
}
|
||||
|
||||
setLastSyncTime(new Date().toISOString());
|
||||
toast.success("Repository synced!", {
|
||||
description: `Found ${data.pageCount} pages`,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error syncing repository:', error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to sync repository");
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("Please enter a design prompt");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v0/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
style: selectedStyle,
|
||||
projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to generate design');
|
||||
}
|
||||
|
||||
toast.success("Design generated successfully!", {
|
||||
description: "Opening in v0...",
|
||||
action: {
|
||||
label: "View",
|
||||
onClick: () => window.open(data.webUrl, '_blank'),
|
||||
},
|
||||
});
|
||||
|
||||
window.open(data.webUrl, '_blank');
|
||||
|
||||
setPrompt("");
|
||||
setSelectedStyle(null);
|
||||
} catch (error) {
|
||||
console.error('Error generating design:', error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to generate design");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sidebarItems = DESIGN_NAV_ITEMS.map((item) => {
|
||||
const fullHref = `/${workspace}/project/${projectId}/design${item.href}`;
|
||||
return {
|
||||
...item,
|
||||
href: fullHref,
|
||||
isActive: pathname === fullHref || pathname.startsWith(fullHref),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{/* GitHub Connection / Sync */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border bg-card">
|
||||
{!isGithubConnected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Github className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Connect Repository</p>
|
||||
<p className="text-xs text-muted-foreground">Sync your GitHub repo to detect pages</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleConnectGithub} size="sm">
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
Connect
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Github className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{githubRepo}</p>
|
||||
{lastSyncTime && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Synced {new Date(lastSyncTime).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSyncRepository}
|
||||
disabled={isSyncing}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Syncing
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Screens - Split into two columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Core Product */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Core Product</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{coreProductTree.map((folder) => (
|
||||
<div key={folder.id}>
|
||||
{/* Folder */}
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm font-medium"
|
||||
>
|
||||
{expandedFolders.has(folder.id) ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium truncate">{folder.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{folder.children.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Pages in folder */}
|
||||
{expandedFolders.has(folder.id) && (
|
||||
<div className="ml-6 space-y-0.5 mt-0.5">
|
||||
{folder.children.map((page: any) => (
|
||||
<button
|
||||
key={page.id}
|
||||
className="flex items-center justify-between gap-2 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{page.name}</span>
|
||||
</div>
|
||||
{page.variations > 0 && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{page.variations}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* AI Suggested Screens */}
|
||||
<Separator className="my-3" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">AI Suggested</h3>
|
||||
</div>
|
||||
|
||||
{suggestedCoreScreens.map((screen) => (
|
||||
<div key={screen.id} className="px-3 py-2.5 rounded-md border border-dashed border-primary/30 bg-primary/5 hover:bg-primary/10 transition-colors">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
||||
<div className="font-medium text-sm text-primary truncate">{screen.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{screen.version}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
toast.success("Generating screen...", {
|
||||
description: `Creating ${screen.name}`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Flows */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Flows</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{userFlowsTree.map((folder) => (
|
||||
<div key={folder.id}>
|
||||
{/* Folder */}
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm font-medium"
|
||||
>
|
||||
{expandedFolders.has(folder.id) ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium truncate">{folder.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{folder.children.length} steps
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Pages in folder - with flow indicators */}
|
||||
{expandedFolders.has(folder.id) && (
|
||||
<div className="ml-6 mt-0.5 space-y-0.5">
|
||||
{folder.children.map((page: any, index: number) => (
|
||||
<div key={page.id}>
|
||||
<button
|
||||
className="flex items-center justify-between gap-3 w-full px-3 py-1.5 rounded-md hover:bg-accent transition-colors text-sm group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="truncate">{page.name}</span>
|
||||
</div>
|
||||
{page.variations > 0 && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{page.variations}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* AI Suggested Flows */}
|
||||
<Separator className="my-3" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">AI Suggested</h3>
|
||||
</div>
|
||||
|
||||
{suggestedFlows.map((flow) => (
|
||||
<div key={flow.id} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleFolder(`suggested-${flow.id}`)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2.5 rounded-md border border-dashed border-primary/30 bg-primary/5 hover:bg-primary/10 transition-colors text-sm"
|
||||
>
|
||||
{expandedFolders.has(`suggested-${flow.id}`) ? (
|
||||
<ChevronDown className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="font-medium text-primary truncate">{flow.name}</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{flow.version}
|
||||
</Badge>
|
||||
<span className="text-xs text-primary shrink-0">
|
||||
{flow.screens.length} screens
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Suggested screens in flow */}
|
||||
{expandedFolders.has(`suggested-${flow.id}`) && (
|
||||
<div className="ml-6 mt-0.5 space-y-0.5">
|
||||
{flow.screens.map((screen: any, index: number) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 rounded-md border border-dashed text-sm">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-muted text-muted-foreground text-xs font-semibold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="font-medium text-sm truncate">{screen.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Generate button */}
|
||||
<div className="pt-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
toast.success("Generating flow...", {
|
||||
description: `Creating ${flow.screens.length} screens for ${flow.name}`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Generate This Flow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,408 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Star,
|
||||
Info,
|
||||
Share2,
|
||||
Archive,
|
||||
Loader2,
|
||||
Target,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
||||
|
||||
type DocType = "all" | "vision" | "features" | "research" | "chats";
|
||||
type ViewType = "public" | "private" | "archived";
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
type: DocType;
|
||||
owner: string;
|
||||
dateModified: string;
|
||||
visibility: ViewType;
|
||||
starred: boolean;
|
||||
chunkCount?: number;
|
||||
}
|
||||
|
||||
export default function DocsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = use(params);
|
||||
const [activeView, setActiveView] = useState<ViewType>("public");
|
||||
const [filterType, setFilterType] = useState<DocType>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<"modified" | "created">("modified");
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments();
|
||||
}, [projectId, activeView, filterType]);
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/knowledge/items?visibility=${activeView}&type=${filterType}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Use returned items or mock data if empty
|
||||
if (data.items && data.items.length > 0) {
|
||||
// Transform knowledge items to document format
|
||||
const docs = data.items.map((item: any, index: number) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.sourceType === 'vision' ? 'vision' :
|
||||
item.sourceType === 'feature' ? 'features' :
|
||||
item.sourceType === 'chat' ? 'chats' : 'research',
|
||||
owner: "You",
|
||||
dateModified: item.updatedAt || item.createdAt,
|
||||
visibility: activeView,
|
||||
starred: false,
|
||||
chunkCount: item.chunkCount,
|
||||
}));
|
||||
setDocuments(docs);
|
||||
} else {
|
||||
// Show mock data when no real data exists
|
||||
setDocuments([
|
||||
{
|
||||
id: "1",
|
||||
title: "Project Vision & Mission",
|
||||
type: "vision",
|
||||
owner: "You",
|
||||
dateModified: new Date().toISOString(),
|
||||
visibility: "public",
|
||||
starred: true,
|
||||
chunkCount: 12,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Core Features Specification",
|
||||
type: "features",
|
||||
owner: "You",
|
||||
dateModified: new Date(Date.now() - 86400000).toISOString(),
|
||||
visibility: "public",
|
||||
starred: false,
|
||||
chunkCount: 24,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Fallback to mock data on error
|
||||
setDocuments([
|
||||
{
|
||||
id: "1",
|
||||
title: "Project Vision & Mission",
|
||||
type: "vision",
|
||||
owner: "You",
|
||||
dateModified: new Date().toISOString(),
|
||||
visibility: "public",
|
||||
starred: true,
|
||||
chunkCount: 12,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading documents:", error);
|
||||
// Show mock data on error
|
||||
setDocuments([
|
||||
{
|
||||
id: "1",
|
||||
title: "Project Vision & Mission",
|
||||
type: "vision",
|
||||
owner: "You",
|
||||
dateModified: new Date().toISOString(),
|
||||
visibility: "public",
|
||||
starred: true,
|
||||
chunkCount: 12,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDocIcon = (type: DocType) => {
|
||||
switch (type) {
|
||||
case "vision":
|
||||
return <Target className="h-4 w-4 text-blue-600" />;
|
||||
case "features":
|
||||
return <Lightbulb className="h-4 w-4 text-purple-600" />;
|
||||
case "research":
|
||||
return <BookOpen className="h-4 w-4 text-green-600" />;
|
||||
case "chats":
|
||||
return <MessageSquare className="h-4 w-4 text-orange-600" />;
|
||||
default:
|
||||
return <FileText className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDocuments = documents.filter((doc) => {
|
||||
if (searchQuery && !doc.title.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar */}
|
||||
<CollapsibleSidebar>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Document Stats</h3>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Docs</span>
|
||||
<span className="font-medium">{documents.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Public</span>
|
||||
<span className="font-medium">{documents.filter(d => d.visibility === 'public').length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Private</span>
|
||||
<span className="font-medium">{documents.filter(d => d.visibility === 'private').length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Starred</span>
|
||||
<span className="font-medium">{documents.filter(d => d.starred).length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-bold">Docs</h1>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{filteredDocuments.length} {filteredDocuments.length === 1 ? "doc" : "docs"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-6 px-4">
|
||||
<button
|
||||
onClick={() => setActiveView("public")}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeView === "public"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView("private")}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeView === "private"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView("archived")}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeView === "archived"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Archived
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-4 p-4 border-b bg-muted/30">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search docs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="modified">Date modified</SelectItem>
|
||||
<SelectItem value="created">Date created</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterType} onValueChange={(value: any) => setFilterType(value)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="vision">Vision</SelectItem>
|
||||
<SelectItem value="features">Features</SelectItem>
|
||||
<SelectItem value="research">Research</SelectItem>
|
||||
<SelectItem value="chats">Chats</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Document List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredDocuments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No documents yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create your first document to get started
|
||||
</p>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add page
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredDocuments.map((doc) => (
|
||||
<Link
|
||||
key={doc.id}
|
||||
href={`/${workspace}/project/${projectId}/docs/${doc.id}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="p-4 hover:bg-accent/50 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{getDocIcon(doc.type)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{doc.title}</h3>
|
||||
{doc.starred && (
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">{doc.owner}</span>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(doc.dateModified).toLocaleDateString()}
|
||||
</span>
|
||||
{doc.chunkCount && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{doc.chunkCount} chunks
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toast.info("Share functionality coming soon");
|
||||
}}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toast.info("Info panel coming soon");
|
||||
}}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Toggle star
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={`h-4 w-4 ${
|
||||
doc.starred ? "fill-yellow-400 text-yellow-400" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toast.info("More options coming soon");
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* End Main Content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Box, Plus } from "lucide-react";
|
||||
|
||||
export default async function FeaturesPage({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page Header */}
|
||||
<div className="border-b bg-card/50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Features</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Plan and track your product features
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Feature
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Feature List</CardTitle>
|
||||
<CardDescription>
|
||||
Features with user stories and acceptance criteria
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="mb-4 rounded-full bg-muted p-3">
|
||||
<Box className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No features yet</h3>
|
||||
<p className="text-sm text-center text-muted-foreground max-w-sm mb-4">
|
||||
Start planning your features with user stories and track their progress
|
||||
</p>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create First Feature
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function AnalyzePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Analyzing Your Project</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Our AI is reviewing your code and documentation to understand your product
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Analysis Progress */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Analysis in Progress
|
||||
</CardTitle>
|
||||
<CardDescription>This may take a few moments...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm">Reading repository structure</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm">Analyzing code patterns</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
||||
<span className="text-sm">Processing ChatGPT conversations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<span className="text-sm text-muted-foreground">Extracting product vision</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<span className="text-sm text-muted-foreground">Identifying features</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Continue Button */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Link href={`/${workspace}/${projectId}/getting-started/summarize`}>
|
||||
<Button size="lg">
|
||||
Continue to Summary
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Github, ArrowRight, Download } from "lucide-react";
|
||||
import { CursorIcon, OpenAIIcon } from "@/components/icons/custom-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function ConnectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Connect Your Sources</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Install the Cursor extension and connect your development sources. Our AI will analyze all of the information and automatically create your project for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* Cursor Extension */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<CursorIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Cursor Extension</CardTitle>
|
||||
<CardDescription>Install our extension to track your development sessions</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Install Extension
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>The extension will help us:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Track your coding sessions and AI interactions</li>
|
||||
<li>Monitor costs and token usage</li>
|
||||
<li>Generate automatic documentation</li>
|
||||
<li>Sync your conversations with Vib'n</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* GitHub Connection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Github className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>GitHub Repository</CardTitle>
|
||||
<CardDescription>Connect your code repository for analysis</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button>
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
Connect GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>We'll need access to:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Read your repository code and structure</li>
|
||||
<li>Access to repository metadata</li>
|
||||
<li>View commit history</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ChatGPT Connection - Optional */}
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/10">
|
||||
<OpenAIIcon className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>ChatGPT Project (MCP)</CardTitle>
|
||||
<span className="px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription>Connect your ChatGPT conversations and docs</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<OpenAIIcon className="h-4 w-4 mr-2" />
|
||||
Install MCP
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>Install the Model Context Protocol to:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Access your ChatGPT project conversations</li>
|
||||
<li>Read product documentation and notes</li>
|
||||
<li>Sync your product vision and requirements</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Link href={`/${workspace}/${projectId}/getting-started/analyze`}>
|
||||
<Button size="lg">
|
||||
Continue to Analyze
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ProjectSidebar } from "@/components/layout/project-sidebar";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function GettingStartedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState("projects");
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Project Sidebar - Getting Started Steps */}
|
||||
<ProjectSidebar projectId={projectId} activeSection={activeSection} workspace={workspace} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Assistant */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle2, ArrowRight, Sparkles } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function SetupPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Setup Your Project</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
We've created your project structure based on the analysis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup Complete */}
|
||||
<Card className="border-green-500/50 bg-green-500/5">
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-1">Project Setup Complete!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Your project has been configured with all the necessary sections
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* What We Created */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>What We've Set Up</CardTitle>
|
||||
<CardDescription>Your project is ready with these sections</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Product Vision</p>
|
||||
<p className="text-sm text-muted-foreground">Your product goals and strategy</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Progress Tracking</p>
|
||||
<p className="text-sm text-muted-foreground">Monitor your development progress</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">UI UX Design</p>
|
||||
<p className="text-sm text-muted-foreground">Design and iterate on your screens</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Code Repository</p>
|
||||
<p className="text-sm text-muted-foreground">Connected to your GitHub repo</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border bg-card">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Deployment & Automation</p>
|
||||
<p className="text-sm text-muted-foreground">CI/CD and automated workflows</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Start Building Button */}
|
||||
<div className="flex justify-center pt-4">
|
||||
<Link href={`/${workspace}/${projectId}/product`}>
|
||||
<Button size="lg" className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Start Building
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle2, ArrowRight, Target, Code2, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function SummarizePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Project Summary</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Here's what we learned about your product
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* Product Vision */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">Product Vision</CardTitle>
|
||||
<CardDescription>What you're building</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
An AI-powered development monitoring platform that tracks coding sessions,
|
||||
analyzes conversations, and maintains living documentation.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<Code2 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">Tech Stack</CardTitle>
|
||||
<CardDescription>Technologies detected</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">Next.js</span>
|
||||
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">TypeScript</span>
|
||||
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">PostgreSQL</span>
|
||||
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">Node.js</span>
|
||||
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">Tailwind CSS</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Key Features */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500/10">
|
||||
<Zap className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">Key Features</CardTitle>
|
||||
<CardDescription>Main capabilities identified</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
<span>Session tracking and cost monitoring</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
<span>AI-powered code analysis with Gemini 2.0 Flash</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
<span>Automatic documentation generation</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
<span>Cursor IDE extension integration</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Link href={`/${workspace}/${projectId}/getting-started/setup`}>
|
||||
<Button size="lg">
|
||||
Continue to Setup
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
57
app/[workspace]/project/[projectId]/grow/page.tsx
Normal file
57
app/[workspace]/project/[projectId]/grow/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
export default function GrowPage() {
|
||||
const items = [
|
||||
{ icon: "📣", title: "Marketing copy", desc: "AI-generated landing page, emails, and social posts tailored to your product." },
|
||||
{ icon: "🎯", title: "Launch channels", desc: "Recommended channels based on your target audience and business model." },
|
||||
{ icon: "👥", title: "User acquisition", desc: "Onboarding flows, referral mechanics, and early adopter campaigns." },
|
||||
{ icon: "💬", title: "Community", desc: "Discord, Slack, or forum setup recommendations for your user base." },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<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 }}>
|
||||
Marketing, launch strategy, and user acquisition — coming once your product is live.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="vibn-enter"
|
||||
style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 16,
|
||||
padding: "18px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
boxShadow: "0 1px 2px #1a1a1a05",
|
||||
animationDelay: `${i * 0.06}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "1.2rem", flexShrink: 0, marginTop: 2 }}>{item.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>{item.title}</div>
|
||||
<div style={{ fontSize: "0.8rem", color: "#6b6560", lineHeight: 1.55 }}>{item.desc}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
marginLeft: "auto", flexShrink: 0,
|
||||
display: "inline-flex", alignItems: "center",
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
fontSize: "0.68rem", fontWeight: 600,
|
||||
color: "#9a7b3a", background: "#d4a04a12",
|
||||
}}>
|
||||
Soon
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
57
app/[workspace]/project/[projectId]/insights/page.tsx
Normal file
57
app/[workspace]/project/[projectId]/insights/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
export default function InsightsPage() {
|
||||
const items = [
|
||||
{ icon: "📊", title: "Usage analytics", desc: "Page views, active users, retention curves, and funnel analysis." },
|
||||
{ icon: "⚡", title: "Performance", desc: "Load times, error rates, and infrastructure health at a glance." },
|
||||
{ icon: "💰", title: "Revenue", desc: "MRR, churn, LTV, and subscription metrics wired from your billing provider." },
|
||||
{ icon: "🔔", title: "Alerts", desc: "Get notified when key metrics drop or anomalies are detected." },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<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 }}>
|
||||
Analytics, performance, and revenue — available once your product is deployed.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="vibn-enter"
|
||||
style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 16,
|
||||
padding: "18px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
boxShadow: "0 1px 2px #1a1a1a05",
|
||||
animationDelay: `${i * 0.06}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "1.2rem", flexShrink: 0, marginTop: 2 }}>{item.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>{item.title}</div>
|
||||
<div style={{ fontSize: "0.8rem", color: "#6b6560", lineHeight: 1.55 }}>{item.desc}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
marginLeft: "auto", flexShrink: 0,
|
||||
display: "inline-flex", alignItems: "center",
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
fontSize: "0.68rem", fontWeight: 600,
|
||||
color: "#9a7b3a", background: "#d4a04a12",
|
||||
}}>
|
||||
Soon
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
GitBranch,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Lightbulb,
|
||||
ShoppingCart,
|
||||
UserPlus,
|
||||
Rocket,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
CreditCard,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
X,
|
||||
Palette,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
||||
|
||||
interface WorkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
status: "built" | "in_progress" | "missing";
|
||||
category: string;
|
||||
sessionsCount: number;
|
||||
commitsCount: number;
|
||||
journeyStage?: string;
|
||||
}
|
||||
|
||||
interface AssetNode {
|
||||
id: string;
|
||||
name: string;
|
||||
asset_type: string;
|
||||
must_have_for_v1: boolean;
|
||||
asset_metadata: {
|
||||
why_it_exists: string;
|
||||
which_user_it_serves?: string;
|
||||
problem_it_helps_with?: string;
|
||||
connection_to_magic_moment: string;
|
||||
journey_stage?: string;
|
||||
visual_style_notes?: string;
|
||||
implementation_notes?: string;
|
||||
};
|
||||
children?: AssetNode[];
|
||||
}
|
||||
|
||||
interface JourneyStage {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: any;
|
||||
description: string;
|
||||
color: string;
|
||||
items: WorkItem[];
|
||||
assets: AssetNode[]; // Visual assets for this stage
|
||||
}
|
||||
|
||||
export default function JourneyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = use(params);
|
||||
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
||||
const [touchpointAssets, setTouchpointAssets] = useState<AssetNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedStage, setSelectedStage] = useState<string | null>(null);
|
||||
const [journeyStages, setJourneyStages] = useState<JourneyStage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadJourneyData();
|
||||
}, [projectId]);
|
||||
|
||||
const loadJourneyData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load work items for stats
|
||||
const timelineResponse = await fetch(`/api/projects/${projectId}/timeline-view`);
|
||||
if (timelineResponse.ok) {
|
||||
const timelineData = await timelineResponse.json();
|
||||
setWorkItems(timelineData.workItems);
|
||||
}
|
||||
|
||||
// Load AI-generated touchpoints tree
|
||||
const mvpResponse = await fetch(`/api/projects/${projectId}/mvp-checklist`);
|
||||
if (mvpResponse.ok) {
|
||||
const mvpData = await mvpResponse.json();
|
||||
|
||||
// Extract touchpoints from AI response if it exists
|
||||
if (mvpData.aiGenerated && mvpData.touchpointsTree) {
|
||||
const allTouchpoints = flattenAssetNodes(mvpData.touchpointsTree.nodes || []);
|
||||
setTouchpointAssets(allTouchpoints);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Build journey stages from both work items and touchpoint assets
|
||||
const stages = buildJourneyStages(workItems, touchpointAssets);
|
||||
setJourneyStages(stages);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading journey data:", error);
|
||||
toast.error("Failed to load journey data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Flatten nested asset nodes
|
||||
const flattenAssetNodes = (nodes: AssetNode[]): AssetNode[] => {
|
||||
const flattened: AssetNode[] = [];
|
||||
|
||||
const flatten = (node: AssetNode) => {
|
||||
flattened.push(node);
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach(child => flatten(child));
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach(node => flatten(node));
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const getJourneySection = (item: WorkItem): string => {
|
||||
const title = item.title.toLowerCase();
|
||||
const path = item.path.toLowerCase();
|
||||
|
||||
// Discovery
|
||||
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
|
||||
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
|
||||
|
||||
// Research
|
||||
if (item.category === 'Content' || path.includes('/docs')) return 'Research';
|
||||
if (title.includes('marketing dashboard')) return 'Research';
|
||||
|
||||
// Onboarding
|
||||
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
|
||||
if (path.includes('signup') || path.includes('signin')) return 'Onboarding';
|
||||
|
||||
// First Use
|
||||
if (title.includes('onboarding')) return 'First Use';
|
||||
if (title.includes('getting started')) return 'First Use';
|
||||
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
|
||||
if (title.includes('creation flow')) return 'First Use';
|
||||
|
||||
// Active
|
||||
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
|
||||
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
|
||||
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
|
||||
|
||||
// Support
|
||||
if (path.includes('settings')) return 'Support';
|
||||
|
||||
// Purchase
|
||||
if (path.includes('billing') || path.includes('payment')) return 'Purchase';
|
||||
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
const buildJourneyStages = (items: WorkItem[], assets: AssetNode[]): JourneyStage[] => {
|
||||
const stageDefinitions = [
|
||||
{
|
||||
id: "discovery",
|
||||
name: "Discovery",
|
||||
stageMappings: ["Awareness", "Discovery"],
|
||||
icon: Search,
|
||||
description: "Found you online via social, blog, or ad",
|
||||
color: "bg-blue-100 border-blue-300 text-blue-900",
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
name: "Research",
|
||||
stageMappings: ["Curiosity", "Research"],
|
||||
icon: Lightbulb,
|
||||
description: "Checking out features, pricing, and value",
|
||||
color: "bg-purple-100 border-purple-300 text-purple-900",
|
||||
},
|
||||
{
|
||||
id: "onboarding",
|
||||
name: "Onboarding",
|
||||
stageMappings: ["First Try", "Onboarding"],
|
||||
icon: UserPlus,
|
||||
description: "Creating account to try the product",
|
||||
color: "bg-green-100 border-green-300 text-green-900",
|
||||
},
|
||||
{
|
||||
id: "first-use",
|
||||
name: "First Use",
|
||||
stageMappings: ["First Real Day", "First Use"],
|
||||
icon: Rocket,
|
||||
description: "Zero to experiencing the magic",
|
||||
color: "bg-orange-100 border-orange-300 text-orange-900",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
name: "Active",
|
||||
stageMappings: ["Habit", "Active", "Post-MVP"],
|
||||
icon: Zap,
|
||||
description: "Using the magic repeatedly",
|
||||
color: "bg-yellow-100 border-yellow-300 text-yellow-900",
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
name: "Support",
|
||||
stageMappings: ["Support"],
|
||||
icon: HelpCircle,
|
||||
description: "Getting help to maximize value",
|
||||
color: "bg-indigo-100 border-indigo-300 text-indigo-900",
|
||||
},
|
||||
{
|
||||
id: "purchase",
|
||||
name: "Purchase",
|
||||
stageMappings: ["Decision to Pay", "Purchase"],
|
||||
icon: CreditCard,
|
||||
description: "Time to pay to keep using",
|
||||
color: "bg-pink-100 border-pink-300 text-pink-900",
|
||||
},
|
||||
];
|
||||
|
||||
return stageDefinitions.map(stage => {
|
||||
// Get work items for this stage
|
||||
const stageItems = items.filter(item => {
|
||||
const section = getJourneySection(item);
|
||||
return section === stage.name;
|
||||
});
|
||||
|
||||
// Get touchpoint assets for this stage from AI-generated metadata
|
||||
const stageAssets = assets.filter(asset => {
|
||||
const assetJourneyStage = asset.asset_metadata?.journey_stage || '';
|
||||
return stage.stageMappings.some(mapping =>
|
||||
assetJourneyStage.toLowerCase().includes(mapping.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...stage,
|
||||
items: stageItems,
|
||||
assets: stageAssets,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
if (status === "built") return <CheckCircle2 className="h-3 w-3 text-green-600" />;
|
||||
if (status === "in_progress") return <Circle className="h-3 w-3 text-blue-600 fill-blue-600" />;
|
||||
return <Circle className="h-3 w-3 text-gray-400" />;
|
||||
};
|
||||
|
||||
const selectedStageData = journeyStages.find(s => s.id === selectedStage);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar */}
|
||||
<CollapsibleSidebar>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Journey Stats</h3>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span className="font-medium">{journeyStages.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Assets</span>
|
||||
<span className="font-medium">{journeyStages.reduce((sum, stage) => sum + stage.assets.length, 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Work Items</span>
|
||||
<span className="font-medium">{workItems.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col bg-background overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<GitBranch className="h-6 w-6" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Customer Journey</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Track touchpoints across the customer lifecycle
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Journey Flow */}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-0 overflow-x-auto pb-4">
|
||||
{journeyStages.map((stage, index) => (
|
||||
<div key={stage.id} className="flex items-center flex-shrink-0">
|
||||
{/* Stage Card */}
|
||||
<Card
|
||||
className={`w-64 border-2 cursor-pointer transition-all hover:shadow-lg ${
|
||||
stage.color
|
||||
} ${selectedStage === stage.id ? "ring-2 ring-primary" : ""}`}
|
||||
onClick={() => setSelectedStage(stage.id)}
|
||||
>
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<stage.icon className="h-5 w-5" />
|
||||
<h3 className="font-bold text-sm">{stage.name}</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{stage.items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs opacity-80 line-clamp-2">{stage.description}</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span>
|
||||
{stage.items.filter(i => i.status === "built").length} built
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Circle className="h-3 w-3 fill-current" />
|
||||
<span>
|
||||
{stage.items.filter(i => i.status === "in_progress").length} in progress
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-white/50 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-current h-1.5 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${
|
||||
stage.items.length > 0
|
||||
? (stage.items.filter(i => i.status === "built").length /
|
||||
stage.items.length) *
|
||||
100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Connector Arrow */}
|
||||
{index < journeyStages.length - 1 && (
|
||||
<ChevronRight className="h-8 w-8 text-muted-foreground mx-2 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Details Panel */}
|
||||
{selectedStageData && (
|
||||
<div className="border-t bg-muted/30 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<selectedStageData.icon className="h-6 w-6" />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">{selectedStageData.name} Touchpoints</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedStageData.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedStage(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedStageData.assets.length === 0 && selectedStageData.items.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No assets defined for this stage yet
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => toast.info("AI will generate assets when you regenerate the plan")}>
|
||||
Generate with AI
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* AI-Generated Visual Assets */}
|
||||
{selectedStageData.assets.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
|
||||
Visual Assets
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{selectedStageData.assets.map((asset) => (
|
||||
<Card key={asset.id} className="overflow-hidden hover:shadow-lg transition-all group cursor-pointer">
|
||||
{/* Visual Preview */}
|
||||
<div className="aspect-video bg-gradient-to-br from-indigo-50 to-purple-50 relative overflow-hidden border-b">
|
||||
{/* Placeholder for actual design preview */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<Palette className="h-10 w-10 text-indigo-400 mx-auto mb-2" />
|
||||
<p className="text-xs text-indigo-600 font-medium line-clamp-2">
|
||||
{asset.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* V1 Badge */}
|
||||
{asset.must_have_for_v1 && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant="default" className="shadow-sm bg-blue-600">
|
||||
V1
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset Type Badge */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<Badge variant="secondary" className="shadow-sm text-xs">
|
||||
{asset.asset_type.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
</div>
|
||||
|
||||
{/* Card Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2">{asset.name}</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{asset.asset_metadata?.why_it_exists}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{asset.asset_metadata?.visual_style_notes && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Style:</span>{" "}
|
||||
{asset.asset_metadata.visual_style_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{asset.asset_metadata?.which_user_it_serves || "All users"}
|
||||
</Badge>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toast.info("Opening in designer...");
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Design
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Work Items */}
|
||||
{selectedStageData.items.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
|
||||
Existing Work
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{selectedStageData.items.map((item) => (
|
||||
<Card key={item.id} className="p-4 hover:bg-accent/50 transition-colors">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<h3 className="font-semibold text-sm">{item.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{item.path}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{item.sessionsCount} sessions</span>
|
||||
<span>•</span>
|
||||
<span>{item.commitsCount} commits</span>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.status === "built"
|
||||
? "Done"
|
||||
: item.status === "in_progress"
|
||||
? "In Progress"
|
||||
: "To-do"}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* End Main Content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { ProjectShell } from "@/components/layout/project-shell";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
async function getProjectName(projectId: string): Promise<string> {
|
||||
interface ProjectData {
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
progress?: number;
|
||||
discoveryPhase?: number;
|
||||
capturedData?: Record<string, string>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
featureCount?: number;
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
}
|
||||
|
||||
async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||
try {
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
|
||||
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
const data = rows[0].data;
|
||||
return data?.productName || data?.name || "Project";
|
||||
const { data, created_at, updated_at } = rows[0];
|
||||
return {
|
||||
name: data?.productName || data?.name || "Project",
|
||||
description: data?.productVision || data?.description,
|
||||
status: data?.status,
|
||||
progress: data?.progress ?? 0,
|
||||
discoveryPhase: data?.discoveryPhase ?? 0,
|
||||
capturedData: data?.capturedData ?? {},
|
||||
createdAt: created_at,
|
||||
updatedAt: updated_at,
|
||||
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
||||
creationMode: data?.creationMode ?? "fresh",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching project name:", error);
|
||||
console.error("Error fetching project:", error);
|
||||
}
|
||||
return "Project";
|
||||
return { name: "Project" };
|
||||
}
|
||||
|
||||
export default async function ProjectLayout({
|
||||
@@ -25,11 +49,24 @@ export default async function ProjectLayout({
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
const projectName = await getProjectName(projectId);
|
||||
const project = await getProjectData(projectId);
|
||||
|
||||
return (
|
||||
<AppShell workspace={workspace} projectId={projectId} projectName={projectName}>
|
||||
<ProjectShell
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
projectName={project.name}
|
||||
projectDescription={project.description}
|
||||
projectStatus={project.status}
|
||||
projectProgress={project.progress}
|
||||
discoveryPhase={project.discoveryPhase}
|
||||
capturedData={project.capturedData}
|
||||
createdAt={project.createdAt}
|
||||
updatedAt={project.updatedAt}
|
||||
featureCount={project.featureCount}
|
||||
creationMode={project.creationMode}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
</ProjectShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import {
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
Globe,
|
||||
Target,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Edit,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageTemplate,
|
||||
PageSection,
|
||||
PageCard,
|
||||
PageGrid,
|
||||
} from "@/components/layout/page-template";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const MARKET_NAV_ITEMS = [
|
||||
{ title: "Value Proposition", icon: Target, href: "/market" },
|
||||
{ title: "Messaging Framework", icon: MessageSquare, href: "/market#messaging" },
|
||||
{ title: "Website Copy", icon: Globe, href: "/market#website" },
|
||||
{ title: "Launch Strategy", icon: Rocket, href: "/market#launch" },
|
||||
{ title: "Target Channels", icon: Megaphone, href: "/market#channels" },
|
||||
];
|
||||
|
||||
export default function MarketPage() {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const sidebarItems = MARKET_NAV_ITEMS.map((item) => {
|
||||
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
|
||||
return {
|
||||
...item,
|
||||
href: fullHref,
|
||||
isActive: pathname === fullHref || pathname.startsWith(fullHref),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
}}
|
||||
>
|
||||
{/* Value Proposition */}
|
||||
<PageSection
|
||||
title="Value Proposition"
|
||||
description="Your core message to the market"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageCard>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Headline
|
||||
</h3>
|
||||
<p className="text-2xl font-bold">
|
||||
Build Your Product Faster with AI-Powered Insights
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Subheadline
|
||||
</h3>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Turn conversations into code, design, and marketing - all in one platform
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Key Benefits
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span>Save weeks of planning and research</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span>Get AI-generated designs and code structure</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span>Launch with confidence and clarity</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Messaging Framework */}
|
||||
<PageSection title="Messaging Framework" description="How you talk about your product">
|
||||
<PageGrid cols={2}>
|
||||
<PageCard>
|
||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
Primary Message
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
For solo founders and small teams building their first product
|
||||
</p>
|
||||
<p className="text-base">
|
||||
"Stop getting stuck in planning. Start building with AI as your co-founder."
|
||||
</p>
|
||||
</PageCard>
|
||||
|
||||
<PageCard>
|
||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
Positioning
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Different from competitors because...
|
||||
</p>
|
||||
<p className="text-base">
|
||||
"We don't just track - we actively guide you from idea to launch with AI."
|
||||
</p>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
|
||||
{/* Website Copy */}
|
||||
<PageSection
|
||||
title="Website Copy"
|
||||
description="Content for your marketing site"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Generate More
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PageCard>
|
||||
<h3 className="font-semibold mb-3">Hero Section</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground mb-1">Headline</p>
|
||||
<p className="font-medium">Build Your SaaS from Idea to Launch</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground mb-1">CTA Button</p>
|
||||
<p className="font-medium">Start Building Free →</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard>
|
||||
<h3 className="font-semibold mb-3">Features Section</h3>
|
||||
<PageGrid cols={3}>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-sm font-medium mb-1">🎯 AI Interview</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chat with AI to define your product
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-sm font-medium mb-1">🎨 Auto Design</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate UI screens instantly
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-sm font-medium mb-1">🚀 Launch Plan</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get a complete go-to-market strategy
|
||||
</p>
|
||||
</div>
|
||||
</PageGrid>
|
||||
</PageCard>
|
||||
|
||||
<PageCard>
|
||||
<h3 className="font-semibold mb-3">Social Proof</h3>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
"This tool cut our planning time from 4 weeks to 2 days. Incredible."
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
- Founder Name, Company
|
||||
</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* Launch Strategy */}
|
||||
<PageSection title="Launch Strategy" description="Your go-to-market plan">
|
||||
<PageCard>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4 text-muted-foreground" />
|
||||
Launch Timeline
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
||||
<div className="text-xs font-medium text-muted-foreground w-20">
|
||||
Week 1-2
|
||||
</div>
|
||||
<div className="text-sm">Soft launch to beta testers</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
||||
<div className="text-xs font-medium text-muted-foreground w-20">
|
||||
Week 3
|
||||
</div>
|
||||
<div className="text-sm">Product Hunt launch</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30">
|
||||
<div className="text-xs font-medium text-muted-foreground w-20">
|
||||
Week 4+
|
||||
</div>
|
||||
<div className="text-sm">Content marketing & SEO</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Target Channels */}
|
||||
<PageSection
|
||||
title="Target Channels"
|
||||
description="Where to reach your audience"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Channel
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageGrid cols={2}>
|
||||
<PageCard hover>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Twitter/X</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Primary channel for developer audience
|
||||
</p>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
High Priority
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard hover>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Rocket className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Product Hunt</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Launch day visibility and early adopters
|
||||
</p>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-muted text-muted-foreground font-medium">
|
||||
Launch Day
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard hover>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Dev Communities</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Indie Hackers, Reddit, Discord servers
|
||||
</p>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-muted text-muted-foreground font-medium">
|
||||
Ongoing
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard hover>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Content Marketing</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Blog posts, tutorials, case studies
|
||||
</p>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-muted text-muted-foreground font-medium">
|
||||
Long-term
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import {
|
||||
Target,
|
||||
Users,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
Plus,
|
||||
Edit,
|
||||
Search,
|
||||
Loader2,
|
||||
Layout,
|
||||
CheckCircle,
|
||||
DollarSign,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageTemplate,
|
||||
PageSection,
|
||||
PageCard,
|
||||
PageGrid,
|
||||
PageEmptyState,
|
||||
} from "@/components/layout/page-template";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MissionContextTree } from "@/components/mission/mission-context-tree";
|
||||
import { MissionIdeaSection } from "@/components/mission/mission-idea-section";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const MISSION_NAV_ITEMS = [
|
||||
{ title: "Target Customer", icon: Users, href: "/mission" },
|
||||
{ title: "Existing Solutions", icon: Layout, href: "/mission#solutions" },
|
||||
];
|
||||
|
||||
interface MissionFramework {
|
||||
targetCustomer: {
|
||||
primaryAudience: string;
|
||||
theirSituation: string;
|
||||
relatedMarkets?: string[];
|
||||
};
|
||||
existingSolutions: Array<{
|
||||
category: string;
|
||||
description: string;
|
||||
products: Array<{
|
||||
name: string;
|
||||
url?: string;
|
||||
}>;
|
||||
}>;
|
||||
innovations: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
ideaValidation: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
financialSuccess: {
|
||||
subscribers: number;
|
||||
pricePoint: number;
|
||||
retentionRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function MissionPage() {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
const [researchingMarket, setResearchingMarket] = useState(false);
|
||||
const [framework, setFramework] = useState<MissionFramework | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// Fetch mission framework on mount
|
||||
useEffect(() => {
|
||||
fetchFramework();
|
||||
}, [projectId]);
|
||||
|
||||
const fetchFramework = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch project data from Firestore to get the saved framework
|
||||
const user = auth.currentUser;
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.project?.phaseData?.missionFramework) {
|
||||
setFramework(data.project.phaseData.missionFramework);
|
||||
console.log('[Mission] Loaded saved framework');
|
||||
} else {
|
||||
console.log('[Mission] No saved framework found');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Mission] Error fetching framework:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateFramework = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/mission/generate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate mission framework');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setFramework(data.framework);
|
||||
toast.success('Mission framework generated successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error generating framework:', error);
|
||||
toast.error('Failed to generate mission framework');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResearchMarket = async () => {
|
||||
setResearchingMarket(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch(`/api/projects/${projectId}/research/market`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to conduct market research');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
toast.success(
|
||||
`Market research complete! Found ${data.research.targetNiches.length} niches, ` +
|
||||
`${data.research.competitors.length} competitors, and ${data.research.marketGaps.length} gaps.`
|
||||
);
|
||||
|
||||
// Regenerate framework with new insights
|
||||
await handleGenerateFramework();
|
||||
} catch (error) {
|
||||
console.error('Error conducting market research:', error);
|
||||
toast.error('Failed to conduct market research');
|
||||
} finally {
|
||||
setResearchingMarket(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build sidebar items with full hrefs and active states
|
||||
const sidebarItems = MISSION_NAV_ITEMS.map((item) => {
|
||||
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
|
||||
return {
|
||||
...item,
|
||||
href: fullHref,
|
||||
isActive: pathname === fullHref || pathname.startsWith(fullHref),
|
||||
};
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
customContent: <MissionContextTree projectId={projectId} />,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
if (!framework) {
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
customContent: <MissionContextTree projectId={projectId} />,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div className="rounded-full bg-muted p-6 mb-4">
|
||||
<Lightbulb className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">No Mission Framework Yet</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Generate your mission framework based on your project's insights and knowledge
|
||||
</p>
|
||||
<Button onClick={handleGenerateFramework} disabled={generating} size="lg">
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Generate Mission Framework
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
customContent: <MissionContextTree projectId={projectId} />,
|
||||
}}
|
||||
>
|
||||
{/* Target Customer */}
|
||||
<PageSection
|
||||
title="Target Customer"
|
||||
description="Who you're building for"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost" onClick={handleGenerateFramework} disabled={generating}>
|
||||
{generating ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Regenerate
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageCard>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Primary Audience</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{framework.targetCustomer.primaryAudience}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Their Situation</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{framework.targetCustomer.theirSituation}
|
||||
</p>
|
||||
</div>
|
||||
{framework.targetCustomer.relatedMarkets && framework.targetCustomer.relatedMarkets.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Related Markets</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{framework.targetCustomer.relatedMarkets.map((market, idx) => (
|
||||
<li key={idx} className="text-sm text-muted-foreground">
|
||||
{market}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Existing Solutions */}
|
||||
<PageSection
|
||||
title="Existing Solutions"
|
||||
description="What alternatives already exist"
|
||||
headerAction={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleResearchMarket}
|
||||
disabled={researchingMarket}
|
||||
>
|
||||
{researchingMarket ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Researching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Research Market
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{framework.existingSolutions.map((solution, idx) => (
|
||||
<PageCard key={idx}>
|
||||
<h4 className="font-semibold text-sm mb-3">{solution.category}</h4>
|
||||
{solution.products && solution.products.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{solution.products.map((product, prodIdx) => (
|
||||
<div key={prodIdx} className="text-sm">
|
||||
{product.url ? (
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{product.name}
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{product.name}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageCard>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import {
|
||||
DollarSign,
|
||||
Receipt,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageTemplate,
|
||||
PageSection,
|
||||
PageCard,
|
||||
PageGrid,
|
||||
} from "@/components/layout/page-template";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MONEY_NAV_ITEMS = [
|
||||
{ title: "Expenses", icon: Receipt, href: "/money" },
|
||||
{ title: "Costs", icon: TrendingUp, href: "/money#costs" },
|
||||
{ title: "Pricing", icon: DollarSign, href: "/money#pricing" },
|
||||
{ title: "Plans", icon: CreditCard, href: "/money#plans" },
|
||||
];
|
||||
|
||||
const SAMPLE_EXPENSES = [
|
||||
{ id: 1, name: "Logo Design", amount: 299, date: "2025-01-15", category: "Design" },
|
||||
{ id: 2, name: "Domain Registration", amount: 12, date: "2025-01-10", category: "Infrastructure" },
|
||||
{ id: 3, name: "SSL Certificate", amount: 69, date: "2025-01-08", category: "Infrastructure" },
|
||||
];
|
||||
|
||||
const SAMPLE_COSTS = [
|
||||
{ id: 1, name: "Vercel Hosting", amount: 20, frequency: "monthly", category: "Infrastructure" },
|
||||
{ id: 2, name: "OpenAI API", amount: 45, frequency: "monthly", category: "Services" },
|
||||
{ id: 3, name: "SendGrid Email", amount: 15, frequency: "monthly", category: "Services" },
|
||||
{ id: 4, name: "Stripe Fees", amount: 0, frequency: "per transaction", category: "Services" },
|
||||
];
|
||||
|
||||
export default function MoneyPage() {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const sidebarItems = MONEY_NAV_ITEMS.map((item) => {
|
||||
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
|
||||
return {
|
||||
...item,
|
||||
href: fullHref,
|
||||
isActive: pathname === fullHref || pathname.startsWith(fullHref),
|
||||
};
|
||||
});
|
||||
|
||||
const totalExpenses = SAMPLE_EXPENSES.reduce((sum, e) => sum + e.amount, 0);
|
||||
const monthlyCosts = SAMPLE_COSTS.filter(c => c.frequency === "monthly").reduce((sum, c) => sum + c.amount, 0);
|
||||
const annualCosts = monthlyCosts * 12;
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
}}
|
||||
>
|
||||
{/* Financial Overview */}
|
||||
<PageSection>
|
||||
<PageGrid cols={4}>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
|
||||
<Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold">${totalExpenses}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Expenses</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">One-time</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
|
||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold">${monthlyCosts}</p>
|
||||
<p className="text-sm text-muted-foreground">Monthly Costs</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Recurring</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold">${annualCosts}</p>
|
||||
<p className="text-sm text-muted-foreground">Annual Costs</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Projected</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted mx-auto mb-2 flex items-center justify-center">
|
||||
<DollarSign className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-3xl font-bold">$0</p>
|
||||
<p className="text-sm text-muted-foreground">Revenue</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Not launched</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
|
||||
{/* Expenses (One-time) */}
|
||||
<PageSection
|
||||
title="Expenses"
|
||||
description="One-time costs"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Expense
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageCard>
|
||||
<div className="space-y-2">
|
||||
{SAMPLE_EXPENSES.map((expense) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{expense.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{expense.date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-muted font-medium">
|
||||
{expense.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">${expense.amount}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Costs (Recurring) */}
|
||||
<PageSection
|
||||
title="Costs"
|
||||
description="Recurring/ongoing expenses"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Cost
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageCard>
|
||||
<div className="space-y-2">
|
||||
{SAMPLE_COSTS.map((cost) => (
|
||||
<div
|
||||
key={cost.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{cost.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{cost.frequency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-muted font-medium">
|
||||
{cost.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">
|
||||
{cost.amount === 0 ? "Variable" : `$${cost.amount}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Pricing Strategy */}
|
||||
<PageSection
|
||||
title="Pricing"
|
||||
description="Your product pricing strategy"
|
||||
>
|
||||
<PageCard>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<DollarSign className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm mb-4">
|
||||
Define your pricing tiers and revenue model
|
||||
</p>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Pricing Plan
|
||||
</Button>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Plans (Revenue Tiers) */}
|
||||
<PageSection
|
||||
title="Plans"
|
||||
description="Subscription tiers and offerings"
|
||||
>
|
||||
<PageGrid cols={3}>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg mb-2">Free</h3>
|
||||
<p className="text-3xl font-bold mb-1">$0</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">per month</p>
|
||||
<ul className="text-sm space-y-2 text-left mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>Basic features</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>Community support</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard className="border-primary">
|
||||
<div className="text-center">
|
||||
<div className="inline-block px-2 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium mb-2">
|
||||
Popular
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">Pro</h3>
|
||||
<p className="text-3xl font-bold mb-1">$29</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">per month</p>
|
||||
<ul className="text-sm space-y-2 text-left mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>All features</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>Priority support</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>API access</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg mb-2">Enterprise</h3>
|
||||
<p className="text-3xl font-bold mb-1">Custom</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">contact us</p>
|
||||
<ul className="text-sm space-y-2 text-left mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>Unlimited everything</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>Dedicated support</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
</div>
|
||||
<span>Custom integrations</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,504 +3,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
Terminal,
|
||||
Rocket,
|
||||
Database,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Code2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ContextSnapshot {
|
||||
lastCommit?: {
|
||||
sha: string;
|
||||
message: string;
|
||||
author?: string;
|
||||
timestamp?: string;
|
||||
url?: string;
|
||||
};
|
||||
currentBranch?: string;
|
||||
recentCommits?: { sha: string; message: string; author?: string; timestamp?: string }[];
|
||||
openPRs?: { number: number; title: string; url: string; from: string; into: string }[];
|
||||
openIssues?: { number: number; title: string; url: string; labels?: string[] }[];
|
||||
lastDeployment?: {
|
||||
status: string;
|
||||
url?: string;
|
||||
timestamp?: string;
|
||||
deploymentUuid?: string;
|
||||
};
|
||||
updatedAt?: string;
|
||||
}
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
productName: string;
|
||||
productVision?: string;
|
||||
slug?: string;
|
||||
workspace?: string;
|
||||
status?: string;
|
||||
currentPhase?: string;
|
||||
projectType?: string;
|
||||
// Gitea
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
giteaCloneUrl?: string;
|
||||
giteaSshUrl?: string;
|
||||
giteaWebhookId?: number;
|
||||
giteaError?: string;
|
||||
// Coolify
|
||||
coolifyProjectUuid?: string;
|
||||
coolifyAppUuid?: string;
|
||||
coolifyDbUuid?: string;
|
||||
deploymentUrl?: string;
|
||||
// Theia
|
||||
theiaWorkspaceUrl?: string;
|
||||
// Context
|
||||
contextSnapshot?: ContextSnapshot;
|
||||
stats?: { sessions: number; costs: number };
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function timeAgo(ts?: string): string {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
const diff = (Date.now() - d.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`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function DeployBadge({ status }: { status?: string }) {
|
||||
if (!status) return <Badge variant="secondary">No deployments</Badge>;
|
||||
const map: Record<string, { label: string; icon: React.ElementType; className: string }> = {
|
||||
finished: { label: "Deployed", icon: CheckCircle2, className: "bg-green-500/10 text-green-600 border-green-500/20" },
|
||||
in_progress: { label: "Deploying", icon: Loader2, className: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
|
||||
queued: { label: "Queued", icon: Clock, className: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20" },
|
||||
failed: { label: "Failed", icon: XCircle, className: "bg-red-500/10 text-red-600 border-red-500/20" },
|
||||
cancelled: { label: "Cancelled", icon: XCircle, className: "bg-gray-500/10 text-gray-500 border-gray-500/20" },
|
||||
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;
|
||||
};
|
||||
const cfg = map[status] ?? { label: status, icon: AlertCircle, className: "bg-gray-500/10 text-gray-500" };
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${cfg.className}`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
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);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [provisioning, setProvisioning] = useState(false);
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to load project");
|
||||
}
|
||||
const data = await res.json();
|
||||
setProject(data.project);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === "authenticated") fetchProject();
|
||||
else if (authStatus === "unauthenticated") setLoading(false);
|
||||
}, [authStatus, projectId]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchProject();
|
||||
};
|
||||
|
||||
const handleProvisionWorkspace = async () => {
|
||||
setProvisioning(true);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/workspace`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.workspaceUrl) {
|
||||
toast.success('Workspace provisioned — starting up…');
|
||||
await fetchProject();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to provision workspace');
|
||||
}
|
||||
} catch {
|
||||
toast.error('An error occurred');
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
if (authStatus !== "authenticated") {
|
||||
if (authStatus === "unauthenticated") setLoading(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [authStatus, projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-6 max-w-5xl">
|
||||
<Card className="border-red-500/30 bg-red-500/5">
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-red-600">{error ?? "Project not found"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
||||
Project not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snap = project.contextSnapshot;
|
||||
const gitea_url = process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.vibnai.com";
|
||||
const projectName = project.productName || project.name || "Untitled";
|
||||
const mode = project.creationMode ?? "fresh";
|
||||
|
||||
if (mode === "chat-import") {
|
||||
return (
|
||||
<ChatImportMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "code-import") {
|
||||
return (
|
||||
<CodeImportMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult}
|
||||
creationStage={project.creationStage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "migration") {
|
||||
return (
|
||||
<MigrateMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult}
|
||||
migrationPlan={project.migrationPlan}
|
||||
creationStage={project.creationStage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: "fresh" — wraps AtlasChat with decision banner
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-6 max-w-5xl space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{project.productName}</h1>
|
||||
{project.productVision && (
|
||||
<p className="text-muted-foreground text-sm mt-1 max-w-xl">{project.productVision}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant={project.status === "active" ? "default" : "secondary"}>
|
||||
{project.status ?? "active"}
|
||||
</Badge>
|
||||
{project.currentPhase && (
|
||||
<Badge variant="outline">{project.currentPhase}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
{project.theiaWorkspaceUrl ? (
|
||||
<Button size="sm" asChild>
|
||||
<a href={project.theiaWorkspaceUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Terminal className="h-4 w-4 mr-1.5" />
|
||||
Open IDE
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={handleProvisionWorkspace} disabled={provisioning}>
|
||||
{provisioning
|
||||
? <><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Provisioning…</>
|
||||
: <><Terminal className="h-4 w-4 mr-1.5" />Provision IDE</>
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Quick Stats ── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Sessions", value: project.stats?.sessions ?? 0 },
|
||||
{ label: "AI Cost", value: `$${(project.stats?.costs ?? 0).toFixed(2)}` },
|
||||
{ label: "Open PRs", value: snap?.openPRs?.length ?? 0 },
|
||||
{ label: "Open Issues", value: snap?.openIssues?.length ?? 0 },
|
||||
].map(({ label, value }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
|
||||
{/* ── Code / Gitea ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Code2 className="h-4 w-4" />
|
||||
Code Repository
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{project.giteaRepo ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-mono text-muted-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{snap?.currentBranch ?? "main"}
|
||||
</div>
|
||||
<a
|
||||
href={project.giteaRepoUrl ?? `${gitea_url}/${project.giteaRepo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{project.giteaRepo}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{snap?.lastCommit ? (
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<GitCommit className="h-3.5 w-3.5" />
|
||||
<span className="font-mono">{snap.lastCommit.sha.slice(0, 8)}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(snap.lastCommit.timestamp)}</span>
|
||||
{snap.lastCommit.author && <span>· {snap.lastCommit.author}</span>}
|
||||
</div>
|
||||
<p className="text-sm font-medium line-clamp-1">{snap.lastCommit.message}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No commits yet — push to get started</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1 pt-1 border-t">
|
||||
<p className="font-medium text-foreground">Clone</p>
|
||||
<p className="font-mono break-all">{project.giteaCloneUrl}</p>
|
||||
{project.giteaSshUrl && (
|
||||
<p className="font-mono break-all">{project.giteaSshUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.giteaError
|
||||
? `Repo provisioning failed: ${project.giteaError}`
|
||||
: "No repository linked"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Deployment ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4" />
|
||||
Deployment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{snap?.lastDeployment ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<DeployBadge status={snap.lastDeployment.status} />
|
||||
<span className="text-xs text-muted-foreground">{timeAgo(snap.lastDeployment.timestamp)}</span>
|
||||
</div>
|
||||
{snap.lastDeployment.url && (
|
||||
<a
|
||||
href={snap.lastDeployment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{snap.lastDeployment.url}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">No deployments yet</p>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/${workspace}/project/${projectId}/deployment`}>
|
||||
Set up deployment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Open PRs ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
Pull Requests
|
||||
{(snap?.openPRs?.length ?? 0) > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto">{snap!.openPRs!.length} open</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{snap?.openPRs?.length ? (
|
||||
<ul className="space-y-2">
|
||||
{snap.openPRs.map(pr => (
|
||||
<li key={pr.number}>
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{pr.number}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium line-clamp-1">{pr.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{pr.from} → {pr.into}</p>
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No open pull requests</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Open Issues ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Issues
|
||||
{(snap?.openIssues?.length ?? 0) > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto">{snap!.openIssues!.length} open</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{snap?.openIssues?.length ? (
|
||||
<ul className="space-y-2">
|
||||
{snap.openIssues.map(issue => (
|
||||
<li key={issue.number}>
|
||||
<a
|
||||
href={issue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2 text-sm hover:bg-accent rounded-md p-2 -mx-2 transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground font-mono text-xs mt-0.5">#{issue.number}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium line-clamp-1">{issue.title}</p>
|
||||
{issue.labels?.length ? (
|
||||
<div className="flex gap-1 flex-wrap mt-0.5">
|
||||
{issue.labels.map(l => (
|
||||
<span key={l} className="text-[10px] px-1.5 py-0.5 bg-muted rounded-full">{l}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No open issues</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Recent Commits ── */}
|
||||
{snap?.recentCommits && snap.recentCommits.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
Recent Commits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{snap.recentCommits.map((c, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-sm py-1.5 border-b last:border-0">
|
||||
<span className="font-mono text-xs text-muted-foreground w-16 shrink-0">{c.sha.slice(0, 8)}</span>
|
||||
<span className="flex-1 line-clamp-1">{c.message}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{c.author ?? ""}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-16 text-right">{timeAgo(c.timestamp)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Resources ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
Resources
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">Databases and services linked to this project</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.coolifyDbUuid ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Database provisioned</span>
|
||||
<Badge variant="outline" className="text-xs ml-auto">{project.coolifyDbUuid}</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">No databases provisioned yet</p>
|
||||
<Button size="sm" variant="outline">
|
||||
<Database className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add Database
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Context snapshot freshness ── */}
|
||||
{snap?.updatedAt && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
Context updated {timeAgo(snap.updatedAt)} via webhooks
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<FreshIdeaMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
Target,
|
||||
ListTodo,
|
||||
Calendar,
|
||||
Plus,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageTemplate,
|
||||
PageSection,
|
||||
PageCard,
|
||||
PageGrid,
|
||||
} from "@/components/layout/page-template";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BUILD_PLAN_NAV_ITEMS = [
|
||||
{ title: "MVP Scope", icon: Target, href: "/build-plan" },
|
||||
{ title: "Backlog", icon: ListTodo, href: "/build-plan#backlog" },
|
||||
{ title: "Milestones", icon: Calendar, href: "/build-plan#milestones" },
|
||||
{ title: "Progress", icon: Clock, href: "/build-plan#progress" },
|
||||
];
|
||||
|
||||
const SAMPLE_MVP_FEATURES = [
|
||||
{ id: 1, title: "User Authentication", status: "completed", priority: "high" },
|
||||
{ id: 2, title: "Dashboard UI", status: "in_progress", priority: "high" },
|
||||
{ id: 3, title: "Core Feature Flow", status: "in_progress", priority: "high" },
|
||||
{ id: 4, title: "Payment Integration", status: "todo", priority: "medium" },
|
||||
{ id: 5, title: "Email Notifications", status: "todo", priority: "low" },
|
||||
];
|
||||
|
||||
const SAMPLE_BACKLOG = [
|
||||
{ id: 1, title: "Advanced Analytics", priority: "medium" },
|
||||
{ id: 2, title: "Team Collaboration", priority: "high" },
|
||||
{ id: 3, title: "API Access", priority: "low" },
|
||||
{ id: 4, title: "Mobile App", priority: "medium" },
|
||||
];
|
||||
|
||||
export default function BuildPlanPage() {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const sidebarItems = BUILD_PLAN_NAV_ITEMS.map((item) => {
|
||||
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
|
||||
return {
|
||||
...item,
|
||||
href: fullHref,
|
||||
isActive: pathname === fullHref || pathname.startsWith(fullHref),
|
||||
};
|
||||
});
|
||||
|
||||
const completedCount = SAMPLE_MVP_FEATURES.filter((f) => f.status === "completed").length;
|
||||
const totalCount = SAMPLE_MVP_FEATURES.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
title: "Build Plan",
|
||||
description: "Track what needs to be built",
|
||||
items: sidebarItems,
|
||||
footer: (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{completedCount} of {totalCount} MVP features done
|
||||
</p>
|
||||
<div className="h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
hero={{
|
||||
icon: ClipboardList,
|
||||
title: "Build Plan",
|
||||
description: "Manage your MVP scope and track progress",
|
||||
actions: [
|
||||
{
|
||||
label: "Generate Tasks",
|
||||
onClick: () => console.log("Generate tasks with AI"),
|
||||
icon: Sparkles,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{/* Progress Overview */}
|
||||
<PageSection>
|
||||
<PageGrid cols={4}>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600 mx-auto mb-2" />
|
||||
<p className="text-3xl font-bold">{completedCount}</p>
|
||||
<p className="text-sm text-muted-foreground">Completed</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<Clock className="h-8 w-8 text-blue-600 mx-auto mb-2" />
|
||||
<p className="text-3xl font-bold">
|
||||
{SAMPLE_MVP_FEATURES.filter((f) => f.status === "in_progress").length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">In Progress</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<Circle className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-3xl font-bold">
|
||||
{SAMPLE_MVP_FEATURES.filter((f) => f.status === "todo").length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">To Do</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<Target className="h-8 w-8 text-primary mx-auto mb-2" />
|
||||
<p className="text-3xl font-bold">{progressPercent}%</p>
|
||||
<p className="text-sm text-muted-foreground">Progress</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
|
||||
{/* MVP Scope */}
|
||||
<PageSection
|
||||
title="MVP Scope"
|
||||
description="Features included in your minimum viable product"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageCard>
|
||||
<div className="space-y-2">
|
||||
{SAMPLE_MVP_FEATURES.map((feature) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg border transition-all hover:border-primary/50",
|
||||
feature.status === "completed" && "bg-green-50/50 dark:bg-green-950/20"
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{feature.status === "completed" && (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
)}
|
||||
{feature.status === "in_progress" && (
|
||||
<Clock className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
{feature.status === "todo" && (
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium",
|
||||
feature.status === "completed" && "line-through text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{feature.title}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-1 rounded-full",
|
||||
feature.priority === "high" &&
|
||||
"bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
feature.priority === "medium" &&
|
||||
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||
feature.priority === "low" &&
|
||||
"bg-gray-500/10 text-gray-700 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
{feature.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-1 rounded-full",
|
||||
feature.status === "completed" &&
|
||||
"bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
feature.status === "in_progress" &&
|
||||
"bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
feature.status === "todo" &&
|
||||
"bg-gray-500/10 text-gray-700 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
{feature.status === "in_progress" ? "in progress" : feature.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Backlog */}
|
||||
<PageSection
|
||||
title="Backlog"
|
||||
description="Features for future iterations"
|
||||
headerAction={
|
||||
<Button size="sm" variant="ghost">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add to Backlog
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageCard>
|
||||
<div className="space-y-2">
|
||||
{SAMPLE_BACKLOG.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border hover:border-primary/50 transition-all"
|
||||
>
|
||||
<ListTodo className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-1 rounded-full",
|
||||
item.priority === "high" &&
|
||||
"bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
item.priority === "medium" &&
|
||||
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||
item.priority === "low" &&
|
||||
"bg-gray-500/10 text-gray-700 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost">
|
||||
Move to MVP
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Milestones */}
|
||||
<PageSection title="Milestones" description="Key dates and goals">
|
||||
<PageGrid cols={3}>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-3">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1">Alpha Release</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">Completed</p>
|
||||
<p className="text-xs text-muted-foreground">Jan 15, 2025</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard className="border-primary">
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-blue-500/10 flex items-center justify-center mx-auto mb-3">
|
||||
<Clock className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1">Beta Launch</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">In Progress</p>
|
||||
<p className="text-xs text-muted-foreground">Feb 1, 2025</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<Target className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1">Public Launch</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">Planned</p>
|
||||
<p className="text-xs text-muted-foreground">Mar 1, 2025</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,768 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState, useEffect, useCallback } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, CheckCircle2, Circle, Clock, RefreshCw, Eye, Cog, GitBranch, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { CollapsibleSidebar } from '@/components/ui/collapsible-sidebar';
|
||||
|
||||
interface WorkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
path: string;
|
||||
status: 'built' | 'missing' | 'in_progress';
|
||||
priority: string;
|
||||
assigned?: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
duration: number;
|
||||
sessionsCount: number;
|
||||
commitsCount: number;
|
||||
totalActivity: number;
|
||||
estimatedCost?: number;
|
||||
requirements: Array<{
|
||||
id: number;
|
||||
text: string;
|
||||
status: 'built' | 'missing' | 'in_progress';
|
||||
}>;
|
||||
evidence: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface TimelineData {
|
||||
workItems: WorkItem[];
|
||||
timeline: {
|
||||
start: string;
|
||||
end: string;
|
||||
totalDays: number;
|
||||
};
|
||||
summary: {
|
||||
totalWorkItems: number;
|
||||
withActivity: number;
|
||||
noActivity: number;
|
||||
built: number;
|
||||
missing: number;
|
||||
};
|
||||
projectCreator?: string;
|
||||
}
|
||||
|
||||
export default function TimelinePlanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { projectId } = use(params);
|
||||
const [data, setData] = useState<TimelineData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = useState<'touchpoints' | 'technical' | 'journey'>('touchpoints');
|
||||
const [collapsedJourneySections, setCollapsedJourneySections] = useState<Set<string>>(new Set());
|
||||
|
||||
// Map work items to types based on path and category
|
||||
const getWorkItemType = (item: WorkItem): string => {
|
||||
// API endpoints are System
|
||||
if (item.path.startsWith('/api/')) return 'System';
|
||||
|
||||
// Flows are Flow
|
||||
if (item.path.startsWith('flow/')) return 'Flow';
|
||||
|
||||
// Auth/OAuth is System
|
||||
if (item.path.includes('auth') || item.path.includes('oauth')) return 'System';
|
||||
|
||||
// Settings is System
|
||||
if (item.path.includes('settings')) return 'System';
|
||||
|
||||
// Marketing/Content pages
|
||||
if (item.category === 'Marketing' || item.category === 'Content') return 'Screen';
|
||||
|
||||
// Social
|
||||
if (item.category === 'Social') return 'Screen';
|
||||
|
||||
// Everything else is a Screen
|
||||
return 'Screen';
|
||||
};
|
||||
|
||||
// Determine if item is a user-facing touchpoint
|
||||
const isTouchpoint = (item: WorkItem): boolean => {
|
||||
const path = item.path.toLowerCase();
|
||||
const title = item.title.toLowerCase();
|
||||
|
||||
// Exclude APIs and backend systems
|
||||
if (path.startsWith('/api/')) return false;
|
||||
if (title.includes(' api') || title.includes('api ')) return false;
|
||||
|
||||
// Exclude pure auth infrastructure (OAuth endpoints)
|
||||
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return false;
|
||||
|
||||
// Include everything else - screens, pages, social posts, blogs, invites, etc.
|
||||
return true;
|
||||
};
|
||||
|
||||
// Determine if item is technical infrastructure
|
||||
const isTechnical = (item: WorkItem): boolean => {
|
||||
const path = item.path.toLowerCase();
|
||||
const title = item.title.toLowerCase();
|
||||
|
||||
// APIs and backend
|
||||
if (path.startsWith('/api/')) return true;
|
||||
if (title.includes(' api') || title.includes('api ')) return true;
|
||||
|
||||
// Auth infrastructure
|
||||
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return true;
|
||||
|
||||
// System settings
|
||||
if (item.category === 'Settings' && title.includes('api')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Map work items to customer lifecycle journey sections
|
||||
const getJourneySection = (item: WorkItem): string => {
|
||||
const title = item.title.toLowerCase();
|
||||
const path = item.path.toLowerCase();
|
||||
|
||||
// Discovery - "I just found you online via social post, blog article, advertisement"
|
||||
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
|
||||
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
|
||||
if (item.category === 'Content' && (title.includes('blog') || title.includes('article'))) return 'Discovery';
|
||||
|
||||
// Research - "Checking out your marketing website - features, price, home page"
|
||||
if (title.includes('marketing dashboard')) return 'Research';
|
||||
if (item.category === 'Marketing' && path !== '/') return 'Research';
|
||||
if (path.includes('/features') || path.includes('/pricing') || path.includes('/about')) return 'Research';
|
||||
if (item.category === 'Content' && path.includes('/docs') && !title.includes('getting started')) return 'Research';
|
||||
|
||||
// Onboarding - "Creating an account to try the product for the first time"
|
||||
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
|
||||
if (path.includes('signup') || path.includes('signin') || path.includes('login')) return 'Onboarding';
|
||||
if (title.includes('authentication') && !title.includes('api')) return 'Onboarding';
|
||||
|
||||
// First Use - "Zero state to experiencing the magic solution"
|
||||
if (title.includes('onboarding')) return 'First Use';
|
||||
if (title.includes('getting started')) return 'First Use';
|
||||
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
|
||||
if (title.includes('creation flow') || title.includes('project creation')) return 'First Use';
|
||||
if (path.includes('/projects') && path.match(/\/projects\/?$/)) return 'First Use'; // Projects list page
|
||||
|
||||
// Active - "I've seen the magic and come back to use it again and again"
|
||||
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
|
||||
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
|
||||
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
|
||||
if (title.includes('plan generation') || title.includes('marketing plan')) return 'Active';
|
||||
if (path.includes('projects/') && path.length > '/projects/'.length) return 'Active'; // Specific project pages
|
||||
|
||||
// Support - "I've got questions, need quick answers to get back to the magic"
|
||||
if (path.includes('settings')) return 'Support';
|
||||
if (path.includes('/help') || path.includes('/faq') || path.includes('/support')) return 'Support';
|
||||
if (item.category === 'Content' && path.includes('/docs') && title.includes('help')) return 'Support';
|
||||
|
||||
// Purchase - "Time to pay so I can keep using the magic"
|
||||
if (path.includes('billing') || path.includes('payment') || path.includes('subscription')) return 'Purchase';
|
||||
if (path.includes('upgrade') || path.includes('checkout') || path.includes('pricing/buy')) return 'Purchase';
|
||||
|
||||
// Default to Active for core product features
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
const toggleJourneySection = (sectionId: string) => {
|
||||
setCollapsedJourneySections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(sectionId)) {
|
||||
newSet.delete(sectionId);
|
||||
} else {
|
||||
newSet.add(sectionId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Get emoji icon for journey section
|
||||
const getJourneySectionIcon = (section: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
'Discovery': '🔍',
|
||||
'Research': '📚',
|
||||
'Onboarding': '🎯',
|
||||
'First Use': '🚀',
|
||||
'Active': '⚡',
|
||||
'Support': '💡',
|
||||
'Purchase': '💳'
|
||||
};
|
||||
return icons[section] || '📋';
|
||||
};
|
||||
|
||||
// Get phase status based on overall item status
|
||||
const getPhaseStatus = (itemStatus: string, phase: 'scope' | 'design' | 'code'): 'built' | 'in_progress' | 'missing' => {
|
||||
if (itemStatus === 'built') return 'built';
|
||||
if (itemStatus === 'missing') return 'missing';
|
||||
|
||||
// If in_progress, show progression through phases
|
||||
if (phase === 'scope') return 'built';
|
||||
if (phase === 'design') return 'in_progress';
|
||||
return 'missing';
|
||||
};
|
||||
|
||||
// Render status badge
|
||||
const renderStatusBadge = (status: 'built' | 'in_progress' | 'missing') => {
|
||||
if (status === 'built') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Done
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
Started
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs font-medium">
|
||||
<Circle className="h-3 w-3" />
|
||||
To-do
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const loadTimelineData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
|
||||
const result = await response.json();
|
||||
|
||||
// Check if the response is an error
|
||||
if (result.error) {
|
||||
console.error('API Error:', result.error, result.details);
|
||||
alert(`Failed to load timeline: ${result.details || result.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error('Error loading timeline:', error);
|
||||
alert('Failed to load timeline data. Check console for details.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimelineData();
|
||||
}, [loadTimelineData]);
|
||||
|
||||
const regeneratePlan = async () => {
|
||||
if (!confirm('Regenerate the plan? This will analyze your project and create a fresh MVP checklist.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setRegenerating(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/mvp-checklist`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to regenerate plan');
|
||||
}
|
||||
|
||||
// Reload the timeline data
|
||||
await loadTimelineData();
|
||||
} catch (error) {
|
||||
console.error('Error regenerating plan:', error);
|
||||
alert('Failed to regenerate plan. Check console for details.');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="p-8 text-center text-muted-foreground">No timeline data available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar */}
|
||||
<CollapsibleSidebar>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Quick Stats</h3>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Items</span>
|
||||
<span className="font-medium">{data.summary.totalWorkItems}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Built</span>
|
||||
<span className="font-medium text-green-600">{data.summary.built}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">In Progress</span>
|
||||
<span className="font-medium text-blue-600">{data.summary.withActivity - data.summary.built}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">To Build</span>
|
||||
<span className="font-medium text-gray-600">{data.summary.missing}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col p-4 space-y-3 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">MVP Checklist</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{data.summary.built} of {data.summary.totalWorkItems} pages built •
|
||||
{data.summary.withActivity} with development activity
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
{/* View Mode Switcher */}
|
||||
<div className="flex items-center border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'touchpoints' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('touchpoints')}
|
||||
className="gap-2 h-7"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Touchpoints
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'technical' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('technical')}
|
||||
className="gap-2 h-7"
|
||||
>
|
||||
<Cog className="h-4 w-4" />
|
||||
Technical
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'journey' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('journey')}
|
||||
className="gap-2 h-7"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Journey
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={regeneratePlan}
|
||||
disabled={regenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
{regenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Regenerate Plan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="text-xs px-3 py-1 bg-green-100 text-green-800 rounded">
|
||||
✅ {data.summary.built} Built
|
||||
</div>
|
||||
<div className="text-xs px-3 py-1 bg-gray-100 text-gray-800 rounded">
|
||||
⏳ {data.summary.missing} To Build
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Touchpoints View - What users see and engage with */}
|
||||
{viewMode === 'touchpoints' && (
|
||||
<Card className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">Everything users see and engage with - screens, features, social posts, blogs, invites, and all customer-facing elements.</p>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 border-b sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Touchpoint</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data?.workItems.filter(item => isTouchpoint(item)).map((item, index) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
newSet.add(item.id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{getWorkItemType(item)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.status === 'built' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
) : item.status === 'in_progress' ? (
|
||||
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
{expandedItems.has(item.id) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
|
||||
{req.status === 'built' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span>{req.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.assigned || data?.projectCreator || 'You'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.sessionsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.commitsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
|
||||
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Journey View - Customer lifecycle stages */}
|
||||
{viewMode === 'journey' && (
|
||||
<Card className="flex-1 overflow-auto p-0">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">Customer lifecycle journey from discovery to purchase - organizing all touchpoints and technical components by user stage.</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{/* Journey Sections - Customer Lifecycle */}
|
||||
{['Discovery', 'Research', 'Onboarding', 'First Use', 'Active', 'Support', 'Purchase'].map(sectionName => {
|
||||
const sectionItems = data.workItems.filter(item => getJourneySection(item) === sectionName);
|
||||
if (sectionItems.length === 0) return null;
|
||||
|
||||
const sectionStats = {
|
||||
done: sectionItems.filter(i => i.status === 'built').length,
|
||||
started: sectionItems.filter(i => i.status === 'in_progress').length,
|
||||
todo: sectionItems.filter(i => i.status === 'missing').length,
|
||||
total: sectionItems.length
|
||||
};
|
||||
|
||||
const isCollapsed = collapsedJourneySections.has(sectionName);
|
||||
|
||||
return (
|
||||
<div key={sectionName}>
|
||||
{/* Section Header */}
|
||||
<div
|
||||
className="bg-muted/30 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors flex items-center justify-between sticky top-0 z-10"
|
||||
onClick={() => toggleJourneySection(sectionName)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-lg">{getJourneySectionIcon(sectionName)}</span>
|
||||
<h3 className="font-semibold text-base">{sectionName}</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sectionStats.done}/{sectionStats.total} complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
{sectionStats.done > 0 && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 rounded">
|
||||
{sectionStats.done} done
|
||||
</span>
|
||||
)}
|
||||
{sectionStats.started > 0 && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded">
|
||||
{sectionStats.started} started
|
||||
</span>
|
||||
)}
|
||||
{sectionStats.todo > 0 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-800 rounded">
|
||||
{sectionStats.todo} to-do
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="divide-y">
|
||||
{sectionItems.map(item => (
|
||||
<div key={item.id} className="px-4 py-3 hover:bg-accent/30 transition-colors">
|
||||
<div
|
||||
className="flex items-start justify-between cursor-pointer"
|
||||
onClick={() => {
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
newSet.add(item.id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{/* Status Icon */}
|
||||
{item.status === 'built' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
) : item.status === 'in_progress' ? (
|
||||
<Clock className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Title and Type */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
|
||||
{getWorkItemType(item)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Phase Status */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Spec:</span>{' '}
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Design:</span>{' '}
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Code:</span>{' '}
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Requirements */}
|
||||
{expandedItems.has(item.id) && (
|
||||
<div className="mt-3 space-y-1 pl-4 border-l-2 border-gray-200">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">Requirements:</p>
|
||||
{item.requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{req.status === 'built' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span>{req.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Stats */}
|
||||
<div className="flex items-start gap-4 text-xs text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Sessions</div>
|
||||
<div className={item.sessionsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.sessionsCount}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Commits</div>
|
||||
<div className={item.commitsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.commitsCount}</div>
|
||||
</div>
|
||||
<div className="text-center min-w-[60px]">
|
||||
<div className="font-medium">Cost</div>
|
||||
<div>{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Technical View - Infrastructure that powers everything */}
|
||||
{viewMode === 'technical' && (
|
||||
<Card className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">Technical infrastructure that powers the product - APIs, backend services, authentication, and system integrations.</p>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 border-b sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Technical Component</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data?.workItems.filter(item => isTechnical(item)).map((item, index) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
newSet.add(item.id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{getWorkItemType(item)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.status === 'built' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
) : item.status === 'in_progress' ? (
|
||||
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
{expandedItems.has(item.id) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
|
||||
{req.status === 'built' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span>{req.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.assigned || data?.projectCreator || 'You'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.sessionsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.commitsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
|
||||
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{/* End Main Content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
459
app/[workspace]/project/[projectId]/prd/page.tsx
Normal file
459
app/[workspace]/project/[projectId]/prd/page.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
// Maps each PRD section to the discovery phase that populates it
|
||||
const PRD_SECTIONS = [
|
||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||
];
|
||||
|
||||
interface SavedPhase {
|
||||
phase: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
data: Record<string, unknown>;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
||||
border: "1px solid #e8e4dc", overflow: "hidden",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{
|
||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||
{phase.summary}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
||||
{expanded ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && entries.length > 0 && (
|
||||
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} style={{ marginTop: 10 }}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
||||
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
||||
}}>
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
||||
{formatValue(v)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
|
||||
interface ArchInfra { name: string; reason: string }
|
||||
interface ArchPackage { name: string; description: string }
|
||||
interface ArchIntegration { name: string; required?: boolean; notes?: string }
|
||||
interface Architecture {
|
||||
productName?: string;
|
||||
productType?: string;
|
||||
summary?: string;
|
||||
apps?: ArchApp[];
|
||||
packages?: ArchPackage[];
|
||||
infrastructure?: ArchInfra[];
|
||||
integrations?: ArchIntegration[];
|
||||
designSurfaces?: string[];
|
||||
riskNotes?: string[];
|
||||
}
|
||||
|
||||
function ArchitectureView({ arch }: { arch: Architecture }) {
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const Card = ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
|
||||
);
|
||||
const Tag = ({ label }: { label: string }) => (
|
||||
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
{arch.summary && (
|
||||
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
|
||||
{arch.summary}
|
||||
</div>
|
||||
)}
|
||||
{(arch.apps ?? []).length > 0 && (
|
||||
<Section title="Applications">
|
||||
{arch.apps!.map(a => (
|
||||
<Card key={a.name}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
|
||||
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
|
||||
{a.tech?.map(t => <Tag key={t} label={t} />)}
|
||||
{a.screens && a.screens.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.packages ?? []).length > 0 && (
|
||||
<Section title="Shared Packages">
|
||||
{arch.packages!.map(p => (
|
||||
<Card key={p.name}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.infrastructure ?? []).length > 0 && (
|
||||
<Section title="Infrastructure">
|
||||
{arch.infrastructure!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.integrations ?? []).length > 0 && (
|
||||
<Section title="Integrations">
|
||||
{arch.integrations!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
|
||||
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
|
||||
</div>
|
||||
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.riskNotes ?? []).length > 0 && (
|
||||
<Section title="Architectural Risks">
|
||||
{arch.riskNotes!.map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
|
||||
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}>⚠</span>
|
||||
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PRDPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const [prd, setPrd] = useState<string | null>(null);
|
||||
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
||||
const [archGenerating, setArchGenerating] = useState(false);
|
||||
const [archError, setArchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||
]).then(([projectData, phaseData]) => {
|
||||
setPrd(projectData?.project?.prd ?? null);
|
||||
setArchitecture(projectData?.project?.architecture ?? null);
|
||||
setSavedPhases(phaseData?.phases ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleGenerateArchitecture = async () => {
|
||||
setArchGenerating(true);
|
||||
setArchError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||
setArchitecture(data.architecture);
|
||||
setActiveTab("architecture");
|
||||
} catch (e) {
|
||||
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
||||
} finally {
|
||||
setArchGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||
|
||||
const sections = PRD_SECTIONS.map(s => ({
|
||||
...s,
|
||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
||||
}));
|
||||
|
||||
const doneCount = sections.filter(s => s.isDone).length;
|
||||
const totalPct = Math.round((doneCount / sections.length) * 100);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "prd" as const, label: "PRD", available: true },
|
||||
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
|
||||
{/* Tab bar — only when at least one doc exists */}
|
||||
{(prd || architecture) && (
|
||||
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
|
||||
{tabs.map(t => {
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => t.available && setActiveTab(t.id)}
|
||||
disabled={!t.available}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
|
||||
background: isActive ? "#1a1a1a" : "transparent",
|
||||
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
|
||||
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}>—</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next step banner — PRD done but no architecture yet */}
|
||||
{prd && !architecture && activeTab === "prd" && (
|
||||
<div style={{
|
||||
marginBottom: 24, padding: "18px 22px",
|
||||
background: "#1a1a1a", borderRadius: 10,
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||
Next: Generate technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||
</div>
|
||||
{archError && (
|
||||
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateArchitecture}
|
||||
disabled={archGenerating}
|
||||
style={{
|
||||
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||
background: archGenerating ? "#4a4640" : "#fff",
|
||||
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||
fontSize: "0.82rem", fontWeight: 700,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: archGenerating ? "default" : "pointer",
|
||||
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
{archGenerating && (
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||
}} />
|
||||
)}
|
||||
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture tab */}
|
||||
{activeTab === "architecture" && architecture && (
|
||||
<ArchitectureView arch={architecture} />
|
||||
)}
|
||||
|
||||
{/* PRD tab — finalized */}
|
||||
{activeTab === "prd" && prd && (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||
Product Requirements
|
||||
</h3>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||
PRD complete
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||
padding: "28px 32px", lineHeight: 1.8,
|
||||
fontSize: "0.88rem", color: "#2a2824",
|
||||
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
{prd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||
{activeTab === "prd" && !prd && (
|
||||
/* ── Section progress view ── */
|
||||
<div style={{ maxWidth: 680 }}>
|
||||
{/* Progress bar */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 16,
|
||||
padding: "16px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
||||
}}>
|
||||
{totalPct}%
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 2,
|
||||
width: `${totalPct}%`, background: "#1a1a1a",
|
||||
transition: "width 0.6s ease",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||
{doneCount}/{sections.length} sections
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
padding: "14px 18px", marginBottom: 6,
|
||||
background: "#fff", borderRadius: 10,
|
||||
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
||||
animationDelay: `${i * 0.04}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Status icon */}
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.65rem", fontWeight: 700,
|
||||
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
||||
}}>
|
||||
{s.isDone ? "✓" : "○"}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
flex: 1, fontSize: "0.84rem",
|
||||
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
||||
fontWeight: s.isDone ? 500 : 400,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
|
||||
{s.isDone && s.savedPhase && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#2e7d32", background: "#2e7d3210",
|
||||
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
||||
}}>
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
{!s.isDone && !s.phaseId && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#b5b0a6", padding: "2px 7px",
|
||||
}}>
|
||||
generated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable phase data */}
|
||||
{s.isDone && s.savedPhase && (
|
||||
<PhaseDataCard phase={s.savedPhase} />
|
||||
)}
|
||||
|
||||
{/* Pending hint */}
|
||||
{!s.isDone && (
|
||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||
{s.phaseId
|
||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||
: "Will be generated when PRD is finalized"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{doneCount === 0 && (
|
||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import {
|
||||
Code2,
|
||||
Globe,
|
||||
Server,
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageTemplate,
|
||||
PageSection,
|
||||
PageCard,
|
||||
PageGrid,
|
||||
} from "@/components/layout/page-template";
|
||||
|
||||
const PRODUCT_NAV_ITEMS = [
|
||||
{ title: "Code", icon: Code2, href: "/code" },
|
||||
{ title: "Website", icon: Globe, href: "/product#website" },
|
||||
{ title: "Chat Agent", icon: MessageSquare, href: "/product#agent" },
|
||||
{ title: "Deployment", icon: Server, href: "/product#deployment" },
|
||||
];
|
||||
|
||||
export default function ProductPage() {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const sidebarItems = PRODUCT_NAV_ITEMS.map((item) => {
|
||||
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
|
||||
return {
|
||||
...item,
|
||||
href: fullHref,
|
||||
isActive: pathname === fullHref || pathname.startsWith(fullHref),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
}}
|
||||
>
|
||||
{/* Quick Navigation Cards */}
|
||||
<PageSection>
|
||||
<PageGrid cols={2}>
|
||||
{PRODUCT_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const fullHref = `/${workspace}/project/${projectId}${item.href}`;
|
||||
|
||||
return (
|
||||
<a key={item.href} href={fullHref}>
|
||||
<PageCard hover>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold mb-1">{item.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.title === "Code" &&
|
||||
"Browse codebase, manage repositories"}
|
||||
{item.title === "Website" &&
|
||||
"Marketing site, landing pages"}
|
||||
{item.title === "Chat Agent" &&
|
||||
"Conversational AI interface"}
|
||||
{item.title === "Deployment" &&
|
||||
"Hosting, CI/CD, environments"}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</div>
|
||||
</PageCard>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
|
||||
{/* Code Section */}
|
||||
<PageSection
|
||||
title="Code"
|
||||
description="Your application codebase"
|
||||
>
|
||||
<PageCard>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Code2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Browse Repository</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View files, commits, and code structure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href={`/${workspace}/project/${projectId}/code`}>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Website Section */}
|
||||
<PageSection
|
||||
title="Website"
|
||||
description="Marketing site and landing pages"
|
||||
>
|
||||
<PageCard>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Globe className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">
|
||||
Manage your marketing website and landing pages
|
||||
</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Chat Agent Section */}
|
||||
<PageSection
|
||||
title="Chat Agent"
|
||||
description="Conversational AI interface"
|
||||
>
|
||||
<PageCard>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">
|
||||
Configure and manage your AI chat agent
|
||||
</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageSection>
|
||||
|
||||
{/* Deployment Section */}
|
||||
<PageSection
|
||||
title="Deployment"
|
||||
description="Hosting and CI/CD"
|
||||
>
|
||||
<PageGrid cols={3}>
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<Server className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1">Production</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">Live</p>
|
||||
<p className="text-xs text-muted-foreground">vercel.app</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<Server className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1">Staging</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">Preview</p>
|
||||
<p className="text-xs text-muted-foreground">staging.vercel.app</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
|
||||
<PageCard>
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<Server className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1">Development</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">Local</p>
|
||||
<p className="text-xs text-muted-foreground">localhost:3000</p>
|
||||
</div>
|
||||
</PageCard>
|
||||
</PageGrid>
|
||||
</PageSection>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ListChecks, Clock, DollarSign, GitBranch, ExternalLink, User } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
|
||||
// Mock data
|
||||
const PROGRESS_ITEMS = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Implemented Product Vision page with file upload",
|
||||
description: "Created dynamic layout system with file upload capabilities for ChatGPT exports",
|
||||
contributor: "Mark Henderson",
|
||||
date: "2025-11-11",
|
||||
time: "2h 15m",
|
||||
tokens: 45000,
|
||||
cost: 0.68,
|
||||
github_link: "https://github.com/user/repo/commit/abc123",
|
||||
type: "feature"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Updated left rail navigation structure",
|
||||
description: "Refactored navigation to remove rounded edges and improve active state",
|
||||
contributor: "Mark Henderson",
|
||||
date: "2025-11-11",
|
||||
time: "45m",
|
||||
tokens: 12000,
|
||||
cost: 0.18,
|
||||
github_link: "https://github.com/user/repo/commit/def456",
|
||||
type: "improvement"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Added section summaries to Overview page",
|
||||
description: "Created cards for Product Vision, Progress, UI UX, Code, Deployment, and Automation",
|
||||
contributor: "Mark Henderson",
|
||||
date: "2025-11-11",
|
||||
time: "1h 30m",
|
||||
tokens: 32000,
|
||||
cost: 0.48,
|
||||
github_link: "https://github.com/user/repo/commit/ghi789",
|
||||
type: "feature"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Fixed database connection issues",
|
||||
description: "Resolved connection pooling and error handling in API routes",
|
||||
contributor: "Mark Henderson",
|
||||
date: "2025-11-10",
|
||||
time: "30m",
|
||||
tokens: 8000,
|
||||
cost: 0.12,
|
||||
github_link: "https://github.com/user/repo/commit/jkl012",
|
||||
type: "fix"
|
||||
},
|
||||
];
|
||||
|
||||
export default async function ProgressPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const { projectId } = await params;
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
projectId={projectId}
|
||||
projectName="AI Proxy"
|
||||
projectEmoji="🤖"
|
||||
pageName="Progress"
|
||||
/>
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
{/* Hero Section */}
|
||||
<div className="border-b bg-gradient-to-r from-green-500/5 to-green-500/10 p-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/10">
|
||||
<ListChecks className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Progress</h1>
|
||||
<p className="text-muted-foreground">Development activity and velocity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Total Items</div>
|
||||
<div className="text-2xl font-bold">{PROGRESS_ITEMS.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Total Time</div>
|
||||
<div className="text-2xl font-bold">5h 0m</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Total Cost</div>
|
||||
<div className="text-2xl font-bold">$1.46</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Total Tokens</div>
|
||||
<div className="text-2xl font-bold">97K</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Progress List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Development Activity</CardTitle>
|
||||
<CardDescription>Sorted by latest</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Latest
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
Cost
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
Time
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{PROGRESS_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
{/* Header Row */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold">{item.title}</h3>
|
||||
<Badge variant={
|
||||
item.type === 'feature' ? 'default' :
|
||||
item.type === 'fix' ? 'destructive' :
|
||||
'secondary'
|
||||
} className="text-xs">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
{/* Contributor */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{item.contributor}</span>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{item.time}</span>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">{item.tokens.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">${item.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* GitHub Link */}
|
||||
<div className="ml-auto">
|
||||
<Button variant="ghost" size="sm" className="h-7" asChild>
|
||||
<a href={item.github_link} target="_blank" rel="noopener noreferrer">
|
||||
<GitBranch className="mr-1.5 h-3.5 w-3.5" />
|
||||
Commit
|
||||
<ExternalLink className="ml-1.5 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(item.date).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
LayoutGrid,
|
||||
Settings,
|
||||
Users,
|
||||
BarChart,
|
||||
Box,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageTemplate,
|
||||
} from "@/components/layout/page-template";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const SANDBOX_NAV_ITEMS = [
|
||||
{ title: "Nav Item 1", icon: LayoutGrid, href: "#item1" },
|
||||
{ title: "Nav Item 2", icon: Box, href: "#item2" },
|
||||
{ title: "Nav Item 3", icon: Users, href: "#item3" },
|
||||
{ title: "Nav Item 4", icon: Settings, href: "#item4" },
|
||||
];
|
||||
|
||||
export default function SandboxPage() {
|
||||
// Mock navigation items for the sidebar
|
||||
const sidebarItems = SANDBOX_NAV_ITEMS.map((item) => ({
|
||||
...item,
|
||||
href: item.href,
|
||||
isActive: item.title === "Nav Item 1", // Mock active state
|
||||
badge: item.title === "Nav Item 2" ? "New" : undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
sidebar={{
|
||||
items: sidebarItems,
|
||||
customContent: (
|
||||
<div className="space-y-4">
|
||||
<div className="px-2 py-1 bg-dashed border border-dashed border-muted-foreground/30 rounded text-xs text-center text-muted-foreground uppercase tracking-wider">
|
||||
Custom Component Area
|
||||
</div>
|
||||
|
||||
{/* Mock Custom Component Example */}
|
||||
<div className="space-y-2 opacity-70">
|
||||
<h3 className="text-sm font-medium">Example Widget</h3>
|
||||
<div className="p-3 rounded bg-muted/50 text-xs text-muted-foreground">
|
||||
This area fills the remaining sidebar height and can hold any custom React component (checklists, filters, etc).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{/* Empty Main Content Area */}
|
||||
<div className="border-2 border-dashed border-muted-foreground/20 rounded-lg h-[400px] flex items-center justify-center text-muted-foreground">
|
||||
<p>Main Content Area (Empty)</p>
|
||||
</div>
|
||||
</PageTemplate>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Activity, Clock, DollarSign, MessageSquare } from "lucide-react";
|
||||
import type { Session, DashboardStats } from "@/lib/types";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
|
||||
async function getSessions(projectId: string): Promise<Session[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/sessions?projectId=${projectId}&limit=20`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch sessions');
|
||||
return res.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getStats(projectId: string): Promise<DashboardStats> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/stats?projectId=${projectId}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch stats');
|
||||
return res.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
totalSessions: 0,
|
||||
totalCost: 0,
|
||||
totalTokens: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
totalDuration: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SessionsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
const [sessions, stats] = await Promise.all([
|
||||
getSessions(projectId),
|
||||
getStats(projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
projectId={projectId}
|
||||
projectName="Project"
|
||||
projectEmoji="📦"
|
||||
pageName="Sessions"
|
||||
/>
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
{/* Stats Section */}
|
||||
<div className="border-b bg-card/50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sessions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Track all your AI coding sessions
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{stats.totalSessions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Duration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{Math.round(stats.totalDuration / 60)}h
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Cost
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
${stats.totalCost.toFixed(2)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Your AI coding activity with conversation history
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="mb-4 rounded-full bg-muted p-3">
|
||||
<Activity className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No sessions yet</h3>
|
||||
<p className="text-sm text-center text-muted-foreground max-w-sm">
|
||||
Start coding with AI and your sessions will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-full bg-primary/10 p-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
{session.summary || `Session ${session.session_id.substring(0, 8)}...`}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{session.duration_minutes} min
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{session.message_count} messages
|
||||
</span>
|
||||
{session.estimated_cost_usd && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
${session.estimated_cost_usd.toFixed(3)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{session.primary_ai_model && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{session.primary_ai_model}
|
||||
</Badge>
|
||||
)}
|
||||
{session.ide_name && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{session.ide_name}
|
||||
</Badge>
|
||||
)}
|
||||
{session.github_branch && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{session.github_branch}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Activity, Clock, DollarSign, MessageSquare } from "lucide-react";
|
||||
import type { Session, DashboardStats } from "@/lib/types";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
|
||||
async function getSessions(projectId: string): Promise<Session[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://localhost:3000/api/sessions?projectId=${projectId}&limit=20`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch sessions');
|
||||
return res.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getStats(projectId: string): Promise<DashboardStats> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://localhost:3000/api/stats?projectId=${projectId}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch stats');
|
||||
return res.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
totalSessions: 0,
|
||||
totalCost: 0,
|
||||
totalTokens: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
totalDuration: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SessionsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { projectId: string };
|
||||
}) {
|
||||
const [sessions, stats] = await Promise.all([
|
||||
getSessions(params.projectId),
|
||||
getStats(params.projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
projectId={params.projectId}
|
||||
projectName="AI Proxy"
|
||||
projectEmoji="🤖"
|
||||
pageName="Sessions"
|
||||
/>
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
{/* Stats Section */}
|
||||
<div className="border-b bg-card/50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sessions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Track all your AI coding sessions
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{stats.totalSessions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Duration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{Math.round(stats.totalDuration / 60)}h
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Cost
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
${stats.totalCost.toFixed(2)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Your AI coding activity with conversation history
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="mb-4 rounded-full bg-muted p-3">
|
||||
<Activity className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No sessions yet</h3>
|
||||
<p className="text-sm text-center text-muted-foreground max-w-sm">
|
||||
Start coding with AI and your sessions will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-full bg-primary/10 p-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
{session.summary || `Session ${session.session_id.substring(0, 8)}...`}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{session.duration_minutes} min
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{session.message_count} messages
|
||||
</span>
|
||||
{session.estimated_cost_usd && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
${session.estimated_cost_usd.toFixed(3)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{session.primary_ai_model && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{session.primary_ai_model}
|
||||
</Badge>
|
||||
)}
|
||||
{session.ide_name && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{session.ide_name}
|
||||
</Badge>
|
||||
)}
|
||||
{session.github_branch && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{session.github_branch}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,357 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2, Save, FolderOpen, AlertCircle } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { db, auth } from "@/lib/firebase/config";
|
||||
import { doc, getDoc, updateDoc, serverTimestamp } from "firebase/firestore";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
productName: string;
|
||||
productVision?: string;
|
||||
workspacePath?: string;
|
||||
workspaceName?: string;
|
||||
githubRepo?: string;
|
||||
chatgptUrl?: string;
|
||||
projectType: string;
|
||||
status: string;
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "22px", marginBottom: 12, boxShadow: "0 1px 2px #1a1a1a05", ...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectSettingsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [orphanedSessionsCount, setOrphanedSessionsCount] = useState(0);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [productName, setProductName] = useState("");
|
||||
const [productVision, setProductVision] = useState("");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
|
||||
const userInitial = session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
|
||||
const userName = session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "You";
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
router.push('/auth');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectDoc = await getDoc(doc(db, 'projects', projectId));
|
||||
if (!projectDoc.exists()) {
|
||||
toast.error('Project not found');
|
||||
router.push(`/${workspace}/projects`);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectData = projectDoc.data() as Project;
|
||||
setProject({ ...projectData, id: projectDoc.id });
|
||||
|
||||
// Set form values
|
||||
setProductName(projectData.productName);
|
||||
setProductVision(projectData.productVision || "");
|
||||
setWorkspacePath(projectData.workspacePath || "");
|
||||
|
||||
// Check for orphaned sessions from old workspace path
|
||||
if (projectData.workspacePath) {
|
||||
// This would require checking sessions - we'll implement this in the API
|
||||
// For now, just show the UI
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching project:', err);
|
||||
toast.error('Failed to load project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = auth.onAuthStateChanged((user) => {
|
||||
if (user) {
|
||||
fetchProject();
|
||||
} else {
|
||||
router.push('/auth');
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [projectId, workspace, router]);
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const p = d.project;
|
||||
setProject(p);
|
||||
setProductName(p?.productName ?? "");
|
||||
setProductVision(p?.productVision ?? "");
|
||||
})
|
||||
.catch(() => toast.error("Failed to load project"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error('Please sign in');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the directory name from the path
|
||||
const workspaceName = workspacePath ? workspacePath.split('/').pop() || '' : '';
|
||||
|
||||
await updateDoc(doc(db, 'projects', projectId), {
|
||||
productName,
|
||||
productVision,
|
||||
workspacePath,
|
||||
workspaceName,
|
||||
updatedAt: serverTimestamp(),
|
||||
const res = await fetch(`/api/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ productName, productVision }),
|
||||
});
|
||||
|
||||
toast.success('Project settings saved!');
|
||||
|
||||
// Refresh project data
|
||||
const projectDoc = await getDoc(doc(db, 'projects', projectId));
|
||||
if (projectDoc.exists()) {
|
||||
setProject({ ...projectDoc.data() as Project, id: projectDoc.id });
|
||||
if (res.ok) {
|
||||
toast.success("Saved");
|
||||
setProject((p) => p ? { ...p, productName, productVision } : p);
|
||||
} else {
|
||||
toast.error("Failed to save");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving project:', error);
|
||||
toast.error('Failed to save settings');
|
||||
} catch {
|
||||
toast.error("An error occurred");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDelete) { setConfirmDelete(true); return; }
|
||||
setDeleting(true);
|
||||
try {
|
||||
// Check if File System Access API is supported
|
||||
if ('showDirectoryPicker' in window) {
|
||||
const dirHandle = await (window as any).showDirectoryPicker({
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
if (dirHandle?.name) {
|
||||
// Provide a path hint (browsers don't expose full paths for security)
|
||||
const pathHint = `~/projects/${dirHandle.name}`;
|
||||
setWorkspacePath(pathHint);
|
||||
|
||||
toast.info('Update the path to match your actual folder location', {
|
||||
description: 'You can get the full path from Finder/Explorer or your terminal'
|
||||
});
|
||||
}
|
||||
const res = await fetch("/api/projects/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Project deleted");
|
||||
router.push(`/${workspace}/projects`);
|
||||
} else {
|
||||
toast.error('Directory picker not supported in this browser', {
|
||||
description: 'Please enter the path manually or use Chrome/Edge'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
// User cancelled or denied permission
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error selecting directory:', error);
|
||||
toast.error('Failed to select directory');
|
||||
toast.error("Failed to delete project");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Project not found</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<h1 className="text-2xl font-bold">Project Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your project configuration and workspace settings
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<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 }}>
|
||||
Configure {project?.productName ?? "this project"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
|
||||
{/* General Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Information</CardTitle>
|
||||
<CardDescription>
|
||||
Basic details about your project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productName">Product Name</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
value={productName}
|
||||
onChange={(e) => setProductName(e.target.value)}
|
||||
placeholder="My Awesome Product"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productVision">Product Vision</Label>
|
||||
<Textarea
|
||||
id="productVision"
|
||||
value={productVision}
|
||||
onChange={(e) => setProductVision(e.target.value)}
|
||||
placeholder="Describe what you're building and who it's for..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workspace Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace Path</CardTitle>
|
||||
<CardDescription>
|
||||
The local directory where you're coding this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Why update this?</AlertTitle>
|
||||
<AlertDescription>
|
||||
If you renamed your project folder or moved it to a different location,
|
||||
update the path here so Vibn can correctly track your coding sessions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspacePath">Local Workspace Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="workspacePath"
|
||||
value={workspacePath}
|
||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||
placeholder="/Users/you/projects/my-project"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSelectDirectory}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 <strong>Tip:</strong> Right-click your project folder → Get Info (Mac) or Properties (Windows) to copy the full path
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{project.workspacePath && workspacePath !== project.workspacePath && (
|
||||
<Alert className="border-orange-500/50 bg-orange-500/10">
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
<AlertTitle>Path Changed</AlertTitle>
|
||||
<AlertDescription>
|
||||
You're changing the workspace path from <code className="text-xs bg-muted px-1 py-0.5 rounded">{project.workspacePath}</code> to <code className="text-xs bg-muted px-1 py-0.5 rounded">{workspacePath}</code>.
|
||||
<br /><br />
|
||||
After saving, Vibn will track sessions from the new path. Any existing sessions from the old path will remain associated with this project.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connected Services */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connected Services</CardTitle>
|
||||
<CardDescription>
|
||||
External integrations for this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">GitHub Repository</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.githubRepo || 'Not connected'}
|
||||
</p>
|
||||
</div>
|
||||
{project.githubRepo && (
|
||||
<a
|
||||
href={`https://github.com/${project.githubRepo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
View on GitHub →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">ChatGPT Project</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.chatgptUrl ? 'Connected' : 'Not connected'}
|
||||
</p>
|
||||
</div>
|
||||
{project.chatgptUrl && (
|
||||
<a
|
||||
href={project.chatgptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Open ChatGPT →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/${workspace}/project/${projectId}/overview`)}
|
||||
{/* General */}
|
||||
<InfoCard>
|
||||
<SectionLabel>General</SectionLabel>
|
||||
<FieldLabel>Project name</FieldLabel>
|
||||
<input
|
||||
value={productName}
|
||||
onChange={(e) => setProductName(e.target.value)}
|
||||
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", marginBottom: 16, boxSizing: "border-box" }}
|
||||
/>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<textarea
|
||||
value={productVision}
|
||||
onChange={(e) => setProductVision(e.target.value)}
|
||||
rows={3}
|
||||
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", resize: "vertical", boxSizing: "border-box" }}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ padding: "8px 20px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: saving ? "not-allowed" : "pointer", opacity: saving ? 0.7 : 1, display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{saving && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Repo */}
|
||||
{project?.giteaRepoUrl && (
|
||||
<InfoCard>
|
||||
<SectionLabel>Repository</SectionLabel>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a", 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" }}>
|
||||
View ↗
|
||||
</a>
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{/* Collaborators */}
|
||||
<InfoCard>
|
||||
<SectionLabel>Collaborators</SectionLabel>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 600, color: "#8a8478" }}>
|
||||
{userInitial}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a" }}>{userName}</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#6b6560", background: "#f0ece4" }}>Owner</span>
|
||||
</div>
|
||||
<button
|
||||
style={{ width: "100%", marginTop: 12, padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
||||
onClick={() => toast.info("Team invites coming soon")}
|
||||
>
|
||||
+ Invite to project
|
||||
</button>
|
||||
</InfoCard>
|
||||
|
||||
{/* Export */}
|
||||
<InfoCard>
|
||||
<SectionLabel>Export</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#6b6560", marginBottom: 14, lineHeight: 1.6 }}>
|
||||
Download your PRD or project data for external use.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
||||
onClick={() => toast.info("PDF export coming soon")}
|
||||
>
|
||||
Export PRD as PDF
|
||||
</button>
|
||||
<button
|
||||
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
||||
onClick={async () => {
|
||||
const res = await fetch(`/api/projects/${projectId}`);
|
||||
const data = await res.json();
|
||||
const blob = new Blob([JSON.stringify(data.project, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = `${productName.replace(/\s+/g, "-")}.json`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Export as JSON
|
||||
</button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div style={{ background: "#fff", border: "1px solid #f5d5d5", borderRadius: 10, padding: "20px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#d32f2f" }}>Delete project</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||
{confirmDelete ? "Click again to confirm — this cannot be undone" : "This action cannot be undone"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
style={{ padding: "6px 14px", borderRadius: 7, border: "1px solid #f5d5d5", background: confirmDelete ? "#d32f2f" : "#fff", color: confirmDelete ? "#fff" : "#d32f2f", fontSize: "0.72rem", fontWeight: 600, cursor: "pointer", transition: "all 0.15s", display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
{deleting && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
||||
{confirmDelete ? "Confirm Delete" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Cog,
|
||||
Database,
|
||||
Github,
|
||||
Globe,
|
||||
Server,
|
||||
Code2,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
Key,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
||||
|
||||
interface WorkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
status: "built" | "in_progress" | "missing";
|
||||
category: string;
|
||||
sessionsCount: number;
|
||||
commitsCount: number;
|
||||
estimatedCost?: number;
|
||||
}
|
||||
|
||||
interface TechResource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "firebase" | "github" | "domain" | "api";
|
||||
status: "active" | "inactive";
|
||||
url?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export default function TechPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = use(params);
|
||||
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
||||
const [resources, setResources] = useState<TechResource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadTechData();
|
||||
}, [projectId]);
|
||||
|
||||
const loadTechData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Filter for technical items only
|
||||
const techItems = data.workItems.filter((item: WorkItem) =>
|
||||
isTechnical(item)
|
||||
);
|
||||
setWorkItems(techItems);
|
||||
}
|
||||
|
||||
// Mock resources data
|
||||
setResources([
|
||||
{
|
||||
id: "1",
|
||||
name: "Firebase Project",
|
||||
type: "firebase",
|
||||
status: "active",
|
||||
url: "https://console.firebase.google.com",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "GitHub Repository",
|
||||
type: "github",
|
||||
status: "active",
|
||||
url: "https://github.com",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error loading tech data:", error);
|
||||
toast.error("Failed to load tech data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isTechnical = (item: WorkItem): boolean => {
|
||||
const path = item.path.toLowerCase();
|
||||
const title = item.title.toLowerCase();
|
||||
|
||||
// APIs and backend
|
||||
if (path.startsWith('/api/')) return true;
|
||||
if (title.includes(' api') || title.includes('api ')) return true;
|
||||
|
||||
// Auth infrastructure
|
||||
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return true;
|
||||
|
||||
// System settings
|
||||
if (item.category === 'Settings' && title.includes('api')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
if (status === "built" || status === "active") return <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
||||
if (status === "in_progress") return <Clock className="h-4 w-4 text-blue-600" />;
|
||||
return <Circle className="h-4 w-4 text-gray-400" />;
|
||||
};
|
||||
|
||||
const getResourceIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "firebase":
|
||||
return <Zap className="h-5 w-5 text-orange-600" />;
|
||||
case "github":
|
||||
return <Github className="h-5 w-5 text-gray-900" />;
|
||||
case "domain":
|
||||
return <Globe className="h-5 w-5 text-blue-600" />;
|
||||
case "api":
|
||||
return <Code2 className="h-5 w-5 text-purple-600" />;
|
||||
default:
|
||||
return <Server className="h-5 w-5 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar */}
|
||||
<CollapsibleSidebar>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Infrastructure</h3>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Resources</span>
|
||||
<span className="font-medium">{resources.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Active</span>
|
||||
<span className="font-medium text-green-600">{resources.filter(r => r.status === 'active').length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Work Items</span>
|
||||
<span className="font-medium">{workItems.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Cog className="h-6 w-6" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Tech Infrastructure</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
APIs, services, and technical resources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4 space-y-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Infrastructure Resources */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Infrastructure Resources</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{resources.map((resource) => (
|
||||
<Card key={resource.id} className="hover:bg-accent/30 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getResourceIcon(resource.type)}
|
||||
<CardTitle className="text-base">{resource.name}</CardTitle>
|
||||
</div>
|
||||
{getStatusIcon(resource.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{resource.type}
|
||||
</Badge>
|
||||
{resource.lastUpdated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Updated {new Date(resource.lastUpdated).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
{resource.url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={() => window.open(resource.url, "_blank")}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open Console
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Technical Work Items */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Technical Work Items</h2>
|
||||
<Badge variant="secondary">{workItems.length} items</Badge>
|
||||
</div>
|
||||
|
||||
{workItems.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Code2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No technical items yet</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Technical items include APIs, services, and infrastructure
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{workItems.map((item) => (
|
||||
<Card key={item.id} className="p-4 hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{getStatusIcon(item.status)}
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* Title and Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{item.title}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.status === "built" ? "Active" : item.status === "in_progress" ? "In Progress" : "Planned"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{item.path}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{item.sessionsCount} sessions</span>
|
||||
<span>•</span>
|
||||
<span>{item.commitsCount} commits</span>
|
||||
{item.estimatedCost && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>${item.estimatedCost.toFixed(2)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.info("Documentation coming soon")}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{item.path.startsWith('/api/') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => toast.info("API testing coming soon")}
|
||||
>
|
||||
<Code2 className="h-4 w-4" />
|
||||
Test API
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Quick Links</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
|
||||
<Link href={`/${workspace}/keys`} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">API Keys</p>
|
||||
<p className="text-xs text-muted-foreground">Manage service credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
|
||||
<a
|
||||
href="https://console.firebase.google.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-orange-600" />
|
||||
<div>
|
||||
<p className="font-medium">Firebase Console</p>
|
||||
<p className="text-xs text-muted-foreground">Manage database & hosting</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Github className="h-5 w-5 text-gray-900" />
|
||||
<div>
|
||||
<p className="font-medium">GitHub</p>
|
||||
<p className="text-xs text-muted-foreground">Code repository & CI/CD</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 hover:bg-accent/30 cursor-pointer transition-colors">
|
||||
<a
|
||||
href="https://vercel.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Deployment</p>
|
||||
<p className="text-xs text-muted-foreground">Production & preview deploys</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* End Main Content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,736 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState, useEffect, useCallback } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, CheckCircle2, Circle, Clock, RefreshCw, Eye, Cog, GitBranch, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface WorkItem {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
path: string;
|
||||
status: 'built' | 'missing' | 'in_progress';
|
||||
priority: string;
|
||||
assigned?: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
duration: number;
|
||||
sessionsCount: number;
|
||||
commitsCount: number;
|
||||
totalActivity: number;
|
||||
estimatedCost?: number;
|
||||
requirements: Array<{
|
||||
id: number;
|
||||
text: string;
|
||||
status: 'built' | 'missing' | 'in_progress';
|
||||
}>;
|
||||
evidence: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface TimelineData {
|
||||
workItems: WorkItem[];
|
||||
timeline: {
|
||||
start: string;
|
||||
end: string;
|
||||
totalDays: number;
|
||||
};
|
||||
summary: {
|
||||
totalWorkItems: number;
|
||||
withActivity: number;
|
||||
noActivity: number;
|
||||
built: number;
|
||||
missing: number;
|
||||
};
|
||||
projectCreator?: string;
|
||||
}
|
||||
|
||||
export default function TimelinePlanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { projectId } = use(params);
|
||||
const [data, setData] = useState<TimelineData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = useState<'touchpoints' | 'technical' | 'journey'>('touchpoints');
|
||||
const [collapsedJourneySections, setCollapsedJourneySections] = useState<Set<string>>(new Set());
|
||||
|
||||
// Map work items to types based on path and category
|
||||
const getWorkItemType = (item: WorkItem): string => {
|
||||
// API endpoints are System
|
||||
if (item.path.startsWith('/api/')) return 'System';
|
||||
|
||||
// Flows are Flow
|
||||
if (item.path.startsWith('flow/')) return 'Flow';
|
||||
|
||||
// Auth/OAuth is System
|
||||
if (item.path.includes('auth') || item.path.includes('oauth')) return 'System';
|
||||
|
||||
// Settings is System
|
||||
if (item.path.includes('settings')) return 'System';
|
||||
|
||||
// Marketing/Content pages
|
||||
if (item.category === 'Marketing' || item.category === 'Content') return 'Screen';
|
||||
|
||||
// Social
|
||||
if (item.category === 'Social') return 'Screen';
|
||||
|
||||
// Everything else is a Screen
|
||||
return 'Screen';
|
||||
};
|
||||
|
||||
// Determine if item is a user-facing touchpoint
|
||||
const isTouchpoint = (item: WorkItem): boolean => {
|
||||
const path = item.path.toLowerCase();
|
||||
const title = item.title.toLowerCase();
|
||||
|
||||
// Exclude APIs and backend systems
|
||||
if (path.startsWith('/api/')) return false;
|
||||
if (title.includes(' api') || title.includes('api ')) return false;
|
||||
|
||||
// Exclude pure auth infrastructure (OAuth endpoints)
|
||||
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return false;
|
||||
|
||||
// Include everything else - screens, pages, social posts, blogs, invites, etc.
|
||||
return true;
|
||||
};
|
||||
|
||||
// Determine if item is technical infrastructure
|
||||
const isTechnical = (item: WorkItem): boolean => {
|
||||
const path = item.path.toLowerCase();
|
||||
const title = item.title.toLowerCase();
|
||||
|
||||
// APIs and backend
|
||||
if (path.startsWith('/api/')) return true;
|
||||
if (title.includes(' api') || title.includes('api ')) return true;
|
||||
|
||||
// Auth infrastructure
|
||||
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return true;
|
||||
|
||||
// System settings
|
||||
if (item.category === 'Settings' && title.includes('api')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Map work items to customer lifecycle journey sections
|
||||
const getJourneySection = (item: WorkItem): string => {
|
||||
const title = item.title.toLowerCase();
|
||||
const path = item.path.toLowerCase();
|
||||
|
||||
// Discovery - "I just found you online via social post, blog article, advertisement"
|
||||
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
|
||||
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
|
||||
if (item.category === 'Content' && (title.includes('blog') || title.includes('article'))) return 'Discovery';
|
||||
|
||||
// Research - "Checking out your marketing website - features, price, home page"
|
||||
if (title.includes('marketing dashboard')) return 'Research';
|
||||
if (item.category === 'Marketing' && path !== '/') return 'Research';
|
||||
if (path.includes('/features') || path.includes('/pricing') || path.includes('/about')) return 'Research';
|
||||
if (item.category === 'Content' && path.includes('/docs') && !title.includes('getting started')) return 'Research';
|
||||
|
||||
// Onboarding - "Creating an account to try the product for the first time"
|
||||
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
|
||||
if (path.includes('signup') || path.includes('signin') || path.includes('login')) return 'Onboarding';
|
||||
if (title.includes('authentication') && !title.includes('api')) return 'Onboarding';
|
||||
|
||||
// First Use - "Zero state to experiencing the magic solution"
|
||||
if (title.includes('onboarding')) return 'First Use';
|
||||
if (title.includes('getting started')) return 'First Use';
|
||||
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
|
||||
if (title.includes('creation flow') || title.includes('project creation')) return 'First Use';
|
||||
if (path.includes('/projects') && path.match(/\/projects\/?$/)) return 'First Use'; // Projects list page
|
||||
|
||||
// Active - "I've seen the magic and come back to use it again and again"
|
||||
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
|
||||
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
|
||||
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
|
||||
if (title.includes('plan generation') || title.includes('marketing plan')) return 'Active';
|
||||
if (path.includes('projects/') && path.length > '/projects/'.length) return 'Active'; // Specific project pages
|
||||
|
||||
// Support - "I've got questions, need quick answers to get back to the magic"
|
||||
if (path.includes('settings')) return 'Support';
|
||||
if (path.includes('/help') || path.includes('/faq') || path.includes('/support')) return 'Support';
|
||||
if (item.category === 'Content' && path.includes('/docs') && title.includes('help')) return 'Support';
|
||||
|
||||
// Purchase - "Time to pay so I can keep using the magic"
|
||||
if (path.includes('billing') || path.includes('payment') || path.includes('subscription')) return 'Purchase';
|
||||
if (path.includes('upgrade') || path.includes('checkout') || path.includes('pricing/buy')) return 'Purchase';
|
||||
|
||||
// Default to Active for core product features
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
const toggleJourneySection = (sectionId: string) => {
|
||||
setCollapsedJourneySections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(sectionId)) {
|
||||
newSet.delete(sectionId);
|
||||
} else {
|
||||
newSet.add(sectionId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Get emoji icon for journey section
|
||||
const getJourneySectionIcon = (section: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
'Discovery': '🔍',
|
||||
'Research': '📚',
|
||||
'Onboarding': '🎯',
|
||||
'First Use': '🚀',
|
||||
'Active': '⚡',
|
||||
'Support': '💡',
|
||||
'Purchase': '💳'
|
||||
};
|
||||
return icons[section] || '📋';
|
||||
};
|
||||
|
||||
// Get phase status based on overall item status
|
||||
const getPhaseStatus = (itemStatus: string, phase: 'scope' | 'design' | 'code'): 'built' | 'in_progress' | 'missing' => {
|
||||
if (itemStatus === 'built') return 'built';
|
||||
if (itemStatus === 'missing') return 'missing';
|
||||
|
||||
// If in_progress, show progression through phases
|
||||
if (phase === 'scope') return 'built';
|
||||
if (phase === 'design') return 'in_progress';
|
||||
return 'missing';
|
||||
};
|
||||
|
||||
// Render status badge
|
||||
const renderStatusBadge = (status: 'built' | 'in_progress' | 'missing') => {
|
||||
if (status === 'built') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Done
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
Started
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs font-medium">
|
||||
<Circle className="h-3 w-3" />
|
||||
To-do
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const loadTimelineData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
|
||||
const result = await response.json();
|
||||
|
||||
// Check if the response is an error
|
||||
if (result.error) {
|
||||
console.error('API Error:', result.error, result.details);
|
||||
alert(`Failed to load timeline: ${result.details || result.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error('Error loading timeline:', error);
|
||||
alert('Failed to load timeline data. Check console for details.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimelineData();
|
||||
}, [loadTimelineData]);
|
||||
|
||||
const regeneratePlan = async () => {
|
||||
if (!confirm('Regenerate the plan? This will analyze your project and create a fresh MVP checklist.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setRegenerating(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/mvp-checklist`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to regenerate plan');
|
||||
}
|
||||
|
||||
// Reload the timeline data
|
||||
await loadTimelineData();
|
||||
} catch (error) {
|
||||
console.error('Error regenerating plan:', error);
|
||||
alert('Failed to regenerate plan. Check console for details.');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="p-8 text-center text-muted-foreground">No timeline data available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">MVP Checklist</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{data.summary.built} of {data.summary.totalWorkItems} pages built •
|
||||
{data.summary.withActivity} with development activity
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
{/* View Mode Switcher */}
|
||||
<div className="flex items-center border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'touchpoints' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('touchpoints')}
|
||||
className="gap-2 h-7"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Touchpoints
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'technical' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('technical')}
|
||||
className="gap-2 h-7"
|
||||
>
|
||||
<Cog className="h-4 w-4" />
|
||||
Technical
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'journey' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('journey')}
|
||||
className="gap-2 h-7"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Journey
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={regeneratePlan}
|
||||
disabled={regenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
{regenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Regenerate Plan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="text-xs px-3 py-1 bg-green-100 text-green-800 rounded">
|
||||
✅ {data.summary.built} Built
|
||||
</div>
|
||||
<div className="text-xs px-3 py-1 bg-gray-100 text-gray-800 rounded">
|
||||
⏳ {data.summary.missing} To Build
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Touchpoints View - What users see and engage with */}
|
||||
{viewMode === 'touchpoints' && (
|
||||
<Card className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">Everything users see and engage with - screens, features, social posts, blogs, invites, and all customer-facing elements.</p>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 border-b sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Touchpoint</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data?.workItems.filter(item => isTouchpoint(item)).map((item, index) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
newSet.add(item.id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{getWorkItemType(item)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.status === 'built' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
) : item.status === 'in_progress' ? (
|
||||
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
{expandedItems.has(item.id) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
|
||||
{req.status === 'built' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span>{req.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.assigned || data?.projectCreator || 'You'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.sessionsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.commitsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
|
||||
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Journey View - Customer lifecycle stages */}
|
||||
{viewMode === 'journey' && (
|
||||
<Card className="flex-1 overflow-auto p-0">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">Customer lifecycle journey from discovery to purchase - organizing all touchpoints and technical components by user stage.</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{/* Journey Sections - Customer Lifecycle */}
|
||||
{['Discovery', 'Research', 'Onboarding', 'First Use', 'Active', 'Support', 'Purchase'].map(sectionName => {
|
||||
const sectionItems = data.workItems.filter(item => getJourneySection(item) === sectionName);
|
||||
if (sectionItems.length === 0) return null;
|
||||
|
||||
const sectionStats = {
|
||||
done: sectionItems.filter(i => i.status === 'built').length,
|
||||
started: sectionItems.filter(i => i.status === 'in_progress').length,
|
||||
todo: sectionItems.filter(i => i.status === 'missing').length,
|
||||
total: sectionItems.length
|
||||
};
|
||||
|
||||
const isCollapsed = collapsedJourneySections.has(sectionName);
|
||||
|
||||
return (
|
||||
<div key={sectionName}>
|
||||
{/* Section Header */}
|
||||
<div
|
||||
className="bg-muted/30 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors flex items-center justify-between sticky top-0 z-10"
|
||||
onClick={() => toggleJourneySection(sectionName)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-lg">{getJourneySectionIcon(sectionName)}</span>
|
||||
<h3 className="font-semibold text-base">{sectionName}</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sectionStats.done}/{sectionStats.total} complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
{sectionStats.done > 0 && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 rounded">
|
||||
{sectionStats.done} done
|
||||
</span>
|
||||
)}
|
||||
{sectionStats.started > 0 && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded">
|
||||
{sectionStats.started} started
|
||||
</span>
|
||||
)}
|
||||
{sectionStats.todo > 0 && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-800 rounded">
|
||||
{sectionStats.todo} to-do
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="divide-y">
|
||||
{sectionItems.map(item => (
|
||||
<div key={item.id} className="px-4 py-3 hover:bg-accent/30 transition-colors">
|
||||
<div
|
||||
className="flex items-start justify-between cursor-pointer"
|
||||
onClick={() => {
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
newSet.add(item.id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{/* Status Icon */}
|
||||
{item.status === 'built' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
) : item.status === 'in_progress' ? (
|
||||
<Clock className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Title and Type */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
|
||||
{getWorkItemType(item)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Phase Status */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Spec:</span>{' '}
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Design:</span>{' '}
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Code:</span>{' '}
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Requirements */}
|
||||
{expandedItems.has(item.id) && (
|
||||
<div className="mt-3 space-y-1 pl-4 border-l-2 border-gray-200">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">Requirements:</p>
|
||||
{item.requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{req.status === 'built' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span>{req.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Stats */}
|
||||
<div className="flex items-start gap-4 text-xs text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Sessions</div>
|
||||
<div className={item.sessionsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.sessionsCount}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Commits</div>
|
||||
<div className={item.commitsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.commitsCount}</div>
|
||||
</div>
|
||||
<div className="text-center min-w-[60px]">
|
||||
<div className="font-medium">Cost</div>
|
||||
<div>{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Technical View - Infrastructure that powers everything */}
|
||||
{viewMode === 'technical' && (
|
||||
<Card className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">Technical infrastructure that powers the product - APIs, backend services, authentication, and system integrations.</p>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 border-b sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Technical Component</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
|
||||
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data?.workItems.filter(item => isTechnical(item)).map((item, index) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
newSet.add(item.id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{getWorkItemType(item)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.status === 'built' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
) : item.status === 'in_progress' ? (
|
||||
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
{expandedItems.has(item.id) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
|
||||
{req.status === 'built' ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span>{req.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.assigned || data?.projectCreator || 'You'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.sessionsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
||||
{item.commitsCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
|
||||
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,765 +0,0 @@
|
||||
"use client";
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Send, Loader2, Paperclip, X, FileText, RotateCcw, Upload, CheckCircle2, AlertTriangle, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import { GitHubRepoPicker } from "@/components/ai/github-repo-picker";
|
||||
import { PhaseSidebar } from "@/components/ai/phase-sidebar";
|
||||
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
||||
import { ExtractionResultsEditable } from "@/components/ai/extraction-results-editable";
|
||||
import type { ChatExtractionData } from "@/lib/ai/chat-extraction-types";
|
||||
import { VisionForm } from "@/components/ai/vision-form";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
showGitHubPicker?: boolean;
|
||||
meta?: {
|
||||
mode?: string;
|
||||
projectPhase?: string;
|
||||
artifactsUsed?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
collector_mode: "Collecting context",
|
||||
extraction_review_mode: "Reviewing signals",
|
||||
vision_mode: "Product vision",
|
||||
mvp_mode: "MVP planning",
|
||||
marketing_mode: "Marketing & launch",
|
||||
general_chat_mode: "General product chat",
|
||||
};
|
||||
|
||||
type ChatApiResponse = {
|
||||
reply: string;
|
||||
mode?: string;
|
||||
projectPhase?: string;
|
||||
artifactsUsed?: string[];
|
||||
};
|
||||
|
||||
function ModeBadge({ mode, phase, artifacts }: { mode: string | null; phase: string | null; artifacts?: string[] }) {
|
||||
if (!mode) return null;
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border px-2 py-0.5">
|
||||
{MODE_LABELS[mode] ?? mode}
|
||||
</span>
|
||||
{phase ? <span>{phase}</span> : null}
|
||||
</div>
|
||||
{artifacts && artifacts.length > 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
Using: {artifacts.join(', ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const { status: sessionStatus } = useSession();
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [attachedFiles, setAttachedFiles] = useState<Array<{name: string, content: string, type: string}>>([]);
|
||||
const [routerMode, setRouterMode] = useState<string | null>(null);
|
||||
const [routerPhase, setRouterPhase] = useState<string | null>(null);
|
||||
const [routerArtifacts, setRouterArtifacts] = useState<string[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showExtractor, setShowExtractor] = useState(false);
|
||||
const [extractForm, setExtractForm] = useState({
|
||||
title: "",
|
||||
provider: "chatgpt",
|
||||
transcript: "",
|
||||
sourceLink: "",
|
||||
});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [extractionStatus, setExtractionStatus] = useState<"idle" | "importing" | "extracting" | "done" | "error">("idle");
|
||||
const [extractionError, setExtractionError] = useState<string | null>(null);
|
||||
const [lastExtraction, setLastExtraction] = useState<ChatExtractionData | null>(null);
|
||||
const [currentPhase, setCurrentPhase] = useState<string>("collector");
|
||||
const [hasVisionAnswers, setHasVisionAnswers] = useState<boolean>(false);
|
||||
const [checkingVision, setCheckingVision] = useState(true);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Load project phase + vision answers from the Postgres-backed API
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const phase = data.project?.currentPhase || 'collector';
|
||||
setCurrentPhase(phase);
|
||||
const hasAnswers = data.project?.visionAnswers?.allAnswered === true;
|
||||
setHasVisionAnswers(hasAnswers);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading project:', error);
|
||||
} finally {
|
||||
setCheckingVision(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
}, [projectId]);
|
||||
|
||||
// Initialize with AI welcome message
|
||||
useEffect(() => {
|
||||
if (!isInitialized && projectId && sessionStatus !== 'loading') {
|
||||
const initialize = async () => {
|
||||
if (sessionStatus === 'unauthenticated') {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
setTimeout(() => sendChatMessage("Hello"), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Signed in via NextAuth — load conversation history
|
||||
try {
|
||||
// Fetch existing conversation history
|
||||
const historyResponse = await fetch(`/api/ai/conversation?projectId=${projectId}`);
|
||||
|
||||
let existingMessages: Message[] = [];
|
||||
|
||||
if (historyResponse.ok) {
|
||||
type StoredMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt?: string | { _seconds: number };
|
||||
};
|
||||
const historyData = await historyResponse.json() as { messages: StoredMessage[] };
|
||||
existingMessages = historyData.messages
|
||||
.filter((msg) =>
|
||||
msg.content !== '[VISION_AGENT_AUTO_START]' &&
|
||||
msg.content.trim() !== "Hi! I'm here to help." &&
|
||||
msg.content.trim() !== "Hello" // Filter out auto-generated greeting trigger
|
||||
)
|
||||
.map((msg) => ({
|
||||
id: crypto.randomUUID(),
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: msg.createdAt
|
||||
? (typeof msg.createdAt === 'string'
|
||||
? new Date(msg.createdAt)
|
||||
: new Date(msg.createdAt._seconds * 1000))
|
||||
: new Date(),
|
||||
}));
|
||||
|
||||
console.log(`[Chat] Loaded ${existingMessages.length} messages from history`);
|
||||
}
|
||||
|
||||
// If there's existing conversation, just show it
|
||||
if (existingMessages.length > 0) {
|
||||
setMessages(existingMessages);
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, trigger AI to generate the first message
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
|
||||
// Automatically send a greeting to get AI's welcome message
|
||||
setTimeout(() => {
|
||||
sendChatMessage("Hello");
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error initializing chat:', error);
|
||||
// Show error state but don't send automatic message
|
||||
setMessages([{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: "Welcome! There was an issue loading your chat history, but let's get started. What would you like to work on?",
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
}
|
||||
}, [projectId, isInitialized, sessionStatus]);
|
||||
|
||||
const sendChatMessage = async (messageContent: string) => {
|
||||
const content = messageContent.trim();
|
||||
if (!content) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId, message: content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Chat API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ChatApiResponse;
|
||||
const aiMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: data.reply || 'No response generated.',
|
||||
timestamp: new Date(),
|
||||
meta: {
|
||||
mode: data.mode,
|
||||
projectPhase: data.projectPhase,
|
||||
artifactsUsed: data.artifactsUsed,
|
||||
},
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, aiMessage]);
|
||||
setRouterMode(data.mode ?? null);
|
||||
setRouterPhase(data.projectPhase ?? null);
|
||||
setRouterArtifacts(data.artifactsUsed ?? []);
|
||||
} catch (error) {
|
||||
console.error('Chat send failed', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: 'Sorry, something went wrong talking to the AI.',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if ((!input.trim() && attachedFiles.length === 0) || isSending) return;
|
||||
|
||||
let messageContent = input.trim();
|
||||
if (attachedFiles.length > 0) {
|
||||
messageContent += '\n\n**Attached Files:**\n';
|
||||
attachedFiles.forEach(file => {
|
||||
messageContent += `\n--- ${file.name} ---\n${file.content}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
setInput("");
|
||||
setAttachedFiles([]);
|
||||
sendChatMessage(messageContent);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const newFiles: Array<{name: string, content: string, type: string}> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Check file size (max 100MB for large exports like ChatGPT conversations)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error(`File ${file.name} is too large (max 100MB)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
newFiles.push({
|
||||
name: file.name,
|
||||
content,
|
||||
type: file.type,
|
||||
});
|
||||
}
|
||||
|
||||
setAttachedFiles([...attachedFiles, ...newFiles]);
|
||||
toast.success(`Added ${newFiles.length} file(s)`);
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === 'file') {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
await handleFileUpload([file] as unknown as FileList);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleResetChat = async () => {
|
||||
if (!confirm('Are you sure you want to reset this conversation? This will delete all messages and start fresh.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ai/conversation?projectId=${projectId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Chat reset! Reloading...');
|
||||
// Reload the page to start fresh
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
toast.error('Failed to reset chat');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resetting chat:', error);
|
||||
toast.error('Failed to reset chat');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportAndExtract = async () => {
|
||||
if (!extractForm.transcript.trim()) {
|
||||
toast.error("Please paste a transcript first");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
setExtractionStatus("importing");
|
||||
setExtractionError(null);
|
||||
|
||||
const importResponse = await fetch(`/api/projects/${projectId}/knowledge/import-ai-chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: extractForm.title || "Imported AI chat",
|
||||
provider: extractForm.provider,
|
||||
transcript: extractForm.transcript,
|
||||
sourceLink: extractForm.sourceLink || null,
|
||||
createdAtOriginal: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!importResponse.ok) {
|
||||
const errorData = await importResponse.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to import transcript");
|
||||
}
|
||||
|
||||
const { knowledgeItem } = await importResponse.json();
|
||||
setExtractionStatus("extracting");
|
||||
|
||||
const extractResponse = await fetch(`/api/projects/${projectId}/extract-from-chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ knowledgeItemId: knowledgeItem.id }),
|
||||
});
|
||||
|
||||
if (!extractResponse.ok) {
|
||||
const errorData = await extractResponse.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to extract signals");
|
||||
}
|
||||
|
||||
const { extraction } = await extractResponse.json();
|
||||
setLastExtraction(extraction.data);
|
||||
setExtractionStatus("done");
|
||||
toast.success("Signals extracted");
|
||||
} catch (error) {
|
||||
console.error("[chat extraction] failed", error);
|
||||
setExtractionStatus("error");
|
||||
setExtractionError(error instanceof Error ? error.message : "Unknown error");
|
||||
toast.error("Could not extract signals");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Show vision form if no answers yet
|
||||
if (checkingVision) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasVisionAnswers) {
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar */}
|
||||
<CollapsibleSidebar>
|
||||
<PhaseSidebar projectId={projectId} />
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Vision Form */}
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<div className="border-b bg-background/95 backdrop-blur-sm">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3">
|
||||
<h2 className="text-lg font-semibold">Let's Start with Your Vision</h2>
|
||||
<p className="text-xs text-muted-foreground">Answer 3 quick questions to generate your MVP plan</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<VisionForm
|
||||
projectId={projectId}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
setHasVisionAnswers(true);
|
||||
toast.success('Vision saved! MVP plan generated.');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar - Phase-based content */}
|
||||
<CollapsibleSidebar>
|
||||
<PhaseSidebar projectId={projectId} />
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* Header with Reset Button */}
|
||||
<div className="border-b bg-background/95 backdrop-blur-sm">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">AI Assistant</h2>
|
||||
<p className="text-xs text-muted-foreground">Building your project step-by-step</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<ModeBadge mode={routerMode} phase={routerPhase} artifacts={routerArtifacts} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResetChat}
|
||||
disabled={isLoading || isSending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reset Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto pb-[200px]">
|
||||
<div className="max-w-3xl mx-auto px-4 pt-8 pb-8">
|
||||
<div className="space-y-6">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3 animate-in fade-in slide-in-from-bottom-4 duration-500",
|
||||
message.role === 'user' ? "flex-row-reverse" : ""
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{message.role === 'assistant' ? (
|
||||
<div className="h-8 w-8 rounded-full shrink-0 overflow-hidden bg-white">
|
||||
<img
|
||||
src="/vibn-logo-circle.png"
|
||||
alt="AI"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground shrink-0">
|
||||
You
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div className="flex-1 space-y-2 max-w-[85%]">
|
||||
<div
|
||||
className={cn(
|
||||
"text-[15px] leading-relaxed rounded-2xl px-4 py-3 shadow-sm whitespace-pre-wrap",
|
||||
message.role === 'assistant'
|
||||
? "bg-muted/50"
|
||||
: "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
{/* GitHub Repo Picker (if AI requested it) */}
|
||||
{message.role === 'assistant' && message.showGitHubPicker && (
|
||||
<div className="mt-2">
|
||||
<GitHubRepoPicker
|
||||
projectId={projectId}
|
||||
onRepoSelected={(repo) => {
|
||||
const confirmMessage = `Yes, I connected ${repo.full_name}`;
|
||||
sendChatMessage(confirmMessage);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={cn(
|
||||
"text-xs text-muted-foreground px-2",
|
||||
message.role === 'user' ? "text-right" : ""
|
||||
)}>
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isSending && (
|
||||
<div className="flex gap-3 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="h-8 w-8 rounded-full shrink-0 overflow-hidden bg-white">
|
||||
<img
|
||||
src="/vibn-logo-circle.png"
|
||||
alt="AI thinking"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="text-sm bg-muted rounded-2xl px-5 py-3 w-fit">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs font-medium">Assistant is thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Extraction Results at bottom if in extraction_review phase */}
|
||||
{(currentPhase === "extraction_review" || currentPhase === "analyzed") && (
|
||||
<div className="mt-8">
|
||||
<ExtractionResultsEditable projectId={projectId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Chat Input - Fixed at Bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur-sm shadow-lg z-10">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3 space-y-3">
|
||||
{false && showExtractor && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Paste AI chat transcript → extract signals
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground uppercase">Title</label>
|
||||
<Input
|
||||
placeholder="ChatGPT brainstorm"
|
||||
value={extractForm.title}
|
||||
onChange={(e) => setExtractForm((prev) => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground uppercase">Provider</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={extractForm.provider}
|
||||
onChange={(e) => setExtractForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||
>
|
||||
{['chatgpt', 'gemini', 'claude', 'cursor', 'vibn', 'other'].map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<label className="text-xs text-muted-foreground uppercase">Transcript</label>
|
||||
<Textarea
|
||||
placeholder="Paste the AI conversation here..."
|
||||
className="min-h-[120px]"
|
||||
value={extractForm.transcript}
|
||||
onChange={(e) => setExtractForm((prev) => ({ ...prev, transcript: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<Button onClick={handleImportAndExtract} disabled={isImporting}>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Processing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import & Extract
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{extractionStatus === "done" && lastExtraction && (
|
||||
<span className="text-emerald-600 text-xs flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Signals captured below
|
||||
</span>
|
||||
)}
|
||||
{extractionStatus === "error" && extractionError && (
|
||||
<span className="text-destructive text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{extractionError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lastExtraction && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground uppercase">Working title</p>
|
||||
<p className="font-medium">
|
||||
{lastExtraction?.project_summary?.working_title || "Not captured"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground uppercase mt-2">One-liner</p>
|
||||
<p>{lastExtraction?.project_summary?.one_liner || "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground uppercase">Primary problem</p>
|
||||
<p>{lastExtraction?.product_vision?.problem_statement?.description || "Not detected"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Attached Files Display */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 bg-muted px-3 py-2 rounded-lg text-sm border"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
placeholder="Message..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
className="min-h-[48px] max-h-[120px] resize-none bg-background shadow-sm text-[15px]"
|
||||
disabled={isLoading || isSending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.md,.json,.js,.ts,.tsx,.jsx,.py,.java,.cpp,.c,.html,.css,.xml,.yaml,.yml"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* File Upload Button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading || isSending}
|
||||
className="h-[48px] w-[48px] shrink-0"
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={(!input.trim() && attachedFiles.length === 0) || isLoading || isSending}
|
||||
className="h-[48px] w-[48px] shrink-0"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 px-1">
|
||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-[10px] border">Enter</kbd> to send • <kbd className="px-1 py-0.5 bg-muted rounded text-[10px] border">Shift+Enter</kbd> for new line
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
@@ -14,26 +13,16 @@ export default function ProjectsLayout({
|
||||
}) {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const [activeSection, setActiveSection] = useState<string>("projects");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
<main style={{ flex: 1, overflow: "auto" }}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
|
||||
{/* Project Association Prompt - Detects new workspaces */}
|
||||
<ProjectAssociationPrompt workspace={workspace} />
|
||||
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export default function NewProjectLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ArrowLeft, ArrowRight, Check, Sparkles, Code2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type ProjectType = "scratch" | "existing" | null;
|
||||
|
||||
export default function NewProjectPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1);
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [projectType, setProjectType] = useState<ProjectType>(null);
|
||||
|
||||
// Product vision (can skip)
|
||||
const [productVision, setProductVision] = useState("");
|
||||
|
||||
// Product details
|
||||
const [productName, setProductName] = useState("");
|
||||
const [isForClient, setIsForClient] = useState<boolean | null>(null);
|
||||
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
|
||||
const [hasDomain, setHasDomain] = useState<boolean | null>(null);
|
||||
const [hasWebsite, setHasWebsite] = useState<boolean | null>(null);
|
||||
const [hasGithub, setHasGithub] = useState<boolean | null>(null);
|
||||
const [hasChatGPT, setHasChatGPT] = useState<boolean | null>(null);
|
||||
|
||||
const [isCheckingSlug, setIsCheckingSlug] = useState(false);
|
||||
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
|
||||
const checkSlugAvailability = async (name: string) => {
|
||||
const slug = generateSlug(name);
|
||||
if (!slug) return;
|
||||
|
||||
setIsCheckingSlug(true);
|
||||
// TODO: Replace with actual API call
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Mock check - in reality, check against database
|
||||
const isAvailable = !["test", "demo", "admin"].includes(slug);
|
||||
setSlugAvailable(isAvailable);
|
||||
setIsCheckingSlug(false);
|
||||
};
|
||||
|
||||
const handleProductNameChange = (value: string) => {
|
||||
setProductName(value);
|
||||
setSlugAvailable(null);
|
||||
if (value.length > 2) {
|
||||
checkSlugAvailability(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && projectName && projectType) {
|
||||
setStep(2);
|
||||
} else if (step === 2) {
|
||||
// Can skip questions
|
||||
setStep(3);
|
||||
} else if (step === 3 && productName && slugAvailable) {
|
||||
handleCreateProject();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) setStep(step - 1);
|
||||
};
|
||||
|
||||
const handleSkipQuestions = () => {
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const slug = generateSlug(productName);
|
||||
|
||||
const projectData = {
|
||||
projectName,
|
||||
projectType,
|
||||
slug,
|
||||
vision: productVision,
|
||||
product: {
|
||||
name: productName,
|
||||
isForClient,
|
||||
hasLogo,
|
||||
hasDomain,
|
||||
hasWebsite,
|
||||
hasGithub,
|
||||
hasChatGPT,
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: API call to create project
|
||||
console.log("Creating project:", projectData);
|
||||
|
||||
// Redirect to the new project
|
||||
router.push(`/${slug}/overview`);
|
||||
};
|
||||
|
||||
const canProceedStep1 = projectName.trim() && projectType;
|
||||
const canProceedStep3 = productName.trim() && slugAvailable;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/projects")}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">Create New Project</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Step {step} of 3
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="flex gap-2 mb-8">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-2 flex-1 rounded-full transition-colors ${
|
||||
s <= step ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Project Setup */}
|
||||
{step === 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Setup</CardTitle>
|
||||
<CardDescription>
|
||||
Give your project a name and choose how you want to start
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectName">Project Name</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
placeholder="My Awesome Project"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Starting Point</Label>
|
||||
<div className="grid gap-3">
|
||||
<button
|
||||
onClick={() => setProjectType("scratch")}
|
||||
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||
projectType === "scratch"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 mt-0.5 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">Start from scratch</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Build a new project with AI assistance
|
||||
</div>
|
||||
</div>
|
||||
{projectType === "scratch" && (
|
||||
<Check className="h-5 w-5 ml-auto text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setProjectType("existing")}
|
||||
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||
projectType === "existing"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Code2 className="h-5 w-5 mt-0.5 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">Existing project</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Import and enhance an existing codebase
|
||||
</div>
|
||||
</div>
|
||||
{projectType === "existing" && (
|
||||
<Check className="h-5 w-5 ml-auto text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Product Vision */}
|
||||
{step === 2 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Describe your product vision</CardTitle>
|
||||
<CardDescription>
|
||||
Help us understand your project (you can skip this)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="Describe who you're building for, what problem they have, and how you plan to solve it..."
|
||||
value={productVision}
|
||||
onChange={(e) => setProductVision(e.target.value)}
|
||||
rows={8}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={handleSkipQuestions}
|
||||
>
|
||||
Skip this step
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Product Details */}
|
||||
{step === 3 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Details</CardTitle>
|
||||
<CardDescription>
|
||||
Tell us about your product
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productName">Product Name *</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
placeholder="Taskify"
|
||||
value={productName}
|
||||
onChange={(e) => handleProductNameChange(e.target.value)}
|
||||
/>
|
||||
{productName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isCheckingSlug ? (
|
||||
<span>Checking availability...</span>
|
||||
) : slugAvailable === true ? (
|
||||
<span className="text-green-600">
|
||||
✓ URL available: vibn.app/{generateSlug(productName)}
|
||||
</span>
|
||||
) : slugAvailable === false ? (
|
||||
<span className="text-red-600">
|
||||
✗ This name is already taken
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Client or Self */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Is this for a client or yourself?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isForClient === true ? "default" : "outline"}
|
||||
onClick={() => setIsForClient(true)}
|
||||
size="sm"
|
||||
className="w-20 h-8"
|
||||
>
|
||||
Client
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isForClient === false ? "default" : "outline"}
|
||||
onClick={() => setIsForClient(false)}
|
||||
size="sm"
|
||||
className="w-20 h-8"
|
||||
>
|
||||
Myself
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Does it have a logo?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasLogo === true ? "default" : "outline"}
|
||||
onClick={() => setHasLogo(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasLogo === false ? "default" : "outline"}
|
||||
onClick={() => setHasLogo(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Does it have a domain?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasDomain === true ? "default" : "outline"}
|
||||
onClick={() => setHasDomain(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasDomain === false ? "default" : "outline"}
|
||||
onClick={() => setHasDomain(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Does it have a website?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasWebsite === true ? "default" : "outline"}
|
||||
onClick={() => setHasWebsite(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasWebsite === false ? "default" : "outline"}
|
||||
onClick={() => setHasWebsite(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Do you have a GitHub repository?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasGithub === true ? "default" : "outline"}
|
||||
onClick={() => setHasGithub(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasGithub === false ? "default" : "outline"}
|
||||
onClick={() => setHasGithub(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ChatGPT */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">Do you have your ideas in a ChatGPT project?</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasChatGPT === true ? "default" : "outline"}
|
||||
onClick={() => setHasChatGPT(true)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasChatGPT === false ? "default" : "outline"}
|
||||
onClick={() => setHasChatGPT(false)}
|
||||
size="sm"
|
||||
className="w-16 h-8"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={handleNext}
|
||||
disabled={
|
||||
(step === 1 && !canProceedStep1) ||
|
||||
(step === 3 && !canProceedStep3) ||
|
||||
isCheckingSlug
|
||||
}
|
||||
>
|
||||
{step === 3 ? "Create Project" : "Next"}
|
||||
{step < 3 && <ArrowRight className="h-4 w-4 ml-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,38 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
Rocket,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ProjectCreationModal } from "@/components/project-creation-modal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -44,34 +15,16 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ContextSnapshot {
|
||||
lastCommit?: { sha: string; message: string; author?: string; timestamp?: string };
|
||||
currentBranch?: string;
|
||||
openPRs?: { number: number; title: string }[];
|
||||
openIssues?: { number: number; title: string }[];
|
||||
lastDeployment?: { status: string; url?: string };
|
||||
}
|
||||
|
||||
interface ProjectWithStats {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
productName: string;
|
||||
productVision?: string;
|
||||
workspacePath?: string;
|
||||
status?: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
theiaWorkspaceUrl?: string;
|
||||
contextSnapshot?: ContextSnapshot;
|
||||
stats: {
|
||||
sessions: number;
|
||||
costs: number;
|
||||
};
|
||||
stats: { sessions: number; costs: number };
|
||||
}
|
||||
|
||||
function timeAgo(dateStr?: string | null): string {
|
||||
@@ -89,19 +42,27 @@ function timeAgo(dateStr?: string | null): string {
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
function DeployDot({ status }: { status?: string }) {
|
||||
if (!status) return null;
|
||||
const map: Record<string, string> = {
|
||||
finished: "bg-green-500",
|
||||
in_progress: "bg-blue-500 animate-pulse",
|
||||
queued: "bg-yellow-400",
|
||||
failed: "bg-red-500",
|
||||
};
|
||||
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
|
||||
className={`inline-block h-2 w-2 rounded-full ${map[status] ?? "bg-gray-400"}`}
|
||||
title={status}
|
||||
/>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0, animation: anim }} />
|
||||
);
|
||||
}
|
||||
|
||||
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: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
<StatusDot status={status} /> {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,24 +73,20 @@ export default function ProjectsPage() {
|
||||
|
||||
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreationModal, setShowCreationModal] = useState(false);
|
||||
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 {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to fetch projects");
|
||||
}
|
||||
if (!res.ok) throw new Error("Failed to fetch projects");
|
||||
const data = await res.json();
|
||||
setProjects(data.projects || []);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setProjects(data.projects ?? []);
|
||||
} catch {
|
||||
/* silent */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -140,7 +97,7 @@ export default function ProjectsPage() {
|
||||
else if (status === "unauthenticated") setLoading(false);
|
||||
}, [status]);
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
const handleDelete = async () => {
|
||||
if (!projectToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
@@ -164,204 +121,203 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const statusSummary = () => {
|
||||
const live = projects.filter((p) => p.status === "live").length;
|
||||
const building = projects.filter((p) => p.status === "building").length;
|
||||
const defining = projects.filter((p) => !p.status || p.status === "defining").length;
|
||||
const parts = [];
|
||||
if (defining) parts.push(`${defining} defining`);
|
||||
if (building) parts.push(`${building} building`);
|
||||
if (live) parts.push(`${live} live`);
|
||||
return `${projects.length} total · ${parts.join(" · ")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container mx-auto py-8 px-4 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">{session?.user?.email}</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreationModal(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Project
|
||||
</Button>
|
||||
<div
|
||||
className="vibn-enter"
|
||||
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: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
|
||||
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
|
||||
lineHeight: 1.15, marginBottom: 4,
|
||||
}}>
|
||||
Projects
|
||||
</h1>
|
||||
{!loading && (
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90" }}>{statusSummary()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* States */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-red-500/30 bg-red-500/5">
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-red-600">Error: {error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Projects Grid */}
|
||||
{!loading && !error && projects.length > 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => {
|
||||
const href = `/${workspace}/project/${project.id}/overview`;
|
||||
const snap = project.contextSnapshot;
|
||||
const deployStatus = snap?.lastDeployment?.status;
|
||||
|
||||
return (
|
||||
<div key={project.id} className="relative group">
|
||||
<Link href={href}>
|
||||
<Card className="hover:border-primary/50 hover:shadow-sm transition-all cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate">{project.productName}</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">
|
||||
{timeAgo(project.updatedAt)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Badge
|
||||
variant={project.status === "active" ? "default" : "secondary"}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{project.status ?? "active"}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e: React.MouseEvent) => e.preventDefault()}>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setProjectToDelete(project);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* Vision */}
|
||||
{project.productVision && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{project.productVision}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Gitea repo + last commit */}
|
||||
{project.giteaRepo && (
|
||||
<div className="rounded-md border bg-muted/20 p-2 space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="font-mono truncate">{project.giteaRepo}</span>
|
||||
{snap?.currentBranch && (
|
||||
<span className="text-[10px] px-1.5 py-0 bg-muted rounded-full shrink-0">
|
||||
{snap.currentBranch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{snap?.lastCommit ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<GitCommit className="h-3 w-3 shrink-0" />
|
||||
<span className="font-mono text-[10px]">{snap.lastCommit.sha.slice(0, 7)}</span>
|
||||
<span className="truncate flex-1">{snap.lastCommit.message}</span>
|
||||
<span className="shrink-0">{timeAgo(snap.lastCommit.timestamp)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">No commits yet</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer row: deploy + stats + IDE */}
|
||||
<div className="flex items-center justify-between pt-1 border-t">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{deployStatus && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DeployDot status={deployStatus} />
|
||||
{deployStatus === "finished" ? "Live" : deployStatus}
|
||||
</span>
|
||||
)}
|
||||
<span>{project.stats.sessions} sessions</span>
|
||||
<span>${project.stats.costs.toFixed(2)}</span>
|
||||
</div>
|
||||
{project.theiaWorkspaceUrl && (
|
||||
<a
|
||||
href={project.theiaWorkspaceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-1 text-[10px] text-primary hover:underline"
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
IDE
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Create card */}
|
||||
<Card
|
||||
className="hover:border-primary/50 transition-all cursor-pointer border-dashed"
|
||||
onClick={() => setShowCreationModal(true)}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center h-full min-h-[220px] p-6">
|
||||
<div className="rounded-full bg-muted p-4 mb-3">
|
||||
<Plus className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-1 text-sm">New Project</h3>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Auto-provisions a Gitea repo and workspace
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && projects.length === 0 && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="rounded-full bg-muted p-6 mb-4">
|
||||
<Sparkles className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No projects yet</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-md mb-6">
|
||||
Create your first project. Vibn will automatically provision a Gitea repo,
|
||||
register webhooks, and prepare your IDE workspace.
|
||||
</p>
|
||||
<Button size="lg" onClick={() => setShowCreationModal(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Project
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowNew(true)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
padding: "8px 16px", borderRadius: 7,
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
border: "1px solid #1a1a1a",
|
||||
fontSize: "0.78rem", fontWeight: 600,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
|
||||
New project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 64 }}>
|
||||
<Loader2 style={{ width: 28, height: 28, color: "#b5b0a6" }} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project list */}
|
||||
{!loading && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{projects.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="vibn-enter"
|
||||
style={{ position: "relative", animationDelay: `${i * 0.05}s` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${workspace}/project/${p.id}/overview`}
|
||||
style={{
|
||||
width: "100%", display: "flex", alignItems: "center",
|
||||
padding: "18px 22px", borderRadius: 10,
|
||||
background: "#fff", border: "1px solid #e8e4dc",
|
||||
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";
|
||||
}}
|
||||
>
|
||||
{/* Project initial */}
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 9, marginRight: 16,
|
||||
background: "#1a1a1a12",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "var(--font-lora), ui-serif, serif",
|
||||
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
|
||||
}}>
|
||||
{p.productName[0]?.toUpperCase() ?? "P"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name + vision */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
|
||||
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "#1a1a1a" }}>
|
||||
{p.productName}
|
||||
</span>
|
||||
<StatusTag status={p.status} />
|
||||
</div>
|
||||
{p.productVision && (
|
||||
<span style={{ fontSize: "0.78rem", color: "#a09a90", display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{p.productVision}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div style={{ display: "flex", gap: 28, alignItems: "center", flexShrink: 0 }}>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>
|
||||
Last active
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#6b6560" }}>{timeAgo(p.updatedAt)}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 2 }}>
|
||||
Sessions
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#6b6560" }}>{p.stats.sessions}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete (visible on row hover) */}
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
|
||||
style={{
|
||||
marginLeft: 16, padding: "6px 8px", borderRadius: 6,
|
||||
border: "none", background: "transparent",
|
||||
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,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* New project card */}
|
||||
<button
|
||||
onClick={() => setShowNew(true)}
|
||||
style={{
|
||||
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: "22px", borderRadius: 10,
|
||||
background: "transparent", border: "1px dashed #d0ccc4",
|
||||
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`,
|
||||
}}
|
||||
className="vibn-enter"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "#8a8478"; e.currentTarget.style.color = "#6b6560"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "#d0ccc4"; e.currentTarget.style.color = "#b5b0a6"; }}
|
||||
>
|
||||
+ New project
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && projects.length === 0 && (
|
||||
<div style={{ textAlign: "center", paddingTop: 64 }}>
|
||||
<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 Vibn what you want to build and it will figure out the rest.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNew(true)}
|
||||
style={{
|
||||
padding: "10px 22px", borderRadius: 7,
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
border: "none", fontSize: "0.84rem", fontWeight: 600,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Create your first project
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProjectCreationModal
|
||||
open={showCreationModal}
|
||||
onOpenChange={(open) => {
|
||||
setShowCreationModal(open);
|
||||
if (!open) fetchProjects();
|
||||
}}
|
||||
open={showNew}
|
||||
onOpenChange={(open) => { setShowNew(open); if (!open) fetchProjects(); }}
|
||||
workspace={workspace}
|
||||
/>
|
||||
|
||||
@@ -377,20 +333,16 @@ export default function ProjectsPage() {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteProject}
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
|
||||
Delete Project
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||
import { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function SettingsLayout({
|
||||
@@ -10,25 +10,18 @@ export default function SettingsLayout({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState<string>("settings");
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
<main style={{ flex: 1, overflow: "auto" }}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
export default function TestApiKeyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState<string>("connections");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
<RightPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function TestApiKeyPage() {
|
||||
const [results, setResults] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const testApiKey = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
|
||||
if (!user) {
|
||||
setResults({ error: "Not authenticated. Please sign in first." });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
console.log('[Test] Calling /api/user/api-key...');
|
||||
console.log('[Test] Token length:', token.length);
|
||||
|
||||
const response = await fetch('/api/user/api-key', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Test] Response status:', response.status);
|
||||
console.log('[Test] Response headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
const text = await response.text();
|
||||
console.log('[Test] Response text:', text);
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
data = { rawResponse: text };
|
||||
}
|
||||
|
||||
setResults({
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
data: data,
|
||||
userInfo: {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Test] Error:', error);
|
||||
setResults({ error: error.message, stack: error.stack });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = auth.onAuthStateChanged((user) => {
|
||||
if (user) {
|
||||
testApiKey();
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto p-8">
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">API Key Test</h1>
|
||||
<p className="text-muted-foreground">Testing /api/user/api-key endpoint</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Test Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && <p>Testing API key endpoint...</p>}
|
||||
{results && (
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-auto text-xs">
|
||||
{JSON.stringify(results, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={testApiKey} disabled={loading}>
|
||||
{loading ? "Testing..." : "Test Again"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
export default function TestAuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState<string>("connections");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function TestAuthPage() {
|
||||
const [results, setResults] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
|
||||
if (!user) {
|
||||
setResults({ error: "Not authenticated. Please sign in first." });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
|
||||
// Test with token
|
||||
const response = await fetch('/api/diagnose', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setResults({
|
||||
...data,
|
||||
clientInfo: {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
tokenLength: token.length,
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
setResults({ error: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = auth.onAuthStateChanged((user) => {
|
||||
console.log('[Test Auth] Auth state changed:', user ? user.uid : 'No user');
|
||||
if (user) {
|
||||
runDiagnostics();
|
||||
} else {
|
||||
setResults({
|
||||
error: "Not authenticated. Please sign in first.",
|
||||
note: "Redirecting to auth page...",
|
||||
});
|
||||
// Redirect to auth page after a delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/auth';
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto p-8">
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Auth Diagnostics</h1>
|
||||
<p className="text-muted-foreground">Testing Firebase authentication and token verification</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Diagnostic Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && <p>Running diagnostics...</p>}
|
||||
{results && (
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-auto text-xs">
|
||||
{JSON.stringify(results, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{!loading && !results && (
|
||||
<p className="text-muted-foreground">Click "Run Diagnostics" to test</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={runDiagnostics} disabled={loading}>
|
||||
{loading ? "Running..." : "Run Diagnostics Again"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export default function TestSessionsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { db, auth } from '@/lib/firebase/config';
|
||||
import { collection, query, where, orderBy, limit, getDocs } from 'firebase/firestore';
|
||||
|
||||
export default function TestSessionsPage() {
|
||||
const [sessions, setSessions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = auth.onAuthStateChanged(async (user) => {
|
||||
if (!user) {
|
||||
setError('Not authenticated');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionsRef = collection(db, 'sessions');
|
||||
const q = query(
|
||||
sessionsRef,
|
||||
where('userId', '==', user.uid),
|
||||
orderBy('createdAt', 'desc'),
|
||||
limit(20)
|
||||
);
|
||||
const snapshot = await getDocs(q);
|
||||
|
||||
const sessionData = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data()
|
||||
}));
|
||||
|
||||
setSessions(sessionData);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching sessions:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Recent Sessions</h1>
|
||||
|
||||
{loading && <p>Loading...</p>}
|
||||
{error && <p className="text-red-500">Error: {error}</p>}
|
||||
|
||||
{!loading && sessions.length === 0 && (
|
||||
<p className="text-gray-500">No sessions found yet. Make sure you're coding in Cursor with the extension enabled!</p>
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="p-4 border rounded-lg bg-card">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><strong>Session ID:</strong> {session.id}</div>
|
||||
<div><strong>User ID:</strong> {session.userId?.substring(0, 20)}...</div>
|
||||
|
||||
<div className="col-span-2 mt-2">
|
||||
<strong>🗂️ Workspace:</strong>
|
||||
<div className="font-mono text-xs bg-muted p-2 rounded mt-1">
|
||||
{session.workspacePath || 'N/A'}
|
||||
</div>
|
||||
{session.workspaceName && (
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Project: <span className="font-medium">{session.workspaceName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div><strong>Created:</strong> {session.createdAt?.toDate?.()?.toLocaleString() || 'N/A'}</div>
|
||||
<div><strong>Duration:</strong> {session.duration ? `${session.duration}s` : 'N/A'}</div>
|
||||
|
||||
<div><strong>Model:</strong> {session.model || 'unknown'}</div>
|
||||
<div><strong>Cost:</strong> ${session.cost?.toFixed(4) || '0.0000'}</div>
|
||||
|
||||
<div><strong>Tokens Used:</strong> {session.tokensUsed || 0}</div>
|
||||
<div><strong>Files Modified:</strong> {session.filesModified?.length || 0}</div>
|
||||
</div>
|
||||
|
||||
{session.filesModified && session.filesModified.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-primary hover:underline text-sm">
|
||||
View Modified Files ({session.filesModified.length})
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-muted rounded text-xs space-y-1">
|
||||
{session.filesModified.map((file: string, idx: number) => (
|
||||
<div key={idx} className="font-mono">{file}</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{session.conversationSummary && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-primary hover:underline text-sm">
|
||||
View Conversation Summary
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-muted rounded text-sm">
|
||||
{session.conversationSummary}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
||||
import { RightPanel } from "@/components/layout/right-panel";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function UsersLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = useState<string>("users");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Left Rail - Workspace Navigation */}
|
||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - AI Chat */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, UserPlus, Crown, Mail } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
interface WorkspaceUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
joinedAt: any;
|
||||
lastActive: any;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const [users, setUsers] = useState<WorkspaceUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentUser, setCurrentUser] = useState<WorkspaceUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const response = await fetch(`/api/workspace/${workspace}/users`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
setCurrentUser(data.currentUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
toast.error('Failed to load workspace users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'bg-purple-500/10 text-purple-600 border-purple-500/20';
|
||||
case 'admin':
|
||||
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-600 border-gray-500/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex-1 p-8 space-y-8 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">Team Members</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Manage workspace access and team collaboration
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Current User Info */}
|
||||
{currentUser && (
|
||||
<Card className="border-primary/50 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Crown className="h-4 w-4" />
|
||||
Your Account
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{currentUser.displayName || 'Unknown'}</p>
|
||||
<p className="text-sm text-muted-foreground">{currentUser.email}</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium border ${getRoleBadgeColor(currentUser.role)}`}>
|
||||
{currentUser.role}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Users List */}
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">Loading team members...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">No team members yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Invite team members to collaborate on projects in this workspace
|
||||
</p>
|
||||
<Button disabled>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Your First Team Member
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => (
|
||||
<Card key={user.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.displayName || 'Unknown'}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
{user.lastActive && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Last active: {new Date(user.lastActive._seconds * 1000).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium border ${getRoleBadgeColor(user.role)}`}>
|
||||
{user.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="border-blue-500/20 bg-blue-500/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Team Collaboration (Coming Soon)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>👥 Team Workspaces:</strong> Invite team members to collaborate on projects together.
|
||||
</p>
|
||||
<p>
|
||||
<strong>🔐 Role-Based Access:</strong> Control what team members can see and do with flexible permissions.
|
||||
</p>
|
||||
<p>
|
||||
<strong>💬 Shared Context:</strong> All team members can access shared AI chat history and project documentation.
|
||||
</p>
|
||||
<p className="text-xs italic pt-2">
|
||||
This feature is currently in development. Check back soon!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
@@ -1,151 +1,96 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
|
||||
import { FieldValue } from 'firebase-admin/firestore';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
/**
|
||||
* Store GitHub connection for authenticated user
|
||||
* Encrypts and stores the access token securely
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const idToken = authHeader.split('Bearer ')[1];
|
||||
const adminAuth = getAdminAuth();
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const decodedToken = await adminAuth.verifyIdToken(idToken);
|
||||
userId = decodedToken.uid;
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { accessToken, githubUser } = await request.json();
|
||||
|
||||
if (!accessToken || !githubUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Encrypt the access token before storing
|
||||
// For now, we'll store it directly (should use crypto.subtle or a library)
|
||||
const encryptedToken = accessToken; // PLACEHOLDER
|
||||
await query(
|
||||
`UPDATE fs_users
|
||||
SET data = data || $1::jsonb, updated_at = NOW()
|
||||
WHERE data->>'email' = $2`,
|
||||
[
|
||||
JSON.stringify({
|
||||
githubConnected: true,
|
||||
githubUserId: githubUser.id,
|
||||
githubUsername: githubUser.login,
|
||||
githubName: githubUser.name,
|
||||
githubEmail: githubUser.email,
|
||||
githubAvatarUrl: githubUser.avatar_url,
|
||||
githubAccessToken: accessToken,
|
||||
githubConnectedAt: new Date().toISOString(),
|
||||
}),
|
||||
session.user.email,
|
||||
]
|
||||
);
|
||||
|
||||
// Store GitHub connection
|
||||
const connectionRef = adminDb.collection('githubConnections').doc(userId);
|
||||
await connectionRef.set({
|
||||
userId,
|
||||
githubUserId: githubUser.id,
|
||||
githubUsername: githubUser.login,
|
||||
githubName: githubUser.name,
|
||||
githubEmail: githubUser.email,
|
||||
githubAvatarUrl: githubUser.avatar_url,
|
||||
accessToken: encryptedToken,
|
||||
connectedAt: FieldValue.serverTimestamp(),
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
githubUsername: githubUser.login,
|
||||
});
|
||||
return NextResponse.json({ success: true, githubUsername: githubUser.login });
|
||||
} catch (error) {
|
||||
console.error('[GitHub Connect] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to store GitHub connection' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Failed to store GitHub connection' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub connection status for authenticated user
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const idToken = authHeader.split('Bearer ')[1];
|
||||
const adminAuth = getAdminAuth();
|
||||
const adminDb = getAdminDb();
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||
[session.user.email]
|
||||
);
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const decodedToken = await adminAuth.verifyIdToken(idToken);
|
||||
userId = decodedToken.uid;
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const connectionDoc = await adminDb
|
||||
.collection('githubConnections')
|
||||
.doc(userId)
|
||||
.get();
|
||||
|
||||
if (!connectionDoc.exists) {
|
||||
if (rows.length === 0 || !rows[0].data?.githubConnected) {
|
||||
return NextResponse.json({ connected: false });
|
||||
}
|
||||
|
||||
const data = connectionDoc.data()!;
|
||||
|
||||
const d = rows[0].data;
|
||||
return NextResponse.json({
|
||||
connected: true,
|
||||
githubUsername: data.githubUsername,
|
||||
githubName: data.githubName,
|
||||
githubAvatarUrl: data.githubAvatarUrl,
|
||||
connectedAt: data.connectedAt,
|
||||
lastSyncedAt: data.lastSyncedAt,
|
||||
githubUsername: d.githubUsername,
|
||||
githubName: d.githubName,
|
||||
githubAvatarUrl: d.githubAvatarUrl,
|
||||
connectedAt: d.githubConnectedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[GitHub Connect] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch GitHub connection' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Failed to fetch GitHub connection' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect GitHub account
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const idToken = authHeader.split('Bearer ')[1];
|
||||
const adminAuth = getAdminAuth();
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const decodedToken = await adminAuth.verifyIdToken(idToken);
|
||||
userId = decodedToken.uid;
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
await adminDb.collection('githubConnections').doc(userId).delete();
|
||||
await query(
|
||||
`UPDATE fs_users
|
||||
SET data = data - 'githubConnected' - 'githubUserId' - 'githubUsername'
|
||||
- 'githubName' - 'githubEmail' - 'githubAvatarUrl'
|
||||
- 'githubAccessToken' - 'githubConnectedAt',
|
||||
updated_at = NOW()
|
||||
WHERE data->>'email' = $1`,
|
||||
[session.user.email]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[GitHub Disconnect] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to disconnect GitHub' },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Failed to disconnect GitHub' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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' },
|
||||
});
|
||||
}
|
||||
209
app/api/projects/[projectId]/agent-chat/route.ts
Normal file
209
app/api/projects/[projectId]/agent-chat/route.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth/authOptions";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — chat_conversations + fs_knowledge_items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadConversation(projectId: string): Promise<any[]> {
|
||||
try {
|
||||
const rows = await query<{ messages: any[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
return rows[0]?.messages ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConversation(projectId: string, messages: any[]): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO chat_conversations (project_id, messages, updated_at)
|
||||
VALUES ($1, $2::jsonb, NOW())
|
||||
ON CONFLICT (project_id) DO UPDATE
|
||||
SET messages = $2::jsonb, updated_at = NOW()`,
|
||||
[projectId, JSON.stringify(messages)]
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[agent-chat] Failed to save conversation:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadKnowledge(projectId: string): Promise<string> {
|
||||
try {
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC LIMIT 50`,
|
||||
[projectId]
|
||||
);
|
||||
if (rows.length === 0) return "";
|
||||
return rows
|
||||
.map((r) => `[${r.data.type ?? "note"}] ${r.data.key}: ${r.data.value}`)
|
||||
.join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMemoryUpdates(
|
||||
projectId: string,
|
||||
updates: Array<{ key: string; type: string; value: string }>
|
||||
): Promise<void> {
|
||||
if (!updates?.length) return;
|
||||
try {
|
||||
for (const u of updates) {
|
||||
// Upsert by project_id + key
|
||||
const existing = await query<{ id: string }>(
|
||||
`SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`,
|
||||
[projectId, u.key]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
await query(
|
||||
`UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||
[JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" }), existing[0].id]
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb)`,
|
||||
[projectId, JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" })]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[agent-chat] Failed to save memory updates:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST — send a message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ 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 { message } = await req.json();
|
||||
|
||||
if (!message?.trim()) {
|
||||
return NextResponse.json({ error: '"message" is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Load project context
|
||||
let projectContext = "";
|
||||
try {
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
const p = rows[0].data;
|
||||
const lines = [
|
||||
`Project: ${p.productName ?? p.name ?? "Unnamed"}`,
|
||||
p.productVision ? `Vision: ${p.productVision}` : null,
|
||||
p.giteaRepo ? `Gitea repo: ${p.giteaRepo}` : null,
|
||||
p.coolifyAppUuid ? `Coolify app UUID: ${p.coolifyAppUuid}` : null,
|
||||
p.deploymentUrl ? `Live URL: ${p.deploymentUrl}` : null,
|
||||
p.theiaWorkspaceUrl ? `IDE: ${p.theiaWorkspaceUrl}` : null,
|
||||
].filter(Boolean);
|
||||
projectContext = lines.join("\n");
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
const sessionId = `project_${projectId}`;
|
||||
|
||||
// Load persistent conversation history and knowledge from DB
|
||||
const [history, knowledgeContext] = await Promise.all([
|
||||
loadConversation(projectId),
|
||||
loadKnowledge(projectId),
|
||||
]);
|
||||
|
||||
// Enrich user message with project context on the very first message
|
||||
const isFirstMessage = history.length === 0;
|
||||
const enrichedMessage =
|
||||
isFirstMessage && projectContext
|
||||
? `[Project context]\n${projectContext}\n\n[User message]\n${message}`
|
||||
: message;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
message: enrichedMessage,
|
||||
session_id: sessionId,
|
||||
history,
|
||||
knowledge_context: knowledgeContext || undefined,
|
||||
}),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Agent runner error: ${res.status} — ${errText.slice(0, 200)}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Persist conversation and any memory updates the AI generated
|
||||
await Promise.all([
|
||||
saveConversation(projectId, data.history ?? []),
|
||||
saveMemoryUpdates(projectId, data.memoryUpdates ?? []),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
reply: data.reply,
|
||||
reasoning: data.reasoning ?? null,
|
||||
toolCalls: data.toolCalls ?? [],
|
||||
turns: data.turns ?? 0,
|
||||
model: data.model || null,
|
||||
sessionId,
|
||||
memoryUpdates: data.memoryUpdates ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json(
|
||||
{ error: msg.includes("fetch") ? "Agent runner is offline" : msg },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE — clear session + conversation history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ 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 sessionId = `project_${projectId}`;
|
||||
|
||||
await Promise.all([
|
||||
// Clear in-memory session from agent runner
|
||||
fetch(`${AGENT_RUNNER_URL}/orchestrator/sessions/${sessionId}`, { method: "DELETE" }).catch(() => {}),
|
||||
// Clear persisted conversation from DB
|
||||
query(`DELETE FROM chat_conversations WHERE project_id = $1`, [projectId]).catch(() => {}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ cleared: true });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
152
app/api/projects/[projectId]/apps/route.ts
Normal file
152
app/api/projects/[projectId]/apps/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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: 30 },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET — returns the project's apps/ directories from Gitea + saved designPackages.
|
||||
* Response: { apps: [{ name, path, type }], designPackages: { appName: packageId } }
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
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/sportsy"
|
||||
const designPackages = (data.designPackages ?? {}) as Record<string, string>;
|
||||
|
||||
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`);
|
||||
apps = contents
|
||||
.filter((item) => item.type === 'dir')
|
||||
.map(({ name, path }) => ({ name, path }));
|
||||
} catch {
|
||||
// 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, isImport: !!(data.isImport || data.creationMode === 'migration') });
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — saves { appName, packageId } → stored in fs_projects.data.designPackages
|
||||
*/
|
||||
export async function PATCH(
|
||||
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 });
|
||||
}
|
||||
|
||||
const { appName, packageId } = await req.json() as { appName: string; packageId: string };
|
||||
|
||||
if (!appName || !packageId) {
|
||||
return NextResponse.json({ error: 'appName and packageId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE fs_projects p
|
||||
SET data = data || jsonb_build_object(
|
||||
'designPackages',
|
||||
COALESCE(data->'designPackages', '{}'::jsonb) || jsonb_build_object($3, $4)
|
||||
),
|
||||
updated_at = NOW()
|
||||
FROM fs_users u
|
||||
WHERE p.id = $1 AND p.user_id = u.id AND u.data->>'email' = $2`,
|
||||
[projectId, session.user.email, appName, packageId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
206
app/api/projects/[projectId]/architecture/route.ts
Normal file
206
app/api/projects/[projectId]/architecture/route.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
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 — return saved architecture (if it exists)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ 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;
|
||||
|
||||
try {
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
const data = rows[0]?.data ?? {};
|
||||
return NextResponse.json({
|
||||
architecture: data.architecture ?? null,
|
||||
prd: data.prd ?? null,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ architecture: null, prd: null });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST — generate architecture recommendation from PRD using AI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ 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 body = await req.json().catch(() => ({}));
|
||||
const forceRegenerate = body.forceRegenerate === true;
|
||||
|
||||
// Load project PRD + phases
|
||||
let prd: string | null = null;
|
||||
let phases: any[] = [];
|
||||
|
||||
try {
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
const data = rows[0]?.data ?? {};
|
||||
prd = data.prd ?? null;
|
||||
|
||||
// Return cached architecture if it exists and not forcing regenerate
|
||||
if (data.architecture && !forceRegenerate) {
|
||||
return NextResponse.json({ architecture: data.architecture, cached: true });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!prd) {
|
||||
return NextResponse.json({ error: "No PRD found — complete discovery first" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const phaseRows = await query<{ phase: string; title: string; summary: string; data: any }>(
|
||||
`SELECT phase, title, summary, data FROM atlas_phases WHERE project_id = $1 ORDER BY saved_at ASC`,
|
||||
[projectId]
|
||||
);
|
||||
phases = phaseRows;
|
||||
} catch { /* phases optional */ }
|
||||
|
||||
// Build a concise context string from phases
|
||||
const phaseContext = phases.map(p =>
|
||||
`## ${p.title}\n${p.summary}\n${JSON.stringify(p.data, null, 2)}`
|
||||
).join("\n\n");
|
||||
|
||||
const prompt = `You are a senior software architect. Analyse the following Product Requirements Document and recommend a technical architecture for a Turborepo monorepo.
|
||||
|
||||
Return ONLY a valid JSON object (no markdown, no explanation) with this exact structure:
|
||||
{
|
||||
"productName": "string",
|
||||
"productType": "string (e.g. PWA Game, SaaS, Marketplace, Internal Tool)",
|
||||
"summary": "2-3 sentence plain-English summary of the recommended architecture",
|
||||
"apps": [
|
||||
{
|
||||
"name": "string (e.g. web, api, simulator)",
|
||||
"type": "string (e.g. Next.js 15, Express API, Node.js service)",
|
||||
"description": "string — what this app does",
|
||||
"tech": ["string array of key technologies"],
|
||||
"screens": ["string array — key screens/routes if applicable, else empty"]
|
||||
}
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"name": "string (e.g. db, types, ui)",
|
||||
"description": "string — what this shared package contains"
|
||||
}
|
||||
],
|
||||
"infrastructure": [
|
||||
{
|
||||
"name": "string (e.g. PostgreSQL, Redis, Background Jobs)",
|
||||
"reason": "string — why this is needed based on the PRD"
|
||||
}
|
||||
],
|
||||
"integrations": [
|
||||
{
|
||||
"name": "string (e.g. Ad Network SDK)",
|
||||
"required": true,
|
||||
"notes": "string"
|
||||
}
|
||||
],
|
||||
"designSurfaces": ["string array — e.g. Web App, Mobile PWA, Admin"],
|
||||
"riskNotes": ["string array — 1-2 key architectural risks from the PRD"]
|
||||
}
|
||||
|
||||
Be specific to this product. Do not use generic boilerplate — base your decisions on the PRD content.
|
||||
|
||||
--- DISCOVERY PHASES ---
|
||||
${phaseContext}
|
||||
|
||||
--- PRD ---
|
||||
${prd}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${AGENT_RUNNER_URL}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt }),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Agent runner responded ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const raw = data.reply ?? "";
|
||||
|
||||
// Extract JSON from response (strip any accidental markdown)
|
||||
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error("No JSON in response");
|
||||
|
||||
const architecture = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// Persist to project data
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architecture}', $2::jsonb, true),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[projectId, JSON.stringify(architecture)]
|
||||
);
|
||||
|
||||
return NextResponse.json({ architecture, cached: false });
|
||||
} catch (err) {
|
||||
console.error("[architecture] Generation failed:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Architecture generation failed. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH — confirm architecture (sets architectureConfirmed flag)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function PATCH(
|
||||
_req: NextRequest,
|
||||
{ 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;
|
||||
|
||||
try {
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architectureConfirmed}', 'true'::jsonb, true),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
return NextResponse.json({ confirmed: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to confirm" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
204
app/api/projects/[projectId]/atlas-chat/route.ts
Normal file
204
app/api/projects/[projectId]/atlas-chat/route.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth/authOptions";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — atlas_conversations table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tableReady = false;
|
||||
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS atlas_conversations (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
async function loadAtlasHistory(projectId: string): Promise<any[]> {
|
||||
try {
|
||||
await ensureTable();
|
||||
const rows = await query<{ messages: any[] }>(
|
||||
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
return rows[0]?.messages ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAtlasHistory(projectId: string, messages: any[]): Promise<void> {
|
||||
try {
|
||||
await ensureTable();
|
||||
await query(
|
||||
`INSERT INTO atlas_conversations (project_id, messages, updated_at)
|
||||
VALUES ($1, $2::jsonb, NOW())
|
||||
ON CONFLICT (project_id) DO UPDATE
|
||||
SET messages = $2::jsonb, updated_at = NOW()`,
|
||||
[projectId, JSON.stringify(messages)]
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[atlas-chat] Failed to save history:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePrd(projectId: string, prdContent: string): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data || jsonb_build_object('prd', $2::text, 'stage', 'architecture'),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[projectId, prdContent]
|
||||
);
|
||||
console.log(`[atlas-chat] PRD saved for project ${projectId}`);
|
||||
} catch (e) {
|
||||
console.error("[atlas-chat] Failed to save PRD:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET — load stored conversation messages for display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ 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 history = await loadAtlasHistory(projectId);
|
||||
|
||||
// Filter to only user/assistant messages (no system prompts) for display
|
||||
const messages = history
|
||||
.filter((m: any) => m.role === "user" || m.role === "assistant")
|
||||
.map((m: any) => ({ role: m.role as "user" | "assistant", content: m.content as string }));
|
||||
|
||||
return NextResponse.json({ messages });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST — send message to Atlas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ 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 { message } = await req.json();
|
||||
if (!message?.trim()) {
|
||||
return NextResponse.json({ error: "message is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sessionId = `atlas_${projectId}`;
|
||||
|
||||
// Load conversation history from DB to persist across agent runner restarts.
|
||||
// Strip tool_call / tool_response messages — replaying them across sessions
|
||||
// causes Gemini to reject the request with a turn-ordering error.
|
||||
const rawHistory = await loadAtlasHistory(projectId);
|
||||
const history = rawHistory.filter((m: any) =>
|
||||
(m.role === "user" || m.role === "assistant") && m.content
|
||||
);
|
||||
|
||||
// __init__ is a special internal trigger used only when there is no existing history.
|
||||
// If history already exists, ignore the init request (conversation already started).
|
||||
const isInit = message.trim() === "__atlas_init__";
|
||||
if (isInit && history.length > 0) {
|
||||
return NextResponse.json({ reply: null, alreadyStarted: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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 Vibn and ask what the user is building. Do not acknowledge this as an internal trigger."
|
||||
: message,
|
||||
session_id: sessionId,
|
||||
history,
|
||||
is_init: isInit,
|
||||
}),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error("[atlas-chat] Agent runner error:", text);
|
||||
return NextResponse.json(
|
||||
{ error: "Vibn is unavailable. Please try again." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Persist updated history
|
||||
await saveAtlasHistory(projectId, data.history ?? []);
|
||||
|
||||
// If Atlas finalized the PRD, save it to the project
|
||||
if (data.prdContent) {
|
||||
await savePrd(projectId, data.prdContent);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
reply: data.reply,
|
||||
sessionId,
|
||||
prdContent: data.prdContent ?? null,
|
||||
model: data.model ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[atlas-chat] Error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Request timed out or failed. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE — clear Atlas conversation for this project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ 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 sessionId = `atlas_${projectId}`;
|
||||
|
||||
try {
|
||||
await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${sessionId}`, { method: "DELETE" });
|
||||
} catch { /* runner may be down */ }
|
||||
|
||||
try {
|
||||
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
|
||||
} catch { /* table may not exist yet */ }
|
||||
|
||||
return NextResponse.json({ cleared: true });
|
||||
}
|
||||
108
app/api/projects/[projectId]/design-surfaces/route.ts
Normal file
108
app/api/projects/[projectId]/design-surfaces/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
/**
|
||||
* GET — returns surfaces[] and surfaceThemes{} for the project.
|
||||
*/
|
||||
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 ?? {};
|
||||
return NextResponse.json({
|
||||
surfaces: (data.surfaces ?? []) as string[],
|
||||
surfaceThemes: (data.surfaceThemes ?? {}) as Record<string, string>,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[design-surfaces GET]', err);
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — two operations:
|
||||
* { surfaces: string[] } — save the active surface list
|
||||
* { surface: string, theme: string } — lock in a theme for one surface
|
||||
*/
|
||||
export async function PATCH(
|
||||
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 });
|
||||
|
||||
// Step 1: read current data — explicit ::text casts on every param
|
||||
let rows: { data: Record<string, unknown> }[];
|
||||
try {
|
||||
rows = await query<{ data: Record<string, unknown> }>(
|
||||
`SELECT p.data FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1::text AND u.data->>'email' = $2::text
|
||||
LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
);
|
||||
} catch (selErr) {
|
||||
console.error('[design-surfaces PATCH] SELECT failed:', selErr);
|
||||
return NextResponse.json({ error: 'Internal error (select)' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (rows.length === 0) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
|
||||
const current = rows[0].data ?? {};
|
||||
const body = await req.json() as
|
||||
| { surfaces: string[] }
|
||||
| { surface: string; theme: string };
|
||||
|
||||
let updated: Record<string, unknown>;
|
||||
|
||||
if ('surfaces' in body) {
|
||||
updated = { ...current, surfaces: body.surfaces, updatedAt: new Date().toISOString() };
|
||||
} else if ('surface' in body && 'theme' in body) {
|
||||
const existing = (current.surfaceThemes ?? {}) as Record<string, string>;
|
||||
updated = {
|
||||
...current,
|
||||
surfaceThemes: { ...existing, [body.surface]: body.theme },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Step 2: write back — explicit ::text cast on id param, ::jsonb on data param
|
||||
try {
|
||||
await query(
|
||||
`UPDATE fs_projects SET data = $1::jsonb WHERE id = $2::text`,
|
||||
[JSON.stringify(updated), projectId]
|
||||
);
|
||||
} catch (updErr) {
|
||||
console.error('[design-surfaces PATCH] UPDATE failed:', updErr);
|
||||
return NextResponse.json({ error: 'Internal error (update)' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[design-surfaces PATCH]', err);
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { createKnowledgeItem } from '@/lib/server/knowledge';
|
||||
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
|
||||
|
||||
@@ -32,9 +34,12 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
|
||||
if (!projectSnap.exists) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const projectRows = await query(`SELECT id FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId]);
|
||||
if (projectRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -8,74 +10,29 @@ export async function GET(
|
||||
try {
|
||||
const { projectId } = await params;
|
||||
|
||||
// Authentication (skip in development if no auth header)
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const auth = getAdminAuth();
|
||||
const decoded = await auth.verifyIdToken(token);
|
||||
|
||||
if (!decoded?.uid) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch knowledge items from Firestore
|
||||
console.log('[API /knowledge/items] Fetching items for project:', projectId);
|
||||
|
||||
let items = [];
|
||||
|
||||
try {
|
||||
const adminDb = getAdminDb();
|
||||
const knowledgeSnapshot = await adminDb
|
||||
.collection('knowledge')
|
||||
.where('projectId', '==', projectId)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(100)
|
||||
.get();
|
||||
|
||||
console.log('[API /knowledge/items] Found', knowledgeSnapshot.size, 'items');
|
||||
|
||||
items = knowledgeSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
title: data.title || data.content?.substring(0, 50) || 'Untitled',
|
||||
sourceType: data.sourceType,
|
||||
content: data.content,
|
||||
sourceMeta: data.sourceMeta,
|
||||
createdAt: data.createdAt?.toDate?.()?.toISOString() || data.createdAt,
|
||||
updatedAt: data.updatedAt?.toDate?.()?.toISOString() || data.updatedAt,
|
||||
};
|
||||
});
|
||||
} catch (firestoreError) {
|
||||
console.error('[API /knowledge/items] Firestore query failed:', firestoreError);
|
||||
console.error('[API /knowledge/items] This is likely due to missing Firebase Admin credentials or Firestore not being set up');
|
||||
// Return empty array instead of failing - the UI will show "No chats yet" and "No images yet"
|
||||
items = [];
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
items,
|
||||
count: items.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API /knowledge/items] Error fetching knowledge items:', error);
|
||||
console.error('[API /knowledge/items] Error stack:', error instanceof Error ? error.stack : 'No stack trace');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch knowledge items',
|
||||
details: error instanceof Error ? error.message : String(error)
|
||||
},
|
||||
{ status: 500 }
|
||||
const rows = await query<{ id: string; data: any; created_at: string; updated_at: string }>(
|
||||
`SELECT id, data, created_at, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.data?.title || row.data?.content?.substring(0, 50) || 'Untitled',
|
||||
sourceType: row.data?.sourceType,
|
||||
content: row.data?.content,
|
||||
sourceMeta: row.data?.sourceMeta,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ success: true, items, count: items.length });
|
||||
} catch (error) {
|
||||
console.error('[API /knowledge/items] Error:', error);
|
||||
return NextResponse.json({ success: true, items: [], count: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
103
app/api/projects/[projectId]/knowledge/route.ts
Normal file
103
app/api/projects/[projectId]/knowledge/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth/authOptions";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
async function assertOwnership(projectId: string, email: string): Promise<boolean> {
|
||||
const rows = await query(
|
||||
`SELECT p.id FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, email]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// GET /api/projects/[projectId]/knowledge
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ 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;
|
||||
if (!(await assertOwnership(projectId, session.user.email))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rows = await query<{ id: string; data: any; updated_at: string }>(
|
||||
`SELECT id, data, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
items: rows.map((r) => ({ id: r.id, ...r.data, updatedAt: r.updated_at })),
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/projects/[projectId]/knowledge — add or update an item
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ 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;
|
||||
if (!(await assertOwnership(projectId, session.user.email))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { key, type, value } = await req.json();
|
||||
if (!key || !value) {
|
||||
return NextResponse.json({ error: "key and value are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const itemType = type ?? "note";
|
||||
const data = JSON.stringify({ key, type: itemType, value, source: "user" });
|
||||
|
||||
// Upsert by key
|
||||
const existing = await query<{ id: string }>(
|
||||
`SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`,
|
||||
[projectId, key]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await query(
|
||||
`UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||
[data, existing[0].id]
|
||||
);
|
||||
return NextResponse.json({ id: existing[0].id, key, type: itemType, value, updated: true });
|
||||
} else {
|
||||
const rows = await query<{ id: string }>(
|
||||
`INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb) RETURNING id`,
|
||||
[projectId, data]
|
||||
);
|
||||
return NextResponse.json({ id: rows[0].id, key, type: itemType, value, created: true });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/projects/[projectId]/knowledge?id=xxx
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ 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;
|
||||
if (!(await assertOwnership(projectId, session.user.email))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
|
||||
await query(
|
||||
`DELETE FROM fs_knowledge_items WHERE id = $1 AND project_id = $2`,
|
||||
[id, projectId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ deleted: true });
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user