fix(gitea-bot): add write:organization scope so bot can create repos

Without this the bot PAT 403s on POST /orgs/{org}/repos, which is
the single most important operation — creating new project repos
inside the workspace's Gitea org.

Made-with: Cursor
This commit is contained in:
2026-04-21 11:05:55 -07:00
parent d9d3514647
commit 6f79a88abd
66 changed files with 2088 additions and 1713 deletions

View File

@@ -58,7 +58,7 @@ export default function PricingPage() {
</Card>
{/* Pro Tier */}
<Card className="border-primary shadow-lg">
<Card className="relative border-primary shadow-lg">
<div className="absolute right-4 top-4 rounded-full bg-primary px-3 py-1 text-xs text-primary-foreground">
Popular
</div>

View File

@@ -3,7 +3,15 @@
import { Suspense, useState, useEffect, useCallback, useRef } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import Link from "next/link";
import { JM } from "@/components/project-creation/modal-theme";
import { AtlasChat } from "@/components/AtlasChat";
import { PRD_PLAN_SECTIONS } from "@/lib/prd-sections";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -17,15 +25,6 @@ interface TreeNode {
// ── Constants ─────────────────────────────────────────────────────────────────
const INFRA_ITEMS = [
{ 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: "≈" },
];
const SURFACE_LABELS: Record<string, string> = {
webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel",
};
@@ -33,6 +32,74 @@ const SURFACE_ICONS: Record<string, string> = {
webapp: "◈", marketing: "◌", admin: "◫",
};
/** Growth pagestyle left rail: cream panel, icon + label rows */
const BUILD_LEFT_BG = "#faf8f5";
const BUILD_LEFT_BORDER = "#e8e4dc";
const BUILD_NAV_GROUP: React.CSSProperties = {
fontSize: "0.6rem",
fontWeight: 700,
color: "#b5b0a6",
letterSpacing: "0.09em",
textTransform: "uppercase",
padding: "14px 12px 6px",
fontFamily: JM.fontSans,
};
const BUILD_PRIMARY = [
{ id: "chat", label: "Chat", icon: "◆" },
{ id: "code", label: "Code", icon: "◇" },
{ id: "layouts", label: "Layouts", icon: "◈" },
{ id: "tasks", label: "Agent", icon: "◎" },
{ id: "preview", label: "Preview", icon: "▢" },
] as const;
function BuildGrowthNavRow({
icon,
label,
active,
onClick,
}: {
icon: string;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
textAlign: "left",
background: active ? "#f0ece4" : "transparent",
border: "none",
cursor: "pointer",
padding: "6px 12px",
borderRadius: 5,
fontSize: "0.78rem",
fontWeight: active ? 600 : 440,
color: active ? "#1a1a1a" : "#5a5550",
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.65rem", opacity: 0.55, width: 14, textAlign: "center", flexShrink: 0 }}>
{icon}
</span>
{label}
</button>
);
}
// ── Language / syntax helpers ─────────────────────────────────────────────────
function langFromName(name: string): string {
@@ -103,28 +170,11 @@ function TreeRow({ node, depth, selectedPath, onSelect, onToggle }: {
// ── Left nav shared styles ────────────────────────────────────────────────────
const NAV_GROUP_LABEL: React.CSSProperties = {
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
fontSize: "0.6rem", fontWeight: 700, color: JM.muted,
letterSpacing: "0.09em", textTransform: "uppercase",
padding: "12px 12px 5px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
padding: "12px 12px 5px", fontFamily: JM.fontSans,
};
function NavItem({ label, active, onClick, indent = false }: { label: string; active: boolean; onClick: () => void; indent?: boolean }) {
return (
<button onClick={onClick} style={{
display: "flex", alignItems: "center", gap: 7, width: "100%", textAlign: "left",
background: active ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
padding: `5px 12px 5px ${indent ? 22 : 12}px`, borderRadius: 5,
fontSize: "0.78rem", fontWeight: active ? 600 : 440,
color: active ? "#1a1a1a" : "#5a5550", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{label}
</button>
);
}
// ── Placeholder panel ─────────────────────────────────────────────────────────
function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) {
@@ -140,30 +190,6 @@ function Placeholder({ icon, title, desc }: { icon: string; title: string; desc:
);
}
// ── Infra content ─────────────────────────────────────────────────────────────
function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) {
const base = `/${workspace}/project/${projectId}/infrastructure`;
const descriptions: Record<string, { icon: string; title: string; desc: string }> = {
databases: { icon: "◫", title: "Databases", desc: "PostgreSQL, Redis, and other databases — provisioned and managed with connection strings auto-injected." },
services: { icon: "◎", title: "Services", desc: "Background workers, queues, email delivery, file storage, and third-party integrations." },
environment: { icon: "≡", title: "Environment", desc: "Environment variables and secrets, encrypted at rest and auto-injected into your containers." },
domains: { icon: "◬", title: "Domains", desc: "Custom domains and SSL certificates for all your deployed services." },
logs: { icon: "≈", title: "Logs", desc: "Runtime logs, request traces, and error reports streaming from deployed services." },
builds: { icon: "⬡", title: "Builds", desc: "Deployment history, build logs, and rollback controls for all your apps." },
};
const d = descriptions[tab];
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ padding: "20px 28px 0", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{tab}</div>
<Link href={`${base}?tab=${tab}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Open full view </Link>
</div>
{d && <Placeholder icon={d.icon} title={d.title} desc={d.desc} />}
</div>
);
}
// ── Layouts content ───────────────────────────────────────────────────────────
function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSelectSurface }: {
@@ -956,7 +982,8 @@ function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: {
}, [projectId]);
useEffect(() => {
if (!rootPath || status !== "authenticated") return;
if (!rootPath) return;
if (!isClientDevProjectBypass() && status !== "authenticated") return;
setTree([]); setTreeLoading(true);
fetchDir(rootPath).then(nodes => { setTree(nodes); setTreeLoading(false); }).catch(() => setTreeLoading(false));
}, [rootPath, status, fetchDir]);
@@ -1046,21 +1073,6 @@ interface PreviewApp { name: string; url: string | null; status: string; }
// ── PRD Content ───────────────────────────────────────────────────────────────
const PRD_SECTIONS = [
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
];
interface SavedPhase { phase: string; title: string; summary: string; data: Record<string, unknown>; saved_at: string; }
function PrdContent({ projectId }: { projectId: string }) {
@@ -1086,7 +1098,7 @@ function PrdContent({ projectId }: { projectId: string }) {
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_SECTIONS.map(s => ({
const sections = PRD_PLAN_SECTIONS.map(s => ({
...s,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
@@ -1272,10 +1284,11 @@ function BuildHubInner() {
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const section = searchParams.get("section") ?? "code";
const section = searchParams.get("section") ?? "chat";
const tasksSubTabRaw = searchParams.get("tab") ?? "tasks";
const tasksSubTab = tasksSubTabRaw === "prd" ? "requirements" : tasksSubTabRaw;
const activeApp = searchParams.get("app") ?? "";
const activeRoot = searchParams.get("root") ?? "";
const activeInfra = searchParams.get("tab") ?? "builds";
const activeSurfaceParam = searchParams.get("surface") ?? "";
const [apps, setApps] = useState<AppEntry[]>([]);
@@ -1292,6 +1305,32 @@ function BuildHubInner() {
const [fileLoading, setFileLoading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
const addAppToChat = useCallback((app: AppEntry) => {
setChatContextRefs(prev => {
const next: ChatContextRef = { kind: "app", label: app.name, path: app.path };
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(() => {
if (searchParams.get("section") !== "infrastructure") return;
const t = searchParams.get("tab") ?? "builds";
router.replace(`/${workspace}/project/${projectId}/run?tab=${encodeURIComponent(t)}`, { scroll: false });
}, [searchParams, workspace, projectId, router]);
useEffect(() => {
if (searchParams.get("section") !== "mvp") return;
router.replace(`/${workspace}/project/${projectId}/mvp-setup/launch`, { scroll: false });
}, [searchParams, workspace, projectId, router]);
useEffect(() => {
fetch(`/api/projects/${projectId}/preview-url`)
.then(r => r.json())
@@ -1336,82 +1375,114 @@ function BuildHubInner() {
router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false });
};
const workspaceAppActive = (app: AppEntry) => {
if (section === "chat") {
return chatContextRefs.some(r => r.kind === "app" && r.path === app.path);
}
if (section === "code" || (section === "tasks" && tasksSubTab !== "requirements")) {
return activeApp === app.name;
}
return false;
};
const onWorkspaceApp = (app: AppEntry) => {
if (section === "chat") addAppToChat(app);
else if (section === "code") navigate({ section: "code", app: app.name, root: app.path });
else if (section === "tasks" && tasksSubTab !== "requirements") {
navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path });
}
else navigate({ section: "code", app: app.name, root: app.path });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
<div style={{ display: "flex", height: "100%", fontFamily: JM.fontSans, overflow: "hidden", background: JM.inputBg }}>
{/* Growth-style left rail */}
<div style={{
width: 200,
flexShrink: 0,
borderRight: `1px solid ${BUILD_LEFT_BORDER}`,
background: BUILD_LEFT_BG,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div style={BUILD_NAV_GROUP}>Build</div>
{BUILD_PRIMARY.map(p => (
<BuildGrowthNavRow
key={p.id}
icon={p.icon}
label={p.label}
active={section === p.id}
onClick={() => {
if (p.id === "tasks") navigate({ section: "tasks", tab: "tasks" });
else navigate({ section: p.id });
}}
/>
))}
{/* ── Build content ── */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{/* Inner nav — contextual items driven by top-bar tool icon */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Code: app list + file tree */}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ flexShrink: 0 }}>
<div style={NAV_GROUP_LABEL}>Apps</div>
{apps.length > 0 ? apps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={activeApp === app.name}
onClick={() => navigate({ section: "code", app: app.name, root: app.path })}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No apps yet</div>
)}
</div>
{activeApp && activeRoot && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderTop: "1px solid #e8e4dc", marginTop: 6 }}>
<div style={{ padding: "7px 12px 4px", flexShrink: 0, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Files</span>
<span style={{ fontSize: "0.62rem", color: "#a09a90", fontFamily: "IBM Plex Mono, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeApp}</span>
</div>
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
</div>
)}
<div style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
borderTop: `1px solid ${BUILD_LEFT_BORDER}`,
marginTop: 6,
paddingTop: 4,
}}>
{section === "chat" && (
<div style={{ padding: "8px 12px 12px", fontSize: 11, color: JM.mid, lineHeight: 1.45, fontFamily: JM.fontSans }}>
Attach monorepo apps from <strong style={{ fontWeight: 600 }}>Workspace</strong> below. Live preview stays on the right when deployed.
</div>
)}
{section === "code" && activeApp && activeRoot && (
<div style={{ display: "flex", flexDirection: "column", paddingBottom: 10, borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, marginBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Files</div>
<div style={{
padding: "2px 12px 6px",
fontSize: "0.62rem",
color: JM.mid,
fontFamily: "IBM Plex Mono, monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{activeApp}
</div>
<div style={{ flex: 1, minHeight: 0, maxHeight: 280, overflow: "auto" }}>
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
</div>
</div>
)}
{/* Layouts: surface list */}
{section === "layouts" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={{ paddingBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Surfaces</div>
{surfaces.length > 0 ? surfaces.map(s => (
<NavItem key={s.id} label={SURFACE_LABELS[s.id] ?? s.id} indent
<BuildGrowthNavRow
key={s.id}
icon={SURFACE_ICONS[s.id] ?? "◈"}
label={SURFACE_LABELS[s.id] ?? s.id}
active={activeSurfaceId === s.id}
onClick={() => { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Not configured</div>
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>Not configured</div>
)}
</div>
)}
{/* Infrastructure: item list */}
{section === "infrastructure" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={NAV_GROUP_LABEL}>Infrastructure</div>
{INFRA_ITEMS.map(item => (
<NavItem key={item.id} label={item.label} indent
active={activeInfra === item.id}
onClick={() => navigate({ section: "infrastructure", tab: item.id })}
/>
))}
</div>
)}
{/* Tasks: sub-nav + app list */}
{section === "tasks" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Tasks | PRD sub-nav */}
<div style={{ flexShrink: 0, padding: "8px 10px", borderBottom: "1px solid #e8e4dc", display: "flex", gap: 4 }}>
{[{ id: "tasks", label: "Tasks" }, { id: "prd", label: "PRD" }].map(item => {
const isActive = (searchParams.get("tab") ?? "tasks") === item.id;
<div style={{ paddingBottom: 8 }}>
<div style={{ padding: "8px 10px", borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, display: "flex", gap: 4, marginBottom: 8 }}>
{[{ id: "tasks", label: "Runs" }, { id: "requirements", label: "Requirements" }].map(item => {
const isActive = tasksSubTab === item.id;
return (
<button key={item.id} onClick={() => navigate({ section: "tasks", tab: item.id })} style={{
<button key={item.id} type="button" onClick={() => navigate({ section: "tasks", tab: item.id })} style={{
flex: 1, padding: "5px 0", border: "none", borderRadius: 6, cursor: "pointer",
fontSize: "0.72rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: isActive ? "#1a1a1a" : "transparent",
color: isActive ? "#fff" : "#a09a90",
fontSize: "0.72rem", fontWeight: 600, fontFamily: JM.fontSans,
background: isActive ? JM.primaryGradient : "transparent",
color: isActive ? "#fff" : JM.mid,
boxShadow: isActive ? JM.primaryShadow : "none",
transition: "all 0.12s",
}}>
{item.label}
@@ -1419,40 +1490,106 @@ function BuildHubInner() {
);
})}
</div>
{/* App list (only in tasks tab) */}
{(searchParams.get("tab") ?? "tasks") !== "prd" && (
<>
<div style={NAV_GROUP_LABEL}>Apps</div>
{apps.length > 0 ? apps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={activeApp === app.name}
onClick={() => navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path })}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No apps yet</div>
)}
</>
)}
</div>
)}
{/* Preview: deployed apps list */}
{section === "preview" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={NAV_GROUP_LABEL}>Apps</div>
<div style={{ paddingBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Deployed</div>
{previewApps.length > 0 ? previewApps.map(app => (
<NavItem key={app.name} label={app.name} indent
<BuildGrowthNavRow
key={app.name}
icon="▢"
label={app.name}
active={activePreviewApp?.name === app.name}
onClick={() => setActivePreviewApp(app)}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No deployments yet</div>
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>No deployments yet</div>
)}
</div>
)}
</div>
{/* Main content panel */}
<div style={{ flexShrink: 0, borderTop: `1px solid ${BUILD_LEFT_BORDER}`, paddingBottom: 10, background: BUILD_LEFT_BG }}>
{apps.length > 0 && (
<>
<div style={BUILD_NAV_GROUP}>Workspace</div>
{apps.map(app => (
<BuildGrowthNavRow
key={app.name}
icon="◈"
label={app.name}
active={workspaceAppActive(app)}
onClick={() => onWorkspaceApp(app)}
/>
))}
</>
)}
{section === "chat" && previewApps.length > 0 && (
<>
<div style={BUILD_NAV_GROUP}>Live</div>
{previewApps.map(app => (
<BuildGrowthNavRow
key={`live-${app.name}`}
icon="▢"
label={app.name}
active={activePreviewApp?.name === app.name}
onClick={() => setActivePreviewApp(app)}
/>
))}
</>
)}
<Link
href={`/${workspace}/project/${projectId}/tasks`}
style={{
display: "block",
margin: "10px 12px 0",
fontSize: 12,
fontWeight: 600,
color: JM.indigo,
textDecoration: "none",
fontFamily: JM.fontSans,
}}
>
Open Tasks
</Link>
</div>
</div>
{/* Main content */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{section === "chat" && (
<div style={{ flex: 1, display: "flex", minWidth: 0, overflow: "hidden" }}>
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<AtlasChat
projectId={projectId}
conversationScope="build"
contextEmptyLabel="Workspace"
emptyStateHint="Plan and implement in your monorepo. Attach apps from the left, preview on the right when deployed."
chatContextRefs={chatContextRefs}
onRemoveChatContextRef={removeChatContextRef}
/>
</div>
<div style={{
width: 400,
flexShrink: 0,
minWidth: 280,
borderLeft: `1px solid ${JM.border}`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
background: JM.inputBg,
}}>
<PreviewContent
projectId={projectId}
apps={previewApps}
activePreviewApp={activePreviewApp}
onSelectApp={setActivePreviewApp}
/>
</div>
</div>
)}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<FileViewer
@@ -1468,13 +1605,10 @@ function BuildHubInner() {
{section === "layouts" && (
<LayoutsContent surfaces={surfaces} projectId={projectId} workspace={workspace} activeSurfaceId={activeSurfaceId} onSelectSurface={id => { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} />
)}
{section === "infrastructure" && (
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
)}
{section === "tasks" && (searchParams.get("tab") ?? "tasks") !== "prd" && (
{section === "tasks" && tasksSubTab !== "requirements" && (
<AgentMode projectId={projectId} appName={activeApp} appPath={activeRoot} />
)}
{section === "tasks" && searchParams.get("tab") === "prd" && (
{section === "tasks" && tasksSubTab === "requirements" && (
<PrdContent projectId={projectId} />
)}
{section === "preview" && (
@@ -1492,7 +1626,7 @@ function BuildHubInner() {
export default function BuildPage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: JM.muted, fontFamily: JM.fontSans, fontSize: "0.85rem" }}>Loading</div>}>
<BuildHubInner />
</Suspense>
);

View File

@@ -1,353 +1,7 @@
"use client";
import { Suspense, useState, useEffect } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
// ── Types ─────────────────────────────────────────────────────────────────────
interface InfraApp {
name: string;
domain?: string | null;
coolifyServiceUuid?: string | null;
}
interface ProjectData {
giteaRepo?: string;
giteaRepoUrl?: string;
apps?: InfraApp[];
}
// ── Tab definitions ───────────────────────────────────────────────────────────
const TABS = [
{ id: "builds", label: "Builds", icon: "⬡" },
{ id: "databases", label: "Databases", icon: "◫" },
{ id: "services", label: "Services", icon: "◎" },
{ id: "environment", label: "Environment", icon: "≡" },
{ id: "domains", label: "Domains", icon: "◬" },
{ id: "logs", label: "Logs", icon: "≈" },
] as const;
type TabId = typeof TABS[number]["id"];
// ── Shared empty state ────────────────────────────────────────────────────────
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
return (
<div style={{
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
padding: 60, textAlign: "center", gap: 16,
}}>
<div style={{
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.5rem", color: "#b5b0a6",
}}>
{icon}
</div>
<div>
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
</div>
<div style={{
marginTop: 8, padding: "8px 18px",
background: "#1a1a1a", color: "#fff",
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
opacity: 0.4, cursor: "default",
}}>
Coming soon
</div>
</div>
);
}
// ── Builds tab ────────────────────────────────────────────────────────────────
function BuildsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="⬡"
title="No deployments yet"
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Deployed Apps
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}></span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
{app.domain && (
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
)}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
</div>
</div>
))}
</div>
</div>
);
}
// ── Databases tab ─────────────────────────────────────────────────────────────
function DatabasesTab() {
return (
<ComingSoonPanel
icon="◫"
title="Databases"
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
/>
);
}
// ── Services tab ──────────────────────────────────────────────────────────────
function ServicesTab() {
return (
<ComingSoonPanel
icon="◎"
title="Services"
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
/>
);
}
// ── Environment tab ───────────────────────────────────────────────────────────
function EnvironmentTab({ project }: { project: ProjectData | null }) {
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Environment Variables & Secrets
</div>
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
overflow: "hidden", marginBottom: 20,
}}>
{/* Header row */}
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "10px 18px", background: "#faf8f5",
borderBottom: "1px solid #e8e4dc",
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.06em", textTransform: "uppercase",
}}>
<span>Key</span><span>Value</span><span />
</div>
{/* Placeholder rows */}
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
<div key={k} style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
alignItems: "center",
}}>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}></span>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
</div>
))}
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
<button style={{
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
cursor: "pointer", width: "100%",
}}>
+ Add variable
</button>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
</div>
</div>
);
}
// ── Domains tab ───────────────────────────────────────────────────────────────
function DomainsTab({ project }: { project: ProjectData | null }) {
const apps = (project?.apps ?? []).filter(a => a.domain);
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Domains & SSL
</div>
{apps.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div>
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
{app.domain}
</div>
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
</div>
</div>
))}
</div>
) : (
<div style={{
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
padding: "32px 24px", textAlign: "center", marginBottom: 20,
}}>
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
</div>
)}
<button style={{
background: "#1a1a1a", color: "#fff", border: "none",
borderRadius: 8, padding: "9px 20px",
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
opacity: 0.5,
}}>
+ Add domain
</button>
</div>
);
}
// ── Logs tab ──────────────────────────────────────────────────────────────────
function LogsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="≈"
title="No logs yet"
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 900 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Runtime Logs
</div>
<div style={{
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
lineHeight: 1.6, minHeight: 200,
}}>
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
</div>
</div>
);
}
// ── Inner page ────────────────────────────────────────────────────────────────
function InfrastructurePageInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
const [project, setProject] = useState<ProjectData | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/apps`)
.then(r => r.json())
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
.catch(() => {});
}, [projectId]);
const setTab = (id: TabId) => {
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
{/* ── Left sub-nav ── */}
<div style={{
width: 190, flexShrink: 0,
borderRight: "1px solid #e8e4dc",
background: "#faf8f5",
display: "flex", flexDirection: "column",
padding: "16px 8px",
gap: 2,
overflow: "auto",
}}>
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.1em", textTransform: "uppercase",
padding: "0 8px 10px",
}}>
Infrastructure
</div>
{TABS.map(tab => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setTab(tab.id)}
style={{
display: "flex", alignItems: "center", gap: 9,
padding: "7px 10px", borderRadius: 6,
background: active ? "#f0ece4" : "transparent",
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
color: active ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
transition: "background 0.1s",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
{tab.label}
</button>
);
})}
</div>
{/* ── Content ── */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
{activeTab === "builds" && <BuildsTab project={project} />}
{activeTab === "databases" && <DatabasesTab />}
{activeTab === "services" && <ServicesTab />}
{activeTab === "environment" && <EnvironmentTab project={project} />}
{activeTab === "domains" && <DomainsTab project={project} />}
{activeTab === "logs" && <LogsTab project={project} />}
</div>
</div>
);
}
// ── Export ────────────────────────────────────────────────────────────────────
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
export default function InfrastructurePage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<InfrastructurePageInner />
</Suspense>
<ProjectInfraPanel routeBase="infrastructure" navGroupLabel="Infrastructure" />
);
}

