Compare commits
136 Commits
8e6406232d
...
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 |
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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ FROM base AS deps
|
|||||||
RUN apk add --no-cache libc6-compat python3 make g++
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --legacy-peer-deps --ignore-scripts
|
RUN npm install --legacy-peer-deps --ignore-scripts
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
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/@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
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Scaffold templates are read at runtime via fs — must be in the runner image
|
||||||
|
COPY --from=builder /app/lib/scaffold ./lib/scaffold
|
||||||
|
|
||||||
# Copy and set up entrypoint
|
# Copy and set up entrypoint
|
||||||
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function FeaturesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container py-8 md:py-12 lg:py-24">
|
<div className="container py-8 md:py-12 lg:py-24">
|
||||||
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4">
|
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4">
|
||||||
<h1 className="text-4xl font-extrabold leading-tight tracking-tighter md:text-6xl lg:leading-[1.1]">
|
<h1 className="font-serif text-4xl font-bold leading-tight tracking-tight md:text-6xl lg:leading-[1.1]">
|
||||||
Powerful Features for AI Developers
|
Powerful Features for AI Developers
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[750px] text-center text-lg text-muted-foreground">
|
<p className="max-w-[750px] text-center text-lg text-muted-foreground">
|
||||||
@@ -30,7 +30,7 @@ export default function FeaturesPage() {
|
|||||||
<div className="mx-auto grid max-w-6xl grid-cols-1 gap-6 pt-12 md:grid-cols-2 lg:grid-cols-3">
|
<div className="mx-auto grid max-w-6xl grid-cols-1 gap-6 pt-12 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Code2 className="h-12 w-12 text-blue-600" />
|
<Code2 className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Automatic Session Tracking</CardTitle>
|
<CardTitle>Automatic Session Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Every coding session is automatically captured with zero configuration.
|
Every coding session is automatically captured with zero configuration.
|
||||||
@@ -48,7 +48,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Brain className="h-12 w-12 text-purple-600" />
|
<Brain className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>AI Usage Analytics</CardTitle>
|
<CardTitle>AI Usage Analytics</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Deep insights into how you and your team use AI tools.
|
Deep insights into how you and your team use AI tools.
|
||||||
@@ -66,7 +66,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<DollarSign className="h-12 w-12 text-green-600" />
|
<DollarSign className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Cost Tracking</CardTitle>
|
<CardTitle>Cost Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Real-time cost monitoring for all your AI services.
|
Real-time cost monitoring for all your AI services.
|
||||||
@@ -84,7 +84,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Clock className="h-12 w-12 text-orange-600" />
|
<Clock className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Productivity Metrics</CardTitle>
|
<CardTitle>Productivity Metrics</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Track your velocity and identify productivity patterns.
|
Track your velocity and identify productivity patterns.
|
||||||
@@ -102,7 +102,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Github className="h-12 w-12 text-gray-600" />
|
<Github className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>GitHub Integration</CardTitle>
|
<CardTitle>GitHub Integration</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Connect your repositories for comprehensive code analysis.
|
Connect your repositories for comprehensive code analysis.
|
||||||
@@ -120,7 +120,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Sparkles className="h-12 w-12 text-pink-600" />
|
<Sparkles className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Smart Summaries</CardTitle>
|
<CardTitle>Smart Summaries</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
AI-powered summaries of your work and progress.
|
AI-powered summaries of your work and progress.
|
||||||
@@ -138,7 +138,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Users className="h-12 w-12 text-cyan-600" />
|
<Users className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Team Collaboration</CardTitle>
|
<CardTitle>Team Collaboration</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Built for teams working with AI tools together.
|
Built for teams working with AI tools together.
|
||||||
@@ -156,7 +156,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<FileCode className="h-12 w-12 text-indigo-600" />
|
<FileCode className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Code Quality Tracking</CardTitle>
|
<CardTitle>Code Quality Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Monitor code quality and AI-generated code effectiveness.
|
Monitor code quality and AI-generated code effectiveness.
|
||||||
@@ -174,7 +174,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<TrendingUp className="h-12 w-12 text-emerald-600" />
|
<TrendingUp className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Trend Analysis</CardTitle>
|
<CardTitle>Trend Analysis</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Understand long-term patterns in your development process.
|
Understand long-term patterns in your development process.
|
||||||
@@ -192,7 +192,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Shield className="h-12 w-12 text-red-600" />
|
<Shield className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Privacy & Security</CardTitle>
|
<CardTitle>Privacy & Security</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your code and data stay private and secure.
|
Your code and data stay private and secure.
|
||||||
@@ -210,7 +210,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Zap className="h-12 w-12 text-yellow-600" />
|
<Zap className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Real-Time Insights</CardTitle>
|
<CardTitle>Real-Time Insights</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Get instant feedback as you code.
|
Get instant feedback as you code.
|
||||||
@@ -228,7 +228,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<BarChart3 className="h-12 w-12 text-violet-600" />
|
<BarChart3 className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Custom Reports</CardTitle>
|
<CardTitle>Custom Reports</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Create custom reports tailored to your needs.
|
Create custom reports tailored to your needs.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function MarketingLayout({
|
|||||||
alt="Vib'n"
|
alt="Vib'n"
|
||||||
className="h-8 w-8"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
<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 {
|
"use client";
|
||||||
Card,
|
|
||||||
CardContent,
|
import { Suspense } from "react";
|
||||||
CardDescription,
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
const SECTIONS = [
|
||||||
} from "@/components/ui/card";
|
{
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
id: "customers",
|
||||||
import { BarChart3, DollarSign, TrendingUp, Zap } from "lucide-react";
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
{/* Page Header */}
|
|
||||||
<div className="border-b bg-card/50 px-6 py-4">
|
{/* Left nav */}
|
||||||
<div>
|
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
<div style={NAV_GROUP}>Analytics</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
{SECTIONS.map(s => {
|
||||||
Cost analysis, token usage, and performance metrics
|
const isActive = activeId === s.id;
|
||||||
</p>
|
return (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||||
{/* Key Metrics */}
|
<div style={{ marginBottom: 24 }}>
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||||
<Card>
|
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Analytics */}
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||||
<Tabs defaultValue="costs" className="space-y-4">
|
{active.items.map(item => (
|
||||||
<TabsList>
|
<div key={item} style={{
|
||||||
<TabsTrigger value="costs">Costs</TabsTrigger>
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||||
<TabsTrigger value="tokens">Tokens</TabsTrigger>
|
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
<TabsTrigger value="performance">Performance</TabsTrigger>
|
}}>
|
||||||
</TabsList>
|
<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>
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="tokens" className="space-y-4">
|
<div style={{
|
||||||
<Card>
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||||
<CardHeader>
|
borderRadius: 12, padding: "24px 28px",
|
||||||
<CardTitle>Token Usage</CardTitle>
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
|
||||||
<CardDescription>
|
}}>
|
||||||
Token consumption over time
|
<div>
|
||||||
</CardDescription>
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
|
||||||
</CardHeader>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</CardContent>
|
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||||
</Card>
|
Give feedback
|
||||||
</TabsContent>
|
</button>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
"use client";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Server } from "lucide-react";
|
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
|
||||||
|
|
||||||
// Mock project data
|
import { useEffect, useState } from "react";
|
||||||
const MOCK_PROJECT = {
|
import { useParams } from "next/navigation";
|
||||||
id: "1",
|
import { toast } from "sonner";
|
||||||
name: "AI Proxy",
|
|
||||||
emoji: "🤖",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageProps {
|
interface Project {
|
||||||
params: Promise<{ projectId: string }>;
|
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) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
const { projectId } = await params;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div style={{
|
||||||
<PageHeader
|
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
||||||
projectId={projectId}
|
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
||||||
projectName={MOCK_PROJECT.name}
|
}}>
|
||||||
projectEmoji={MOCK_PROJECT.emoji}
|
{children}
|
||||||
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>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
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 {
|
try {
|
||||||
const rows = await query<{ data: any }>(
|
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
|
||||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
[projectId]
|
[projectId]
|
||||||
);
|
);
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
const data = rows[0].data;
|
const { data, created_at, updated_at } = rows[0];
|
||||||
return data?.productName || data?.name || "Project";
|
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) {
|
} 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({
|
export default async function ProjectLayout({
|
||||||
@@ -25,11 +49,24 @@ export default async function ProjectLayout({
|
|||||||
params: Promise<{ workspace: string; projectId: string }>;
|
params: Promise<{ workspace: string; projectId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { workspace, projectId } = await params;
|
const { workspace, projectId } = await params;
|
||||||
const projectName = await getProjectName(projectId);
|
const project = await getProjectData(projectId);
|
||||||
|
|
||||||
return (
|
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}
|
{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 { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import {
|
import { Loader2 } from "lucide-react";
|
||||||
Card,
|
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||||
CardContent,
|
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||||
CardHeader,
|
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||||
CardTitle,
|
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
productName: string;
|
productName: string;
|
||||||
productVision?: string;
|
name?: string;
|
||||||
slug?: string;
|
stage?: "discovery" | "architecture" | "building" | "active";
|
||||||
workspace?: string;
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
status?: string;
|
creationStage?: string;
|
||||||
currentPhase?: string;
|
sourceData?: {
|
||||||
projectType?: string;
|
chatText?: string;
|
||||||
// Gitea
|
repoUrl?: string;
|
||||||
giteaRepo?: string;
|
liveUrl?: string;
|
||||||
giteaRepoUrl?: string;
|
hosting?: string;
|
||||||
giteaCloneUrl?: string;
|
description?: 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" },
|
|
||||||
};
|
};
|
||||||
const cfg = map[status] ?? { label: status, icon: AlertCircle, className: "bg-gray-500/10 text-gray-500" };
|
analysisResult?: Record<string, unknown>;
|
||||||
const Icon = cfg.icon;
|
migrationPlan?: string;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectOverviewPage() {
|
export default function ProjectOverviewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const projectId = params.projectId as string;
|
const projectId = params.projectId as string;
|
||||||
const workspace = params.workspace as string;
|
|
||||||
const { status: authStatus } = useSession();
|
const { status: authStatus } = useSession();
|
||||||
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (authStatus === "authenticated") fetchProject();
|
if (authStatus !== "authenticated") {
|
||||||
else if (authStatus === "unauthenticated") setLoading(false);
|
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]);
|
}, [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 (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-32">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-6 max-w-5xl">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
||||||
<Card className="border-red-500/30 bg-red-500/5">
|
Project not found.
|
||||||
<CardContent className="py-8 text-center">
|
|
||||||
<p className="text-sm text-red-600">{error ?? "Project not found"}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snap = project.contextSnapshot;
|
const projectName = project.productName || project.name || "Untitled";
|
||||||
const gitea_url = process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.vibnai.com";
|
const mode = project.creationMode ?? "fresh";
|
||||||
|
|
||||||
|
if (mode === "chat-import") {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-6 max-w-5xl space-y-6">
|
<ChatImportMain
|
||||||
|
projectId={projectId}
|
||||||
{/* ── Header ── */}
|
projectName={projectName}
|
||||||
<div className="flex items-start justify-between">
|
sourceData={project.sourceData}
|
||||||
<div>
|
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||||
<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 ── */}
|
if (mode === "code-import") {
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
return (
|
||||||
{[
|
<CodeImportMain
|
||||||
{ label: "Sessions", value: project.stats?.sessions ?? 0 },
|
projectId={projectId}
|
||||||
{ label: "AI Cost", value: `$${(project.stats?.costs ?? 0).toFixed(2)}` },
|
projectName={projectName}
|
||||||
{ label: "Open PRs", value: snap?.openPRs?.length ?? 0 },
|
sourceData={project.sourceData}
|
||||||
{ label: "Open Issues", value: snap?.openIssues?.length ?? 0 },
|
analysisResult={project.analysisResult}
|
||||||
].map(({ label, value }) => (
|
creationStage={project.creationStage}
|
||||||
<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">
|
if (mode === "migration") {
|
||||||
|
return (
|
||||||
|
<MigrateMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult}
|
||||||
|
migrationPlan={project.migrationPlan}
|
||||||
|
creationStage={project.creationStage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* ── Code / Gitea ── */}
|
// Default: "fresh" — wraps AtlasChat with decision banner
|
||||||
<Card>
|
return (
|
||||||
<CardHeader className="pb-3">
|
<FreshIdeaMain
|
||||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
projectId={projectId}
|
||||||
<Code2 className="h-4 w-4" />
|
projectName={projectName}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { useParams, useRouter } from "next/navigation";
|
||||||
import { db, auth } from "@/lib/firebase/config";
|
import { useSession } from "next-auth/react";
|
||||||
import { doc, getDoc, updateDoc, serverTimestamp } from "firebase/firestore";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { Loader2 } from "lucide-react";
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle,
|
|
||||||
} from "@/components/ui/alert";
|
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
productName: string;
|
productName: string;
|
||||||
productVision?: string;
|
productVision?: string;
|
||||||
workspacePath?: string;
|
giteaRepo?: string;
|
||||||
workspaceName?: string;
|
giteaRepoUrl?: string;
|
||||||
githubRepo?: string;
|
status?: string;
|
||||||
chatgptUrl?: string;
|
}
|
||||||
projectType: 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() {
|
export default function ProjectSettingsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
const projectId = params.projectId as string;
|
const projectId = params.projectId as string;
|
||||||
const workspace = params.workspace as string;
|
const workspace = params.workspace as string;
|
||||||
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
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 [productName, setProductName] = useState("");
|
||||||
const [productVision, setProductVision] = 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(() => {
|
useEffect(() => {
|
||||||
const fetchProject = async () => {
|
fetch(`/api/projects/${projectId}`)
|
||||||
try {
|
.then((r) => r.json())
|
||||||
const user = auth.currentUser;
|
.then((d) => {
|
||||||
if (!user) {
|
const p = d.project;
|
||||||
toast.error('Please sign in');
|
setProject(p);
|
||||||
router.push('/auth');
|
setProductName(p?.productName ?? "");
|
||||||
return;
|
setProductVision(p?.productVision ?? "");
|
||||||
}
|
})
|
||||||
|
.catch(() => toast.error("Failed to load project"))
|
||||||
const projectDoc = await getDoc(doc(db, 'projects', projectId));
|
.finally(() => setLoading(false));
|
||||||
if (!projectDoc.exists()) {
|
}, [projectId]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const user = auth.currentUser;
|
const res = await fetch(`/api/projects/${projectId}`, {
|
||||||
if (!user) {
|
method: "PATCH",
|
||||||
toast.error('Please sign in');
|
headers: { "Content-Type": "application/json" },
|
||||||
return;
|
body: JSON.stringify({ productName, productVision }),
|
||||||
}
|
|
||||||
|
|
||||||
// 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(),
|
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
toast.success('Project settings saved!');
|
toast.success("Saved");
|
||||||
|
setProject((p) => p ? { ...p, productName, productVision } : p);
|
||||||
// Refresh project data
|
} else {
|
||||||
const projectDoc = await getDoc(doc(db, 'projects', projectId));
|
toast.error("Failed to save");
|
||||||
if (projectDoc.exists()) {
|
|
||||||
setProject({ ...projectDoc.data() as Project, id: projectDoc.id });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error saving project:', error);
|
toast.error("An error occurred");
|
||||||
toast.error('Failed to save settings');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectDirectory = async () => {
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete) { setConfirmDelete(true); return; }
|
||||||
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
// Check if File System Access API is supported
|
const res = await fetch("/api/projects/delete", {
|
||||||
if ('showDirectoryPicker' in window) {
|
method: "POST",
|
||||||
const dirHandle = await (window as any).showDirectoryPicker({
|
headers: { "Content-Type": "application/json" },
|
||||||
mode: 'read',
|
body: JSON.stringify({ projectId }),
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
if (dirHandle?.name) {
|
toast.success("Project deleted");
|
||||||
// Provide a path hint (browsers don't expose full paths for security)
|
router.push(`/${workspace}/projects`);
|
||||||
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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error('Directory picker not supported in this browser', {
|
toast.error("Failed to delete project");
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("An error occurred");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground">Project not found</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-auto">
|
<div
|
||||||
{/* Header */}
|
className="vibn-enter"
|
||||||
<div className="border-b px-6 py-4">
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
<h1 className="text-2xl font-bold">Project Settings</h1>
|
>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<div style={{ maxWidth: 480 }}>
|
||||||
Manage your project configuration and workspace settings
|
<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>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* General */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<InfoCard>
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<SectionLabel>General</SectionLabel>
|
||||||
|
<FieldLabel>Project name</FieldLabel>
|
||||||
{/* General Settings */}
|
<input
|
||||||
<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}
|
value={productName}
|
||||||
onChange={(e) => setProductName(e.target.value)}
|
onChange={(e) => setProductName(e.target.value)}
|
||||||
placeholder="My Awesome Product"
|
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", marginBottom: 16, boxSizing: "border-box" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
<FieldLabel>Description</FieldLabel>
|
||||||
|
<textarea
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="productVision">Product Vision</Label>
|
|
||||||
<Textarea
|
|
||||||
id="productVision"
|
|
||||||
value={productVision}
|
value={productVision}
|
||||||
onChange={(e) => setProductVision(e.target.value)}
|
onChange={(e) => setProductVision(e.target.value)}
|
||||||
placeholder="Describe what you're building and who it's for..."
|
rows={3}
|
||||||
rows={4}
|
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>
|
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
|
||||||
</CardContent>
|
<button
|
||||||
</Card>
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
{/* Workspace Settings */}
|
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 }}
|
||||||
<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" />
|
{saving && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
||||||
</Button>
|
{saving ? "Saving…" : "Save"}
|
||||||
</div>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
{project.workspacePath && workspacePath !== project.workspacePath && (
|
{/* Repo */}
|
||||||
<Alert className="border-orange-500/50 bg-orange-500/10">
|
{project?.giteaRepoUrl && (
|
||||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
<InfoCard>
|
||||||
<AlertTitle>Path Changed</AlertTitle>
|
<SectionLabel>Repository</SectionLabel>
|
||||||
<AlertDescription>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
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>.
|
<div style={{ flex: 1 }}>
|
||||||
<br /><br />
|
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a", fontWeight: 500 }}>{project.giteaRepo}</div>
|
||||||
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>
|
</div>
|
||||||
{project.githubRepo && (
|
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
||||||
<a
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none" }}>
|
||||||
href={`https://github.com/${project.githubRepo}`}
|
View ↗
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
View on GitHub →
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</InfoCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
{/* Collaborators */}
|
||||||
<div>
|
<InfoCard>
|
||||||
<p className="font-medium">ChatGPT Project</p>
|
<SectionLabel>Collaborators</SectionLabel>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
|
||||||
{project.chatgptUrl ? 'Connected' : 'Not connected'}
|
<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>
|
</p>
|
||||||
</div>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
{project.chatgptUrl && (
|
<button
|
||||||
<a
|
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
||||||
href={project.chatgptUrl}
|
onClick={() => toast.info("PDF export coming soon")}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
>
|
||||||
Open ChatGPT →
|
Export PRD as PDF
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</InfoCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Danger zone */}
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div style={{ background: "#fff", border: "1px solid #f5d5d5", borderRadius: 10, padding: "20px" }}>
|
||||||
<Button
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
variant="outline"
|
<div>
|
||||||
onClick={() => router.push(`/${workspace}/project/${projectId}/overview`)}
|
<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 }}
|
||||||
>
|
>
|
||||||
Cancel
|
{deleting && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
||||||
</Button>
|
{confirmDelete ? "Confirm Delete" : "Delete"}
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
</button>
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||||
import { RightPanel } from "@/components/layout/right-panel";
|
|
||||||
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
|
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
@@ -14,26 +13,16 @@ export default function ProjectsLayout({
|
|||||||
}) {
|
}) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const workspace = params.workspace as string;
|
const workspace = params.workspace as string;
|
||||||
const [activeSection, setActiveSection] = useState<string>("projects");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||||
{/* Left Rail - Workspace Navigation */}
|
<VIBNSidebar workspace={workspace} />
|
||||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
<main style={{ flex: 1, overflow: "auto" }}>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Right Panel - AI Chat */}
|
|
||||||
<RightPanel />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Association Prompt - Detects new workspaces */}
|
|
||||||
<ProjectAssociationPrompt workspace={workspace} />
|
<ProjectAssociationPrompt workspace={workspace} />
|
||||||
|
|
||||||
<Toaster position="top-center" />
|
<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 { useEffect, useState } from "react";
|
||||||
import { useSession } from "next-auth/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 { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import { ProjectCreationModal } from "@/components/project-creation-modal";
|
import { ProjectCreationModal } from "@/components/project-creation-modal";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -44,34 +15,16 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Loader2, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 {
|
interface ProjectWithStats {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
productName: string;
|
productName: string;
|
||||||
productVision?: string;
|
productVision?: string;
|
||||||
workspacePath?: string;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
giteaRepo?: string;
|
stats: { sessions: number; costs: number };
|
||||||
giteaRepoUrl?: string;
|
|
||||||
theiaWorkspaceUrl?: string;
|
|
||||||
contextSnapshot?: ContextSnapshot;
|
|
||||||
stats: {
|
|
||||||
sessions: number;
|
|
||||||
costs: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr?: string | null): string {
|
function timeAgo(dateStr?: string | null): string {
|
||||||
@@ -89,19 +42,27 @@ function timeAgo(dateStr?: string | null): string {
|
|||||||
return `${Math.floor(days / 30)}mo ago`;
|
return `${Math.floor(days / 30)}mo ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeployDot({ status }: { status?: string }) {
|
function StatusDot({ status }: { status?: string }) {
|
||||||
if (!status) return null;
|
const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#d4a04a";
|
||||||
const map: Record<string, string> = {
|
const anim = status === "building" ? "vibn-breathe 2.5s ease infinite" : "none";
|
||||||
finished: "bg-green-500",
|
|
||||||
in_progress: "bg-blue-500 animate-pulse",
|
|
||||||
queued: "bg-yellow-400",
|
|
||||||
failed: "bg-red-500",
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0, animation: anim }} />
|
||||||
className={`inline-block h-2 w-2 rounded-full ${map[status] ?? "bg-gray-400"}`}
|
);
|
||||||
title={status}
|
}
|
||||||
/>
|
|
||||||
|
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,38 +73,20 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
|
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [showCreationModal, setShowCreationModal] = useState(false);
|
|
||||||
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch("/api/projects");
|
const res = await fetch("/api/projects");
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error("Failed to fetch projects");
|
||||||
const err = await res.json();
|
|
||||||
throw new Error(err.error || "Failed to fetch projects");
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const loaded: ProjectWithStats[] = data.projects || [];
|
setProjects(data.projects ?? []);
|
||||||
setProjects(loaded);
|
} catch {
|
||||||
setError(null);
|
/* silent */
|
||||||
|
|
||||||
// Fire-and-forget: prewarm all provisioned IDE workspaces so containers
|
|
||||||
// are already running by the time the user clicks "Open IDE"
|
|
||||||
const warmUrls = loaded
|
|
||||||
.map((p) => p.theiaWorkspaceUrl)
|
|
||||||
.filter((u): u is string => Boolean(u));
|
|
||||||
if (warmUrls.length > 0) {
|
|
||||||
fetch("/api/projects/prewarm", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ urls: warmUrls }),
|
|
||||||
}).catch(() => {}); // ignore errors — this is best-effort
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : "Unknown error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -154,7 +97,7 @@ export default function ProjectsPage() {
|
|||||||
else if (status === "unauthenticated") setLoading(false);
|
else if (status === "unauthenticated") setLoading(false);
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const handleDeleteProject = async () => {
|
const handleDelete = async () => {
|
||||||
if (!projectToDelete) return;
|
if (!projectToDelete) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
@@ -178,204 +121,203 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading") {
|
const statusSummary = () => {
|
||||||
return (
|
const live = projects.filter((p) => p.status === "live").length;
|
||||||
<div className="flex items-center justify-center py-16">
|
const building = projects.filter((p) => p.status === "building").length;
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
const defining = projects.filter((p) => !p.status || p.status === "defining").length;
|
||||||
</div>
|
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 (
|
return (
|
||||||
<>
|
<div
|
||||||
<div className="container mx-auto py-8 px-4 max-w-6xl">
|
className="vibn-enter"
|
||||||
{/* Header */}
|
style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 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"}
|
{/* Header */}
|
||||||
</Badge>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
|
||||||
<DropdownMenu>
|
<div>
|
||||||
<DropdownMenuTrigger asChild onClick={(e: React.MouseEvent) => e.preventDefault()}>
|
<h1 style={{
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity">
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
|
||||||
</Button>
|
lineHeight: 1.15, marginBottom: 4,
|
||||||
</DropdownMenuTrigger>
|
}}>
|
||||||
<DropdownMenuContent align="end">
|
Projects
|
||||||
<DropdownMenuItem
|
</h1>
|
||||||
className="text-red-600 focus:text-red-600"
|
{!loading && (
|
||||||
onClick={(e: React.MouseEvent) => {
|
<p style={{ fontSize: "0.82rem", color: "#a09a90" }}>{statusSummary()}</p>
|
||||||
e.preventDefault();
|
)}
|
||||||
setProjectToDelete(project);
|
</div>
|
||||||
|
<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",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
|
||||||
Delete
|
New project
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-3">
|
{/* Loading */}
|
||||||
{/* Vision */}
|
{loading && (
|
||||||
{project.productVision && (
|
<div style={{ display: "flex", justifyContent: "center", paddingTop: 64 }}>
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
<Loader2 style={{ width: 28, height: 28, color: "#b5b0a6" }} className="animate-spin" />
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer row: deploy + stats + IDE */}
|
{/* Project list */}
|
||||||
<div className="flex items-center justify-between pt-1 border-t">
|
{!loading && (
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{deployStatus && (
|
{projects.map((p, i) => (
|
||||||
<span className="flex items-center gap-1">
|
<div
|
||||||
<DeployDot status={deployStatus} />
|
key={p.id}
|
||||||
{deployStatus === "finished" ? "Live" : deployStatus}
|
className="vibn-enter"
|
||||||
</span>
|
style={{ position: "relative", animationDelay: `${i * 0.05}s` }}
|
||||||
)}
|
|
||||||
<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" />
|
<Link
|
||||||
IDE
|
href={`/${workspace}/project/${p.id}/overview`}
|
||||||
</a>
|
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>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* 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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Create card */}
|
{/* New project card */}
|
||||||
<Card
|
<button
|
||||||
className="hover:border-primary/50 transition-all cursor-pointer border-dashed"
|
onClick={() => setShowNew(true)}
|
||||||
onClick={() => setShowCreationModal(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"; }}
|
||||||
>
|
>
|
||||||
<CardContent className="flex flex-col items-center justify-center h-full min-h-[220px] p-6">
|
+ New project
|
||||||
<div className="rounded-full bg-muted p-4 mb-3">
|
</button>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!loading && !error && projects.length === 0 && (
|
{!loading && projects.length === 0 && (
|
||||||
<Card className="border-dashed">
|
<div style={{ textAlign: "center", paddingTop: 64 }}>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
||||||
<div className="rounded-full bg-muted p-6 mb-4">
|
No projects yet
|
||||||
<Sparkles className="h-12 w-12 text-muted-foreground" />
|
</h3>
|
||||||
</div>
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 24 }}>
|
||||||
<h3 className="text-lg font-semibold mb-2">No projects yet</h3>
|
Tell Vibn what you want to build and it will figure out the rest.
|
||||||
<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>
|
</p>
|
||||||
<Button size="lg" onClick={() => setShowCreationModal(true)}>
|
<button
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
onClick={() => setShowNew(true)}
|
||||||
Create Your First Project
|
style={{
|
||||||
</Button>
|
padding: "10px 22px", borderRadius: 7,
|
||||||
</CardContent>
|
background: "#1a1a1a", color: "#fff",
|
||||||
</Card>
|
border: "none", fontSize: "0.84rem", fontWeight: 600,
|
||||||
)}
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create your first project
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ProjectCreationModal
|
<ProjectCreationModal
|
||||||
open={showCreationModal}
|
open={showNew}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => { setShowNew(open); if (!open) fetchProjects(); }}
|
||||||
setShowCreationModal(open);
|
|
||||||
if (!open) fetchProjects();
|
|
||||||
}}
|
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -391,20 +333,16 @@ export default function ProjectsPage() {
|
|||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDeleteProject}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="bg-red-600 hover:bg-red-700"
|
className="bg-red-600 hover:bg-red-700"
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Delete Project
|
Delete Project
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { WorkspaceLeftRail } from "@/components/layout/workspace-left-rail";
|
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||||
import { RightPanel } from "@/components/layout/right-panel";
|
import { ReactNode } from "react";
|
||||||
import { ReactNode, useState } from "react";
|
import { useParams } from "next/navigation";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
export default function SettingsLayout({
|
export default function SettingsLayout({
|
||||||
@@ -10,25 +10,18 @@ export default function SettingsLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [activeSection, setActiveSection] = useState<string>("settings");
|
const params = useParams();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||||
{/* Left Rail - Workspace Navigation */}
|
<VIBNSidebar workspace={workspace} />
|
||||||
<WorkspaceLeftRail activeSection={activeSection} onSectionChange={setActiveSection} />
|
<main style={{ flex: 1, overflow: "auto" }}>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Right Panel - AI Chat */}
|
|
||||||
<RightPanel />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toaster position="top-center" />
|
<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 { NextResponse } from 'next/server';
|
||||||
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
|
import { getServerSession } from 'next-auth';
|
||||||
import { FieldValue } from 'firebase-admin/firestore';
|
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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.headers.get('Authorization');
|
const session = await getServerSession(authOptions);
|
||||||
if (!authHeader?.startsWith('Bearer ')) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
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();
|
const { accessToken, githubUser } = await request.json();
|
||||||
|
|
||||||
if (!accessToken || !githubUser) {
|
if (!accessToken || !githubUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
{ error: 'Missing required fields' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Encrypt the access token before storing
|
await query(
|
||||||
// For now, we'll store it directly (should use crypto.subtle or a library)
|
`UPDATE fs_users
|
||||||
const encryptedToken = accessToken; // PLACEHOLDER
|
SET data = data || $1::jsonb, updated_at = NOW()
|
||||||
|
WHERE data->>'email' = $2`,
|
||||||
// Store GitHub connection
|
[
|
||||||
const connectionRef = adminDb.collection('githubConnections').doc(userId);
|
JSON.stringify({
|
||||||
await connectionRef.set({
|
githubConnected: true,
|
||||||
userId,
|
|
||||||
githubUserId: githubUser.id,
|
githubUserId: githubUser.id,
|
||||||
githubUsername: githubUser.login,
|
githubUsername: githubUser.login,
|
||||||
githubName: githubUser.name,
|
githubName: githubUser.name,
|
||||||
githubEmail: githubUser.email,
|
githubEmail: githubUser.email,
|
||||||
githubAvatarUrl: githubUser.avatar_url,
|
githubAvatarUrl: githubUser.avatar_url,
|
||||||
accessToken: encryptedToken,
|
githubAccessToken: accessToken,
|
||||||
connectedAt: FieldValue.serverTimestamp(),
|
githubConnectedAt: new Date().toISOString(),
|
||||||
lastSyncedAt: null,
|
}),
|
||||||
});
|
session.user.email,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({ success: true, githubUsername: githubUser.login });
|
||||||
success: true,
|
|
||||||
githubUsername: githubUser.login,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GitHub Connect] Error:', error);
|
console.error('[GitHub Connect] Error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Failed to store GitHub connection' }, { status: 500 });
|
||||||
{ error: 'Failed to store GitHub connection' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get GitHub connection status for authenticated user
|
|
||||||
*/
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.headers.get('Authorization');
|
const session = await getServerSession(authOptions);
|
||||||
if (!authHeader?.startsWith('Bearer ')) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const idToken = authHeader.split('Bearer ')[1];
|
const rows = await query<{ data: any }>(
|
||||||
const adminAuth = getAdminAuth();
|
`SELECT data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||||
const adminDb = getAdminDb();
|
[session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
let userId: string;
|
if (rows.length === 0 || !rows[0].data?.githubConnected) {
|
||||||
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) {
|
|
||||||
return NextResponse.json({ connected: false });
|
return NextResponse.json({ connected: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = connectionDoc.data()!;
|
const d = rows[0].data;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
connected: true,
|
connected: true,
|
||||||
githubUsername: data.githubUsername,
|
githubUsername: d.githubUsername,
|
||||||
githubName: data.githubName,
|
githubName: d.githubName,
|
||||||
githubAvatarUrl: data.githubAvatarUrl,
|
githubAvatarUrl: d.githubAvatarUrl,
|
||||||
connectedAt: data.connectedAt,
|
connectedAt: d.githubConnectedAt,
|
||||||
lastSyncedAt: data.lastSyncedAt,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GitHub Connect] Error:', error);
|
console.error('[GitHub Connect] Error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Failed to fetch GitHub connection' }, { status: 500 });
|
||||||
{ error: 'Failed to fetch GitHub connection' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect GitHub account
|
|
||||||
*/
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
try {
|
try {
|
||||||
const authHeader = request.headers.get('Authorization');
|
const session = await getServerSession(authOptions);
|
||||||
if (!authHeader?.startsWith('Bearer ')) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const idToken = authHeader.split('Bearer ')[1];
|
await query(
|
||||||
const adminAuth = getAdminAuth();
|
`UPDATE fs_users
|
||||||
const adminDb = getAdminDb();
|
SET data = data - 'githubConnected' - 'githubUserId' - 'githubUsername'
|
||||||
|
- 'githubName' - 'githubEmail' - 'githubAvatarUrl'
|
||||||
let userId: string;
|
- 'githubAccessToken' - 'githubConnectedAt',
|
||||||
try {
|
updated_at = NOW()
|
||||||
const decodedToken = await adminAuth.verifyIdToken(idToken);
|
WHERE data->>'email' = $1`,
|
||||||
userId = decodedToken.uid;
|
[session.user.email]
|
||||||
} catch (error) {
|
);
|
||||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await adminDb.collection('githubConnections').doc(userId).delete();
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GitHub Disconnect] Error:', error);
|
console.error('[GitHub Disconnect] Error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Failed to disconnect GitHub' }, { status: 500 });
|
||||||
{ 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 { 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 { createKnowledgeItem } from '@/lib/server/knowledge';
|
||||||
import type { KnowledgeSourceMeta } from '@/lib/types/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 });
|
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminDb = getAdminDb();
|
const session = await getServerSession(authOptions);
|
||||||
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
|
if (!session?.user?.email) {
|
||||||
if (!projectSnap.exists) {
|
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 });
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -8,74 +10,29 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
// Authentication (skip in development if no auth header)
|
const session = await getServerSession(authOptions);
|
||||||
const authHeader = request.headers.get('Authorization');
|
if (!session?.user?.email) {
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
||||||
|
|
||||||
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
|
|
||||||
if (!authHeader?.startsWith('Bearer ')) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
const rows = await query<{ id: string; data: any; created_at: string; updated_at: string }>(
|
||||||
const auth = getAdminAuth();
|
`SELECT id, data, created_at, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100`,
|
||||||
const decoded = await auth.verifyIdToken(token);
|
[projectId]
|
||||||
|
|
||||||
if (!decoded?.uid) {
|
|
||||||
return NextResponse.json({ error: 'Invalid token' }, { 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 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