feat(ai): optimize tool loops, fix deployments, and integrate new onboarding flow
This commit is contained in:
@@ -1,366 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Send,
|
||||
Loader2,
|
||||
Wrench,
|
||||
Bot,
|
||||
User,
|
||||
RotateCcw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolCalls?: string[];
|
||||
turns?: number;
|
||||
model?: string;
|
||||
reasoning?: string | null;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
interface OrchestratorChatProps {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Friendly labels for tool call names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
spawn_agent: "Dispatched agent",
|
||||
get_job_status: "Checked job",
|
||||
list_repos: "Listed repos",
|
||||
list_all_issues: "Checked issues",
|
||||
list_all_apps: "Checked deployments",
|
||||
get_app_status: "Checked app status",
|
||||
read_repo_file: "Read file",
|
||||
deploy_app: "Triggered deploy",
|
||||
gitea_create_issue: "Created issue",
|
||||
gitea_list_issues: "Listed issues",
|
||||
gitea_close_issue: "Closed issue",
|
||||
gitea_comment_issue: "Added comment",
|
||||
save_memory: "Saved to memory",
|
||||
};
|
||||
|
||||
function friendlyToolName(raw: string): string {
|
||||
return TOOL_LABELS[raw] ?? raw.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggestion chips shown before the first message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What's the current status of this project?",
|
||||
"Check if there are any open issues or PRs",
|
||||
"What was the last deployment?",
|
||||
"Write a quick summary of what's been built so far",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg }: { msg: Message }) {
|
||||
const [showReasoning, setShowReasoning] = useState(false);
|
||||
const isUser = msg.role === "user";
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`shrink-0 w-7 h-7 rounded-full flex items-center justify-center mt-0.5
|
||||
${isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
{isUser ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div className={`flex-1 max-w-[85%] space-y-1.5 ${isUser ? "items-end flex flex-col" : ""}`}>
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap
|
||||
${isUser
|
||||
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||
: msg.error
|
||||
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-tl-sm"
|
||||
: "bg-muted/60 border border-border rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
|
||||
{/* Tool calls & meta */}
|
||||
{!isUser && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1">
|
||||
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{/* Deduplicate tool calls before rendering */}
|
||||
{[...new Set(msg.toolCalls)].map((t, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-muted border border-border text-muted-foreground"
|
||||
>
|
||||
<Wrench className="h-2.5 w-2.5 shrink-0" />
|
||||
{friendlyToolName(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{msg.reasoning && (
|
||||
<button
|
||||
onClick={() => setShowReasoning(v => !v)}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
reasoning
|
||||
{showReasoning ? <ChevronUp className="h-2.5 w-2.5" /> : <ChevronDown className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
)}
|
||||
{msg.model && msg.model !== "unknown" && (
|
||||
<span className="text-[10px] text-muted-foreground/60 ml-auto">
|
||||
{msg.model.includes("glm") ? "GLM-5" : msg.model.includes("gemini") ? "Gemini" : msg.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasoning panel */}
|
||||
{!isUser && showReasoning && msg.reasoning && (
|
||||
<div className="mx-1 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground leading-relaxed font-mono max-h-40 overflow-y-auto">
|
||||
{msg.reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typing indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 w-7 h-7 rounded-full bg-muted border border-border flex items-center justify-center">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="bg-muted/60 border border-border rounded-2xl rounded-tl-sm px-4 py-3">
|
||||
<div className="flex gap-1 items-center h-4">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 animate-bounce"
|
||||
style={{ animationDelay: `${i * 0.15}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OrchestratorChat({
|
||||
projectId,
|
||||
projectName,
|
||||
placeholder = "Ask your AI team anything…",
|
||||
}: OrchestratorChatProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || loading) return;
|
||||
|
||||
setInput("");
|
||||
setMessages(prev => [...prev, { role: "user", content: trimmed }]);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/agent-chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: trimmed }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: "assistant", content: data.error ?? "Something went wrong.", error: true },
|
||||
]);
|
||||
} else {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: data.reply || "(no reply)",
|
||||
toolCalls: data.toolCalls,
|
||||
turns: data.turns,
|
||||
model: data.model,
|
||||
reasoning: data.reasoning,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: err instanceof Error ? err.message : "Network error — is the agent runner online?",
|
||||
error: true,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}
|
||||
},
|
||||
[projectId, loading]
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage(input);
|
||||
}
|
||||
};
|
||||
|
||||
const clearChat = async () => {
|
||||
setMessages([]);
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/agent-chat`, { method: "DELETE" });
|
||||
} catch { /* best-effort */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-background overflow-hidden shadow-sm">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-sm font-medium">
|
||||
{projectName ? `${projectName} AI` : "Project AI"}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">GLM-5</Badge>
|
||||
</div>
|
||||
{hasMessages && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearChat}
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{!hasMessages ? (
|
||||
/* Empty state — Lovable-style centered prompt */
|
||||
<div className="flex flex-col items-center justify-center px-6 py-10 gap-6 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-semibold">What should we build?</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your AI team is ready. Ask them anything about this project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2 max-w-lg">
|
||||
{SUGGESTIONS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => sendMessage(s)}
|
||||
className="text-xs px-3 py-1.5 rounded-full border border-border bg-muted/40 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[360px]">
|
||||
<div className="px-4 py-4 space-y-5">
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} />
|
||||
))}
|
||||
{loading && <TypingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border px-3 py-3 bg-muted/10">
|
||||
<div className="flex gap-2 items-end">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="resize-none min-h-[40px] max-h-[120px] flex-1 text-sm bg-background border-border focus-visible:ring-1 rounded-xl"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || loading}
|
||||
className="h-10 w-10 shrink-0 rounded-xl"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/50 mt-1.5 pl-1">
|
||||
Enter to send · Shift+Enter for newline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ProjectCreationModal } from "@/components/project-creation-modal";
|
||||
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const justineJakarta = Plus_Jakarta_Sans({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
variable: "--font-justine-jakarta",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
interface ProjectWithStats {
|
||||
id: string;
|
||||
productName: string;
|
||||
productVision?: string;
|
||||
status?: string;
|
||||
updatedAt: string | null;
|
||||
stats: { sessions: number; costs: number };
|
||||
}
|
||||
|
||||
const ICON_BG = ["#6366F1", "#8B5CF6", "#06B6D4", "#EC4899", "#9CA3AF"];
|
||||
|
||||
function timeAgo(dateStr?: string | null): string {
|
||||
if (!dateStr) return "—";
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return "—";
|
||||
const diff = (Date.now() - date.getTime()) / 1000;
|
||||
if (diff < 60) return "just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
const days = Math.floor(diff / 86400);
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
function greetingName(session: { user?: { name?: string | null; email?: string | null } } | null): string {
|
||||
const n = session?.user?.name?.trim();
|
||||
if (n) return n.split(/\s+/)[0] ?? "there";
|
||||
const e = session?.user?.email;
|
||||
if (e) return e.split("@")[0] ?? "there";
|
||||
return "there";
|
||||
}
|
||||
|
||||
function greetingPrefix(): string {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return "Good morning";
|
||||
if (h < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status?: string }) {
|
||||
if (status === "live") {
|
||||
return (
|
||||
<span className="pill pill-live">
|
||||
<span className="dot-live" />
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "building") {
|
||||
return (
|
||||
<span className="pill pill-building">
|
||||
<span className="dot-building" />
|
||||
Building
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="pill pill-draft">Defining</span>;
|
||||
}
|
||||
|
||||
export function JustineWorkspaceProjectsDashboard({ workspace }: { workspace: string }) {
|
||||
const { data: session, status: authStatus } = useSession();
|
||||
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const t = localStorage.getItem("jd-dashboard-theme");
|
||||
if (t === "dark") setTheme("dark");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === "light" ? "dark" : "light";
|
||||
try {
|
||||
localStorage.setItem("jd-dashboard-theme", next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
setProjects(data.projects ?? []);
|
||||
} catch {
|
||||
/* silent */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isClientDevProjectBypass()) {
|
||||
void fetchProjects();
|
||||
return;
|
||||
}
|
||||
if (authStatus === "authenticated") fetchProjects();
|
||||
else if (authStatus === "unauthenticated") setLoading(false);
|
||||
}, [authStatus, fetchProjects]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return projects;
|
||||
return projects.filter((p) => p.productName.toLowerCase().includes(q));
|
||||
}, [projects, search]);
|
||||
|
||||
const liveN = projects.filter((p) => p.status === "live").length;
|
||||
const buildingN = projects.filter((p) => p.status === "building").length;
|
||||
const totalCosts = projects.reduce((s, p) => s + (p.stats?.costs ?? 0), 0);
|
||||
|
||||
const userInitial =
|
||||
session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
|
||||
const displayName = session?.user?.name?.trim() || session?.user?.email?.split("@")[0] || "Account";
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const res = await fetch("/api/projects/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId: projectToDelete.id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Project deleted");
|
||||
setProjectToDelete(null);
|
||||
fetchProjects();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.error || "Failed to delete");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const firstName = greetingName(session);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-justine-dashboard
|
||||
data-theme={theme === "dark" ? "dark" : undefined}
|
||||
className={`${justineJakarta.variable} justine-dashboard-root`}
|
||||
>
|
||||
<nav
|
||||
className="jd-topnav"
|
||||
style={{
|
||||
background: "rgba(250,250,250,0.97)",
|
||||
borderBottom: "1px solid #E5E7EB",
|
||||
height: 56,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 24px",
|
||||
flexShrink: 0,
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 27,
|
||||
height: 27,
|
||||
background: "linear-gradient(135deg,#2E2A5E,#4338CA)",
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span className="f" style={{ fontSize: 13, fontWeight: 700, color: "#FFFFFF" }}>
|
||||
V
|
||||
</span>
|
||||
</div>
|
||||
<span className="f" style={{ fontSize: 17, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.02em" }}>
|
||||
vibn
|
||||
</span>
|
||||
</Link>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<button type="button" className="btn-secondary mob-hide" onClick={toggleTheme} style={{ fontSize: 12.5, padding: "7px 14px" }}>
|
||||
{theme === "dark" ? "☀️ Light" : "🌙 Dark"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/${workspace}/settings`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
cursor: "pointer",
|
||||
padding: "5px 8px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
fontFamily: "var(--sans)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 29,
|
||||
height: 29,
|
||||
borderRadius: "50%",
|
||||
background: "#6366F1",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 11,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{userInitial}
|
||||
</div>
|
||||
<div className="mob-hide" style={{ textAlign: "left" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)", lineHeight: 1.2 }}>{displayName}</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Workspace · {workspace}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="app-shell">
|
||||
<aside className="proj-nav">
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{ padding: "10px 8px 8px" }}>
|
||||
<Link
|
||||
href={`/${workspace}/projects`}
|
||||
id="nav-dashboard"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 11,
|
||||
padding: "11px 12px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: "rgba(255,255,255,0.55)",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
fontFamily: "var(--sans)",
|
||||
transition: "background 0.18s ease",
|
||||
backdropFilter: "blur(4px)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: "linear-gradient(135deg,#4338CA,#6366F1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 15,
|
||||
flexShrink: 0,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
⬟
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>Dashboard</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Overview</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "10px 8px 8px", marginTop: 2 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 4px", marginBottom: 8 }}>
|
||||
<div className="nav-group-label" style={{ margin: 0 }}>
|
||||
Projects
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(true)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "var(--indigo)",
|
||||
padding: "0 4px",
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
}}
|
||||
title="New project"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="nav-search-wrap">
|
||||
<svg
|
||||
style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M8.5 3a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 8.5a6.5 6.5 0 1111.436 4.23l3.857 3.857a.75.75 0 01-1.06 1.06l-3.857-3.857A6.5 6.5 0 012 8.5z"
|
||||
fill="#9CA3AF"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
className="nav-search"
|
||||
type="search"
|
||||
placeholder="Search projects…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proj-list">
|
||||
{loading && (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 24 }}>
|
||||
<Loader2 style={{ width: 22, height: 22, color: "var(--muted)" }} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && projects.length === 0 && (
|
||||
<div style={{ padding: "20px 8px 12px", textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: "var(--indigo-dim)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto 10px",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
✦
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", marginBottom: 4 }}>No projects yet</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Start building your first product with vibn.
|
||||
</div>
|
||||
<button type="button" className="btn-primary" style={{ fontSize: 11.5, padding: "7px 14px", width: "100%" }} onClick={() => setShowNew(true)}>
|
||||
+ Create first project
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && projects.length > 0 && filtered.length === 0 && (
|
||||
<div style={{ padding: "16px 12px", textAlign: "center", fontSize: 12, color: "var(--muted)" }}>No projects match your search.</div>
|
||||
)}
|
||||
{!loading &&
|
||||
filtered.map((p, i) => (
|
||||
<div key={p.id} className="proj-row">
|
||||
<Link
|
||||
href={`/${workspace}/project/${p.id}`}
|
||||
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "flex-start", gap: 10, textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
<div
|
||||
className="proj-icon"
|
||||
style={{
|
||||
background: ICON_BG[i % ICON_BG.length],
|
||||
}}
|
||||
>
|
||||
{(p.productName[0] ?? "P").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="proj-row-name">
|
||||
{p.productName} <StatusPill status={p.status} />
|
||||
</div>
|
||||
<div className="proj-row-metric" style={{ fontWeight: 400, color: "var(--muted)" }}>
|
||||
{p.productVision ? `${p.productVision.slice(0, 42)}${p.productVision.length > 42 ? "…" : ""}` : "Personal"}
|
||||
</div>
|
||||
<div className="proj-row-time">{timeAgo(p.updatedAt)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="proj-edit-btn"
|
||||
title="Delete project"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setProjectToDelete(p);
|
||||
}}
|
||||
>
|
||||
<Trash2 style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: "12px 8px 16px", display: "flex", flexDirection: "column" }}>
|
||||
<div className="nav-group-label" style={{ marginBottom: 4 }}>
|
||||
Workspace
|
||||
</div>
|
||||
<Link href={`/${workspace}/activity`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
|
||||
<div className="nav-icon">↗</div>
|
||||
<div>
|
||||
<div className="nav-label">Activity</div>
|
||||
<div className="nav-sub">Timeline & runs</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={`/${workspace}/settings`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
|
||||
<div className="nav-icon">⚙</div>
|
||||
<div className="nav-label">Settings</div>
|
||||
</Link>
|
||||
<div style={{ height: 1, background: "var(--border)", margin: "8px 4px" }} />
|
||||
<div className="nav-group-label" style={{ marginBottom: 4 }}>
|
||||
Account
|
||||
</div>
|
||||
<button type="button" className="nav-item-btn" onClick={() => toast.message("Help — docs coming soon.")}>
|
||||
<div className="nav-icon">?</div>
|
||||
<div className="nav-label">Help</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="workspace">
|
||||
<div id="ws-dashboard" className="ws-section active">
|
||||
<div className="ws-inner">
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20, marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 className="f" style={{ fontSize: 28, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.03em", marginBottom: 7 }}>
|
||||
{greetingPrefix()}, {firstName}.
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.5 }}>
|
||||
Open a project from the sidebar or start a new one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="dash-header-actions" style={{ display: "flex", gap: 9, alignItems: "center", flexShrink: 0, paddingTop: 4 }}>
|
||||
<button type="button" className="btn-primary" onClick={() => setShowNew(true)}>
|
||||
+ New project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && projects.length === 0 && (
|
||||
<div style={{ marginBottom: 36 }}>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--white)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 16,
|
||||
padding: "48px 36px",
|
||||
textAlign: "center",
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg,rgba(99,102,241,0.12),rgba(139,92,246,0.12))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto 20px",
|
||||
fontSize: 26,
|
||||
}}
|
||||
>
|
||||
✦
|
||||
</div>
|
||||
<h2 className="f" style={{ fontSize: 20, fontWeight: 700, color: "var(--ink)", marginBottom: 8, letterSpacing: "-0.02em" }}>
|
||||
Build your first product
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.6, marginBottom: 24 }}>
|
||||
Describe your idea, and vibn will architect, design, and help you ship it — no code required.
|
||||
</p>
|
||||
<button type="button" className="btn-primary" style={{ padding: "11px 28px", fontSize: 14 }} onClick={() => setShowNew(true)}>
|
||||
+ Start a new project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dash-section-title">Portfolio snapshot</div>
|
||||
<div className="snap-grid">
|
||||
<div className="snap-card">
|
||||
<div className="snap-value">{projects.length}</div>
|
||||
<div className="snap-label">Active projects</div>
|
||||
</div>
|
||||
<div className="snap-card">
|
||||
<div className="snap-value" style={{ color: "var(--green)" }}>
|
||||
{liveN}
|
||||
</div>
|
||||
<div className="snap-label">Live products</div>
|
||||
</div>
|
||||
<div className="snap-card">
|
||||
<div className="snap-value" style={{ color: "#4338CA" }}>
|
||||
{buildingN}
|
||||
</div>
|
||||
<div className="snap-label">Building now</div>
|
||||
</div>
|
||||
<div className="snap-card" style={{ borderColor: "var(--amber-border)", background: "var(--amber-dim)" }}>
|
||||
<div className="snap-value" style={{ color: "#92400E" }}>
|
||||
{totalCosts > 0 ? `$${totalCosts.toFixed(2)}` : "—"}
|
||||
</div>
|
||||
<div className="snap-label" style={{ color: "#B45309" }}>
|
||||
API spend (est.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ProjectCreationModal
|
||||
open={showNew}
|
||||
onOpenChange={(open) => {
|
||||
setShowNew(open);
|
||||
if (!open) fetchProjects();
|
||||
}}
|
||||
workspace={workspace}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!projectToDelete} onOpenChange={(open) => !open && setProjectToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{projectToDelete?.productName}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove the project record. Sessions will be preserved but unlinked. The Gitea repo will not be deleted automatically.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-red-600 hover:bg-red-700">
|
||||
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
|
||||
Delete project
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface CooMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
source?: "atlas" | "coo"; // atlas = discovery history, coo = orchestrator response
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export function CooChat({ projectId }: { projectId: string }) {
|
||||
const [messages, setMessages] = useState<CooMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Scroll to bottom whenever messages change
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Pre-load Atlas discovery history on mount
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/atlas-chat`)
|
||||
.then(r => r.json())
|
||||
.then((data: { messages?: Array<{ role: "user" | "assistant"; content: string }> }) => {
|
||||
const atlasMessages: CooMessage[] = (data.messages ?? [])
|
||||
.filter(m => m.content?.trim())
|
||||
.map((m, i) => ({
|
||||
id: `atlas_${i}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
source: "atlas" as const,
|
||||
}));
|
||||
|
||||
if (atlasMessages.length > 0) {
|
||||
// Add a small divider message at the bottom of Atlas history
|
||||
setMessages([
|
||||
...atlasMessages,
|
||||
{
|
||||
id: "coo_divider",
|
||||
role: "assistant",
|
||||
content: "Discovery complete. I'm your product COO — I have the full context above. What do you need?",
|
||||
source: "coo" as const,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// No Atlas history — show default COO welcome
|
||||
setMessages([{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
|
||||
source: "coo" as const,
|
||||
}]);
|
||||
}
|
||||
setHistoryLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setMessages([{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
|
||||
source: "coo" as const,
|
||||
}]);
|
||||
setHistoryLoaded(true);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
setInput("");
|
||||
|
||||
const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text, source: "coo" };
|
||||
const assistantId = (Date.now() + 1).toString();
|
||||
const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", source: "coo", streaming: true };
|
||||
|
||||
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
||||
setLoading(true);
|
||||
|
||||
// Build history from COO messages only (skip atlas history for context to orchestrator)
|
||||
const history = messages
|
||||
.filter(m => m.source === "coo" && m.id !== "coo_divider" && m.content)
|
||||
.map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/advisor`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text, history }),
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: "Something went wrong. Please try again.", streaming: false }
|
||||
: m));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: m.content + chunk }
|
||||
: m));
|
||||
}
|
||||
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
|
||||
} catch {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: "Connection error. Please try again.", streaming: false }
|
||||
: m));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (!historyLoaded) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 4, height: 4, borderRadius: "50%",
|
||||
background: "#d4cfc6", display: "inline-block",
|
||||
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "12px 14px 8px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{messages.map((msg, idx) => {
|
||||
const isAtlas = msg.source === "atlas";
|
||||
const isUser = msg.role === "user";
|
||||
const isCoo = !isUser && !isAtlas;
|
||||
|
||||
// Separator before the divider message
|
||||
const prevMsg = messages[idx - 1];
|
||||
const showSeparator = msg.id === "coo_divider" && prevMsg?.source === "atlas";
|
||||
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{showSeparator && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
margin: "8px 0 4px", opacity: 0.5,
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
|
||||
<span style={{ fontSize: "0.58rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
|
||||
Discovery · COO
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: isUser ? "row-reverse" : "row",
|
||||
alignItems: "flex-end",
|
||||
gap: 6,
|
||||
}}>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 5,
|
||||
background: isAtlas ? "#4a6fa5" : "#1a1a1a",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: isAtlas ? "0.48rem" : "0.48rem",
|
||||
color: "#fff", flexShrink: 0,
|
||||
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "inherit",
|
||||
fontWeight: isAtlas ? 700 : 400,
|
||||
}}>
|
||||
{isAtlas ? "A" : "◈"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
maxWidth: "88%",
|
||||
padding: isUser ? "7px 10px" : "0",
|
||||
background: isUser ? "#f0ece4" : "transparent",
|
||||
borderRadius: isUser ? 10 : 0,
|
||||
fontSize: isAtlas ? "0.75rem" : "0.79rem",
|
||||
color: isAtlas ? "#4a4540" : "#1a1a1a",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
opacity: isAtlas ? 0.85 : 1,
|
||||
}}>
|
||||
{msg.content}
|
||||
{msg.streaming && msg.content === "" && (
|
||||
<span style={{ display: "inline-flex", gap: 3, alignItems: "center", height: "1em" }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 4, height: 4, borderRadius: "50%",
|
||||
background: "#b5b0a6", display: "inline-block",
|
||||
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
|
||||
}} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{msg.streaming && msg.content !== "" && (
|
||||
<span style={{
|
||||
display: "inline-block", width: 2, height: "0.85em",
|
||||
background: "#1a1a1a", marginLeft: 1,
|
||||
verticalAlign: "text-bottom",
|
||||
animation: "cooBlink 1s step-end infinite",
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{ flexShrink: 0, borderTop: "1px solid #e8e4dc", padding: "10px 12px 10px", background: "#fff" }}>
|
||||
<div style={{ display: "flex", gap: 7, alignItems: "flex-end" }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
}}
|
||||
placeholder={loading ? "Thinking…" : "Ask anything…"}
|
||||
disabled={loading}
|
||||
rows={2}
|
||||
style={{
|
||||
flex: 1, resize: "none",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "8px 10px", fontSize: "0.79rem",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
color: "#1a1a1a", outline: "none",
|
||||
background: "#faf8f5", lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!input.trim() || loading}
|
||||
style={{
|
||||
width: 32, height: 32, flexShrink: 0,
|
||||
border: "none", borderRadius: 8,
|
||||
background: input.trim() && !loading ? "#1a1a1a" : "#e8e4dc",
|
||||
color: input.trim() && !loading ? "#fff" : "#b5b0a6",
|
||||
cursor: input.trim() && !loading ? "pointer" : "default",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>↑</button>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.6rem", color: "#c5c0b8", marginTop: 5, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
↵ send · Shift+↵ newline
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes cooBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-4px); }
|
||||
}
|
||||
@keyframes cooBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ReactNode, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
interface ProjectShellProps {
|
||||
children: ReactNode;
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectDescription?: string;
|
||||
projectStatus?: string;
|
||||
projectProgress?: number;
|
||||
discoveryPhase?: number;
|
||||
capturedData?: Record<string, string>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
featureCount?: number;
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "overview", label: "Vibn", path: "overview" },
|
||||
{ id: "mvp-setup", label: "Plan", path: "mvp-setup" },
|
||||
{ id: "tasks", label: "Task", path: "tasks" },
|
||||
{ id: "build", label: "Build", path: "build" },
|
||||
{ id: "run", label: "Run", path: "run" },
|
||||
{ id: "growth", label: "Grow", path: "growth" },
|
||||
{ id: "assist", label: "Assist", path: "assist" },
|
||||
{ id: "analytics", label: "Analyze", path: "analytics" },
|
||||
] as const;
|
||||
|
||||
|
||||
function ProjectShellInner({
|
||||
children,
|
||||
workspace,
|
||||
projectId,
|
||||
projectName,
|
||||
}: ProjectShellProps) {
|
||||
const pathname = usePathname();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const activeSection =
|
||||
pathname?.includes("/overview") ? "overview" :
|
||||
pathname?.includes("/mvp-setup") ? "mvp-setup" :
|
||||
pathname?.includes("/tasks") ? "tasks" :
|
||||
pathname?.includes("/prd") ? "tasks" :
|
||||
pathname?.includes("/build") ? "build" :
|
||||
pathname?.includes("/run") ? "run" :
|
||||
pathname?.includes("/infrastructure") ? "run" :
|
||||
pathname?.includes("/growth") ? "growth" :
|
||||
pathname?.includes("/assist") ? "assist" :
|
||||
pathname?.includes("/analytics") ? "analytics" :
|
||||
"overview";
|
||||
|
||||
const userInitial = (
|
||||
session?.user?.name?.[0] ?? session?.user?.email?.[0] ?? "?"
|
||||
).toUpperCase();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column",
|
||||
height: "100dvh", overflow: "hidden",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
background: "var(--background)",
|
||||
}}>
|
||||
|
||||
{/* ── Top bar ── */}
|
||||
<header style={{
|
||||
height: 48, flexShrink: 0,
|
||||
display: "flex", alignItems: "stretch",
|
||||
background: "var(--card)", borderBottom: "1px solid var(--border)",
|
||||
zIndex: 10,
|
||||
}}>
|
||||
|
||||
{/* Logo + project name */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center",
|
||||
padding: "0 16px", gap: 9, flexShrink: 0,
|
||||
borderRight: "1px solid var(--border)",
|
||||
}}>
|
||||
<Link
|
||||
href={`/${workspace}/projects`}
|
||||
style={{ display: "flex", alignItems: "center", textDecoration: "none", flexShrink: 0 }}
|
||||
>
|
||||
<div style={{ width: 22, height: 22, borderRadius: 6, overflow: "hidden" }}>
|
||||
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
</div>
|
||||
</Link>
|
||||
<span style={{
|
||||
fontSize: "0.82rem", fontWeight: 600, color: "var(--foreground)",
|
||||
maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}}>
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tab nav */}
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", padding: "0 12px", gap: 2 }}>
|
||||
{SECTIONS.map(s => {
|
||||
const isActive = activeSection === s.id;
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/${workspace}/project/${projectId}/${s.path}`}
|
||||
style={{
|
||||
padding: "5px 12px", borderRadius: 8,
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: isActive ? 600 : 440,
|
||||
color: isActive ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
background: isActive ? "var(--secondary)" : "transparent",
|
||||
textDecoration: "none",
|
||||
transition: "background 0.1s, color 0.1s",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--muted)"; }}
|
||||
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
{s.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* User avatar */}
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/auth" })}
|
||||
title={`${session?.user?.name ?? session?.user?.email ?? "Account"} — Sign out`}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: "50%",
|
||||
background: "var(--secondary)", border: "none", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.65rem", fontWeight: 700, color: "var(--muted-foreground)", flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{userInitial}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Full-width content ── */}
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense because useSearchParams requires it
|
||||
export function ProjectShell(props: ProjectShellProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ProjectShellInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { db, auth } from '@/lib/firebase/config';
|
||||
import { collection, query, where, limit, getDocs, orderBy } from 'firebase/firestore';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FolderOpen, Plus, Link as LinkIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ProjectCreationModal } from './project-creation-modal';
|
||||
|
||||
interface UnassociatedWorkspace {
|
||||
workspacePath: string;
|
||||
workspaceName: string;
|
||||
sessionCount: number;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
productName: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function ProjectAssociationPrompt({ workspace }: { workspace: string }) {
|
||||
// Temporarily disabled - will be re-enabled with better UX
|
||||
return null;
|
||||
|
||||
const [unassociatedWorkspace, setUnassociatedWorkspace] = useState<UnassociatedWorkspace | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [showCreationModal, setShowCreationModal] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dismissedWorkspaces, setDismissedWorkspaces] = useState<Set<string>>(new Set());
|
||||
const [hasCheckedThisSession, setHasCheckedThisSession] = useState(false);
|
||||
|
||||
// Load dismissed workspaces from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('dismissedWorkspaces');
|
||||
if (stored) {
|
||||
try {
|
||||
setDismissedWorkspaces(new Set(JSON.parse(stored)));
|
||||
} catch (e) {
|
||||
console.error('Error loading dismissed workspaces:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: () => void;
|
||||
|
||||
const checkForUnassociatedSessions = async (user: any) => {
|
||||
// Check if we've already shown the prompt in this browser session
|
||||
const lastPromptTime = sessionStorage.getItem('vibn_last_workspace_prompt');
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
if (lastPromptTime && (now - parseInt(lastPromptTime)) < fiveMinutes) {
|
||||
console.log('⏭️ Already checked recently, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Mark that we've checked
|
||||
sessionStorage.setItem('vibn_last_workspace_prompt', now.toString());
|
||||
|
||||
// Check for sessions that need project association
|
||||
const sessionsRef = collection(db, 'sessions');
|
||||
const q = query(
|
||||
sessionsRef,
|
||||
where('userId', '==', user.uid),
|
||||
where('needsProjectAssociation', '==', true),
|
||||
orderBy('createdAt', 'desc'),
|
||||
limit(1)
|
||||
);
|
||||
|
||||
const snapshot = await getDocs(q);
|
||||
|
||||
if (!snapshot.empty) {
|
||||
const session = snapshot.docs[0].data();
|
||||
|
||||
// Check if this workspace was dismissed
|
||||
if (dismissedWorkspaces.has(session.workspacePath)) {
|
||||
console.log('⏭️ Workspace was dismissed, skipping prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
// Count sessions from this workspace
|
||||
const countQuery = query(
|
||||
sessionsRef,
|
||||
where('userId', '==', user.uid),
|
||||
where('workspacePath', '==', session.workspacePath),
|
||||
where('needsProjectAssociation', '==', true)
|
||||
);
|
||||
const countSnapshot = await getDocs(countQuery);
|
||||
|
||||
setUnassociatedWorkspace({
|
||||
workspacePath: session.workspacePath,
|
||||
workspaceName: session.workspaceName || 'Unknown',
|
||||
sessionCount: countSnapshot.size,
|
||||
});
|
||||
|
||||
// Fetch user's projects for linking
|
||||
const projectsRef = collection(db, 'projects');
|
||||
const projectsQuery = query(
|
||||
projectsRef,
|
||||
where('userId', '==', user.uid),
|
||||
orderBy('createdAt', 'desc')
|
||||
);
|
||||
const projectsSnapshot = await getDocs(projectsQuery);
|
||||
|
||||
const userProjects = projectsSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.data().name,
|
||||
productName: doc.data().productName,
|
||||
slug: doc.data().slug,
|
||||
}));
|
||||
|
||||
setProjects(userProjects);
|
||||
setShowDialog(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Silently handle index building errors - the feature will work once indexes are ready
|
||||
if (error?.message?.includes('index')) {
|
||||
console.log('⏳ Firestore indexes are still building. Project detection will be available shortly.');
|
||||
} else {
|
||||
console.error('Error checking for unassociated sessions:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribe = auth.onAuthStateChanged((user) => {
|
||||
if (user) {
|
||||
checkForUnassociatedSessions(user);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
};
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
const handleCreateNewProject = () => {
|
||||
setShowDialog(false);
|
||||
setShowCreationModal(true);
|
||||
};
|
||||
|
||||
const handleRemindLater = () => {
|
||||
if (!unassociatedWorkspace) return;
|
||||
|
||||
// Add to dismissed list
|
||||
const newDismissed = new Set(dismissedWorkspaces);
|
||||
newDismissed.add(unassociatedWorkspace.workspacePath);
|
||||
setDismissedWorkspaces(newDismissed);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('dismissedWorkspaces', JSON.stringify(Array.from(newDismissed)));
|
||||
|
||||
// Close dialog
|
||||
setShowDialog(false);
|
||||
setUnassociatedWorkspace(null);
|
||||
|
||||
toast.info('💡 We\'ll remind you next time you visit');
|
||||
};
|
||||
|
||||
const handleLinkToProject = async (projectId: string) => {
|
||||
if (!unassociatedWorkspace || !auth.currentUser) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/sessions/associate-project', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspacePath: unassociatedWorkspace.workspacePath,
|
||||
projectId,
|
||||
userId: auth.currentUser.uid,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast.success(`✅ Linked ${data.sessionsUpdated} sessions to project!`);
|
||||
setShowDialog(false);
|
||||
setUnassociatedWorkspace(null);
|
||||
} else {
|
||||
toast.error('Failed to link sessions to project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error linking project:', error);
|
||||
toast.error('An error occurred while linking');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!unassociatedWorkspace) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
New Workspace Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
We detected coding activity in a new workspace
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{unassociatedWorkspace && (
|
||||
<div className="my-4 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">📂</span>
|
||||
<div>
|
||||
<p className="font-semibold">{unassociatedWorkspace?.workspaceName}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate" title={unassociatedWorkspace?.workspacePath}>
|
||||
{unassociatedWorkspace?.workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{unassociatedWorkspace?.sessionCount} coding session{(unassociatedWorkspace?.sessionCount || 0) > 1 ? 's' : ''} tracked
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">What would you like to do?</p>
|
||||
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
onClick={handleCreateNewProject}
|
||||
disabled={loading}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create New Project
|
||||
</Button>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or link to existing project
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<Button
|
||||
key={project.id}
|
||||
className="w-full justify-start"
|
||||
variant="ghost"
|
||||
onClick={() => handleLinkToProject(project.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{project.productName}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="ghost" onClick={handleRemindLater} disabled={loading}>
|
||||
Remind Me Later
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Project Creation Modal */}
|
||||
<ProjectCreationModal
|
||||
open={showCreationModal}
|
||||
onOpenChange={(open) => {
|
||||
setShowCreationModal(open);
|
||||
if (!open) {
|
||||
// Refresh to check for newly created project
|
||||
setUnassociatedWorkspace(null);
|
||||
}
|
||||
}}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,330 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
|
||||
interface AnalysisResult {
|
||||
decisions: string[];
|
||||
ideas: string[];
|
||||
openQuestions: string[];
|
||||
architecture: string[];
|
||||
targetUsers: string[];
|
||||
}
|
||||
|
||||
interface ChatImportMainProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sourceData?: { chatText?: string };
|
||||
analysisResult?: AnalysisResult;
|
||||
}
|
||||
|
||||
type Stage = "intake" | "extracting" | "review";
|
||||
|
||||
function EditableList({
|
||||
label,
|
||||
items,
|
||||
accent,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
items: string[];
|
||||
accent: string;
|
||||
onChange: (items: string[]) => void;
|
||||
}) {
|
||||
const handleEdit = (i: number, value: string) => {
|
||||
const next = [...items];
|
||||
next[i] = value;
|
||||
onChange(next);
|
||||
};
|
||||
const handleDelete = (i: number) => {
|
||||
onChange(items.filter((_, idx) => idx !== i));
|
||||
};
|
||||
const handleAdd = () => {
|
||||
onChange([...items, ""]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: accent, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
|
||||
{label}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<p style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", margin: "0 0 6px" }}>
|
||||
Nothing captured.
|
||||
</p>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 5 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={e => handleEdit(i, e.target.value)}
|
||||
style={{
|
||||
flex: 1, padding: "7px 10px", borderRadius: 6,
|
||||
border: "1px solid #e0dcd4", background: "#faf8f5",
|
||||
fontSize: "0.81rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
color: "#1a1a1a", outline: "none",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDelete(i)}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "#c5c0b8", fontSize: "0.85rem", padding: "4px 6px" }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = "#e53e3e")}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
style={{
|
||||
background: "none", border: "1px dashed #e0dcd4", cursor: "pointer",
|
||||
borderRadius: 6, padding: "5px 10px", fontSize: "0.72rem", color: "#a09a90",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", width: "100%",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
||||
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatImportMain({
|
||||
projectId,
|
||||
projectName,
|
||||
sourceData,
|
||||
analysisResult: initialResult,
|
||||
}: ChatImportMainProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const workspace = params?.workspace as string;
|
||||
|
||||
const hasChatText = !!sourceData?.chatText;
|
||||
const [stage, setStage] = useState<Stage>(
|
||||
initialResult ? "review" : hasChatText ? "extracting" : "intake"
|
||||
);
|
||||
const [chatText, setChatText] = useState(sourceData?.chatText ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<AnalysisResult>(
|
||||
initialResult ?? { decisions: [], ideas: [], openQuestions: [], architecture: [], targetUsers: [] }
|
||||
);
|
||||
|
||||
// Kick off extraction automatically if chatText is ready
|
||||
useEffect(() => {
|
||||
if (stage === "extracting") {
|
||||
runExtraction();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage]);
|
||||
|
||||
const runExtraction = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/analyze-chats`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chatText }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Extraction failed");
|
||||
setResult(data.analysisResult);
|
||||
setStage("review");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||
setStage("intake");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/tasks`);
|
||||
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
|
||||
|
||||
// ── Stage: intake ─────────────────────────────────────────────────────────
|
||||
if (stage === "intake") {
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||
<div style={{ width: "100%", maxWidth: 640, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||
Paste your chat history
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||
{projectName} — Atlas will extract decisions, ideas, architecture notes, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={chatText}
|
||||
onChange={e => setChatText(e.target.value)}
|
||||
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nCopy the full conversation — Atlas handles the cleanup."}
|
||||
rows={14}
|
||||
style={{
|
||||
width: "100%", padding: "14px 16px", marginBottom: 16,
|
||||
borderRadius: 10, border: "1px solid #e0dcd4",
|
||||
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.6,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||
outline: "none", resize: "vertical", boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (chatText.trim().length > 20) {
|
||||
setStage("extracting");
|
||||
}
|
||||
}}
|
||||
disabled={chatText.trim().length < 20}
|
||||
style={{
|
||||
width: "100%", padding: "13px",
|
||||
borderRadius: 8, border: "none",
|
||||
background: chatText.trim().length > 20 ? "#1a1a1a" : "#e0dcd4",
|
||||
color: chatText.trim().length > 20 ? "#fff" : "#b5b0a6",
|
||||
fontSize: "0.9rem", fontWeight: 600,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: chatText.trim().length > 20 ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Extract insights →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: extracting ─────────────────────────────────────────────────────
|
||||
if (stage === "extracting") {
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: "50%",
|
||||
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
|
||||
animation: "vibn-chat-spin 0.8s linear infinite",
|
||||
margin: "0 auto 20px",
|
||||
}} />
|
||||
<style>{`@keyframes vibn-chat-spin { to { transform:rotate(360deg); } }`}</style>
|
||||
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>
|
||||
Analysing your chats…
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
|
||||
Atlas is extracting decisions, ideas, and insights
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: review ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||
What Atlas found
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
|
||||
Review and edit the extracted insights for <strong>{projectName}</strong>. These will seed your PRD or MVP plan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
|
||||
{/* Left column */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||
<EditableList
|
||||
label="Decisions made"
|
||||
items={result.decisions}
|
||||
accent="#1a3a5c"
|
||||
onChange={items => setResult(r => ({ ...r, decisions: items }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||
<EditableList
|
||||
label="Ideas & features"
|
||||
items={result.ideas}
|
||||
accent="#2e5a4a"
|
||||
onChange={items => setResult(r => ({ ...r, ideas: items }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right column */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||
<EditableList
|
||||
label="Open questions"
|
||||
items={result.openQuestions}
|
||||
accent="#9a7b3a"
|
||||
onChange={items => setResult(r => ({ ...r, openQuestions: items }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||
<EditableList
|
||||
label="Architecture notes"
|
||||
items={result.architecture}
|
||||
accent="#4a3728"
|
||||
onChange={items => setResult(r => ({ ...r, architecture: items }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
|
||||
<EditableList
|
||||
label="Target users"
|
||||
items={result.targetUsers}
|
||||
accent="#4a2a5a"
|
||||
onChange={items => setResult(r => ({ ...r, targetUsers: items }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision buttons */}
|
||||
<div style={{
|
||||
background: "#1a1a1a", borderRadius: 12, padding: "22px 24px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to move forward?</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Choose how you want to proceed with {projectName}.</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button
|
||||
onClick={handlePRD}
|
||||
style={{
|
||||
padding: "11px 22px", borderRadius: 8, border: "none",
|
||||
background: "#fff", color: "#1a1a1a",
|
||||
fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||
>
|
||||
Generate PRD →
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMVP}
|
||||
style={{
|
||||
padding: "11px 22px", borderRadius: 8,
|
||||
border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
|
||||
fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
Plan MVP →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
|
||||
interface ArchRow {
|
||||
category: string;
|
||||
item: string;
|
||||
status: "found" | "partial" | "missing";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
summary: string;
|
||||
rows: ArchRow[];
|
||||
suggestedSurfaces: string[];
|
||||
}
|
||||
|
||||
interface CodeImportMainProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sourceData?: { repoUrl?: string };
|
||||
analysisResult?: AnalysisResult;
|
||||
creationStage?: string;
|
||||
}
|
||||
|
||||
type Stage = "input" | "cloning" | "mapping" | "surfaces";
|
||||
|
||||
const STATUS_COLORS = {
|
||||
found: { bg: "#f0fdf4", text: "#15803d", label: "Found" },
|
||||
partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" },
|
||||
missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" },
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"Tech Stack", "Infrastructure", "Database", "API Surface",
|
||||
"Frontend", "Auth", "Third-party", "Missing / Gaps",
|
||||
];
|
||||
|
||||
const PROGRESS_STEPS = [
|
||||
{ key: "cloning", label: "Cloning repository" },
|
||||
{ key: "reading", label: "Reading key files" },
|
||||
{ key: "analyzing", label: "Mapping architecture" },
|
||||
{ key: "done", label: "Analysis complete" },
|
||||
];
|
||||
|
||||
export function CodeImportMain({
|
||||
projectId,
|
||||
projectName,
|
||||
sourceData,
|
||||
analysisResult: initialResult,
|
||||
creationStage,
|
||||
}: CodeImportMainProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const workspace = params?.workspace as string;
|
||||
|
||||
const hasRepo = !!sourceData?.repoUrl;
|
||||
const getInitialStage = (): Stage => {
|
||||
if (initialResult) return "mapping";
|
||||
if (creationStage === "surfaces") return "surfaces";
|
||||
if (hasRepo) return "cloning";
|
||||
return "input";
|
||||
};
|
||||
|
||||
const [stage, setStage] = useState<Stage>(getInitialStage);
|
||||
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
|
||||
const [progressStep, setProgressStep] = useState<string>("cloning");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(initialResult ?? null);
|
||||
const [confirmedSurfaces, setConfirmedSurfaces] = useState<string[]>(
|
||||
initialResult?.suggestedSurfaces ?? []
|
||||
);
|
||||
|
||||
// Kick off analysis when in cloning stage
|
||||
useEffect(() => {
|
||||
if (stage !== "cloning") return;
|
||||
startAnalysis();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage]);
|
||||
|
||||
// Poll for analysis status when cloning
|
||||
useEffect(() => {
|
||||
if (stage !== "cloning") return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
|
||||
const data = await res.json();
|
||||
setProgressStep(data.stage ?? "cloning");
|
||||
if (data.stage === "done" && data.analysisResult) {
|
||||
setResult(data.analysisResult);
|
||||
setConfirmedSurfaces(data.analysisResult.suggestedSurfaces ?? []);
|
||||
clearInterval(interval);
|
||||
setStage("mapping");
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}, 2500);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage]);
|
||||
|
||||
const startAnalysis = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/analyze-repo`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ repoUrl }),
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to start analysis");
|
||||
setStage("input");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSurfaces = async () => {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/design-surfaces`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ surfaces: confirmedSurfaces }),
|
||||
});
|
||||
router.push(`/${workspace}/project/${projectId}/design`);
|
||||
} catch { /* navigate anyway */ }
|
||||
};
|
||||
|
||||
const toggleSurface = (s: string) => {
|
||||
setConfirmedSurfaces(prev =>
|
||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||
);
|
||||
};
|
||||
|
||||
// ── Stage: input ──────────────────────────────────────────────────────────
|
||||
if (stage === "input") {
|
||||
const isValid = repoUrl.trim().startsWith("http");
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||
Import your repository
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||
{projectName} — paste a clone URL to map your existing stack.
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||
Repository URL (HTTPS)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={repoUrl}
|
||||
onChange={e => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/yourorg/your-repo"
|
||||
style={{
|
||||
width: "100%", padding: "12px 14px", marginBottom: 16,
|
||||
borderRadius: 8, border: "1px solid #e0dcd4",
|
||||
background: "#faf8f5", fontSize: "0.9rem",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
|
||||
outline: "none", boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||
onKeyDown={e => { if (e.key === "Enter" && isValid) setStage("cloning"); }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||
Atlas will clone and map your stack — tech, database, auth, APIs, and what's missing for a complete go-to-market build.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (isValid) setStage("cloning"); }}
|
||||
disabled={!isValid}
|
||||
style={{
|
||||
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||
background: isValid ? "#1a1a1a" : "#e0dcd4",
|
||||
color: isValid ? "#fff" : "#b5b0a6",
|
||||
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: isValid ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Map this repo →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: cloning ────────────────────────────────────────────────────────
|
||||
if (stage === "cloning") {
|
||||
const currentIdx = PROGRESS_STEPS.findIndex(s => s.key === progressStep);
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ textAlign: "center", maxWidth: 400 }}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: "50%",
|
||||
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
|
||||
animation: "vibn-repo-spin 0.85s linear infinite",
|
||||
margin: "0 auto 24px",
|
||||
}} />
|
||||
<style>{`@keyframes vibn-repo-spin { to { transform:rotate(360deg); } }`}</style>
|
||||
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>
|
||||
Mapping your codebase
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>
|
||||
{repoUrl || sourceData?.repoUrl || "Repository"}
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
|
||||
{PROGRESS_STEPS.map((step, i) => {
|
||||
const done = i < currentIdx;
|
||||
const active = i === currentIdx;
|
||||
return (
|
||||
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: "50%", flexShrink: 0,
|
||||
background: done ? "#1a1a1a" : active ? "#f6f4f0" : "#f6f4f0",
|
||||
border: active ? "2px solid #1a1a1a" : done ? "none" : "2px solid #e0dcd4",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90",
|
||||
}}>
|
||||
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#1a1a1a", display: "block" }} /> : ""}
|
||||
</div>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: mapping ────────────────────────────────────────────────────────
|
||||
if (stage === "mapping" && result) {
|
||||
const byCategory: Record<string, ArchRow[]> = {};
|
||||
for (const row of result.rows) {
|
||||
const cat = row.category || "Other";
|
||||
if (!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(row);
|
||||
}
|
||||
const categories = [
|
||||
...CATEGORY_ORDER.filter(c => byCategory[c]),
|
||||
...Object.keys(byCategory).filter(c => !CATEGORY_ORDER.includes(c)),
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ maxWidth: 800, margin: "0 auto" }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||
Architecture map
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 4px" }}>
|
||||
{projectName} — {result.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
|
||||
{categories.map((cat, catIdx) => (
|
||||
<div key={cat}>
|
||||
{catIdx > 0 && <div style={{ height: 1, background: "#f0ece4" }} />}
|
||||
<div style={{ padding: "12px 20px", background: "#faf8f5", fontSize: "0.68rem", fontWeight: 700, color: "#6b6560", letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
{cat}
|
||||
</div>
|
||||
{byCategory[cat].map((row, i) => {
|
||||
const sc = STATUS_COLORS[row.status];
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 20px", borderTop: "1px solid #f6f4f0" }}>
|
||||
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
|
||||
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
|
||||
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>
|
||||
{sc.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStage("surfaces")}
|
||||
style={{
|
||||
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||
>
|
||||
Choose what to build next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: surfaces ───────────────────────────────────────────────────────
|
||||
const SURFACE_OPTIONS = [
|
||||
{ id: "marketing", label: "Marketing Site", icon: "◎", desc: "Landing page, pricing, blog" },
|
||||
{ id: "web-app", label: "Web App", icon: "⬡", desc: "Core SaaS product with auth" },
|
||||
{ id: "admin", label: "Admin Panel", icon: "◫", desc: "Ops dashboard, content management" },
|
||||
{ id: "api", label: "API Layer", icon: "⌁", desc: "REST/GraphQL endpoints" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||
What should Atlas build?
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||
Based on the gap analysis, Atlas suggests the surfaces below. Confirm or adjust.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 24 }}>
|
||||
{SURFACE_OPTIONS.map(s => {
|
||||
const selected = confirmedSurfaces.includes(s.id);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => toggleSurface(s.id)}
|
||||
style={{
|
||||
padding: "18px", borderRadius: 10, textAlign: "left",
|
||||
border: `2px solid ${selected ? "#1a1a1a" : "#e8e4dc"}`,
|
||||
background: selected ? "#1a1a1a08" : "#fff",
|
||||
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
transition: "all 0.12s",
|
||||
}}
|
||||
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = "#d0ccc4"; }}
|
||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = "#e8e4dc"; }}
|
||||
>
|
||||
<div style={{ fontSize: "1.2rem", marginBottom: 8 }}>{s.icon}</div>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 3 }}>{s.label}</div>
|
||||
<div style={{ fontSize: "0.73rem", color: "#8a8478" }}>{s.desc}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConfirmSurfaces}
|
||||
disabled={confirmedSurfaces.length === 0}
|
||||
style={{
|
||||
width: "100%", padding: "13px", borderRadius: 8, border: "none",
|
||||
background: confirmedSurfaces.length > 0 ? "#1a1a1a" : "#e0dcd4",
|
||||
color: confirmedSurfaces.length > 0 ? "#fff" : "#b5b0a6",
|
||||
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: confirmedSurfaces.length > 0 ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Go to Design →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,705 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { AtlasChat } from "@/components/AtlasChat";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpDown, Filter, LayoutPanelLeft, Search } from "lucide-react";
|
||||
import { JM, JV } from "@/components/project-creation/modal-theme";
|
||||
import {
|
||||
type ChatContextRef,
|
||||
contextRefKey,
|
||||
} from "@/lib/chat-context-refs";
|
||||
|
||||
const DISCOVERY_PHASES = [
|
||||
"big_picture",
|
||||
"users_personas",
|
||||
"features_scope",
|
||||
"business_model",
|
||||
"screens_data",
|
||||
"risks_questions",
|
||||
] as const;
|
||||
|
||||
const PHASE_DISPLAY: Record<string, string> = {
|
||||
big_picture: "Big picture",
|
||||
users_personas: "Users & personas",
|
||||
features_scope: "Features & scope",
|
||||
business_model: "Business model",
|
||||
screens_data: "Screens & data",
|
||||
risks_questions: "Risks & questions",
|
||||
};
|
||||
|
||||
// Maps discovery phases → the PRD sections they populate
|
||||
const PRD_SECTIONS: { label: string; phase: string | null }[] = [
|
||||
{ label: "Executive Summary", phase: "big_picture" },
|
||||
{ label: "Problem Statement", phase: "big_picture" },
|
||||
{ label: "Vision & Success Metrics", phase: "big_picture" },
|
||||
{ label: "Users & Personas", phase: "users_personas" },
|
||||
{ label: "User Flows", phase: "users_personas" },
|
||||
{ label: "Feature Requirements", phase: "features_scope" },
|
||||
{ label: "Screen Specs", phase: "features_scope" },
|
||||
{ label: "Business Model", phase: "business_model" },
|
||||
{ label: "Integrations & Dependencies", phase: "screens_data" },
|
||||
{ label: "Non-Functional Reqs", phase: "features_scope" },
|
||||
{ label: "Risks & Mitigations", phase: "risks_questions" },
|
||||
{ label: "Open Questions", phase: "risks_questions" },
|
||||
];
|
||||
|
||||
type SidebarTab = "tasks" | "phases";
|
||||
type GroupBy = "none" | "phase" | "status";
|
||||
|
||||
function sectionDone(
|
||||
phase: string | null,
|
||||
savedPhaseIds: Set<string>,
|
||||
allDone: boolean
|
||||
): boolean {
|
||||
return phase === null ? allDone : savedPhaseIds.has(phase);
|
||||
}
|
||||
|
||||
interface FreshIdeaMainProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const workspace = params?.workspace as string;
|
||||
|
||||
const [savedPhaseIds, setSavedPhaseIds] = useState<Set<string>>(new Set());
|
||||
const [allDone, setAllDone] = useState(false);
|
||||
const [prdLoading, setPrdLoading] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [hasPrd, setHasPrd] = useState(false);
|
||||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("tasks");
|
||||
const [sectionSearch, setSectionSearch] = useState("");
|
||||
const [phaseScope, setPhaseScope] = useState<string>("all");
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>("none");
|
||||
const [pendingOnly, setPendingOnly] = useState(false);
|
||||
const [sortAlpha, setSortAlpha] = useState(false);
|
||||
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
|
||||
|
||||
const addSectionToChat = useCallback((label: string, phase: string | null) => {
|
||||
setChatContextRefs(prev => {
|
||||
const next: ChatContextRef = { kind: "section", label, phaseId: phase };
|
||||
const k = contextRefKey(next);
|
||||
if (prev.some(r => contextRefKey(r) === k)) return prev;
|
||||
return [...prev, next];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addPhaseToChat = useCallback((phaseId: string, label: string) => {
|
||||
setChatContextRefs(prev => {
|
||||
const next: ChatContextRef = { kind: "phase", phaseId, label };
|
||||
const k = contextRefKey(next);
|
||||
if (prev.some(r => contextRefKey(r) === k)) return prev;
|
||||
return [...prev, next];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeChatContextRef = useCallback((key: string) => {
|
||||
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if PRD already exists on the project
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.project?.prd) setHasPrd(true); })
|
||||
.catch(() => {});
|
||||
|
||||
const poll = () => {
|
||||
fetch(`/api/projects/${projectId}/save-phase`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const ids = new Set<string>((d.phases ?? []).map((p: { phase: string }) => p.phase));
|
||||
setSavedPhaseIds(ids);
|
||||
const done = DISCOVERY_PHASES.every(id => ids.has(id));
|
||||
setAllDone(done);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
poll();
|
||||
const interval = setInterval(poll, 8_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [projectId]);
|
||||
|
||||
const handleGeneratePRD = async () => {
|
||||
if (prdLoading) return;
|
||||
setPrdLoading(true);
|
||||
try {
|
||||
router.push(`/${workspace}/project/${projectId}/tasks`);
|
||||
} finally {
|
||||
setPrdLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMVP = () => {
|
||||
router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
|
||||
};
|
||||
|
||||
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
|
||||
|
||||
const completedSections = PRD_SECTIONS.filter(({ phase }) =>
|
||||
phase === null ? allDone : savedPhaseIds.has(phase)
|
||||
).length;
|
||||
const totalSections = PRD_SECTIONS.length;
|
||||
|
||||
const filteredSections = useMemo(() => {
|
||||
const q = sectionSearch.trim().toLowerCase();
|
||||
let rows = PRD_SECTIONS.map((s, index) => ({ ...s, index }));
|
||||
if (q) {
|
||||
rows = rows.filter(r => r.label.toLowerCase().includes(q));
|
||||
}
|
||||
if (phaseScope !== "all") {
|
||||
rows = rows.filter(r => r.phase === phaseScope);
|
||||
}
|
||||
if (pendingOnly) {
|
||||
rows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
|
||||
}
|
||||
if (sortAlpha) {
|
||||
rows = [...rows].sort((a, b) => a.label.localeCompare(b.label));
|
||||
} else {
|
||||
rows = [...rows].sort((a, b) => a.index - b.index);
|
||||
}
|
||||
return rows;
|
||||
}, [sectionSearch, phaseScope, pendingOnly, sortAlpha, savedPhaseIds, allDone]);
|
||||
|
||||
const effectiveGroupBy: GroupBy = sidebarTab === "phases" ? "phase" : groupBy;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: "100%", display: "flex", flexDirection: "row", overflow: "hidden",
|
||||
fontFamily: JM.fontSans,
|
||||
}}>
|
||||
|
||||
{/* ── Left: Atlas chat (Justine describe column) ── */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
|
||||
|
||||
{hasPrd && (
|
||||
<div style={{
|
||||
background: JM.primaryGradient,
|
||||
boxShadow: JM.primaryShadow,
|
||||
padding: "10px 20px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexShrink: 0,
|
||||
borderBottom: `1px solid rgba(255,255,255,0.12)`,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.92)", fontFamily: JM.fontSans }}>
|
||||
✦ PRD saved — keep refining here or open the full document.
|
||||
</div>
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/tasks`}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 8,
|
||||
background: "#fff", color: JM.ink,
|
||||
fontSize: 12, fontWeight: 600,
|
||||
textDecoration: "none", flexShrink: 0,
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
View PRD →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allDone && !dismissed && !hasPrd && (
|
||||
<div style={{
|
||||
background: JM.primaryGradient,
|
||||
boxShadow: JM.primaryShadow,
|
||||
padding: "14px 20px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexShrink: 0, flexWrap: "wrap",
|
||||
borderBottom: `1px solid rgba(255,255,255,0.12)`,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#fff", fontFamily: JM.fontDisplay, marginBottom: 2 }}>
|
||||
✦ Discovery complete — what's next?
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.75)", fontFamily: JM.fontSans }}>
|
||||
All 6 phases captured. Generate your PRD or open the MVP plan flow.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexShrink: 0, alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGeneratePRD}
|
||||
disabled={prdLoading}
|
||||
style={{
|
||||
padding: "8px 16px", borderRadius: 8, border: "none",
|
||||
background: "#fff", color: JM.ink,
|
||||
fontSize: 13, fontWeight: 700,
|
||||
fontFamily: JM.fontSans, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{prdLoading ? "Navigating…" : "Generate PRD →"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMVP}
|
||||
style={{
|
||||
padding: "8px 16px", borderRadius: 8,
|
||||
border: "1px solid rgba(255,255,255,0.35)",
|
||||
background: "transparent", color: "#fff",
|
||||
fontSize: 13, fontWeight: 600,
|
||||
fontFamily: JM.fontSans, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Plan MVP →
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
color: "rgba(255,255,255,0.55)", fontSize: 18, padding: "4px 6px",
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AtlasChat
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
chatContextRefs={chatContextRefs}
|
||||
onRemoveChatContextRef={removeChatContextRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Teams-style task rail (requirements = PRD sections as tasks) ── */}
|
||||
<div style={{
|
||||
width: 348, flexShrink: 0,
|
||||
background: "#F4F2FA",
|
||||
borderLeft: `1px solid ${JM.border}`,
|
||||
display: "flex", flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
flexShrink: 0,
|
||||
padding: "0 8px",
|
||||
gap: 2,
|
||||
background: "#FAF8FF",
|
||||
}}>
|
||||
<span style={{ display: "flex", padding: "10px 6px", color: JM.muted }} title="Panel">
|
||||
<LayoutPanelLeft size={16} strokeWidth={1.75} />
|
||||
</span>
|
||||
{([
|
||||
{ id: "tasks" as const, label: "Tasks" },
|
||||
{ id: "phases" as const, label: "Phases" },
|
||||
]).map(t => {
|
||||
const active = sidebarTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setSidebarTab(t.id)}
|
||||
style={{
|
||||
padding: "10px 12px 8px",
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 500,
|
||||
color: active ? JM.ink : JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
borderBottom: active ? `2px solid ${JM.indigo}` : "2px solid transparent",
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search + tools */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "8px 10px",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
background: "#FAF8FF",
|
||||
}}>
|
||||
<Search size={15} strokeWidth={1.75} color={JM.muted} style={{ flexShrink: 0 }} />
|
||||
<input
|
||||
type="search"
|
||||
value={sectionSearch}
|
||||
onChange={e => setSectionSearch(e.target.value)}
|
||||
placeholder="Search sections…"
|
||||
aria-label="Search sections"
|
||||
style={{
|
||||
flex: 1, minWidth: 0,
|
||||
border: "none", background: "transparent",
|
||||
fontSize: 12, fontFamily: JM.fontSans,
|
||||
color: JM.ink, outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={sortAlpha ? "Sort: document order" : "Sort: A–Z"}
|
||||
onClick={() => setSortAlpha(s => !s)}
|
||||
style={{
|
||||
border: "none", background: sortAlpha ? JV.violetTint : "transparent",
|
||||
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={pendingOnly ? "Show all sections" : "Pending only"}
|
||||
onClick={() => setPendingOnly(p => !p)}
|
||||
style={{
|
||||
border: "none", background: pendingOnly ? JV.violetTint : "transparent",
|
||||
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
|
||||
}}
|
||||
>
|
||||
<Filter size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scope + group (Tasks tab only shows group pills; Phases tab locks grouping) */}
|
||||
<div style={{
|
||||
padding: "8px 10px 10px",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
background: "#F7F5FC",
|
||||
}}>
|
||||
<select
|
||||
value={phaseScope}
|
||||
onChange={e => setPhaseScope(e.target.value)}
|
||||
aria-label="Filter by discovery phase"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${JM.border}`,
|
||||
background: "#fff",
|
||||
fontSize: 12,
|
||||
fontFamily: JM.fontSans,
|
||||
color: JM.ink,
|
||||
marginBottom: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<option value="all">All sections</option>
|
||||
{DISCOVERY_PHASES.map(p => (
|
||||
<option key={p} value={p}>{PHASE_DISPLAY[p]}</option>
|
||||
))}
|
||||
</select>
|
||||
{sidebarTab === "tasks" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: JM.muted, fontFamily: JM.fontSans }}>
|
||||
Group by
|
||||
</span>
|
||||
{([
|
||||
{ id: "none" as const, label: "None" },
|
||||
{ id: "phase" as const, label: "Phase" },
|
||||
{ id: "status" as const, label: "Status" },
|
||||
]).map(opt => {
|
||||
const on = groupBy === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setGroupBy(opt.id)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${on ? JM.indigo : JM.border}`,
|
||||
background: on ? JV.violetTint : "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: on ? 600 : 500,
|
||||
color: on ? JM.indigo : JM.mid,
|
||||
fontFamily: JM.fontSans,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{sidebarTab === "phases" && (
|
||||
<div style={{ fontSize: 11, color: JM.muted, fontFamily: JM.fontSans }}>
|
||||
Grouped by discovery phase
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress summary */}
|
||||
<div style={{
|
||||
padding: "10px 12px",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
background: "#F4F2FA",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ height: 3, background: "#E0E7FF", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 99,
|
||||
background: JM.primaryGradient,
|
||||
width: `${Math.round((completedSections / totalSections) * 100)}%`,
|
||||
transition: "width 0.4s ease",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: JM.muted, marginTop: 6, fontFamily: JM.fontSans }}>
|
||||
{completedSections} of {totalSections} sections · Requirements task
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: JM.indigo, marginTop: 5, fontFamily: JM.fontSans, opacity: 0.9 }}>
|
||||
Click a section row or phase header to attach it to your next message.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", background: "#F4F2FA" }}>
|
||||
{(() => {
|
||||
const rows = filteredSections;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "28px 16px",
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
No sections match your search or filters.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderRow = (label: string, phase: string | null, key: string) => {
|
||||
const isDone = sectionDone(phase, savedPhaseIds, allDone);
|
||||
const phaseSlug = phase ? phase.replace(/_/g, "-") : "prd";
|
||||
const phaseLine = phase ? PHASE_DISPLAY[phase] ?? phase : "PRD";
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
title="Add this section to chat context for Vibn"
|
||||
onClick={() => addSectionToChat(label, phase)}
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderBottom: `1px solid rgba(229,231,235,0.85)`,
|
||||
borderTop: "none",
|
||||
borderLeft: "none",
|
||||
borderRight: "none",
|
||||
display: "flex", gap: 10, alignItems: "flex-start",
|
||||
background: isDone ? "rgba(237,233,254,0.55)" : "transparent",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: "50%", flexShrink: 0, marginTop: 1,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: isDone ? JM.indigo : "#fff",
|
||||
border: isDone ? "none" : `1.5px solid ${JM.border}`,
|
||||
color: isDone ? "#fff" : "transparent",
|
||||
fontFamily: JM.fontSans,
|
||||
}}>
|
||||
{isDone ? "✓" : ""}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: JM.ink,
|
||||
lineHeight: 1.3,
|
||||
fontFamily: JM.fontSans,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: JM.indigo,
|
||||
fontFamily: JM.fontSans,
|
||||
}}>
|
||||
{phaseSlug}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: isDone ? "#059669" : JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}>
|
||||
{isDone ? "Done" : "Pending"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: JM.muted,
|
||||
marginTop: 3,
|
||||
fontFamily: JM.fontSans,
|
||||
lineHeight: 1.35,
|
||||
}}>
|
||||
Discovery · {phaseLine}
|
||||
{!isDone ? " · complete in chat" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
if (effectiveGroupBy === "none") {
|
||||
return rows.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`));
|
||||
}
|
||||
|
||||
if (effectiveGroupBy === "phase") {
|
||||
const byPhase = new Map<string, typeof rows>();
|
||||
for (const r of rows) {
|
||||
const pk = r.phase ?? "null";
|
||||
if (!byPhase.has(pk)) byPhase.set(pk, []);
|
||||
byPhase.get(pk)!.push(r);
|
||||
}
|
||||
const order = [...DISCOVERY_PHASES, "null"];
|
||||
return order.flatMap(pk => {
|
||||
const list = byPhase.get(pk);
|
||||
if (!list?.length) return [];
|
||||
const header = pk === "null" ? "Final" : PHASE_DISPLAY[pk] ?? pk;
|
||||
const phaseClickable = pk !== "null";
|
||||
return [
|
||||
phaseClickable ? (
|
||||
<button
|
||||
key={`h-${pk}`}
|
||||
type="button"
|
||||
title={`Add discovery phase "${header}" to chat context`}
|
||||
onClick={() => addPhaseToChat(pk, header)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "8px 12px 6px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
border: "none",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={`h-${pk}`}
|
||||
style={{
|
||||
padding: "8px 12px 4px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
),
|
||||
...list.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`)),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const doneRows = rows.filter(r => sectionDone(r.phase, savedPhaseIds, allDone));
|
||||
const todoRows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
|
||||
const statusBlocks: ReactNode[] = [];
|
||||
if (todoRows.length > 0) {
|
||||
statusBlocks.push(
|
||||
<div
|
||||
key="h-todo"
|
||||
style={{
|
||||
padding: "8px 12px 4px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
}}
|
||||
>
|
||||
To do
|
||||
</div>
|
||||
);
|
||||
todoRows.forEach(r => {
|
||||
statusBlocks.push(renderRow(r.label, r.phase, `todo-${r.label}-${r.index}`));
|
||||
});
|
||||
}
|
||||
if (doneRows.length > 0) {
|
||||
statusBlocks.push(
|
||||
<div
|
||||
key="h-done"
|
||||
style={{
|
||||
padding: "8px 12px 4px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</div>
|
||||
);
|
||||
doneRows.forEach(r => {
|
||||
statusBlocks.push(renderRow(r.label, r.phase, `done-${r.label}-${r.index}`));
|
||||
});
|
||||
}
|
||||
return statusBlocks;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{allDone && (
|
||||
<div style={{ padding: "10px 12px", borderTop: `1px solid ${JM.border}`, flexShrink: 0, background: "#FAF8FF" }}>
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/tasks`}
|
||||
style={{
|
||||
display: "block", textAlign: "center",
|
||||
padding: "10px 0", borderRadius: 8,
|
||||
background: JM.primaryGradient,
|
||||
color: "#fff",
|
||||
fontSize: 12, fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontFamily: JM.fontSans,
|
||||
boxShadow: JM.primaryShadow,
|
||||
}}
|
||||
>
|
||||
Open Tasks →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
|
||||
interface MigrateMainProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
|
||||
analysisResult?: Record<string, unknown>;
|
||||
migrationPlan?: string;
|
||||
creationStage?: string;
|
||||
}
|
||||
|
||||
type Stage = "input" | "auditing" | "review" | "planning" | "plan";
|
||||
|
||||
const HOSTING_OPTIONS = [
|
||||
{ value: "", label: "Select hosting provider" },
|
||||
{ value: "vercel", label: "Vercel" },
|
||||
{ value: "aws", label: "AWS" },
|
||||
{ value: "heroku", label: "Heroku" },
|
||||
{ value: "digitalocean", label: "DigitalOcean" },
|
||||
{ value: "gcp", label: "Google Cloud Platform" },
|
||||
{ value: "azure", label: "Microsoft Azure" },
|
||||
{ value: "railway", label: "Railway" },
|
||||
{ value: "render", label: "Render" },
|
||||
{ value: "netlify", label: "Netlify" },
|
||||
{ value: "self-hosted", label: "Self-hosted / VPS" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
function MarkdownRenderer({ md }: { md: string }) {
|
||||
const lines = md.split('\n');
|
||||
return (
|
||||
<div style={{ fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem", color: "#1a1a1a", lineHeight: 1.7 }}>
|
||||
{lines.map((line, i) => {
|
||||
if (line.startsWith('## ')) return <h2 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, margin: "24px 0 10px", color: "#1a1a1a" }}>{line.slice(3)}</h2>;
|
||||
if (line.startsWith('### ')) return <h3 key={i} style={{ fontSize: "0.88rem", fontWeight: 700, margin: "18px 0 6px", color: "#1a1a1a" }}>{line.slice(4)}</h3>;
|
||||
if (line.startsWith('# ')) return <h1 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.5rem", fontWeight: 400, margin: "0 0 16px", color: "#1a1a1a" }}>{line.slice(2)}</h1>;
|
||||
if (line.match(/^- \[ \] /)) return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
|
||||
<input type="checkbox" style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
|
||||
<span>{line.slice(6)}</span>
|
||||
</div>
|
||||
);
|
||||
if (line.match(/^- \[x\] /i)) return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
|
||||
<input type="checkbox" defaultChecked style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
|
||||
<span style={{ textDecoration: "line-through", color: "#a09a90" }}>{line.slice(6)}</span>
|
||||
</div>
|
||||
);
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} style={{ paddingLeft: 16, marginBottom: 4 }}>• {line.slice(2)}</div>;
|
||||
if (line.startsWith('---')) return <hr key={i} style={{ border: "none", borderTop: "1px solid #e8e4dc", margin: "16px 0" }} />;
|
||||
if (!line.trim()) return <div key={i} style={{ height: "0.6em" }} />;
|
||||
// Bold inline
|
||||
const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) =>
|
||||
seg.startsWith("**") && seg.endsWith("**")
|
||||
? <strong key={j}>{seg.slice(2, -2)}</strong>
|
||||
: <span key={j}>{seg}</span>
|
||||
);
|
||||
return <p key={i} style={{ margin: "0 0 4px" }}>{parts}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MigrateMain({
|
||||
projectId,
|
||||
projectName,
|
||||
sourceData,
|
||||
analysisResult: initialAnalysis,
|
||||
migrationPlan: initialPlan,
|
||||
creationStage,
|
||||
}: MigrateMainProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const workspace = params?.workspace as string;
|
||||
|
||||
const getInitialStage = (): Stage => {
|
||||
if (initialPlan) return "plan";
|
||||
if (creationStage === "planning") return "planning";
|
||||
if (creationStage === "review" || initialAnalysis) return "review";
|
||||
if (sourceData?.repoUrl || sourceData?.liveUrl) return "auditing";
|
||||
return "input";
|
||||
};
|
||||
|
||||
const [stage, setStage] = useState<Stage>(getInitialStage);
|
||||
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
|
||||
const [liveUrl, setLiveUrl] = useState(sourceData?.liveUrl ?? "");
|
||||
const [hosting, setHosting] = useState(sourceData?.hosting ?? "");
|
||||
const [analysisResult, setAnalysisResult] = useState<Record<string, unknown> | null>(initialAnalysis ?? null);
|
||||
const [migrationPlan, setMigrationPlan] = useState<string>(initialPlan ?? "");
|
||||
const [progressStep, setProgressStep] = useState<string>("cloning");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Poll during audit
|
||||
useEffect(() => {
|
||||
if (stage !== "auditing") return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
|
||||
const data = await res.json();
|
||||
setProgressStep(data.stage ?? "cloning");
|
||||
if (data.stage === "done" && data.analysisResult) {
|
||||
setAnalysisResult(data.analysisResult);
|
||||
clearInterval(interval);
|
||||
setStage("review");
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
}, 2500);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage]);
|
||||
|
||||
const startAudit = async () => {
|
||||
setError(null);
|
||||
setStage("auditing");
|
||||
if (repoUrl) {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/analyze-repo`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ repoUrl, liveUrl, hosting }),
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to start audit");
|
||||
setStage("input");
|
||||
}
|
||||
} else {
|
||||
// No repo — just use live URL fingerprinting via generate-migration-plan directly
|
||||
setStage("review");
|
||||
setAnalysisResult({ summary: `Live product at ${liveUrl}`, rows: [], suggestedSurfaces: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const startPlanning = async () => {
|
||||
setStage("planning");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/generate-migration-plan`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ analysisResult, sourceData: { repoUrl, liveUrl, hosting } }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Planning failed");
|
||||
setMigrationPlan(data.migrationPlan);
|
||||
setStage("plan");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Planning failed");
|
||||
setStage("review");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Stage: input ──────────────────────────────────────────────────────────
|
||||
if (stage === "input") {
|
||||
const canProceed = repoUrl.trim().startsWith("http") || liveUrl.trim().startsWith("http");
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
|
||||
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
|
||||
Tell us about your product
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
|
||||
{projectName} — Atlas will audit your current setup and build a safe migration plan.
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||
Repository URL (recommended)
|
||||
</label>
|
||||
<input type="text" value={repoUrl} onChange={e => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/yourorg/your-repo"
|
||||
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")} autoFocus
|
||||
/>
|
||||
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||
Live URL (optional)
|
||||
</label>
|
||||
<input type="text" value={liveUrl} onChange={e => setLiveUrl(e.target.value)}
|
||||
placeholder="https://yourproduct.com"
|
||||
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||
/>
|
||||
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
|
||||
Current hosting provider
|
||||
</label>
|
||||
<select value={hosting} onChange={e => setHosting(e.target.value)}
|
||||
style={{ width: "100%", padding: "11px 14px", marginBottom: 20, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.88rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90", outline: "none", boxSizing: "border-box", appearance: "none" }}
|
||||
>
|
||||
{HOSTING_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
|
||||
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Your existing product stays live throughout. Atlas duplicates, never deletes.
|
||||
</div>
|
||||
<button onClick={startAudit} disabled={!canProceed}
|
||||
style={{ width: "100%", padding: "13px", borderRadius: 8, border: "none", background: canProceed ? "#1a1a1a" : "#e0dcd4", color: canProceed ? "#fff" : "#b5b0a6", fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: canProceed ? "pointer" : "not-allowed" }}
|
||||
>
|
||||
Start audit →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: auditing ───────────────────────────────────────────────────────
|
||||
if (stage === "auditing") {
|
||||
const steps = [
|
||||
{ key: "cloning", label: "Cloning repository" },
|
||||
{ key: "reading", label: "Reading configuration" },
|
||||
{ key: "analyzing", label: "Auditing infrastructure" },
|
||||
{ key: "done", label: "Audit complete" },
|
||||
];
|
||||
const currentIdx = steps.findIndex(s => s.key === progressStep);
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ textAlign: "center", maxWidth: 400 }}>
|
||||
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 24px" }} />
|
||||
<style>{`@keyframes vibn-mig-spin { to { transform:rotate(360deg); } }`}</style>
|
||||
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>Auditing your product</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>This is non-destructive — your live product is untouched</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
|
||||
{steps.map((step, i) => {
|
||||
const done = i < currentIdx;
|
||||
const active = i === currentIdx;
|
||||
return (
|
||||
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ width: 22, height: 22, borderRadius: "50%", flexShrink: 0, background: done ? "#4a2a5a" : "#f6f4f0", border: active ? "2px solid #4a2a5a" : done ? "none" : "2px solid #e0dcd4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90" }}>
|
||||
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4a2a5a", display: "block" }} /> : ""}
|
||||
</div>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: review ─────────────────────────────────────────────────────────
|
||||
if (stage === "review") {
|
||||
const rows = (analysisResult?.rows as Array<{ category: string; item: string; status: string; detail?: string }>) ?? [];
|
||||
const summary = (analysisResult?.summary as string) ?? '';
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Audit complete</h2>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{summary || `${projectName} — review your current infrastructure below.`}</p>
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
|
||||
{rows.map((row, i) => {
|
||||
const colorMap = { found: { bg: "#f0fdf4", text: "#15803d", label: "Found" }, partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" }, missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" } };
|
||||
const sc = colorMap[row.status as keyof typeof colorMap] ?? colorMap.found;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderTop: i > 0 ? "1px solid #f6f4f0" : "none" }}>
|
||||
<div style={{ fontSize: "0.7rem", color: "#a09a90", width: 110, flexShrink: 0 }}>{row.category}</div>
|
||||
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
|
||||
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
|
||||
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>{sc.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ background: "#1a1a1a", borderRadius: 12, padding: "22px 24px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to build the migration plan?</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Atlas will generate a phased migration doc with Mirror, Validate, Cutover, and Decommission phases.</div>
|
||||
</div>
|
||||
<button onClick={startPlanning}
|
||||
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#fff", color: "#1a1a1a", fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", flexShrink: 0 }}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||
>
|
||||
Generate plan →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: planning ───────────────────────────────────────────────────────
|
||||
if (stage === "planning") {
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 20px" }} />
|
||||
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>Generating migration plan…</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>Atlas is designing a safe, phased migration strategy</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage: plan ───────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
{/* Non-destructive banner */}
|
||||
<div style={{ background: "#4a2a5a12", borderBottom: "1px solid #4a2a5a30", padding: "12px 32px", display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: "1rem" }}>🛡️</span>
|
||||
<div>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: 700, color: "#4a2a5a" }}>Non-destructive migration — </span>
|
||||
<span style={{ fontSize: "0.8rem", color: "#6b6560" }}>your existing product stays live throughout every phase. Atlas duplicates, never deletes.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "32px 40px" }}>
|
||||
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Migration Plan</h2>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{projectName} — four phased migration with rollback plan</p>
|
||||
</div>
|
||||
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "28px 32px" }}>
|
||||
<MarkdownRenderer md={migrationPlan} />
|
||||
</div>
|
||||
<div style={{ marginTop: 20, display: "flex", gap: 10 }}>
|
||||
<button
|
||||
onClick={() => router.push(`/${workspace}/project/${projectId}/design`)}
|
||||
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
|
||||
>
|
||||
Go to Design →
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
style={{ padding: "11px 22px", borderRadius: 8, border: "1px solid #e0dcd4", background: "#fff", color: "#6b6560", fontSize: "0.85rem", fontWeight: 500, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
|
||||
>
|
||||
Print / Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { JM, JV } from "@/components/project-creation/modal-theme";
|
||||
import { AtlasChat } from "@/components/AtlasChat";
|
||||
import {
|
||||
BuildLivePlanPanel,
|
||||
addSectionContextRef,
|
||||
} from "@/components/project-main/BuildLivePlanPanel";
|
||||
import {
|
||||
type ChatContextRef,
|
||||
contextRefKey,
|
||||
} from "@/lib/chat-context-refs";
|
||||
|
||||
export function MvpSetupDescribeView({ projectId, workspace }: { projectId: string; workspace: string }) {
|
||||
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
|
||||
const [tab, setTab] = useState<"chat" | "plan">("chat");
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 900px)");
|
||||
const apply = () => setNarrow(mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
}, []);
|
||||
|
||||
const removeChatContextRef = useCallback((key: string) => {
|
||||
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
|
||||
}, []);
|
||||
|
||||
const addPlanSectionToChat = useCallback((label: string, phaseId: string | null) => {
|
||||
setChatContextRefs(prev => addSectionContextRef(prev, label, phaseId));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, background: JV.chatColumnBg }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "18px 28px 14px",
|
||||
background: "#fff",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: JM.ink, marginBottom: 3, fontFamily: JM.fontDisplay }}>
|
||||
Describe
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: JM.muted }}>
|
||||
Tell Vibn about your idea — your plan fills in on the right as you go.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{narrow && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
background: "#EEF0FF",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{(["chat", "plan"] as const).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "11px 8px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
fontWeight: tab === id ? 600 : 500,
|
||||
color: tab === id ? JM.indigo : JM.muted,
|
||||
borderBottom: tab === id ? `2px solid ${JM.indigo}` : "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
{id === "chat" ? "Chat" : "Your plan"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: narrow && tab !== "chat" ? "none" : "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AtlasChat
|
||||
projectId={projectId}
|
||||
conversationScope="overview"
|
||||
contextEmptyLabel="Plan"
|
||||
emptyStateHint="Answer Vibn’s questions — each phase you complete updates your plan."
|
||||
chatContextRefs={chatContextRefs}
|
||||
onRemoveChatContextRef={removeChatContextRef}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: narrow ? undefined : 308,
|
||||
flex: narrow && tab === "plan" ? 1 : undefined,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
display: narrow && tab !== "plan" ? "none" : "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<div style={{ flex: 1, background: JV.prdPanelBg }} />}>
|
||||
<BuildLivePlanPanel
|
||||
projectId={projectId}
|
||||
workspace={workspace}
|
||||
chatContextRefs={chatContextRefs}
|
||||
onAddSectionRef={addPlanSectionToChat}
|
||||
compactHeader={narrow}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
const BUILD_LEFT_BG = "#faf8f5";
|
||||
const BUILD_LEFT_BORDER = "#e8e4dc";
|
||||
|
||||
export function MvpSetupLayoutClient({
|
||||
workspace,
|
||||
projectId,
|
||||
children,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const base = `/${workspace}/project/${projectId}/mvp-setup`;
|
||||
|
||||
const steps = [
|
||||
{ href: `${base}/describe`, label: "Describe", sub: "Your idea", suffix: "/describe" },
|
||||
{ href: `${base}/architect`, label: "Architect", sub: "Discovery", suffix: "/architect" },
|
||||
{ href: `${base}/design`, label: "Design", sub: "Look & feel", suffix: "/design" },
|
||||
{ href: `${base}/website`, label: "Website", sub: "Grow", suffix: "/website" },
|
||||
{ href: `${base}/launch`, label: "Build MVP", sub: "Review & launch", suffix: "/launch" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
fontFamily: JM.fontSans,
|
||||
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 200,
|
||||
flexShrink: 0,
|
||||
borderRight: `1px solid ${BUILD_LEFT_BORDER}`,
|
||||
background: "#fff",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "18px 12px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "0 6px", marginBottom: 20 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: JM.primaryGradient,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>V</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: JM.ink, letterSpacing: "-0.02em" }}>MVP setup</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: JM.muted, paddingLeft: 34 }}>New product flow</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
padding: "0 6px",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Steps
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
{steps.map(step => {
|
||||
const active = pathname.includes(`${base}${step.suffix}`);
|
||||
return (
|
||||
<Link
|
||||
key={step.suffix}
|
||||
href={step.href}
|
||||
scroll={false}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
padding: "9px 10px",
|
||||
borderRadius: 8,
|
||||
textDecoration: "none",
|
||||
background: active ? "#fafaff" : "transparent",
|
||||
border: active ? `1px solid rgba(99,102,241,0.2)` : "1px solid transparent",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: active ? JM.primaryGradient : "#e5e7eb",
|
||||
color: active ? "#fff" : JM.muted,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{active ? "▲" : "○"}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: active ? 600 : 500, color: JM.ink }}>{step.label}</div>
|
||||
<div style={{ fontSize: 10, color: JM.muted }}>{step.sub}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: `1px solid ${BUILD_LEFT_BORDER}`, marginTop: 14, paddingTop: 12, flexShrink: 0 }}>
|
||||
<Link
|
||||
href={`/${workspace}/projects`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
background: "#eef2ff",
|
||||
border: "1px solid #e0e7ff",
|
||||
borderRadius: 8,
|
||||
padding: "9px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: JM.indigo,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Save & go to dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/build`}
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: 10,
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: JM.muted,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Open Build workspace →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
export function MvpSetupStepPlaceholder({
|
||||
title,
|
||||
subtitle,
|
||||
body,
|
||||
primaryHref,
|
||||
primaryLabel,
|
||||
nextHref,
|
||||
nextLabel,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
body: string;
|
||||
primaryHref: string;
|
||||
primaryLabel: string;
|
||||
nextHref: string;
|
||||
nextLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
padding: "28px 32px",
|
||||
fontFamily: JM.fontSans,
|
||||
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 520 }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: JM.ink, margin: "0 0 8px", fontFamily: JM.fontDisplay }}>
|
||||
{title}
|
||||
</h1>
|
||||
<p style={{ fontSize: 13.5, color: JM.muted, margin: "0 0 24px", lineHeight: 1.55 }}>{subtitle}</p>
|
||||
<p style={{ fontSize: 14, color: JM.ink, lineHeight: 1.65, margin: "0 0 28px" }}>{body}</p>
|
||||
<Link
|
||||
href={primaryHref}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "12px 22px",
|
||||
borderRadius: 10,
|
||||
background: JM.primaryGradient,
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
boxShadow: JM.primaryShadow,
|
||||
marginRight: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href={nextHref}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "12px 18px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${JM.border}`,
|
||||
color: JM.indigo,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{nextLabel} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
export type ProjectInfraRouteBase = "run" | "infrastructure";
|
||||
|
||||
export interface ProjectInfraPanelProps {
|
||||
routeBase: ProjectInfraRouteBase;
|
||||
/** Uppercase rail heading (e.g. Run vs Infrastructure) */
|
||||
navGroupLabel: string;
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
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,
|
||||
}}>
|
||||
<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>
|
||||
{["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 type="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 type="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 type="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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function ProjectInfraPanelInner({ routeBase, navGroupLabel }: ProjectInfraPanelProps) {
|
||||
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}/${routeBase}?tab=${id}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: JM.fontSans, overflow: "hidden" }}>
|
||||
|
||||
<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",
|
||||
}}>
|
||||
{navGroupLabel}
|
||||
</div>
|
||||
{TABS.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
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",
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
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>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{activeTab === "builds" && <BuildsTab project={project} />}
|
||||
{activeTab === "databases" && <DatabasesTab />}
|
||||
{activeTab === "services" && <ServicesTab />}
|
||||
{activeTab === "environment" && <EnvironmentTab />}
|
||||
{activeTab === "domains" && <DomainsTab project={project} />}
|
||||
{activeTab === "logs" && <LogsTab project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectInfraPanel(props: ProjectInfraPanelProps) {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: JM.muted, fontFamily: JM.fontSans, fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<ProjectInfraPanelInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project header URL chips — surfaces the user's "front door" URLs
|
||||
* next to the status pill so they're one click away from any tab.
|
||||
*
|
||||
* - Live chips → every Coolify endpoint with an attached fqdn
|
||||
* - Prev. chips → every running dev-server preview
|
||||
*
|
||||
* When there are more than MAX_VISIBLE total links, extras collapse
|
||||
* into a "+N" pill that opens a popover with the full list as
|
||||
* clickable links (was a `title=` tooltip before — popover is
|
||||
* discoverable on touch and keyboard, tooltip wasn't).
|
||||
*
|
||||
* Polls anatomy every 30s for URL chips (stage pill polls faster while deploying).
|
||||
*/
|
||||
|
||||
import { ExternalLink, Globe, Play, Zap } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { dashboardBridgeScriptUrl } from "@/lib/dashboard-bridge-url";
|
||||
import { useAnatomy } from "./use-anatomy";
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
const START_PREVIEW_PROMPT_BASE =
|
||||
"Start the dev server for the user-facing app in this project on port 3000 and share the preview URL so I can see what it looks like. If multiple services exist (frontend + API + worker), pick the user-facing one. If a server is already running, just share the URL.";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ProjectHeaderUrls({ projectId }: Props) {
|
||||
/** Rare churn — stage pill polls when deploys are active; 30s is plenty for new preview URLs */
|
||||
const { anatomy } = useAnatomy(projectId, { pollMs: 30000 });
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const overflowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close popover on outside click / Escape — both expected by users
|
||||
// who don't realize it's modal-ish.
|
||||
useEffect(() => {
|
||||
if (!overflowOpen) return;
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
|
||||
setOverflowOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOverflowOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [overflowOpen]);
|
||||
|
||||
if (!anatomy) return null;
|
||||
|
||||
const liveLinks = anatomy.hosting.live
|
||||
.filter((l) => !!l.fqdn)
|
||||
.map((l) => ({
|
||||
key: l.uuid,
|
||||
kind: "live" as const,
|
||||
label: l.name,
|
||||
url: ensureScheme(l.fqdn!),
|
||||
host: stripScheme(l.fqdn!),
|
||||
}));
|
||||
|
||||
const previewLinks = anatomy.hosting.previews
|
||||
.filter((p) => p.state === "running" && p.url)
|
||||
.map((p) => ({
|
||||
key: p.id,
|
||||
kind: "preview" as const,
|
||||
label: `${p.name}:${p.port}`,
|
||||
url: p.url,
|
||||
host: hostOf(p.url),
|
||||
}));
|
||||
|
||||
const allLinks = [...liveLinks, ...previewLinks];
|
||||
|
||||
// Empty header is dishonest UX — the user has no idea whether they
|
||||
// SHOULD have a preview or whether nothing has been built yet. Surface
|
||||
// a one-click "Start preview" affordance instead. Clicking it opens
|
||||
// the chat panel (if collapsed) and fires the canned prompt; the
|
||||
// moment dev_server_start lands in anatomy (poll cadence: 4s) this
|
||||
// button is replaced by the real preview chip.
|
||||
if (allLinks.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const bridgeUrl = dashboardBridgeScriptUrl();
|
||||
const prompt =
|
||||
START_PREVIEW_PROMPT_BASE +
|
||||
` After startup, add the preview picker script once (e.g. next/script in the root layout): ${bridgeUrl}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("vibn:chat-prompt", {
|
||||
detail: { prompt, scopeProjectId: projectId },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
title="Start the dev server and share a preview URL in chat"
|
||||
style={startPreviewBtn}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#f6f2ec";
|
||||
e.currentTarget.style.borderColor = "#d9d2c5";
|
||||
e.currentTarget.style.color = "#1a1a1a";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "#fff";
|
||||
e.currentTarget.style.borderColor = "#e8e4dc";
|
||||
e.currentTarget.style.color = "#6b665e";
|
||||
}}
|
||||
>
|
||||
<Play size={10} style={{ flexShrink: 0 }} fill="currentColor" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const visible = allLinks.slice(0, MAX_VISIBLE);
|
||||
const hidden = allLinks.slice(MAX_VISIBLE);
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
{visible.map((l) => (
|
||||
<a
|
||||
key={l.key}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={l.kind === "live" ? liveChip : previewChip}
|
||||
title={`${l.label} → ${l.host}`}
|
||||
>
|
||||
{l.kind === "live"
|
||||
? <Globe size={11} style={{ flexShrink: 0 }} />
|
||||
: <Zap size={11} style={{ flexShrink: 0 }} />}
|
||||
<span style={chipLabel}>{l.label}</span>
|
||||
<ExternalLink size={10} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
</a>
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
<div ref={overflowRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOverflowOpen((v) => !v)}
|
||||
aria-expanded={overflowOpen}
|
||||
aria-haspopup="true"
|
||||
style={overflowPill}
|
||||
>
|
||||
+{hidden.length}
|
||||
</button>
|
||||
{overflowOpen && (
|
||||
<div role="menu" style={popoverStyle}>
|
||||
<div style={popoverHeader}>{hidden.length} more endpoint{hidden.length === 1 ? "" : "s"}</div>
|
||||
{hidden.map((l) => (
|
||||
<a
|
||||
key={l.key}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={popoverItem}
|
||||
onClick={() => setOverflowOpen(false)}
|
||||
role="menuitem"
|
||||
>
|
||||
{l.kind === "live"
|
||||
? <Globe size={12} style={{ flexShrink: 0, color: "#1a1a1a" }} />
|
||||
: <Zap size={12} style={{ flexShrink: 0, color: "#3d5afe" }} />}
|
||||
<div style={popoverItemText}>
|
||||
<div style={popoverItemLabel}>{l.label}</div>
|
||||
<div style={popoverItemHost}>{l.host}</div>
|
||||
</div>
|
||||
<ExternalLink size={11} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function ensureScheme(host: string): string {
|
||||
if (/^https?:\/\//i.test(host)) return host;
|
||||
return `https://${host}`;
|
||||
}
|
||||
function stripScheme(host: string): string {
|
||||
return host.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
||||
}
|
||||
function hostOf(url: string): string {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
|
||||
const wrap: React.CSSProperties = {
|
||||
display: "flex", gap: 6, alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const chipBase: React.CSSProperties = {
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "4px 10px", borderRadius: 4,
|
||||
fontSize: "0.72rem", fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap", maxWidth: 220,
|
||||
border: "1px solid",
|
||||
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
transition: "background 0.15s, border-color 0.15s",
|
||||
};
|
||||
const liveChip: React.CSSProperties = {
|
||||
...chipBase,
|
||||
color: "#1a1a1a", borderColor: "#e8e4dc", background: "#fff",
|
||||
};
|
||||
const previewChip: React.CSSProperties = {
|
||||
...chipBase,
|
||||
color: "#3d5afe", borderColor: "#3d5afe33", background: "#3d5afe08",
|
||||
};
|
||||
const startPreviewBtn: React.CSSProperties = {
|
||||
...chipBase,
|
||||
color: "#6b665e",
|
||||
borderColor: "#e8e4dc",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.15s, border-color 0.15s, color 0.15s",
|
||||
};
|
||||
const chipLabel: React.CSSProperties = {
|
||||
overflow: "hidden", textOverflow: "ellipsis",
|
||||
maxWidth: 180,
|
||||
};
|
||||
const overflowPill: React.CSSProperties = {
|
||||
...chipBase,
|
||||
borderColor: "#e8e4dc",
|
||||
color: "#6b665e",
|
||||
background: "#f8f5f0",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: "calc(100% + 6px)",
|
||||
right: 0,
|
||||
minWidth: 240,
|
||||
maxWidth: 360,
|
||||
padding: 4,
|
||||
background: "#fff",
|
||||
border: "1px solid #e8e4dc",
|
||||
borderRadius: 6,
|
||||
boxShadow: "0 6px 20px rgba(0,0,0,0.08)",
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
};
|
||||
const popoverHeader: React.CSSProperties = {
|
||||
padding: "6px 10px 4px",
|
||||
fontSize: "0.65rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "#a09a90",
|
||||
fontWeight: 600,
|
||||
};
|
||||
const popoverItem: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 4,
|
||||
textDecoration: "none",
|
||||
color: "#1a1a1a",
|
||||
fontSize: "0.78rem",
|
||||
cursor: "pointer",
|
||||
};
|
||||
const popoverItemText: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
};
|
||||
const popoverItemLabel: React.CSSProperties = {
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
const popoverItemHost: React.CSSProperties = {
|
||||
fontSize: "0.7rem",
|
||||
color: "#a09a90",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
@@ -1,343 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project header status pill — surfaces what Coolify is actually doing.
|
||||
*
|
||||
* Priority (highest urgency wins):
|
||||
* 1. Build failed — most recent finished deploy errored
|
||||
* 2. Deploying — at least one in-flight deployment (queued / in_progress)
|
||||
* 3. Down — apps exist but none running
|
||||
* 4. Live — at least one app/service running healthy
|
||||
* 5. Empty — no apps deployed yet (replaces the old false "Live"
|
||||
* fallback when data.status="active")
|
||||
*
|
||||
* while deploys or unhealthy states need attention (~8s); stays quiet when live or empty.
|
||||
* On hover the pill shows a tooltip with the breakdown of why we're
|
||||
* in the current state ("vibn-frontend is deploying", "twenty-live last
|
||||
* deploy failed 3m ago", etc.) — no more guessing.
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect, useState } from "react";
|
||||
import { Loader2, ExternalLink } from "lucide-react";
|
||||
import { useAnatomy, type Anatomy } from "./use-anatomy";
|
||||
|
||||
interface ProjectStagePillProps {
|
||||
projectId: string;
|
||||
/** Stage value pulled from fs_projects.data.status — only used while
|
||||
* the first anatomy fetch is in flight, so the user sees something
|
||||
* immediately instead of an empty header. */
|
||||
fallbackStage: "discovery" | "architecture" | "building" | "active";
|
||||
}
|
||||
|
||||
type PillState =
|
||||
| { kind: "build_failed"; reason: string }
|
||||
| { kind: "deploying"; reason: string }
|
||||
| { kind: "down"; reason: string }
|
||||
| { kind: "live"; reason: string }
|
||||
| { kind: "empty"; reason: string };
|
||||
|
||||
export function ProjectStagePill({
|
||||
projectId,
|
||||
fallbackStage,
|
||||
}: ProjectStagePillProps) {
|
||||
const [anatomyPollMs, setAnatomyPollMs] = useState(0);
|
||||
const { anatomy, loading } = useAnatomy(projectId, { pollMs: anatomyPollMs });
|
||||
|
||||
useEffect(() => {
|
||||
if (!anatomy) {
|
||||
// Don't call setState here if not needed
|
||||
if (anatomyPollMs !== 0) setAnatomyPollMs(0);
|
||||
return;
|
||||
}
|
||||
const s = derivePillState(anatomy);
|
||||
const targetPollMs = s.kind === "live" || s.kind === "empty" ? 0 : 8000;
|
||||
if (anatomyPollMs !== targetPollMs) {
|
||||
setAnatomyPollMs(targetPollMs);
|
||||
}
|
||||
}, [anatomy, anatomyPollMs]);
|
||||
|
||||
const state = useMemo<PillState | null>(() => {
|
||||
if (!anatomy) return null;
|
||||
return derivePillState(anatomy);
|
||||
}, [anatomy]);
|
||||
|
||||
if (loading && !anatomy) {
|
||||
const f = FALLBACK_PRESETS[fallbackStage];
|
||||
return (
|
||||
<Pill
|
||||
label={f.label}
|
||||
color={f.color}
|
||||
bg={f.bg}
|
||||
title="Loading project status…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!state) {
|
||||
const f = FALLBACK_PRESETS[fallbackStage];
|
||||
return (
|
||||
<Pill
|
||||
label={f.label}
|
||||
color={f.color}
|
||||
bg={f.bg}
|
||||
title="Project status unavailable."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visual = VISUALS[state.kind];
|
||||
|
||||
// Deep-link target for the "Logs" affordance. Coolify v4 redirects
|
||||
// `/project/<uuid>` to that project's first environment, putting
|
||||
// the user one click from any application's deployment logs. We
|
||||
// don't store the environment UUID in anatomy (yet), so this is
|
||||
// the closest we can get without an extra Coolify call. When
|
||||
// anatomy exposes `lastBuildDeploymentUuid` we can deep-link
|
||||
// straight to `/applications/.../deployment/<dep-uuid>`.
|
||||
const coolifyBase = process.env.NEXT_PUBLIC_COOLIFY_URL ?? "";
|
||||
const coolifyProjectUuid = anatomy?.project?.coolifyProjectUuid;
|
||||
const coolifyDeepLink =
|
||||
coolifyBase && coolifyProjectUuid
|
||||
? `${coolifyBase.replace(/\/$/, "")}/project/${coolifyProjectUuid}`
|
||||
: coolifyBase || null;
|
||||
|
||||
// Show the "Logs" affordance whenever there's something interesting
|
||||
// happening Coolify-side: a build is in flight, the last build
|
||||
// failed, or apps are down. Hide on `live` and `empty` to avoid
|
||||
// visual noise when nothing's wrong.
|
||||
const showLogsLink =
|
||||
coolifyDeepLink &&
|
||||
(state.kind === "build_failed" ||
|
||||
state.kind === "deploying" ||
|
||||
state.kind === "down");
|
||||
|
||||
const logsLinkColor =
|
||||
state.kind === "build_failed" || state.kind === "down"
|
||||
? "#c5392b"
|
||||
: "#3d5afe";
|
||||
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<Pill
|
||||
label={visual.label}
|
||||
color={visual.color}
|
||||
bg={visual.bg}
|
||||
title={state.reason}
|
||||
spinning={state.kind === "deploying"}
|
||||
/>
|
||||
{showLogsLink && (
|
||||
<a
|
||||
href={coolifyDeepLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`Open Coolify build logs in a new tab`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
fontSize: "0.68rem",
|
||||
color: logsLinkColor,
|
||||
textDecoration: "none",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
Logs <ExternalLink size={9} />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Coolify reports container status as `<phase>` or `<phase>:<health>`,
|
||||
// e.g. "running:healthy", "starting:unknown", "exited:unhealthy".
|
||||
// Phase taxonomy:
|
||||
// running → up
|
||||
// starting → transient (booting / health-check pending)
|
||||
// restarting → transient
|
||||
// created / paused → transient (rare in our flow)
|
||||
// exited / dead → down
|
||||
// We classify each app, then aggregate to a pill state.
|
||||
type AppPhase = "up" | "transient" | "down" | "unknown";
|
||||
function classifyAppStatus(raw?: string): AppPhase {
|
||||
const s = (raw ?? "").toLowerCase().trim();
|
||||
if (!s || s === "unknown") return "unknown";
|
||||
if (/^(running|healthy)/.test(s)) return "up";
|
||||
if (/healthy/.test(s) && !/unhealthy/.test(s)) return "up";
|
||||
if (
|
||||
/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(
|
||||
s,
|
||||
)
|
||||
)
|
||||
return "transient";
|
||||
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
|
||||
// Default to transient for anything unrecognised — Coolify occasionally
|
||||
// emits novel phases during upgrades; better to wait than mis-flag red.
|
||||
return "transient";
|
||||
}
|
||||
|
||||
// Pure function. Exported-style intent only — keeps logic testable.
|
||||
function derivePillState(a: Anatomy): PillState {
|
||||
const live = a.hosting?.live ?? [];
|
||||
|
||||
if (live.length === 0) {
|
||||
return {
|
||||
kind: "empty",
|
||||
reason: "No apps deployed yet. Use the chat to spin one up.",
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Active build in flight — highest priority signal.
|
||||
const deploying = live.filter((l) => l.inFlightBuild);
|
||||
if (deploying.length > 0) {
|
||||
const names = deploying.map((l) => l.name).join(", ");
|
||||
const stage = deploying[0].inFlightBuild?.status ?? "in progress";
|
||||
return {
|
||||
kind: "deploying",
|
||||
reason: `Deploying ${names}\nCoolify status: ${stage}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Container is currently booting (starting / restarting). Surface
|
||||
// as "Deploying" since to the user this is the same wait state.
|
||||
const transient = live.filter(
|
||||
(l) => classifyAppStatus(l.status) === "transient",
|
||||
);
|
||||
if (transient.length > 0) {
|
||||
const lines = transient.map((l) => `${l.name}: ${l.status}`);
|
||||
return {
|
||||
kind: "deploying",
|
||||
reason: `Containers starting:\n${lines.join("\n")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Last finished build errored — call attention regardless of
|
||||
// whether the previous container is still serving.
|
||||
const failed = live.filter(
|
||||
(l) => l.lastBuild && /fail|error|cancel/i.test(l.lastBuild.status),
|
||||
);
|
||||
if (failed.length > 0) {
|
||||
const lines = failed.map(
|
||||
(l) =>
|
||||
`${l.name}: ${l.lastBuild?.status}` +
|
||||
(l.lastBuild?.finishedAt
|
||||
? ` · ${relTime(l.lastBuild.finishedAt)}`
|
||||
: ""),
|
||||
);
|
||||
return {
|
||||
kind: "build_failed",
|
||||
reason: `Last deploy failed:\n${lines.join("\n")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const phases = live.map((l) => classifyAppStatus(l.status));
|
||||
const upCount = phases.filter((p) => p === "up").length;
|
||||
const downCount = phases.filter((p) => p === "down").length;
|
||||
|
||||
if (upCount === live.length) {
|
||||
return {
|
||||
kind: "live",
|
||||
reason: `All ${live.length} ${live.length === 1 ? "service is" : "services are"} running.`,
|
||||
};
|
||||
}
|
||||
if (upCount > 0) {
|
||||
return {
|
||||
kind: "live",
|
||||
reason: `${upCount}/${live.length} services running.`,
|
||||
};
|
||||
}
|
||||
if (downCount > 0) {
|
||||
const sample = live
|
||||
.slice(0, 3)
|
||||
.map((l) => `${l.name}: ${l.status}`)
|
||||
.join("\n");
|
||||
return { kind: "down", reason: `Apps are not running.\n${sample}` };
|
||||
}
|
||||
|
||||
// All "unknown" — Coolify hasn't reported state yet (fresh project,
|
||||
// API hiccup). Treat as transient rather than red.
|
||||
return {
|
||||
kind: "deploying",
|
||||
reason: "Waiting on Coolify to report container state…",
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
const VISUALS: Record<
|
||||
PillState["kind"],
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
build_failed: { label: "Build failed", color: "#c5392b", bg: "#c5392b14" },
|
||||
deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
down: { label: "Down", color: "#c5392b", bg: "#c5392b14" },
|
||||
live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" },
|
||||
};
|
||||
|
||||
const FALLBACK_PRESETS: Record<
|
||||
"discovery" | "architecture" | "building" | "active",
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
|
||||
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
};
|
||||
|
||||
function Pill({
|
||||
label,
|
||||
color,
|
||||
bg,
|
||||
title,
|
||||
spinning,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
title?: string;
|
||||
spinning?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.02em",
|
||||
color,
|
||||
background: bg,
|
||||
whiteSpace: "nowrap",
|
||||
cursor: title ? "help" : "default",
|
||||
}}
|
||||
>
|
||||
{spinning ? (
|
||||
<Loader2 size={9} className="animate-spin" style={{ color }} />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return "";
|
||||
const min = Math.floor(ms / 60_000);
|
||||
if (min < 1) return "just now";
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project tab bar — Product · Infrastructure · Hosting.
|
||||
*
|
||||
* Lives at the top of the cream main area, right below the project
|
||||
* header. The active tab is determined by the URL pathname so back /
|
||||
* forward / refresh always highlight the right one.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Box, Cloud, Server, NotebookPen } from "lucide-react";
|
||||
|
||||
const TABS = [
|
||||
{ id: "plan", label: "Plan", icon: NotebookPen, blurb: "Vision, ideas, tasks, and decisions for this project." },
|
||||
{ id: "product", label: "Product", icon: Box, blurb: "Custom code, design, and content built for this vision." },
|
||||
{ id: "infrastructure", label: "Infrastructure", icon: Server, blurb: "Swappable services this product depends on." },
|
||||
{ id: "hosting", label: "Hosting", icon: Cloud, blurb: "Where it runs and how people reach it." },
|
||||
] as const;
|
||||
|
||||
export function ProjectTabBar({
|
||||
workspace,
|
||||
projectId,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const activeTab =
|
||||
TABS.find(t => pathname.includes(`/project/${projectId}/${t.id}`))?.id ??
|
||||
"plan";
|
||||
|
||||
return (
|
||||
<nav style={tabBar} aria-label="Project sections">
|
||||
{TABS.map(tab => {
|
||||
const isActive = tab.id === activeTab;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={`/${workspace}/project/${projectId}/${tab.id}`}
|
||||
style={{
|
||||
...tabLink,
|
||||
color: isActive ? "#1a1a1a" : "#5f5e5a",
|
||||
borderBottomColor: isActive ? "#1a1a1a" : "transparent",
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
}}
|
||||
title={tab.blurb}
|
||||
>
|
||||
<Icon size={14} style={{ opacity: isActive ? 1 : 0.7 }} />
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const tabBar: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
marginTop: 22,
|
||||
marginBottom: -1,
|
||||
};
|
||||
|
||||
const tabLink: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 14px",
|
||||
fontSize: "0.82rem",
|
||||
textDecoration: "none",
|
||||
borderBottom: "2px solid transparent",
|
||||
transition: "color 0.15s, border-color 0.15s",
|
||||
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
};
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Shared layout for the Product / Infrastructure / Hosting tabs.
|
||||
*
|
||||
* The tab bar in the page header already names the section, so the
|
||||
* page itself is just two columns:
|
||||
* - left: a "what lives here" grid of sub-areas
|
||||
* - right: live status panels (counts, empty states, CTAs)
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface SubArea {
|
||||
label: string;
|
||||
hint: string;
|
||||
/** When provided, the tile renders as a button; pair with `active`. */
|
||||
onClick?: () => void;
|
||||
/** Visually mark this tile as the current selection. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface SectionScaffoldProps {
|
||||
subAreas: SubArea[];
|
||||
rightPanel: ReactNode;
|
||||
/** Defaults to "What lives here". Pass e.g. "Codebases" for the Product tab. */
|
||||
subAreasHeading?: string;
|
||||
/** Optional heading above the right panel — keeps both columns
|
||||
* vertically aligned. If omitted, an invisible spacer is rendered
|
||||
* with the same height so panels still line up with tiles. */
|
||||
rightHeading?: string;
|
||||
}
|
||||
|
||||
export function SectionScaffold({
|
||||
subAreas,
|
||||
rightPanel,
|
||||
subAreasHeading = "What lives here",
|
||||
rightHeading,
|
||||
}: SectionScaffoldProps) {
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={grid}>
|
||||
<section style={leftCol}>
|
||||
<h3 style={subHeading}>{subAreasHeading}</h3>
|
||||
<ul style={subList}>
|
||||
{subAreas.map(area => {
|
||||
const interactive = typeof area.onClick === "function";
|
||||
const style: React.CSSProperties = {
|
||||
...subItem,
|
||||
cursor: interactive ? "pointer" : "default",
|
||||
borderColor: area.active ? INK.ink : INK.borderSoft,
|
||||
boxShadow: area.active ? "0 0 0 1px " + INK.ink : "none",
|
||||
transition: "border-color 0.12s, box-shadow 0.12s, background 0.12s",
|
||||
background: area.active ? "#fffdf8" : INK.cardBg,
|
||||
};
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
...subItemDot,
|
||||
background: area.active ? INK.ink : INK.stone,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={subItemLabel}>{area.label}</div>
|
||||
<div style={subItemHint}>{area.hint}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return interactive ? (
|
||||
<li key={area.label} style={{ listStyle: "none" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={area.onClick}
|
||||
style={{
|
||||
...style,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
}}
|
||||
aria-pressed={area.active}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</li>
|
||||
) : (
|
||||
<li key={area.label} style={style}>
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<aside style={rightCol}>
|
||||
<h3 style={{ ...subHeading, visibility: rightHeading ? "visible" : "hidden" }}>
|
||||
{rightHeading ?? "\u00A0"}
|
||||
</h3>
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
{rightPanel}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPanel({
|
||||
title,
|
||||
children,
|
||||
cta,
|
||||
}: {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
cta?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={panel}>
|
||||
{(title || cta) && (
|
||||
<div style={panelHeader}>
|
||||
{title && <span style={panelTitle}>{title}</span>}
|
||||
{cta}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
message,
|
||||
hint,
|
||||
}: {
|
||||
message: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={emptyWrap}>
|
||||
<div style={emptyMsg}>{message}</div>
|
||||
{hint && <div style={emptyHint}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
stone: "#b5b0a6",
|
||||
border: "#e8e4dc",
|
||||
borderSoft: "#efebe1",
|
||||
cardBg: "#fff",
|
||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
} as const;
|
||||
|
||||
const pageWrap: React.CSSProperties = {
|
||||
padding: "28px 48px 48px",
|
||||
fontFamily: INK.fontSans,
|
||||
color: INK.ink,
|
||||
};
|
||||
|
||||
const grid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(220px, 280px) minmax(0, 1fr)",
|
||||
gap: 28,
|
||||
maxWidth: 1280,
|
||||
margin: "0 auto",
|
||||
alignItems: "stretch",
|
||||
};
|
||||
|
||||
const leftCol: React.CSSProperties = {
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const rightCol: React.CSSProperties = {
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const subHeading: React.CSSProperties = {
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
color: INK.muted,
|
||||
margin: "0 0 14px",
|
||||
};
|
||||
|
||||
const subList: React.CSSProperties = {
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const subItem: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
alignItems: "flex-start",
|
||||
padding: "12px 14px",
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.borderSoft}`,
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const subItemDot: React.CSSProperties = {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: INK.stone,
|
||||
marginTop: 7,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const subItemLabel: React.CSSProperties = {
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: INK.ink,
|
||||
marginBottom: 2,
|
||||
};
|
||||
|
||||
const subItemHint: React.CSSProperties = {
|
||||
fontSize: "0.75rem",
|
||||
color: INK.mid,
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const panel: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10,
|
||||
padding: 18,
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
const panelHeader: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 14,
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const panelTitle: React.CSSProperties = {
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: INK.ink,
|
||||
};
|
||||
|
||||
const emptyWrap: React.CSSProperties = {
|
||||
padding: "20px 0 4px",
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const emptyMsg: React.CSSProperties = {
|
||||
fontSize: "0.85rem",
|
||||
color: INK.mid,
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const emptyHint: React.CSSProperties = {
|
||||
fontSize: "0.74rem",
|
||||
color: INK.muted,
|
||||
fontStyle: "italic",
|
||||
};
|
||||
@@ -155,13 +155,14 @@ export function useAnatomy(
|
||||
projectId: string,
|
||||
options: UseAnatomyOptions = {},
|
||||
): UseAnatomyResult {
|
||||
const pollMs = options.pollMs && options.pollMs > 0 ? options.pollMs : 0;
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR<Anatomy, Error>(
|
||||
projectId ? `/api/projects/${projectId}/anatomy` : null,
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval:
|
||||
options.pollMs && options.pollMs > 0 ? options.pollMs : 0,
|
||||
dedupingInterval: 2000,
|
||||
refreshInterval: pollMs,
|
||||
dedupingInterval: 5000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
},
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CollapsibleSidebarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
initialExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function CollapsibleSidebar({ children, className, initialExpanded = true }: CollapsibleSidebarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(initialExpanded);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-r bg-muted/30 transition-all duration-300 relative flex-shrink-0 group",
|
||||
isExpanded ? "w-52" : "w-3 hover:w-4 bg-muted/50 cursor-pointer",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!isExpanded) setIsExpanded(true);
|
||||
}}
|
||||
>
|
||||
{/* Toggle Button */}
|
||||
{isExpanded && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
className="absolute top-2 right-2 z-20 p-1 hover:bg-background/50 rounded-sm transition-colors"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Content Container */}
|
||||
<div className={cn(
|
||||
"h-full overflow-y-auto p-3",
|
||||
isExpanded ? "opacity-100" : "opacity-0 pointer-events-none hidden"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,163 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronDown, Circle, CheckCircle2, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
status?: "built" | "in_progress" | "missing";
|
||||
children?: TreeNode[];
|
||||
metadata?: {
|
||||
sessionsCount?: number;
|
||||
commitsCount?: number;
|
||||
cost?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TreeViewProps {
|
||||
data: TreeNode[];
|
||||
selectedId?: string | null;
|
||||
onSelect?: (node: TreeNode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TreeNodeItemProps {
|
||||
node: TreeNode;
|
||||
level: number;
|
||||
selectedId?: string | null;
|
||||
onSelect?: (node: TreeNode) => void;
|
||||
}
|
||||
|
||||
function TreeNodeItem({ node, level, selectedId, onSelect }: TreeNodeItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(level === 0); // Auto-expand top level
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isSelected = selectedId === node.id;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!node.status) return null;
|
||||
|
||||
switch (node.status) {
|
||||
case "built":
|
||||
return <CheckCircle2 className="h-3 w-3 text-primary" />;
|
||||
case "in_progress":
|
||||
return <Clock className="h-3 w-3 text-muted-foreground" />;
|
||||
case "missing":
|
||||
return <Circle className="h-3 w-3 text-muted-foreground/50" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!node.status) return "";
|
||||
|
||||
switch (node.status) {
|
||||
case "built":
|
||||
return "bg-secondary hover:bg-muted border-l-2 border-l-primary";
|
||||
case "in_progress":
|
||||
return "bg-muted/40 hover:bg-muted border-l-2 border-l-border";
|
||||
case "missing":
|
||||
return "hover:bg-muted/30 border-l-2 border-l-transparent";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 py-1.5 px-2 rounded-md cursor-pointer transition-all group",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: getStatusColor(),
|
||||
level > 0 && "text-sm"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
if (onSelect) {
|
||||
onSelect(node);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="hover:bg-muted/50 rounded p-0.5 transition-transform"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className={cn(
|
||||
"flex-1 truncate",
|
||||
level === 0 && "font-semibold text-xs uppercase tracking-wide text-muted-foreground",
|
||||
level === 1 && "font-medium",
|
||||
level > 1 && "text-muted-foreground"
|
||||
)}>
|
||||
{node.label}
|
||||
</span>
|
||||
|
||||
{node.metadata && (
|
||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{node.metadata.sessionsCount && node.metadata.sessionsCount > 0 && (
|
||||
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||
{node.metadata.sessionsCount}s
|
||||
</span>
|
||||
)}
|
||||
{node.metadata.commitsCount && node.metadata.commitsCount > 0 && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{node.metadata.commitsCount}c
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="space-y-0.5">
|
||||
{node.children!.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeView({ data, selectedId, onSelect, className }: TreeViewProps) {
|
||||
return (
|
||||
<div className={cn("space-y-0.5", className)}>
|
||||
{data.map((node) => (
|
||||
<TreeNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user