View File

@@ -3,7 +3,9 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import { Loader2 } from "lucide-react";
import { JM } from "@/components/project-creation/modal-theme";
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
@@ -35,10 +37,12 @@ export default function ProjectOverviewPage() {
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authStatus !== "authenticated") {
const bypass = isClientDevProjectBypass();
if (!bypass && authStatus !== "authenticated") {
if (authStatus === "unauthenticated") setLoading(false);
return;
}
if (!bypass && authStatus === "loading") return;
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => setProject(d.project))
@@ -48,15 +52,23 @@ export default function ProjectOverviewPage() {
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: "100%", fontFamily: JM.fontSans,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}>
<Loader2 style={{ width: 24, height: 24, color: JM.indigo }} className="animate-spin" />
</div>
);
}
if (!project) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: "100%", fontFamily: JM.fontSans, color: JM.muted, fontSize: 14,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}>
Project not found.
</div>
);

View File

@@ -1,459 +1,11 @@
"use client";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
// Maps each PRD section to the discovery phase that populates it
const PRD_SECTIONS = [
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
];
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
function formatValue(v: unknown): string {
if (v === null || v === undefined) return "—";
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
return String(v);
}
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
const [expanded, setExpanded] = useState(false);
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
return (
<div style={{
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
border: "1px solid #e8e4dc", overflow: "hidden",
}}>
<button
onClick={() => setExpanded(e => !e)}
style={{
width: "100%", textAlign: "left", padding: "10px 14px",
background: "none", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "space-between",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
{phase.summary}
</span>
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
{expanded ? "▲" : "▼"}
</span>
</button>
{expanded && entries.length > 0 && (
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
{entries.map(([k, v]) => (
<div key={k} style={{ marginTop: 10 }}>
<div style={{
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
}}>
{k.replace(/_/g, " ")}
</div>
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
{formatValue(v)}
</div>
</div>
))}
</div>
)}
</div>
);
}
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
interface ArchInfra { name: string; reason: string }
interface ArchPackage { name: string; description: string }
interface ArchIntegration { name: string; required?: boolean; notes?: string }
interface Architecture {
productName?: string;
productType?: string;
summary?: string;
apps?: ArchApp[];
packages?: ArchPackage[];
infrastructure?: ArchInfra[];
integrations?: ArchIntegration[];
designSurfaces?: string[];
riskNotes?: string[];
}
function ArchitectureView({ arch }: { arch: Architecture }) {
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
{children}
</div>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
);
const Tag = ({ label }: { label: string }) => (
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
);
return (
<div style={{ maxWidth: 760 }}>
{arch.summary && (
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
{arch.summary}
</div>
)}
{(arch.apps ?? []).length > 0 && (
<Section title="Applications">
{arch.apps!.map(a => (
<Card key={a.name}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
{a.tech?.map(t => <Tag key={t} label={t} />)}
{a.screens && a.screens.length > 0 && (
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
)}
</Card>
))}
</Section>
)}
{(arch.packages ?? []).length > 0 && (
<Section title="Shared Packages">
{arch.packages!.map(p => (
<Card key={p.name}>
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
</div>
</Card>
))}
</Section>
)}
{(arch.infrastructure ?? []).length > 0 && (
<Section title="Infrastructure">
{arch.infrastructure!.map(i => (
<Card key={i.name}>
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
</Card>
))}
</Section>
)}
{(arch.integrations ?? []).length > 0 && (
<Section title="Integrations">
{arch.integrations!.map(i => (
<Card key={i.name}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
</div>
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
</Card>
))}
</Section>
)}
{(arch.riskNotes ?? []).length > 0 && (
<Section title="Architectural Risks">
{arch.riskNotes!.map((r, i) => (
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}></span>
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
</div>
))}
</Section>
)}
</div>
);
}
export default function PRDPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [prd, setPrd] = useState<string | null>(null);
const [architecture, setArchitecture] = useState<Architecture | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
const [archGenerating, setArchGenerating] = useState(false);
const [archError, setArchError] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
]).then(([projectData, phaseData]) => {
setPrd(projectData?.project?.prd ?? null);
setArchitecture(projectData?.project?.architecture ?? null);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
const router = useRouter();
const handleGenerateArchitecture = async () => {
setArchGenerating(true);
setArchError(null);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Generation failed");
setArchitecture(data.architecture);
setActiveTab("architecture");
} catch (e) {
setArchError(e instanceof Error ? e.message : "Something went wrong");
} finally {
setArchGenerating(false);
}
};
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_SECTIONS.map(s => ({
...s,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
}));
const doneCount = sections.filter(s => s.isDone).length;
const totalPct = Math.round((doneCount / sections.length) * 100);
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
const tabs = [
{ id: "prd" as const, label: "PRD", available: true },
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
];
return (
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{/* Tab bar — only when at least one doc exists */}
{(prd || architecture) && (
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
{tabs.map(t => {
const isActive = activeTab === t.id;
return (
<button
key={t.id}
onClick={() => t.available && setActiveTab(t.id)}
disabled={!t.available}
style={{
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
background: isActive ? "#1a1a1a" : "transparent",
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "all 0.1s",
}}
>
{t.label}
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}></span>}
</button>
);
})}
</div>
)}
{/* Next step banner — PRD done but no architecture yet */}
{prd && !architecture && activeTab === "prd" && (
<div style={{
marginBottom: 24, padding: "18px 22px",
background: "#1a1a1a", borderRadius: 10,
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
Next: Generate technical architecture
</div>
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
</div>
{archError && (
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}> {archError}</div>
)}
</div>
<button
onClick={handleGenerateArchitecture}
disabled={archGenerating}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: archGenerating ? "#4a4640" : "#fff",
color: archGenerating ? "#a09a90" : "#1a1a1a",
fontSize: "0.82rem", fontWeight: 700,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: archGenerating ? "default" : "pointer",
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
transition: "opacity 0.15s",
}}
>
{archGenerating && (
<span style={{
width: 12, height: 12, borderRadius: "50%",
border: "2px solid #60606040", borderTopColor: "#a09a90",
animation: "spin 0.7s linear infinite", display: "inline-block",
}} />
)}
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
</button>
</div>
)}
{/* Architecture tab */}
{activeTab === "architecture" && architecture && (
<ArchitectureView arch={architecture} />
)}
{/* PRD tab — finalized */}
{activeTab === "prd" && prd && (
<div style={{ maxWidth: 760 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Product Requirements
</h3>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
PRD complete
</span>
</div>
<div style={{
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
padding: "28px 32px", lineHeight: 1.8,
fontSize: "0.88rem", color: "#2a2824",
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
{prd}
</div>
</div>
)}
{/* PRD tab — section progress (no finalized PRD yet) */}
{activeTab === "prd" && !prd && (
/* ── Section progress view ── */
<div style={{ maxWidth: 680 }}>
{/* Progress bar */}
<div style={{
display: "flex", alignItems: "center", gap: 16,
padding: "16px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
}}>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
}}>
{totalPct}%
</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
<div style={{
height: "100%", borderRadius: 2,
width: `${totalPct}%`, background: "#1a1a1a",
transition: "width 0.6s ease",
}} />
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{doneCount}/{sections.length} sections
</span>
</div>
{/* Sections */}
{sections.map((s, i) => (
<div
key={s.id}
style={{
padding: "14px 18px", marginBottom: 6,
background: "#fff", borderRadius: 10,
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
animationDelay: `${i * 0.04}s`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Status icon */}
<div style={{
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700,
color: s.isDone ? "#2e7d32" : "#c5c0b8",
}}>
{s.isDone ? "✓" : "○"}
</div>
<span style={{
flex: 1, fontSize: "0.84rem",
color: s.isDone ? "#1a1a1a" : "#a09a90",
fontWeight: s.isDone ? 500 : 400,
}}>
{s.label}
</span>
{s.isDone && s.savedPhase && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
}}>
saved
</span>
)}
{!s.isDone && !s.phaseId && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#b5b0a6", padding: "2px 7px",
}}>
generated
</span>
)}
</div>
{/* Expandable phase data */}
{s.isDone && s.savedPhase && (
<PhaseDataCard phase={s.savedPhase} />
)}
{/* Pending hint */}
{!s.isDone && (
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
{s.phaseId
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
: "Will be generated when PRD is finalized"}
</div>
)}
</div>
))}
{doneCount === 0 && (
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
Continue chatting with Vibn saved phases will appear here automatically.
</p>
)}
</div>
)}
</div>
);
/** Legacy URL — project work now lives under Tasks (PRD is the first task). */
export default async function PrdRedirectPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
redirect(`/${workspace}/project/${projectId}/tasks`);
}

View File

@@ -1,72 +1,12 @@
import { ProjectShell } from "@/components/layout/project-shell";
import { query } from "@/lib/db-postgres";
/**
* Passthrough layout for the project route.
*
* Two sibling route groups provide their own scaffolds:
* - (home)/ — VIBNSidebar scaffold for the project home page.
* - (workspace)/ — ProjectShell (top tab nav) for overview/build/run/etc.
*/
import { ReactNode } from "react";
interface ProjectData {
name: string;
description?: string;
status?: string;
progress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
}
async function getProjectData(projectId: string): Promise<ProjectData> {
try {
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (rows.length > 0) {
const { data, created_at, updated_at } = rows[0];
return {
name: data?.productName || data?.name || "Project",
description: data?.productVision || data?.description,
status: data?.status,
progress: data?.progress ?? 0,
discoveryPhase: data?.discoveryPhase ?? 0,
capturedData: data?.capturedData ?? {},
createdAt: created_at,
updatedAt: updated_at,
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
creationMode: data?.creationMode ?? "fresh",
};
}
} catch (error) {
console.error("Error fetching project:", error);
}
return { name: "Project" };
}
export default async function ProjectLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
const project = await getProjectData(projectId);
return (
<ProjectShell
workspace={workspace}
projectId={projectId}
projectName={project.name}
projectDescription={project.description}
projectStatus={project.status}
projectProgress={project.progress}
discoveryPhase={project.discoveryPhase}
capturedData={project.capturedData}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
featureCount={project.featureCount}
creationMode={project.creationMode}
>
{children}
</ProjectShell>
);
export default function ProjectRootLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -184,7 +184,7 @@ export default function ProjectsPage() {
style={{ position: "relative", animationDelay: `${i * 0.05}s` }}
>
<Link
href={`/${workspace}/project/${p.id}/overview`}
href={`/${workspace}/project/${p.id}`}
style={{
width: "100%", display: "flex", alignItems: "center",
padding: "18px 22px", borderRadius: 10,

View File

@@ -1,11 +1,10 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -43,7 +42,7 @@ export async function POST(request: Request) {
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -73,7 +72,7 @@ export async function GET(request: Request) {
export async function DELETE(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -10,8 +10,7 @@
* and injects it as knowledge_context into the orchestrator's system prompt.
*/
import { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com';
@@ -129,7 +128,7 @@ export async function POST(
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return new Response('Unauthorized', { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
@@ -87,7 +86,7 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -190,7 +189,7 @@ export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -8,8 +8,7 @@
* Body: { commitMessage: string }
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
@@ -29,7 +28,7 @@ export async function POST(
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -6,8 +6,7 @@
* Batch append from vibn-agent-runner (x-agent-runner-secret).
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query, getPool } from "@/lib/db-postgres";
export interface AgentSessionEventRow {
@@ -23,7 +22,7 @@ export async function GET(
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -2,8 +2,7 @@
* GET /api/projects/.../agent/sessions/.../events/stream?afterSeq=0
* Server-Sent Events: tail agent_session_events while the session is active.
*/
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query, queryOne } from "@/lib/db-postgres";
export const dynamic = "force-dynamic";
@@ -17,7 +16,7 @@ export async function GET(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return new Response("Unauthorized", { status: 401 });
}

View File

@@ -9,8 +9,7 @@
* understands what was already tried
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
@@ -21,7 +20,7 @@ export async function POST(
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -7,8 +7,7 @@
* (handled in /stop/route.ts)
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
export async function GET(
@@ -17,7 +16,7 @@ export async function GET(
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
@@ -11,7 +10,7 @@ export async function POST(
) {
try {
const { projectId, sessionId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -9,8 +9,7 @@
* List all sessions for a project, newest first.
*/
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
@@ -33,7 +32,7 @@ export async function POST(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -131,7 +130,7 @@ export async function GET(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function GET(
@@ -9,7 +8,7 @@ export async function GET(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export const maxDuration = 60;
@@ -37,7 +36,7 @@ export async function POST(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { execSync } from 'child_process';
import { existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
@@ -79,7 +78,7 @@ export async function POST(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
@@ -10,7 +9,7 @@ export async function GET(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -68,7 +67,7 @@ export async function POST(
_req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
@@ -25,7 +24,7 @@ export async function GET(
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -125,7 +124,7 @@ export async function PATCH(
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
@@ -13,7 +12,7 @@ export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -43,7 +42,7 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -184,7 +183,7 @@ export async function PATCH(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -1,18 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
import {
augmentAtlasMessage,
parseContextRefs,
} from "@/lib/chat-context-refs";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
const ALLOWED_SCOPES = new Set(["overview", "build"]);
function normalizeScope(raw: string | null | undefined): "overview" | "build" {
const s = (raw ?? "overview").trim();
return ALLOWED_SCOPES.has(s) ? (s as "overview" | "build") : "overview";
}
function runnerSessionId(projectId: string, scope: "overview" | "build"): string {
return scope === "overview" ? `atlas_${projectId}` : `atlas_${projectId}__build`;
}
// ---------------------------------------------------------------------------
// DB helpers — atlas_conversations table
// DB — atlas_chat_threads (project_id + scope); legacy atlas_conversations → overview
// ---------------------------------------------------------------------------
let tableReady = false;
let threadsTableReady = false;
let legacyTableChecked = false;
async function ensureTable() {
if (tableReady) return;
async function ensureThreadsTable() {
if (threadsTableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS atlas_chat_threads (
project_id TEXT NOT NULL,
scope TEXT NOT NULL,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (project_id, scope)
)
`);
threadsTableReady = true;
}
async function ensureLegacyConversationsTable() {
if (legacyTableChecked) return;
await query(`
CREATE TABLE IF NOT EXISTS atlas_conversations (
project_id TEXT PRIMARY KEY,
@@ -20,31 +49,47 @@ async function ensureTable() {
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
tableReady = true;
legacyTableChecked = true;
}
async function loadAtlasHistory(projectId: string): Promise<any[]> {
async function loadAtlasHistory(projectId: string, scope: "overview" | "build"): Promise<any[]> {
try {
await ensureTable();
await ensureThreadsTable();
const rows = await query<{ messages: any[] }>(
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
[projectId]
`SELECT messages FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
[projectId, scope]
);
return rows[0]?.messages ?? [];
if (rows.length > 0) {
const fromThreads = rows[0]?.messages;
return Array.isArray(fromThreads) ? fromThreads : [];
}
if (scope === "overview") {
await ensureLegacyConversationsTable();
const leg = await query<{ messages: any[] }>(
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
[projectId]
);
const legacyMsgs = leg[0]?.messages ?? [];
if (Array.isArray(legacyMsgs) && legacyMsgs.length > 0) {
await saveAtlasHistory(projectId, scope, legacyMsgs);
return legacyMsgs;
}
}
return [];
} catch {
return [];
}
}
async function saveAtlasHistory(projectId: string, messages: any[]): Promise<void> {
async function saveAtlasHistory(projectId: string, scope: "overview" | "build", messages: any[]): Promise<void> {
try {
await ensureTable();
await ensureThreadsTable();
await query(
`INSERT INTO atlas_conversations (project_id, messages, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (project_id) DO UPDATE
SET messages = $2::jsonb, updated_at = NOW()`,
[projectId, JSON.stringify(messages)]
`INSERT INTO atlas_chat_threads (project_id, scope, messages, updated_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (project_id, scope) DO UPDATE
SET messages = $3::jsonb, updated_at = NOW()`,
[projectId, scope, JSON.stringify(messages)]
);
} catch (e) {
console.error("[atlas-chat] Failed to save history:", e);
@@ -66,21 +111,36 @@ async function savePrd(projectId: string, prdContent: string): Promise<void> {
}
}
/** Replace the latest user message content so DB/UI never show the internal ref prefix. */
function scrubLastUserMessageContent(history: unknown[], cleanText: string): unknown[] {
if (!Array.isArray(history) || history.length === 0) return history;
const h = history.map(m => (m && typeof m === "object" ? { ...(m as object) } : m));
for (let i = h.length - 1; i >= 0; i--) {
const m = h[i] as { role?: string; content?: string };
if (m?.role === "user" && typeof m.content === "string") {
h[i] = { ...m, content: cleanText };
break;
}
}
return h;
}
// ---------------------------------------------------------------------------
// GET — load stored conversation messages for display
// ---------------------------------------------------------------------------
export async function GET(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const history = await loadAtlasHistory(projectId);
const scope = normalizeScope(req.nextUrl.searchParams.get("scope"));
const history = await loadAtlasHistory(projectId, scope);
// Filter to only user/assistant messages (no system prompts) for display
const messages = history
@@ -98,43 +158,50 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const { message } = await req.json();
const body = await req.json();
const message = body?.message as string | undefined;
const contextRefs = parseContextRefs(body?.contextRefs);
if (!message?.trim()) {
return NextResponse.json({ error: "message is required" }, { status: 400 });
}
const sessionId = `atlas_${projectId}`;
const scope = normalizeScope(body?.scope as string | undefined);
const sessionId = runnerSessionId(projectId, scope);
const cleanUserText = message.trim();
// Load conversation history from DB to persist across agent runner restarts.
// Strip tool_call / tool_response messages — replaying them across sessions
// causes Gemini to reject the request with a turn-ordering error.
const rawHistory = await loadAtlasHistory(projectId);
const rawHistory = await loadAtlasHistory(projectId, scope);
const history = rawHistory.filter((m: any) =>
(m.role === "user" || m.role === "assistant") && m.content
);
// __init__ is a special internal trigger used only when there is no existing history.
// If history already exists, ignore the init request (conversation already started).
const isInit = message.trim() === "__atlas_init__";
const isInit = cleanUserText === "__atlas_init__";
if (isInit && history.length > 0) {
return NextResponse.json({ reply: null, alreadyStarted: true });
}
const runnerMessage = isInit
? scope === "build"
? "Begin as Vibn in build mode. The user is working in their monorepo. Ask what they want to ship or fix next, and offer concrete implementation guidance. Do not acknowledge this as an internal trigger."
: "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger."
: augmentAtlasMessage(cleanUserText, contextRefs);
try {
const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// For init, send the greeting prompt but don't store it as a user message
message: isInit
? "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger."
: message,
message: runnerMessage,
session_id: sessionId,
history,
is_init: isInit,
@@ -153,11 +220,16 @@ export async function POST(
const data = await res.json();
// Persist updated history
await saveAtlasHistory(projectId, data.history ?? []);
let historyOut = data.history ?? [];
// Store the user's line without the internal reference block (UI shows clean text).
if (!isInit && cleanUserText !== "__atlas_init__") {
historyOut = scrubLastUserMessageContent(historyOut, cleanUserText);
}
// If Atlas finalized the PRD, save it to the project
if (data.prdContent) {
await saveAtlasHistory(projectId, scope, historyOut);
// If Atlas finalized the PRD, save it to the project (discovery / overview)
if (data.prdContent && scope === "overview") {
await savePrd(projectId, data.prdContent);
}
@@ -181,24 +253,35 @@ export async function POST(
// ---------------------------------------------------------------------------
export async function DELETE(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const sessionId = `atlas_${projectId}`;
const scope = normalizeScope(req.nextUrl.searchParams.get("scope"));
const sessionId = runnerSessionId(projectId, scope);
try {
await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${sessionId}`, { method: "DELETE" });
await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
} catch { /* runner may be down */ }
try {
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
await ensureThreadsTable();
await query(
`DELETE FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
[projectId, scope]
);
} catch { /* table may not exist yet */ }
if (scope === "overview") {
try {
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
} catch { /* legacy */ }
}
return NextResponse.json({ cleared: true });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
/**
@@ -12,7 +11,7 @@ export async function GET(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const rows = await query<{ data: Record<string, unknown> }>(
@@ -49,7 +48,7 @@ export async function PATCH(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Step 1: read current data — explicit ::text casts on every param

View File

@@ -6,8 +6,7 @@
* Response for file: { type: "file", content: string, encoding: "utf8" | "base64" }
*/
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
@@ -39,7 +38,7 @@ export async function GET(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export const maxDuration = 120;
@@ -28,7 +27,7 @@ export async function POST(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { createKnowledgeItem } from '@/lib/server/knowledge';
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
@@ -34,7 +33,7 @@ export async function POST(
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
}
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function GET(
@@ -10,7 +9,7 @@ export async function GET(
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
async function assertOwnership(projectId: string, email: string): Promise<boolean> {
@@ -18,7 +17,7 @@ export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { projectId } = await params;
@@ -41,7 +40,7 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { projectId } = await params;
@@ -83,7 +82,7 @@ export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { projectId } = await params;

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { listApplications, CoolifyApplication } from '@/lib/coolify';
@@ -20,7 +19,7 @@ export async function GET(
) {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function GET(
@@ -10,7 +9,7 @@ export async function GET(
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -45,7 +44,7 @@ export async function PATCH(
const { projectId } = await params;
const body = await request.json();
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
// ---------------------------------------------------------------------------
@@ -11,7 +10,7 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -85,7 +84,7 @@ export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function POST(
@@ -9,7 +8,7 @@ export async function POST(
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
@@ -11,7 +10,7 @@ export async function POST(
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,11 +1,10 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -8,14 +8,13 @@
*/
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { deployApplication } from '@/lib/coolify';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { prewarmWorkspace } from '@/lib/cloud-run-workspace';
/**
@@ -12,7 +11,7 @@ import { prewarmWorkspace } from '@/lib/cloud-run-workspace';
* to avoid CORS issues with run.app domains.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,11 +1,10 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function GET() {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,11 +1,10 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json([], { status: 200 });
}

View File

@@ -1,12 +1,11 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { v4 as uuidv4 } from 'uuid';
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions);
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'No authorization token provided' }, { status: 401 });
}

View File

@@ -1,4 +1,20 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans } from "next/font/google";
import { Toaster } from "sonner";
import { JustineAuthShell } from "@/marketing/components/justine/JustineAuthShell";
import "../styles/justine/02-signup.css";
const justineJakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-justine-jakarta",
display: "swap",
});
export const metadata: Metadata = {
title: "Sign in · vibn",
description: "Sign in to your vibn workspace with Google.",
};
export default function AuthLayout({
children,
@@ -6,10 +22,12 @@ export default function AuthLayout({
children: React.ReactNode;
}) {
return (
<>
{children}
<div
data-justine-auth
className={`${justineJakarta.variable} justine-auth-root flex min-h-screen flex-col`}
>
<JustineAuthShell>{children}</JustineAuthShell>
<Toaster position="top-center" />
</>
</div>
);
}

View File

@@ -29,10 +29,10 @@ function AuthPageInner() {
if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-4" />
<p className="text-muted-foreground">Loading authentication...</p>
<div className="justine-auth-inner">
<div className="justine-auth-loading-wrap">
<div className="justine-auth-spinner" aria-hidden />
<p className="justine-auth-loading-text">Loading authentication</p>
</div>
</div>
);

View File

@@ -1,84 +1,190 @@
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
export default function NextAuthComponent() {
function authErrorMessage(code: string | null): string | null {
if (!code) return null;
if (code === "Callback") {
return (
"Google could not complete sign-in. Most often: DATABASE_URL in vibn-frontend/.env.local must reach Postgres from " +
"this machine (Coolify internal hostnames only work inside Docker). Use a public host/port, tunnel, or proxy; " +
"then run npx prisma db push. Also confirm NEXTAUTH_URL matches the browser (http://localhost:3000) and " +
"Google redirect URI http://localhost:3000/api/auth/callback/google. Dev check: GET /api/debug/prisma — see terminal for [next-auth] logs."
);
}
if (code === "Configuration") {
return "Auth is misconfigured (check GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET).";
}
if (code === "AccessDenied") {
return "Access was denied. You may need to be added as a test user if the OAuth app is in testing mode.";
}
return `Sign-in error: ${code}`;
}
const showDevLocalSignIn =
process.env.NODE_ENV === "development" &&
Boolean(process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL?.trim());
function NextAuthForm() {
const [isLoading, setIsLoading] = useState(false);
const [devSecret, setDevSecret] = useState("");
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/auth";
const errorCode = searchParams.get("error");
const errorHint = authErrorMessage(errorCode);
const handleGoogleSignIn = async () => {
setIsLoading(true);
try {
// Sign in with Google using NextAuth
await signIn("google", {
callbackUrl: "/auth",
});
await signIn("google", { callbackUrl });
} catch (error) {
console.error("Google sign-in error:", error);
setIsLoading(false);
}
};
const handleDevLocalSignIn = async () => {
setIsLoading(true);
try {
await signIn("dev-local", {
callbackUrl,
password: devSecret,
redirect: true,
});
} catch (error) {
console.error("Dev local sign-in error:", error);
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-6">
{/* Logo */}
<div className="flex justify-center">
<img
src="/vibn-black-circle-logo.png"
alt="Vib'n"
className="h-16 w-16"
/>
</div>
<div className="justine-auth-inner">
<div className="justine-auth-card">
<h1 className="justine-auth-title f">Welcome back</h1>
<p className="justine-auth-sub">Sign in with Google to open your workspace.</p>
{/* Auth Card */}
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
Welcome to Vib'n
</CardTitle>
<CardDescription className="text-center">
Sign in to continue
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleSignIn}
disabled={isLoading}
{errorHint && (
<div
role="alert"
style={{
marginBottom: 18,
padding: "12px 14px",
borderRadius: 10,
fontSize: 12.5,
lineHeight: 1.55,
color: "#991B1B",
background: "#FEF2F2",
border: "1px solid #FECACA",
}}
>
{errorHint}
</div>
)}
<button
type="button"
className="justine-auth-btn-google"
onClick={handleGoogleSignIn}
disabled={isLoading}
>
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
{isLoading ? "Signing in…" : "Continue with Google"}
</button>
{showDevLocalSignIn && (
<div
style={{
marginTop: 22,
paddingTop: 22,
borderTop: "1px solid rgba(0,0,0,0.08)",
}}
>
<p className="justine-auth-sub" style={{ marginBottom: 12 }}>
Local only: sign in without Google as{" "}
<strong>{process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL}</strong>
</p>
<form
onSubmit={(e) => {
e.preventDefault();
void handleDevLocalSignIn();
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
{isLoading ? "Signing in..." : "Continue with Google"}
</Button>
</CardContent>
</Card>
<input
type="password"
name="dev-local-secret"
autoComplete="current-password"
placeholder="Dev secret (only if DEV_LOCAL_AUTH_SECRET is set)"
value={devSecret}
onChange={(e) => setDevSecret(e.target.value)}
className="justine-auth-dev-input"
style={{
width: "100%",
marginBottom: 10,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(0,0,0,0.12)",
fontSize: 14,
}}
/>
<button
type="submit"
className="justine-auth-btn-google"
disabled={isLoading}
style={{
background: "#111827",
color: "#fff",
}}
>
{isLoading ? "Signing in…" : "Continue (local dev account)"}
</button>
</form>
</div>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
By continuing, you agree to our Terms of Service and Privacy Policy.
<p className="justine-auth-legal">
By continuing, you agree to our{" "}
<Link href="/terms">Terms</Link> and <Link href="/privacy">Privacy Policy</Link>.
</p>
</div>
</div>
);
}
function NextAuthFallback() {
return (
<div className="justine-auth-inner">
<div className="justine-auth-loading-wrap">
<div className="justine-auth-spinner" aria-hidden />
<p className="justine-auth-loading-text">Loading</p>
</div>
</div>
);
}
export default function NextAuthComponent() {
return (
<Suspense fallback={<NextAuthFallback />}>
<NextAuthForm />
</Suspense>
);
}

View File

@@ -58,13 +58,20 @@ export default function RootLayout({
{children}
<Toaster />
</Providers>
<script dangerouslySetInnerHTML={{ __html: `
{/* Service worker breaks Next dev (RSC / __nextjs_* fetches need a real Response). Prod only. */}
{process.env.NODE_ENV === "production" ? (
<script
dangerouslySetInnerHTML={{
__html: `
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
`}} />
`,
}}
/>
) : null}
</body>
</html>
);

View File

@@ -29,7 +29,8 @@
width: 100%;
}
[data-justine] * {
/* Only Justines static homepage needs the full * reset; it beats Tailwind utilities if applied to main. */
[data-justine] .justine-home-page * {
box-sizing: border-box;
margin: 0;
padding: 0;

View File

@@ -1,9 +1,14 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { ChevronDown, Plus, Trash2, X } from "lucide-react";
import { JM, JV } from "@/components/project-creation/modal-theme";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
interface ChatMessage {
role: "user" | "assistant";
@@ -13,6 +18,15 @@ interface ChatMessage {
interface AtlasChatProps {
projectId: string;
projectName?: string;
/** Sidebar picks — shown as chips; sent with each user message until removed */
chatContextRefs?: ChatContextRef[];
onRemoveChatContextRef?: (key: string) => void;
/** Separate thread from overview discovery chat (stored in DB per scope). */
conversationScope?: "overview" | "build";
/** Shown in the composer when no context refs (e.g. Discovery vs Workspace). */
contextEmptyLabel?: string;
/** Empty-state subtitle under the Vibn title */
emptyStateHint?: string;
}
// ---------------------------------------------------------------------------
@@ -66,7 +80,7 @@ function renderContent(text: string | null | undefined) {
return text.split("\n").map((line, i) => {
const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) =>
seg.startsWith("**") && seg.endsWith("**")
? <strong key={j} style={{ fontWeight: 600, color: "#1a1a1a" }}>{seg.slice(2, -2)}</strong>
? <strong key={j} style={{ fontWeight: 600, color: JM.ink }}>{seg.slice(2, -2)}</strong>
: <span key={j}>{seg}</span>
);
return <div key={i} style={{ minHeight: line.length ? undefined : "0.75em" }}>{parts}</div>;
@@ -77,10 +91,9 @@ function renderContent(text: string | null | undefined) {
// Message row
// ---------------------------------------------------------------------------
function MessageRow({
msg, userInitial, projectId, workspace,
msg, projectId, workspace,
}: {
msg: ChatMessage;
userInitial: string;
projectId: string;
workspace: string;
}) {
@@ -131,33 +144,73 @@ function MessageRow({
}
};
return (
<div style={{ display: "flex", gap: 12, marginBottom: 22, animation: "enter 0.3s ease both" }}>
{/* Avatar */}
if (!isAtlas) {
return (
<div style={{
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
background: isAtlas ? "#1a1a1a" : "#e8e4dc",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.68rem", fontWeight: 700,
color: isAtlas ? "#fff" : "#8a8478",
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "var(--font-inter), ui-sans-serif, sans-serif",
marginBottom: 20,
marginTop: -4,
animation: "enter 0.3s ease both",
display: "flex",
justifyContent: "flex-end",
}}>
{isAtlas ? "A" : userInitial}
<div style={{
maxWidth: "min(85%, 480px)",
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 4,
}}>
<div style={{
background: JV.userBubbleBg,
border: `1px solid ${JV.userBubbleBorder}`,
borderRadius: 18,
padding: "12px 16px",
fontSize: 14,
color: JM.ink,
lineHeight: 1.65,
fontFamily: JM.fontSans,
whiteSpace: "pre-wrap",
}}>
{renderContent(clean)}
</div>
<div style={{
fontSize: 10, fontWeight: 600, color: JM.muted,
textTransform: "uppercase", letterSpacing: "0.06em",
fontFamily: JM.fontSans,
paddingRight: 2,
}}>
You
</div>
</div>
</div>
);
}
return (
<div style={{ display: "flex", gap: 12, marginBottom: 26, animation: "enter 0.3s ease both" }}>
<div style={{
width: 28, height: 28, borderRadius: 8, flexShrink: 0, marginTop: 2,
background: JM.primaryGradient,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700,
color: "#fff",
fontFamily: JM.fontSans,
boxShadow: JM.primaryShadow,
}}>
A
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{/* Label */}
<div style={{
fontSize: "0.68rem", fontWeight: 600, color: "#a09a90",
marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.04em",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
fontSize: 10, fontWeight: 600, color: JM.muted,
marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.05em",
fontFamily: JM.fontSans,
}}>
{isAtlas ? "Vibn" : "You"}
Vibn
</div>
{/* Content */}
<div style={{
fontSize: "0.88rem", color: "#2a2824", lineHeight: 1.72,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
whiteSpace: isAtlas ? "normal" : "pre-wrap",
fontSize: 15, color: JM.ink, lineHeight: 1.75,
fontFamily: JM.fontSans,
whiteSpace: "normal",
}}>
{renderContent(clean)}
</div>
@@ -166,27 +219,29 @@ function MessageRow({
{phase && (
<div style={{ marginTop: 14 }}>
<button
type="button"
onClick={handleSavePhase}
disabled={saved || saving}
style={{
display: "inline-flex", alignItems: "center", gap: 7,
padding: "8px 16px", borderRadius: 8,
background: saved ? "#e8f5e9" : "#1a1a1a",
background: saved ? "#e8f5e9" : JM.primaryGradient,
color: saved ? "#2e7d32" : "#fff",
border: saved ? "1px solid #a5d6a7" : "none",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
fontSize: 12, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: saved || saving ? "default" : "pointer",
transition: "all 0.15s",
opacity: saving ? 0.7 : 1,
boxShadow: saved ? "none" : JM.primaryShadow,
}}
>
{saved ? "✓ Phase saved" : saving ? "Saving…" : `Save phase — ${phase.title}`}
</button>
{!saved && (
<div style={{
marginTop: 6, fontSize: "0.72rem", color: "#a09a90",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", lineHeight: 1.4,
marginTop: 6, fontSize: 11, color: JM.muted,
fontFamily: JM.fontSans, lineHeight: 1.4,
}}>
{phase.summary}
</div>
@@ -199,10 +254,11 @@ function MessageRow({
<div style={{
marginTop: 16,
padding: "16px 18px",
background: "#fff",
border: "1px solid #e8e4dc",
borderRadius: 10,
borderLeft: "3px solid #1a1a1a",
background: JV.composerSurface,
border: `1px solid ${JM.border}`,
borderRadius: 14,
borderLeft: `3px solid ${JM.indigo}`,
boxShadow: "0 1px 8px rgba(30,27,75,0.04)",
}}>
{archState === "done" ? (
<div>
@@ -215,10 +271,11 @@ function MessageRow({
<Link
href={`/${workspace}/project/${projectId}/build`}
style={{
display: "inline-block", padding: "8px 16px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.76rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", textDecoration: "none",
display: "inline-block", padding: "8px 16px", borderRadius: 8,
background: JM.primaryGradient, color: "#fff",
fontSize: 12, fontWeight: 600,
fontFamily: JM.fontSans, textDecoration: "none",
boxShadow: JM.primaryShadow,
}}
>
Review architecture
@@ -230,11 +287,12 @@ function MessageRow({
{archError}
</div>
<button
type="button"
onClick={() => { setArchState("idle"); setArchError(null); }}
style={{
padding: "7px 14px", borderRadius: 6, border: "1px solid #e0dcd4",
background: "none", fontSize: "0.74rem", color: "#6b6560",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
padding: "7px 14px", borderRadius: 8, border: `1px solid ${JM.border}`,
background: "none", fontSize: 12, color: JM.mid,
fontFamily: JM.fontSans, cursor: "pointer",
}}
>
Try again
@@ -242,23 +300,25 @@ function MessageRow({
</div>
) : (
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 5 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink, marginBottom: 5, fontFamily: JM.fontSans }}>
Next: Technical architecture
</div>
<p style={{ fontSize: "0.76rem", color: "#6b6560", margin: "0 0 14px", lineHeight: 1.55 }}>
<p style={{ fontSize: 12, color: JM.mid, margin: "0 0 14px", lineHeight: 1.55, fontFamily: JM.fontSans }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes about 30 seconds.
</p>
<button
type="button"
onClick={handleGenerateArchitecture}
disabled={archState === "loading"}
style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "9px 18px", borderRadius: 8, border: "none",
background: archState === "loading" ? "#8a8478" : "#1a1a1a",
color: "#fff", fontSize: "0.78rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: archState === "loading" ? JM.muted : JM.primaryGradient,
color: "#fff", fontSize: 12, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: archState === "loading" ? "default" : "pointer",
transition: "background 0.15s",
boxShadow: archState === "loading" ? "none" : JM.primaryShadow,
}}
>
{archState === "loading" && (
@@ -284,16 +344,18 @@ function MessageRow({
// ---------------------------------------------------------------------------
function TypingIndicator() {
return (
<div style={{ display: "flex", gap: 12, marginBottom: 22, animation: "enter 0.2s ease" }}>
<div style={{ display: "flex", gap: 12, marginBottom: 26, animation: "enter 0.2s ease", alignItems: "center" }}>
<div style={{
width: 28, height: 28, borderRadius: 7, flexShrink: 0, marginTop: 2,
background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.68rem", fontWeight: 700, color: "#fff", fontFamily: "var(--font-lora), ui-serif, serif",
width: 28, height: 28, borderRadius: 8, flexShrink: 0,
background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700, color: "#fff", fontFamily: JM.fontSans,
}}>A</div>
<div style={{ display: "flex", gap: 5, paddingTop: 10 }}>
<div style={{ display: "flex", gap: 5, alignItems: "center", paddingTop: 2 }}>
{[0, 1, 2].map(d => (
<div key={d} style={{
width: 5, height: 5, borderRadius: "50%", background: "#b5b0a6",
width: 5, height: 5, borderRadius: "50%", background: JM.muted,
animation: `blink 1s ease ${d * 0.15}s infinite`,
}} />
))}
@@ -305,26 +367,41 @@ function TypingIndicator() {
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function AtlasChat({ projectId }: AtlasChatProps) {
const { data: session } = useSession();
export function AtlasChat({
projectId,
chatContextRefs = [],
onRemoveChatContextRef,
conversationScope = "overview",
contextEmptyLabel = "Discovery",
emptyStateHint,
}: AtlasChatProps) {
const params = useParams();
const workspace = (params?.workspace as string) ?? "";
const userInitial =
session?.user?.name?.[0]?.toUpperCase() ??
session?.user?.email?.[0]?.toUpperCase() ??
"Y";
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [historyLoaded, setHistoryLoaded] = useState(false);
const [showScrollFab, setShowScrollFab] = useState(false);
const initTriggered = useRef(false);
const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const visibleMessages = messages.filter(msg => msg.content);
const syncScrollFab = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollFab(dist > 120 && visibleMessages.length > 0);
}, [visibleMessages.length]);
// Scroll to bottom whenever messages change
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
requestAnimationFrame(syncScrollFab);
}, [messages, isStreaming, syncScrollFab]);
// Send a message to Atlas — optionally hidden from UI (for init trigger)
const sendToAtlas = useCallback(async (text: string, hideUserMsg = false) => {
@@ -333,11 +410,17 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
}
setIsStreaming(true);
const isInit = text.trim() === "__atlas_init__";
const payload: { message: string; contextRefs?: ChatContextRef[] } = { message: text };
if (!isInit && chatContextRefs.length > 0) {
payload.contextRefs = chatContextRefs;
}
try {
const res = await fetch(`/api/projects/${projectId}/atlas-chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
body: JSON.stringify({ ...payload, scope: conversationScope }),
});
if (!res.ok) {
@@ -359,13 +442,13 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
} finally {
setIsStreaming(false);
}
}, [projectId]);
}, [projectId, chatContextRefs, conversationScope]);
// On mount: load stored history; if empty, trigger Atlas greeting exactly once
useEffect(() => {
let cancelled = false; // guard against unmount during fetch
fetch(`/api/projects/${projectId}/atlas-chat`)
fetch(`/api/projects/${projectId}/atlas-chat?scope=${encodeURIComponent(conversationScope)}`)
.then(r => r.json())
.then((data: { messages: ChatMessage[] }) => {
if (cancelled) return;
@@ -386,12 +469,15 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
}, [projectId, conversationScope]);
const handleReset = async () => {
if (!confirm("Clear this conversation and start fresh?")) return;
try {
await fetch(`/api/projects/${projectId}/atlas-chat`, { method: "DELETE" });
await fetch(
`/api/projects/${projectId}/atlas-chat?scope=${encodeURIComponent(conversationScope)}`,
{ method: "DELETE" }
);
setMessages([]);
setHistoryLoaded(false);
initTriggered.current = false;
@@ -419,13 +505,15 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
}
};
const visibleMessages = messages.filter(msg => msg.content);
const isEmpty = visibleMessages.length === 0 && !isStreaming;
const feedPad = { paddingLeft: 20, paddingRight: 20 } as const;
return (
<div style={{
display: "flex", flexDirection: "column", height: "100%",
background: "#f6f4f0", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: JV.chatColumnBg,
fontFamily: JM.fontSans,
}}>
<style>{`
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
@@ -438,148 +526,354 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
<div style={{
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
gap: 12, padding: "40px 32px",
gap: 12, padding: "40px 20px",
}}>
<div style={{
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, color: "#fff",
animation: "breathe 2.5s ease infinite",
}}>A</div>
<style>{`@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }`}</style>
<div style={{ textAlign: "center" }}>
<p style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Vibn</p>
<p style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 260, lineHeight: 1.5 }}>
Your product strategist. Let&apos;s define what you&apos;re building.
</p>
<div style={{ width: "100%", maxWidth: JV.chatFeedMaxWidth, margin: "0 auto" }}>
<div style={{
display: "flex", flexDirection: "column", alignItems: "center", gap: 12,
}}>
<div style={{
width: 44, height: 44, borderRadius: 14, background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: JM.fontSans, fontSize: 18, fontWeight: 600, color: "#fff",
animation: "breathe 2.5s ease infinite",
}}>A</div>
<style>{`@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }`}</style>
<div style={{ textAlign: "center" }}>
<p style={{ fontSize: 15, fontWeight: 600, color: JM.ink, marginBottom: 4, fontFamily: JM.fontDisplay }}>Vibn</p>
<p style={{ fontSize: 13, color: JM.muted, maxWidth: 320, lineHeight: 1.55, margin: 0 }}>
{emptyStateHint ??
"Your product strategist. Let\u2019s define what you\u2019re building."}
</p>
</div>
</div>
</div>
</div>
)}
{/* Messages */}
{!isEmpty && (
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px", position: "relative" }}>
{/* Reset button — top right, only visible on hover of the area */}
<button
onClick={handleReset}
title="Reset conversation"
style={{
position: "absolute", top: 12, right: 16,
background: "none", border: "none", cursor: "pointer",
fontSize: "0.68rem", color: "#d0ccc4", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
padding: "3px 7px", borderRadius: 4, transition: "color 0.12s",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#8a8478")}
onMouseLeave={e => (e.currentTarget.style.color = "#d0ccc4")}
>
Reset
</button>
{visibleMessages.map((msg, i) => (
<MessageRow key={i} msg={msg} userInitial={userInitial} projectId={projectId} workspace={workspace} />
))}
{isStreaming && <TypingIndicator />}
<div ref={endRef} />
</div>
)}
{/* Loading history state */}
{isEmpty && isStreaming && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<TypingIndicator />
</div>
)}
{/* Quick-action chips — shown when there's a conversation and AI isn't typing */}
{!isEmpty && !isStreaming && (
<div style={{ padding: "0 32px 8px", display: "flex", gap: 6, flexWrap: "wrap" }}>
{[
{ label: "Give me suggestions", prompt: "Can you give me some examples or suggestions to help me think through this?" },
{ label: "What's most important?", prompt: "What's the most important thing for me to nail down right now?" },
{ label: "Move on", prompt: "That's enough detail for now — let's move to the next phase." },
].map(({ label, prompt }) => (
<button
key={label}
onClick={() => sendToAtlas(prompt, false)}
style={{
padding: "5px 12px", borderRadius: 20,
border: "1px solid #e0dcd4",
background: "#fff", color: "#6b6560",
fontSize: "0.73rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: "pointer", transition: "all 0.1s",
whiteSpace: "nowrap",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.background = "#f0ece4";
(e.currentTarget as HTMLElement).style.borderColor = "#c8c4bc";
(e.currentTarget as HTMLElement).style.color = "#1a1a1a";
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.background = "#fff";
(e.currentTarget as HTMLElement).style.borderColor = "#e0dcd4";
(e.currentTarget as HTMLElement).style.color = "#6b6560";
}}
>
{label}
</button>
))}
</div>
)}
{/* Input bar */}
<div style={{ padding: "6px 32px max(22px, env(safe-area-inset-bottom))", flexShrink: 0 }}>
<div style={{
display: "flex", gap: 8, padding: "5px 5px 5px 16px",
background: "#fff", border: "1px solid #e0dcd4", borderRadius: 10,
alignItems: "center", boxShadow: "0 1px 4px #1a1a1a06",
flex: 1, minHeight: 0, position: "relative", display: "flex", flexDirection: "column",
}}>
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe your thinking..."
rows={1}
disabled={isStreaming}
<div
ref={scrollRef}
onScroll={syncScrollFab}
style={{
flex: 1, border: "none", background: "none",
fontSize: "0.86rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", padding: "8px 0",
resize: "none", outline: "none",
minHeight: 24, maxHeight: 120,
flex: 1, overflowY: "auto", paddingTop: 24, paddingBottom: 16,
...feedPad,
}}
/>
{isStreaming ? (
>
<div style={{ maxWidth: JV.chatFeedMaxWidth, margin: "0 auto", width: "100%" }}>
{visibleMessages.map((msg, i) => (
<MessageRow key={i} msg={msg} projectId={projectId} workspace={workspace} />
))}
{isStreaming && <TypingIndicator />}
<div ref={endRef} />
</div>
</div>
{showScrollFab && (
<button
onClick={() => setIsStreaming(false)}
type="button"
title="Scroll to latest"
onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
style={{
padding: "9px 16px", borderRadius: 7, border: "none",
background: "#eae6de", color: "#8a8478",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: "pointer", flexShrink: 0,
display: "flex", alignItems: "center", gap: 6,
position: "absolute",
right: `max(20px, calc((100% - ${JV.chatFeedMaxWidth}px) / 2 + 8px))`,
bottom: 12,
width: 36,
height: 36,
borderRadius: "50%",
border: `1px solid ${JM.border}`,
background: JV.composerSurface,
boxShadow: "0 2px 12px rgba(30,27,75,0.1)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: JM.mid,
}}
>
<span style={{ width: 10, height: 10, background: "#8a8478", borderRadius: 2, display: "inline-block" }} />
Stop
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim()}
style={{
padding: "9px 16px", borderRadius: 7, border: "none",
background: input.trim() ? "#1a1a1a" : "#eae6de",
color: input.trim() ? "#fff" : "#b5b0a6",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: input.trim() ? "pointer" : "default",
flexShrink: 0, transition: "all 0.15s",
}}
onMouseEnter={e => { if (input.trim()) (e.currentTarget.style.opacity = "0.8"); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
>
Send
<ChevronDown size={18} strokeWidth={2} />
</button>
)}
</div>
)}
{isEmpty && isStreaming && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", ...feedPad }}>
<div style={{ maxWidth: JV.chatFeedMaxWidth, width: "100%" }}>
<TypingIndicator />
</div>
</div>
)}
{!isEmpty && !isStreaming && (
<div style={{
padding: "0 0 10px",
...feedPad,
display: "flex",
justifyContent: "center",
}}>
<div style={{
maxWidth: JV.chatFeedMaxWidth,
width: "100%",
display: "flex",
gap: 8,
flexWrap: "wrap",
}}>
{[
{ label: "Give me suggestions", prompt: "Can you give me some examples or suggestions to help me think through this?" },
{ label: "What's most important?", prompt: "What's the most important thing for me to nail down right now?" },
{ label: "Move on", prompt: "That's enough detail for now — let's move to the next phase." },
].map(({ label, prompt }) => (
<button
type="button"
key={label}
onClick={() => sendToAtlas(prompt, false)}
style={{
padding: "6px 14px", borderRadius: 999,
border: `1px solid ${JM.border}`,
background: JV.composerSurface, color: JM.mid,
fontSize: 12, fontFamily: JM.fontSans,
cursor: "pointer", transition: "all 0.1s",
whiteSpace: "nowrap",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.background = JV.violetTint;
(e.currentTarget as HTMLElement).style.borderColor = JV.bubbleAiBorder;
(e.currentTarget as HTMLElement).style.color = JM.ink;
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.background = JV.composerSurface;
(e.currentTarget as HTMLElement).style.borderColor = JM.border;
(e.currentTarget as HTMLElement).style.color = JM.mid;
}}
>
{label}
</button>
))}
</div>
</div>
)}
<div style={{
padding: `10px 20px max(20px, env(safe-area-inset-bottom))`,
flexShrink: 0,
display: "flex",
justifyContent: "center",
}}>
<div style={{ width: "100%", maxWidth: JV.chatFeedMaxWidth }}>
<div style={{
background: JV.composerSurface,
border: `1px solid ${JM.border}`,
borderRadius: JV.composerRadius,
boxShadow: JV.composerShadow,
overflow: "hidden",
}}>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Reply…"
rows={2}
disabled={isStreaming}
style={{
display: "block",
width: "100%",
border: "none",
background: "transparent",
fontSize: 15,
fontFamily: JM.fontSans,
color: JM.ink,
padding: "16px 18px 10px",
resize: "none",
outline: "none",
minHeight: 52,
maxHeight: 200,
lineHeight: 1.5,
boxSizing: "border-box",
}}
/>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "8px 10px 10px 12px",
borderTop: `1px solid ${JM.border}`,
background: "rgba(249,250,251,0.6)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
title="Focus composer"
onClick={() => textareaRef.current?.focus()}
style={{
width: 36,
height: 36,
borderRadius: 10,
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: JM.mid,
}}
>
<Plus size={20} strokeWidth={1.75} />
</button>
<button
type="button"
title="Clear conversation"
onClick={handleReset}
style={{
width: 36,
height: 36,
borderRadius: 10,
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: JM.muted,
}}
>
<Trash2 size={18} strokeWidth={1.75} />
</button>
</div>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 8,
flex: 1,
minWidth: 0,
flexWrap: "wrap",
}}>
{chatContextRefs.length > 0 && onRemoveChatContextRef && (
<div style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
justifyContent: "flex-end",
minWidth: 0,
flex: "1 1 auto",
maxWidth: "100%",
}}>
{chatContextRefs.map(ref => {
const key = contextRefKey(ref);
const prefix =
ref.kind === "section" ? "Section" : ref.kind === "phase" ? "Phase" : "App";
return (
<span
key={key}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
maxWidth: 200,
padding: "4px 6px 4px 10px",
borderRadius: 999,
border: `1px solid ${JV.bubbleAiBorder}`,
background: JV.violetTint,
fontSize: 11,
fontWeight: 600,
color: JM.indigo,
fontFamily: JM.fontSans,
flexShrink: 1,
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>
{prefix}: {ref.label}
</span>
<button
type="button"
title="Remove reference"
onClick={() => onRemoveChatContextRef(key)}
style={{
border: "none",
background: "rgba(255,255,255,0.7)",
borderRadius: "50%",
width: 20,
height: 20,
padding: 0,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: JM.mid,
flexShrink: 0,
}}
>
<X size={12} strokeWidth={2.5} />
</button>
</span>
);
})}
</div>
)}
{chatContextRefs.length === 0 && (
<span style={{
fontSize: 11,
fontWeight: 600,
color: JM.mid,
fontFamily: JM.fontSans,
padding: "5px 10px",
borderRadius: 999,
border: `1px solid ${JM.border}`,
background: JV.violetTint,
whiteSpace: "nowrap",
flexShrink: 0,
}}>
{contextEmptyLabel}
</span>
)}
{isStreaming ? (
<button
type="button"
onClick={() => setIsStreaming(false)}
style={{
padding: "9px 18px", borderRadius: 10, border: "none",
background: JV.violetTint, color: JM.mid,
fontSize: 12, fontWeight: 600, fontFamily: JM.fontSans,
cursor: "pointer",
display: "flex", alignItems: "center", gap: 6,
flexShrink: 0,
}}
>
<span style={{ width: 8, height: 8, background: JM.indigo, borderRadius: 2, display: "inline-block" }} />
Stop
</button>
) : (
<button
type="button"
onClick={handleSend}
disabled={!input.trim()}
style={{
padding: "9px 20px", borderRadius: 10, border: "none",
background: input.trim() ? JM.primaryGradient : JV.violetTint,
color: input.trim() ? "#fff" : JM.muted,
fontSize: 12, fontWeight: 600, fontFamily: JM.fontSans,
cursor: input.trim() ? "pointer" : "default",
boxShadow: input.trim() ? JM.primaryShadow : "none",
transition: "opacity 0.15s",
flexShrink: 0,
}}
onMouseEnter={e => { if (input.trim()) (e.currentTarget.style.opacity = "0.92"); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
>
Send
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -24,11 +24,13 @@ interface ProjectShellProps {
const SECTIONS = [
{ id: "overview", label: "Vibn", path: "overview" },
{ id: "prd", label: "PRD", path: "prd" },
{ id: "mvp-setup", label: "Plan", path: "mvp-setup" },
{ id: "tasks", label: "Task", path: "tasks" },
{ id: "build", label: "Build", path: "build" },
{ id: "growth", label: "Growth", path: "growth" },
{ id: "run", label: "Run", path: "run" },
{ id: "growth", label: "Grow", path: "growth" },
{ id: "assist", label: "Assist", path: "assist" },
{ id: "analytics", label: "Analytics", path: "analytics" },
{ id: "analytics", label: "Analyze", path: "analytics" },
] as const;
@@ -43,8 +45,12 @@ function ProjectShellInner({
const activeSection =
pathname?.includes("/overview") ? "overview" :
pathname?.includes("/prd") ? "prd" :
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" :

View File

@@ -281,7 +281,7 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
const isActive = activeProjectId === p.id;
const color = p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a";
return (
<Link key={p.id} href={`/${workspace}/project/${p.id}/overview`}
<Link key={p.id} href={`/${workspace}/project/${p.id}`}
title={collapsed ? p.productName : undefined}
style={{
width: "100%", display: "flex", alignItems: "center",

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
@@ -45,10 +46,10 @@ export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ padding: 28 }}>
<SetupHeader
icon="⌁" label="Import Chats" tagline="You've been thinking"
accent="#2e5a4a" onBack={onBack} onClose={onClose}
accent="#059669" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
@@ -66,14 +67,14 @@ export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nVibn will extract decisions, ideas, open questions, and architecture notes."}
rows={8}
style={{
width: "100%", padding: "12px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.55,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 20,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 13, lineHeight: 1.55,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
@@ -47,10 +48,10 @@ export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ padding: 28 }}>
<SetupHeader
icon="⌘" label="Import Code" tagline="Already have a repo"
accent="#1a3a5c" onBack={onBack} onClose={onClose}
accent="#1D4ED8" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
@@ -70,7 +71,7 @@ export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
<FieldLabel>
Personal Access Token{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(required for private repos)</span>
<span style={{ color: JM.muted, fontWeight: 400 }}>(required for private repos)</span>
</FieldLabel>
<input
type="password"
@@ -78,17 +79,21 @@ export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
onChange={e => setPat(e.target.value)}
placeholder="ghp_… or similar"
style={{
width: "100%", padding: "11px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 20,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<div style={{
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
padding: "12px 14px", background: JM.cream, borderRadius: 8,
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
}}>
Vibn will clone your repo, read key files, and build a full architecture map tech stack, routes, database, auth, and third-party integrations. Tokens are used only for cloning and are not stored.
</div>

View File

@@ -2,11 +2,20 @@
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus_Jakarta_Sans } from "next/font/google";
import { TypeSelector } from "./TypeSelector";
import { FreshIdeaSetup } from "./FreshIdeaSetup";
import { ChatImportSetup } from "./ChatImportSetup";
import { CodeImportSetup } from "./CodeImportSetup";
import { MigrateSetup } from "./MigrateSetup";
import { JM } from "./modal-theme";
const modalFont = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-justine-jakarta",
display: "swap",
});
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
@@ -62,29 +71,31 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
<div
onClick={() => onOpenChange(false)}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(26,26,26,0.38)",
position: "fixed", inset: 0, zIndex: 200,
background: JM.overlay,
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(2px)",
animation: "vibn-fadeIn 0.15s ease",
}}
/>
{/* Modal container */}
{/* Modal container — matches justine/03_dashboard.html #modal-new */}
<div style={{
position: "fixed", inset: 0, zIndex: 51,
position: "fixed", inset: 0, zIndex: 201,
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24, pointerEvents: "none",
}}>
<div
onClick={e => e.stopPropagation()}
className={modalFont.variable}
style={{
background: "#fff", borderRadius: 16,
boxShadow: "0 12px 48px rgba(26,26,26,0.16)",
boxShadow: JM.cardShadow,
width: "100%",
maxWidth: 520,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
maxWidth: JM.cardMaxWidth,
fontFamily: JM.fontSans,
pointerEvents: "all",
animation: "vibn-slideUp 0.18s cubic-bezier(0.4,0,0.2,1)",
transition: "max-width 0.2s ease",
overflow: "hidden",
}}
>

View File

@@ -3,11 +3,13 @@
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
import { JM } from "./modal-theme";
import { FieldLabel, TextInput, PrimaryButton, ForWhomSelector, type SetupProps } from "./setup-shared";
export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
const [loading, setLoading] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
@@ -24,7 +26,7 @@ export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
product: { name: name.trim(), isForClient: forWhom === "client" },
creationMode: "fresh",
sourceData: {},
}),
@@ -45,29 +47,45 @@ export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ fontSize: "1.15rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "var(--font-lora), ui-serif, serif" }}>
<div style={{ padding: 28 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 22 }}>
<h3 style={{
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, letterSpacing: "-0.02em",
}}>
New project
</div>
</h3>
<button
type="button"
onClick={onClose}
style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "1.1rem", padding: "2px 6px", lineHeight: 1 }}
>×</button>
style={{
background: "none", border: "none", cursor: "pointer",
color: JM.muted, fontSize: 20, padding: 4, lineHeight: 1,
fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
</div>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. Foxglove, Meridian, OpsAI…"
onKeyDown={e => { if (e.key === "Enter" && canCreate) handleCreate(); }}
inputRef={nameRef}
autoFocus
/>
<div style={{ marginBottom: 16 }}>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. My SaaS App"
onKeyDown={e => { if (e.key === "Enter" && canCreate) void handleCreate(); }}
inputRef={nameRef}
autoFocus
/>
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start
<ForWhomSelector value={forWhom} onChange={setForWhom} />
<PrimaryButton onClick={() => { void handleCreate(); }} disabled={!canCreate} loading={loading}>
Create project
</PrimaryButton>
</div>
);

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
const HOSTING_OPTIONS = [
@@ -70,7 +71,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ padding: 28 }}>
<SetupHeader
icon="⇢" label="Migrate Product" tagline="Move an existing product"
accent="#4a2a5a" onBack={onBack} onClose={onClose}
@@ -86,7 +87,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
<FieldLabel>
Repository URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(recommended)</span>
<span style={{ color: JM.muted, fontWeight: 400 }}>(recommended)</span>
</FieldLabel>
<TextInput
value={repoUrl}
@@ -96,7 +97,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
<FieldLabel>
Live URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span>
<span style={{ color: JM.muted, fontWeight: 400 }}>(optional)</span>
</FieldLabel>
<TextInput
value={liveUrl}
@@ -111,14 +112,16 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
value={hosting}
onChange={e => setHosting(e.target.value)}
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.88rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90",
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 13,
fontFamily: JM.fontSans, color: hosting ? JM.ink : JM.muted,
outline: "none", boxSizing: "border-box", appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23a09a90' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%239CA3AF' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
>
{HOSTING_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
@@ -127,7 +130,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
</div>
<div>
<FieldLabel>
PAT{" "}<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(private repos)</span>
PAT{" "}<span style={{ color: JM.muted, fontWeight: 400 }}>(private repos)</span>
</FieldLabel>
<input
type="password"
@@ -135,20 +138,24 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
onChange={e => setPat(e.target.value)}
placeholder="ghp_…"
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Vibn builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
<div style={{
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
padding: "12px 14px", background: JM.cream, borderRadius: 8,
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
}}>
<strong style={{ color: "#5B21B6" }}>Non-destructive.</strong> Vibn builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>

View File

@@ -1,6 +1,7 @@
"use client";
import type { CreationMode } from "./CreateProjectFlow";
import { JM } from "./modal-theme";
interface TypeSelectorProps {
onSelect: (mode: CreationMode) => void;
@@ -22,7 +23,7 @@ const ALL_FLOW_TYPES: {
label: "Fresh Idea",
tagline: "Start from scratch",
desc: "Talk through your idea with Vibn. We'll explore it together and shape it into a full product plan.",
accent: "#4a3728",
accent: "#4338CA",
},
{
id: "chat-import",
@@ -30,7 +31,7 @@ const ALL_FLOW_TYPES: {
label: "Import Chats",
tagline: "You've been thinking",
desc: "Paste conversations from ChatGPT or Claude. Vibn extracts your decisions, ideas, and open questions.",
accent: "#2e5a4a",
accent: "#059669",
},
{
id: "code-import",
@@ -38,7 +39,7 @@ const ALL_FLOW_TYPES: {
label: "Import Code",
tagline: "Already have a repo",
desc: "Point Vibn at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
accent: "#1a3a5c",
accent: "#1D4ED8",
hidden: true,
},
{
@@ -47,7 +48,7 @@ const ALL_FLOW_TYPES: {
label: "Migrate Product",
tagline: "Move an existing product",
desc: "Bring your live product into the VIBN infrastructure. Vibn builds a safe, phased migration plan.",
accent: "#4a2a5a",
accent: "#7C3AED",
hidden: true,
},
];
@@ -56,89 +57,88 @@ const FLOW_TYPES = ALL_FLOW_TYPES.filter(t => !t.hidden);
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
return (
<div style={{ padding: "32px 36px 36px" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ padding: 28 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div>
<h2 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.4rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 4,
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, marginBottom: 4, letterSpacing: "-0.02em",
}}>
Start a new project
</h2>
<p style={{ fontSize: "0.78rem", color: "#a09a90", margin: 0 }}>
<p style={{ fontSize: 13, color: JM.mid, margin: 0, fontFamily: JM.fontSans, lineHeight: 1.45 }}>
How would you like to begin?
</p>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4,
color: JM.muted, fontSize: 20, lineHeight: 1,
padding: 4, fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
</div>
{/* Type cards */}
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 10 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 8 }}>
{FLOW_TYPES.map(type => (
<button
key={type.id}
type="button"
onClick={() => onSelect(type.id)}
style={{
display: "flex", flexDirection: "column", alignItems: "flex-start",
gap: 0, padding: "20px", borderRadius: 12, textAlign: "left",
border: "1px solid #e8e4dc",
background: "#faf8f5",
gap: 0, padding: 16, borderRadius: 12, textAlign: "left",
border: `1px solid ${JM.border}`,
background: JM.inputBg,
cursor: "pointer",
transition: "all 0.14s",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "border-color 0.15s, background 0.15s, box-shadow 0.15s",
fontFamily: JM.fontSans,
position: "relative",
overflow: "hidden",
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = "#d0ccc4";
e.currentTarget.style.background = "#fff";
e.currentTarget.style.boxShadow = "0 2px 12px rgba(26,26,26,0.07)";
e.currentTarget.style.borderColor = JM.indigo;
e.currentTarget.style.background = JM.cream;
e.currentTarget.style.boxShadow = "0 2px 12px rgba(99,102,241,0.1)";
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = "#e8e4dc";
e.currentTarget.style.background = "#faf8f5";
e.currentTarget.style.borderColor = JM.border;
e.currentTarget.style.background = JM.inputBg;
e.currentTarget.style.boxShadow = "none";
}}
>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 9, marginBottom: 14,
background: `${type.accent}10`,
width: 36, height: 36, borderRadius: 9, marginBottom: 12,
background: "rgba(99,102,241,0.12)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.1rem", color: type.accent,
fontSize: "1.05rem", color: type.accent,
}}>
{type.icon}
</div>
{/* Label + tagline */}
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 2 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: JM.ink, marginBottom: 2 }}>
{type.label}
</div>
<div style={{ fontSize: "0.68rem", fontWeight: 600, color: type.accent, letterSpacing: "0.03em", marginBottom: 8, textTransform: "uppercase" }}>
<div style={{
fontSize: 10.5, fontWeight: 600, color: type.accent,
letterSpacing: "0.07em", marginBottom: 8, textTransform: "uppercase",
}}>
{type.tagline}
</div>
{/* Description */}
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
<div style={{ fontSize: 12.5, color: JM.mid, lineHeight: 1.55 }}>
{type.desc}
</div>
{/* Arrow */}
<div style={{
position: "absolute", right: 16, bottom: 16,
fontSize: "0.85rem", color: "#c5c0b8",
fontSize: 14, color: JM.muted,
}}>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { ReactNode, CSSProperties } from "react";
import { JM } from "./modal-theme";
export interface SetupProps {
workspace: string;
@@ -8,7 +9,68 @@ export interface SetupProps {
onBack: () => void;
}
// Shared modal header
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{
display: "block", fontSize: 12, fontWeight: 600, color: JM.mid,
marginBottom: 6, fontFamily: JM.fontSans,
}}>
{children}
</label>
);
}
export function ForWhomSelector({
value,
onChange,
}: {
value: "personal" | "client";
onChange: (v: "personal" | "client") => void;
}) {
const cardBase: CSSProperties = {
flex: 1,
border: `1px solid ${JM.border}`,
borderRadius: 9,
padding: 14,
cursor: "pointer",
textAlign: "center" as const,
background: JM.inputBg,
transition: "all 0.15s",
fontFamily: JM.fontSans,
};
const row = (key: "personal" | "client", emoji: string, title: string, sub: string) => {
const sel = value === key;
return (
<button
type="button"
key={key}
onClick={() => onChange(key)}
style={{
...cardBase,
borderColor: sel ? JM.indigo : JM.border,
background: sel ? JM.cream : JM.inputBg,
boxShadow: sel ? "0 0 0 1px rgba(99,102,241,0.2)" : undefined,
}}
>
<div style={{ fontSize: 20, marginBottom: 5 }}>{emoji}</div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: JM.ink }}>{title}</div>
<div style={{ fontSize: 11, color: JM.muted, marginTop: 2 }}>{sub}</div>
</button>
);
};
return (
<div style={{ marginBottom: 22 }}>
<FieldLabel>This project is for</FieldLabel>
<div style={{ display: "flex", gap: 8 }}>
{row("personal", "🧑‍💻", "Myself", "My own product")}
{row("client", "🤝", "A client", "Client project")}
</div>
</div>
);
}
export function SetupHeader({
icon,
label,
@@ -25,41 +87,47 @@ export function SetupHeader({
onClose: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onBack}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1rem", padding: "3px 5px",
color: JM.muted, fontSize: "1rem", padding: "3px 5px",
borderRadius: 4, lineHeight: 1, flexShrink: 0,
fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
onMouseEnter={e => (e.currentTarget.style.color = JM.ink)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
</button>
<div>
<h2 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 3,
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, marginBottom: 3, letterSpacing: "-0.02em",
}}>
{label}
</h2>
<p style={{ fontSize: "0.72rem", fontWeight: 600, color: accent, textTransform: "uppercase", letterSpacing: "0.04em", margin: 0 }}>
<p style={{
fontSize: 10.5, fontWeight: 600, color: accent, textTransform: "uppercase",
letterSpacing: "0.07em", margin: 0, fontFamily: JM.fontSans,
}}>
{tagline}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4, flexShrink: 0,
color: JM.muted, fontSize: 20, lineHeight: 1,
padding: 4, flexShrink: 0, fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
@@ -67,14 +135,6 @@ export function SetupHeader({
);
}
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
{children}
</label>
);
}
export function TextInput({
value,
onChange,
@@ -88,13 +148,13 @@ export function TextInput({
placeholder?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
inputRef?: React.RefObject<HTMLInputElement>;
inputRef?: React.RefObject<HTMLInputElement | null> | React.RefObject<HTMLInputElement>;
}) {
const base: CSSProperties = {
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
};
return (
@@ -107,8 +167,8 @@ export function TextInput({
placeholder={placeholder}
autoFocus={autoFocus}
style={base}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
);
}
@@ -127,27 +187,44 @@ export function PrimaryButton({
const active = !disabled && !loading;
return (
<button
type="button"
onClick={onClick}
disabled={!active}
style={{
width: "100%", padding: "12px",
borderRadius: 8, border: "none",
background: active ? "#1a1a1a" : "#e0dcd4",
color: active ? "#fff" : "#b5b0a6",
fontSize: "0.88rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: active ? JM.primaryGradient : "#E5E7EB",
color: active ? "#fff" : JM.muted,
fontSize: 14, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: active ? "pointer" : "not-allowed",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
boxShadow: active ? JM.primaryShadow : "none",
transition: "box-shadow 0.2s, transform 0.15s, opacity 0.15s",
}}
onMouseEnter={e => {
if (active) {
e.currentTarget.style.boxShadow = JM.primaryShadowHover;
e.currentTarget.style.transform = "translateY(-1px)";
}
}}
onMouseLeave={e => {
e.currentTarget.style.boxShadow = active ? JM.primaryShadow : "none";
e.currentTarget.style.transform = "none";
}}
onMouseEnter={e => { if (active) e.currentTarget.style.opacity = "0.85"; }}
onMouseLeave={e => { e.currentTarget.style.opacity = "1"; }}
>
{loading ? (
<>
<span style={{ width: 14, height: 14, borderRadius: "50%", border: "2px solid #fff4", borderTopColor: "#fff", animation: "vibn-spin 0.7s linear infinite", display: "inline-block" }} />
<span style={{
width: 14, height: 14, borderRadius: "50%",
border: "2px solid rgba(255,255,255,0.35)", borderTopColor: "#fff",
animation: "vibn-spin 0.7s linear infinite", display: "inline-block",
}} />
Creating
</>
) : children}
) : (
children
)}
</button>
);
}

View File

@@ -140,8 +140,8 @@ export function ChatImportMain({
}
};
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/prd`);
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/build`);
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/tasks`);
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
// ── Stage: intake ─────────────────────────────────────────────────────────
if (stage === "intake") {
@@ -320,7 +320,7 @@ export function ChatImportMain({
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
>
Plan MVP Test
Plan MVP
</button>
</div>
</div>

View File

@@ -1,9 +1,15 @@
"use client";
import { useEffect, useState } from "react";
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",
@@ -12,7 +18,16 @@ const DISCOVERY_PHASES = [
"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 }[] = [
@@ -30,6 +45,17 @@ const PRD_SECTIONS: { label: string; phase: string | null }[] = [
{ 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;
@@ -45,6 +71,35 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
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
@@ -73,14 +128,14 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
if (prdLoading) return;
setPrdLoading(true);
try {
router.push(`/${workspace}/project/${projectId}/prd`);
router.push(`/${workspace}/project/${projectId}/tasks`);
} finally {
setPrdLoading(false);
}
};
const handleMVP = () => {
router.push(`/${workspace}/project/${projectId}/build`);
router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
};
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
@@ -90,29 +145,57 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
).length;
const totalSections = PRD_SECTIONS.length;
return (
<div style={{ height: "100%", display: "flex", flexDirection: "row", overflow: "hidden" }}>
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]);
{/* ── Left: Atlas chat ── */}
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 }}>
{/* PRD ready notice — replaces the decision banner once PRD is saved */}
{hasPrd && (
<div style={{
background: "#1a1a1a", padding: "10px 20px",
background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
padding: "10px 20px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexShrink: 0, borderBottom: "1px solid #333",
gap: 16, flexShrink: 0,
borderBottom: `1px solid rgba(255,255,255,0.12)`,
}}>
<div style={{ fontSize: "0.8rem", color: "#e8e4dc", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
PRD saved you can keep refining here or view the full document.
<div 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}/prd`}
href={`/${workspace}/project/${projectId}/tasks`}
style={{
padding: "6px 14px", borderRadius: 7,
background: "#fff", color: "#1a1a1a",
fontSize: "0.76rem", fontWeight: 600,
padding: "6px 14px", borderRadius: 8,
background: "#fff", color: JM.ink,
fontSize: 12, fontWeight: 600,
textDecoration: "none", flexShrink: 0,
fontFamily: JM.fontSans,
}}
>
View PRD
@@ -120,151 +203,499 @@ export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
</div>
)}
{/* Decision banner — shown when all 6 phases are saved but PRD not yet generated */}
{allDone && !dismissed && !hasPrd && (
<div style={{
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)",
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 #333",
borderBottom: `1px solid rgba(255,255,255,0.12)`,
}}>
<div>
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#fff", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 2 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#fff", fontFamily: JM.fontDisplay, marginBottom: 2 }}>
Discovery complete what&apos;s next?
</div>
<div style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
All 6 phases captured. Generate your PRD or jump into Build.
<div 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 }}>
<div style={{ display: "flex", gap: 8, flexShrink: 0, alignItems: "center" }}>
<button
type="button"
onClick={handleGeneratePRD}
disabled={prdLoading}
style={{
padding: "8px 16px", borderRadius: 7, border: "none",
background: "#fff", color: "#1a1a1a",
fontSize: "0.8rem", fontWeight: 700,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
transition: "opacity 0.12s",
padding: "8px 16px", borderRadius: 8, border: "none",
background: "#fff", color: JM.ink,
fontSize: 13, fontWeight: 700,
fontFamily: JM.fontSans, cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
{prdLoading ? "Navigating…" : "Generate PRD →"}
</button>
<button
type="button"
onClick={handleMVP}
style={{
padding: "8px 16px", borderRadius: 7,
border: "1px solid rgba(255,255,255,0.2)",
padding: "8px 16px", borderRadius: 8,
border: "1px solid rgba(255,255,255,0.35)",
background: "transparent", color: "#fff",
fontSize: "0.8rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
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: "#888", fontSize: "1rem", padding: "4px 6px",
color: "rgba(255,255,255,0.55)", fontSize: 18, padding: "4px 6px",
}}
title="Dismiss"
>×</button>
>
×
</button>
</div>
</div>
)}
<AtlasChat projectId={projectId} projectName={projectName} />
<AtlasChat
projectId={projectId}
projectName={projectName}
chatContextRefs={chatContextRefs}
onRemoveChatContextRef={removeChatContextRef}
/>
</div>
{/* ── Right: PRD section tracker ── */}
{/* ── Right: Teams-style task rail (requirements = PRD sections as tasks) ── */}
<div style={{
width: 240, flexShrink: 0,
background: "#faf8f5",
borderLeft: "1px solid #e8e4dc",
width: 348, flexShrink: 0,
background: "#F4F2FA",
borderLeft: `1px solid ${JM.border}`,
display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
{/* Header */}
{/* Tab bar */}
<div style={{
padding: "14px 16px 10px",
borderBottom: "1px solid #e8e4dc",
display: "flex", alignItems: "center",
borderBottom: `1px solid ${JM.border}`,
flexShrink: 0,
padding: "0 8px",
gap: 2,
background: "#FAF8FF",
}}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#1a1a1a", letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
PRD Sections
</div>
{/* Progress bar */}
<div style={{ height: 3, background: "#e8e4dc", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", borderRadius: 99,
background: "#1a1a1a",
width: `${Math.round((completedSections / totalSections) * 100)}%`,
transition: "width 0.4s ease",
}} />
</div>
<div style={{ fontSize: "0.68rem", color: "#a09a90", marginTop: 5 }}>
{completedSections} of {totalSections} sections complete
</div>
</div>
{/* Section list */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0" }}>
{PRD_SECTIONS.map(({ label, phase }) => {
const isDone = phase === null
? allDone // non-functional reqs generated when all done
: savedPhaseIds.has(phase);
<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 (
<div
key={label}
<button
key={t.id}
type="button"
onClick={() => setSidebarTab(t.id)}
style={{
padding: "8px 16px",
display: "flex", alignItems: "flex-start", gap: 10,
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,
}}
>
{/* Status dot */}
<div style={{
width: 8, height: 8, borderRadius: "50%", flexShrink: 0, marginTop: 4,
background: isDone ? "#1a1a1a" : "transparent",
border: isDone ? "none" : "1.5px solid #c8c4bc",
transition: "all 0.3s",
}} />
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: "0.78rem", fontWeight: isDone ? 600 : 400,
color: isDone ? "#1a1a1a" : "#6b6560",
lineHeight: 1.3,
}}>
{label}
</div>
{!isDone && (
<div style={{ fontSize: "0.65rem", color: "#a09a90", marginTop: 2, lineHeight: 1.3 }}>
Complete this phase in Vibn
</div>
)}
</div>
</div>
{t.label}
</button>
);
})}
</div>
{/* Footer CTA */}
{/* 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: AZ"}
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: "12px 16px", borderTop: "1px solid #e8e4dc", flexShrink: 0 }}>
<div style={{ padding: "10px 12px", borderTop: `1px solid ${JM.border}`, flexShrink: 0, background: "#FAF8FF" }}>
<Link
href={`/${workspace}/project/${projectId}/prd`}
href={`/${workspace}/project/${projectId}/tasks`}
style={{
display: "block", textAlign: "center",
padding: "9px 0", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600,
padding: "10px 0", borderRadius: 8,
background: JM.primaryGradient,
color: "#fff",
fontSize: 12, fontWeight: 600,
textDecoration: "none",
fontFamily: JM.fontSans,
boxShadow: JM.primaryShadow,
}}
>
Generate PRD
Open Tasks
</Link>
</div>
)}

View File

@@ -199,7 +199,17 @@ export async function createAccessTokenFor(opts: {
name: string;
scopes?: string[];
}): Promise<{ id: number; name: string; sha1: string; token_last_eight: string }> {
const { username, password, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts;
const {
username,
password,
name,
scopes = [
'write:repository',
'write:issue',
'write:user',
'write:organization',
],
} = opts;
const basic = Buffer.from(`${username}:${password}`).toString('base64');
const url = `${GITEA_API_URL}/api/v1/users/${username}/tokens`;
const res = await fetch(url, {

View File

@@ -297,7 +297,12 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom
username: botUsername,
password,
name: `vibn-${workspace.slug}-${Date.now().toString(36)}`,
scopes: ['write:repository', 'write:issue', 'write:user'],
scopes: [
'write:repository',
'write:issue',
'write:user',
'write:organization',
],
});
botTokenEncrypted = encryptSecret(pat.sha1);
}

View File

@@ -7,7 +7,7 @@ import Link from "next/link";
*/
export function JustineHomePage() {
return (
<>
<div className="justine-home-page">
<section
className="hero-section"
style={{ maxWidth: 980, margin: "0 auto", padding: "88px 52px 72px" }}
@@ -513,6 +513,6 @@ export function JustineHomePage() {
<div style={{ fontSize: 12.5, color: "var(--muted)" }}>Joins 280+ non-technical founders already live</div>
</div>
</section>
</>
</div>
);
}

View File

@@ -56,7 +56,7 @@ export function JustineNav() {
<Link href="/pricing" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
Pricing
</Link>
<Link href="/features" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
<Link href="/stories" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
Stories
</Link>
<span style={{ fontSize: 14, color: "var(--muted)" }}>Blog</span>
@@ -93,7 +93,7 @@ export function JustineNav() {
<Link href="/pricing" onClick={close}>
Pricing
</Link>
<Link href="/features" onClick={close}>
<Link href="/stories" onClick={close}>
Stories
</Link>
<Link href="#" onClick={(e) => { e.preventDefault(); close(); }}>

View File

@@ -1,3 +1,4 @@
export { JustineNav } from "./JustineNav";
export { JustineFooter } from "./JustineFooter";
export { JustineHomePage } from "./JustineHomePage";
export { JustineAuthShell } from "./JustineAuthShell";

View File

@@ -1,7 +1,22 @@
import type { NextConfig } from "next";
import path from "path";
import { fileURLToPath } from "url";
// This app lives in vibn-frontend; a lockfile under $HOME can make Turbopack pick the wrong root
// and hydrate with a mismatched client bundle (e.g. different JustineNav markup).
const turbopackRoot = path.dirname(fileURLToPath(import.meta.url));
// Google OAuth on localhost: NextAuth must build the same callback URL Google expects.
// If NEXTAUTH_URL is unset in dev, default it (set explicitly if you use 127.0.0.1 or another port).
if (process.env.NODE_ENV === "development" && !process.env.NEXTAUTH_URL?.trim()) {
process.env.NEXTAUTH_URL = "http://localhost:3000";
}
const nextConfig: NextConfig = {
output: 'standalone',
turbopack: {
root: turbopackRoot,
},
output: "standalone",
serverExternalPackages: ["@prisma/client", "prisma"],
typescript: {
ignoreBuildErrors: true,

View File

@@ -16,7 +16,12 @@
"firebase:deploy:app": "npm run build && firebase deploy --only functions,hosting",
"firebase:deploy:all": "npm run build && firebase deploy",
"mcp:server": "node mcp-server.js",
"prisma:generate": "prisma generate"
"prisma:generate": "prisma generate",
"db:push": "node scripts/prisma-db-push.mjs",
"db:sync:coolify": "node scripts/sync-db-url-from-coolify.mjs",
"db:local:up": "docker compose -f docker-compose.local-db.yml up -d",
"db:local:down": "docker compose -f docker-compose.local-db.yml down",
"db:local:push": "node scripts/prisma-db-push.mjs"
},
"dependencies": {
"@assistant-ui/react": "^0.12.14",
@@ -75,5 +80,6 @@
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}

View File

@@ -1,38 +1,43 @@
// VIBN Service Worker — enables PWA install + basic offline shell
// VIBN Service Worker — PWA shell (production). Must always resolve respondWith to a Response.
const CACHE = 'vibn-v1';
// Cache the app shell on install
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then(cache =>
cache.addAll(['/', '/manifest.json'])
)
caches.open(CACHE).then((cache) => cache.addAll(['/', '/manifest.json']))
);
self.skipWaiting();
});
self.addEventListener('activate', () => self.clients.claim());
// Network-first for API calls, cache-first for static assets
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = new URL(request.url);
// Never cache API calls
if (url.pathname.startsWith('/api/')) return;
// Let the browser handle Next.js RSC, Turbopack/HMR, and dev endpoints — do not intercept.
if (
url.pathname.startsWith('/_next/') ||
url.pathname.includes('__nextjs') ||
url.search.includes('_rsc=')
) {
return;
}
if (url.pathname.startsWith('/api/')) {
return;
}
// Cache-first for static assets
if (
request.destination === 'image' ||
request.destination === 'font' ||
url.pathname.startsWith('/_next/static/')
) {
e.respondWith(
caches.match(request).then(cached => {
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then(res => {
return fetch(request).then((res) => {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(request, clone));
caches.open(CACHE).then((c) => c.put(request, clone));
return res;
});
})
@@ -40,8 +45,17 @@ self.addEventListener('fetch', (e) => {
return;
}
// Network-first for everything else (HTML pages)
// Network-first; cache fallback must be a real Response (undefined breaks FetchEvent).
e.respondWith(
fetch(request).catch(() => caches.match(request))
fetch(request)
.catch(() => caches.match(request))
.then((cachedOrFailed) => {
if (cachedOrFailed instanceof Response) return cachedOrFailed;
return new Response('Offline', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' },
});
})
);
});