Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out: - Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim) - Delete app/api/projects/[projectId]/workspace/route.ts and app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning) - Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts - Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/ theiaError from app/api/projects/create/route.ts response - Remove Theia callbackUrl branch in app/auth/page.tsx - Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx - Drop theiaWorkspaceUrl from deployment/page.tsx Project type - Strip Theia IDE line + theia-code-os from advisor + agent-chat context strings - Scrub Theia mention from lib/auth/workspace-auth.ts comment P5.1 (custom apex domains + DNS): - lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS XML auth, Cloud DNS plumbing - scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS + prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup In-progress (Justine onboarding/build, MVP setup, agent telemetry): - New (justine)/stories, project (home) layouts, mvp-setup, run, tasks routes + supporting components - Project shell + sidebar + nav refactor for the Stackless palette - Agent session API hardening (sessions, events, stream, approve, retry, stop) + atlas-chat, advisor, design-surfaces refresh - New scripts/sync-db-url-from-coolify.mjs + scripts/prisma-db-push.mjs + docker-compose.local-db.yml for local Prisma workflows - lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts - Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
1
app/(justine)/stories/page.tsx
Normal file
1
app/(justine)/stories/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../features/page";
|
||||
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project home scaffold.
|
||||
*
|
||||
* Mirrors the /[workspace]/projects scaffold: VIBNSidebar on the left,
|
||||
* cream main area on the right. Used only for the project home page
|
||||
* (`/{workspace}/project/{id}`) — sub-routes use the (workspace) group
|
||||
* with the ProjectShell tab nav instead.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Toaster } from "sonner";
|
||||
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
|
||||
|
||||
export default function ProjectHomeLayout({ children }: { children: ReactNode }) {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
<main style={{ flex: 1, overflow: "auto" }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<ProjectAssociationPrompt workspace={workspace} />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project home page.
|
||||
*
|
||||
* Sits between the projects list and the AI interview. Gives users two
|
||||
* simplified entry tiles — Code (their Gitea repo) and Infrastructure
|
||||
* (their Coolify deployment) — plus a quiet "Continue setup" link if
|
||||
* the discovery interview isn't done.
|
||||
*
|
||||
* Styled to match the production "ink & parchment" design:
|
||||
* Newsreader serif headings, Outfit sans body, warm beige borders,
|
||||
* solid black CTAs. No indigo. No gradients.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||
import {
|
||||
ArrowRight,
|
||||
Code2,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
Rocket,
|
||||
} from "lucide-react";
|
||||
|
||||
// ── Design tokens (mirrors the prod ink & parchment palette) ─────────
|
||||
const INK = {
|
||||
fontSerif: '"Newsreader", "Lora", Georgia, serif',
|
||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
fontMono: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
ink: "#1a1a1a",
|
||||
ink2: "#2c2c2a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
stone: "#b5b0a6",
|
||||
border: "#e8e4dc",
|
||||
borderHover: "#d0ccc4",
|
||||
cardBg: "#fff",
|
||||
pageBg: "#f7f4ee",
|
||||
shadow: "0 1px 2px #1a1a1a05",
|
||||
shadowHover: "0 2px 8px #1a1a1a0a",
|
||||
iconWrapBg: "#1a1a1a08",
|
||||
} as const;
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
productName?: string;
|
||||
name?: string;
|
||||
productVision?: string;
|
||||
description?: string;
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
stage?: "discovery" | "architecture" | "building" | "active";
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
discoveryPhase?: number;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface FileTreeItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "dir";
|
||||
}
|
||||
|
||||
interface PreviewApp {
|
||||
name: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function ProjectHomePage() {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
const { status: authStatus } = useSession();
|
||||
|
||||
const [project, setProject] = useState<ProjectSummary | null>(null);
|
||||
const [projectLoading, setProjectLoading] = useState(true);
|
||||
|
||||
const [files, setFiles] = useState<FileTreeItem[] | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(true);
|
||||
|
||||
const [apps, setApps] = useState<PreviewApp[]>([]);
|
||||
const [appsLoading, setAppsLoading] = useState(true);
|
||||
|
||||
const ready = useMemo(
|
||||
() => isClientDevProjectBypass() || authStatus === "authenticated",
|
||||
[authStatus]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
if (authStatus === "unauthenticated") setProjectLoading(false);
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${projectId}`, { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project ?? null))
|
||||
.catch(() => {})
|
||||
.finally(() => setProjectLoading(false));
|
||||
}, [ready, authStatus, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
fetch(`/api/projects/${projectId}/file?path=`, { credentials: "include" })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => {
|
||||
if (d?.type === "dir" && Array.isArray(d.items)) {
|
||||
setFiles(d.items as FileTreeItem[]);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setFiles([]))
|
||||
.finally(() => setFilesLoading(false));
|
||||
}, [ready, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
fetch(`/api/projects/${projectId}/preview-url`, { credentials: "include" })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => setApps(Array.isArray(d?.apps) ? d.apps : []))
|
||||
.catch(() => {})
|
||||
.finally(() => setAppsLoading(false));
|
||||
}, [ready, projectId]);
|
||||
|
||||
const projectName = project?.productName || project?.name || "Untitled project";
|
||||
const projectDesc = project?.productVision || project?.description;
|
||||
const stage = project?.stage ?? "discovery";
|
||||
const interviewIncomplete = stage === "discovery";
|
||||
const liveApp = apps.find(a => a.url) ?? apps[0] ?? null;
|
||||
|
||||
if (projectLoading) {
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={centeredFiller}>
|
||||
<Loader2 className="animate-spin" size={22} style={{ color: INK.stone }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={{ ...centeredFiller, color: INK.muted, fontSize: 14, fontFamily: INK.fontSans }}>
|
||||
Project not found.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={pageInner}>
|
||||
{/* ── Hero ─────────────────────────────────────────────── */}
|
||||
<header style={heroStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={eyebrow}>Project</div>
|
||||
<h1 style={heroTitle}>{projectName}</h1>
|
||||
{projectDesc && <p style={heroDesc}>{projectDesc}</p>}
|
||||
</div>
|
||||
<StagePill stage={stage} />
|
||||
</header>
|
||||
|
||||
{/* ── Continue setup link (quiet, only when in discovery) ── */}
|
||||
{interviewIncomplete && (
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/overview`}
|
||||
style={continueRow}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<span style={continueDot} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={continueTitle}>Continue setup</div>
|
||||
<div style={continueSub}>
|
||||
Pick up the AI interview where you left off.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} style={{ color: INK.ink, flexShrink: 0 }} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* ── Two big tiles ────────────────────────────────────── */}
|
||||
<div style={tileGrid}>
|
||||
<CodeTile
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
files={files}
|
||||
loading={filesLoading}
|
||||
giteaRepo={project.giteaRepo}
|
||||
/>
|
||||
<InfraTile
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
app={liveApp}
|
||||
loading={appsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Tiles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CodeTile({
|
||||
workspace,
|
||||
projectId,
|
||||
files,
|
||||
loading,
|
||||
giteaRepo,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
files: FileTreeItem[] | null;
|
||||
loading: boolean;
|
||||
giteaRepo?: string;
|
||||
}) {
|
||||
const items = files ?? [];
|
||||
const dirCount = items.filter(i => i.type === "dir").length;
|
||||
const fileCount = items.filter(i => i.type === "file").length;
|
||||
const previewItems = items.slice(0, 6);
|
||||
|
||||
return (
|
||||
<Link href={`/${workspace}/project/${projectId}/code`} style={tileLink}>
|
||||
<article
|
||||
style={tileCard}
|
||||
onMouseEnter={hoverEnter}
|
||||
onMouseLeave={hoverLeave}
|
||||
>
|
||||
<header style={tileHeader}>
|
||||
<span style={tileIconWrap}>
|
||||
<Code2 size={16} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={tileTitle}>Code</h2>
|
||||
<p style={tileSubtitle}>What the AI is building, file by file.</p>
|
||||
</div>
|
||||
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||
</header>
|
||||
|
||||
<div style={tileBody}>
|
||||
{loading ? (
|
||||
<TileLoader label="Reading repository…" />
|
||||
) : items.length === 0 ? (
|
||||
<TileEmpty
|
||||
icon={<Folder size={18} />}
|
||||
title="No files yet"
|
||||
subtitle={
|
||||
giteaRepo
|
||||
? "Your repository is empty. The AI will commit the first files when you start building."
|
||||
: "This project doesn't have a repository yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={tileMetaRow}>
|
||||
<Metric label="Folders" value={dirCount} />
|
||||
<Metric label="Files" value={fileCount} />
|
||||
</div>
|
||||
<ul style={fileList}>
|
||||
{previewItems.map(item => (
|
||||
<li key={item.path} style={fileRow}>
|
||||
<span style={fileIconWrap}>
|
||||
{item.type === "dir" ? (
|
||||
<Folder size={13} />
|
||||
) : (
|
||||
<FileText size={13} />
|
||||
)}
|
||||
</span>
|
||||
<span style={fileName}>{item.name}</span>
|
||||
<span style={fileType}>
|
||||
{item.type === "dir" ? "folder" : ext(item.name)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{items.length > previewItems.length && (
|
||||
<div style={tileMore}>
|
||||
+{items.length - previewItems.length} more
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function InfraTile({
|
||||
workspace,
|
||||
projectId,
|
||||
app,
|
||||
loading,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
app: PreviewApp | null;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const status = app?.status?.toLowerCase() ?? "unknown";
|
||||
const isLive = !!app?.url && (status.includes("running") || status.includes("healthy"));
|
||||
const isBuilding = status.includes("queued") || status.includes("in_progress") || status.includes("starting");
|
||||
|
||||
return (
|
||||
<Link href={`/${workspace}/project/${projectId}/infrastructure`} style={tileLink}>
|
||||
<article
|
||||
style={tileCard}
|
||||
onMouseEnter={hoverEnter}
|
||||
onMouseLeave={hoverLeave}
|
||||
>
|
||||
<header style={tileHeader}>
|
||||
<span style={tileIconWrap}>
|
||||
<Rocket size={16} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={tileTitle}>Infrastructure</h2>
|
||||
<p style={tileSubtitle}>What's live and how it's running.</p>
|
||||
</div>
|
||||
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||
</header>
|
||||
|
||||
<div style={tileBody}>
|
||||
{loading ? (
|
||||
<TileLoader label="Checking deployment…" />
|
||||
) : !app ? (
|
||||
<TileEmpty
|
||||
icon={<Rocket size={18} />}
|
||||
title="Nothing is live yet"
|
||||
subtitle="The AI will deploy your project here once the build is ready."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={tileMetaRow}>
|
||||
<StatusBlock
|
||||
color={isLive ? "#2e7d32" : isBuilding ? "#3d5afe" : "#9a7b3a"}
|
||||
label={isLive ? "Live" : isBuilding ? "Building" : statusFriendly(status)}
|
||||
/>
|
||||
<Metric label="App" value={app.name} />
|
||||
</div>
|
||||
{app.url ? (
|
||||
<div style={liveUrlRow}>
|
||||
<span style={liveUrlLabel}>Live URL</span>
|
||||
<span style={liveUrlValue}>{shortUrl(app.url)}</span>
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={liveUrlOpen}
|
||||
aria-label="Open live site"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div style={liveUrlRow}>
|
||||
<span style={liveUrlLabel}>Status</span>
|
||||
<span style={liveUrlValue}>{statusFriendly(status)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Small bits
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StagePill({ stage }: { stage: string }) {
|
||||
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||||
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a12" },
|
||||
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
};
|
||||
const s = map[stage] ?? map.discovery;
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||
color: s.color, background: s.bg, fontFamily: INK.fontSans,
|
||||
whiteSpace: "nowrap", flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBlock({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={metricLabel}>Status</span>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
||||
<span style={metricLabel}>{label}</span>
|
||||
<span style={{
|
||||
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TileLoader({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 8, padding: "32px 0", color: INK.muted, fontSize: 13,
|
||||
fontFamily: INK.fontSans,
|
||||
}}>
|
||||
<Loader2 className="animate-spin" size={14} /> {label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TileEmpty({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "28px 8px",
|
||||
textAlign: "center",
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
||||
fontFamily: INK.fontSans,
|
||||
}}>
|
||||
<span style={{ ...tileIconWrap, width: 38, height: 38 }}>{icon}</span>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: INK.ink }}>{title}</div>
|
||||
<div style={{ fontSize: 12.5, color: INK.muted, lineHeight: 1.55, maxWidth: 280 }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusFriendly(status: string): string {
|
||||
if (!status || status === "unknown") return "Unknown";
|
||||
return status.replace(/[:_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function ext(name: string): string {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot > 0 ? name.slice(dot + 1) : "file";
|
||||
}
|
||||
|
||||
function shortUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.host + (u.pathname === "/" ? "" : u.pathname);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function hoverEnter(e: React.MouseEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
el.style.borderColor = INK.borderHover;
|
||||
el.style.boxShadow = INK.shadowHover;
|
||||
}
|
||||
function hoverLeave(e: React.MouseEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
el.style.borderColor = INK.border;
|
||||
el.style.boxShadow = INK.shadow;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const pageWrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
background: INK.pageBg,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const pageInner: React.CSSProperties = {
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
padding: "44px 52px 64px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 28,
|
||||
};
|
||||
|
||||
const centeredFiller: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
height: "100%", padding: 64,
|
||||
};
|
||||
|
||||
const heroStyle: React.CSSProperties = {
|
||||
display: "flex", alignItems: "flex-start", justifyContent: "space-between",
|
||||
gap: 24,
|
||||
};
|
||||
|
||||
const eyebrow: React.CSSProperties = {
|
||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
fontFamily: INK.fontSans, marginBottom: 8,
|
||||
};
|
||||
|
||||
const heroTitle: React.CSSProperties = {
|
||||
fontFamily: INK.fontSerif,
|
||||
fontSize: "1.9rem", fontWeight: 400,
|
||||
color: INK.ink, letterSpacing: "-0.03em",
|
||||
lineHeight: 1.15, margin: 0,
|
||||
};
|
||||
|
||||
const heroDesc: React.CSSProperties = {
|
||||
fontSize: "0.88rem", color: INK.mid, marginTop: 10, maxWidth: 620,
|
||||
lineHeight: 1.6, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const continueRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16,
|
||||
background: INK.cardBg, border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10, padding: "14px 18px",
|
||||
textDecoration: "none", color: "inherit",
|
||||
boxShadow: INK.shadow,
|
||||
fontFamily: INK.fontSans,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
};
|
||||
|
||||
const continueDot: React.CSSProperties = {
|
||||
width: 7, height: 7, borderRadius: "50%",
|
||||
background: "#d4a04a", flexShrink: 0,
|
||||
};
|
||||
|
||||
const continueTitle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 600, color: INK.ink,
|
||||
};
|
||||
|
||||
const continueSub: React.CSSProperties = {
|
||||
fontSize: 12, color: INK.muted, marginTop: 2,
|
||||
};
|
||||
|
||||
const tileGrid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
|
||||
gap: 14,
|
||||
};
|
||||
|
||||
const tileLink: React.CSSProperties = {
|
||||
textDecoration: "none", color: "inherit",
|
||||
};
|
||||
|
||||
const tileCard: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10,
|
||||
padding: 22,
|
||||
display: "flex", flexDirection: "column", gap: 18,
|
||||
minHeight: 280,
|
||||
boxShadow: INK.shadow,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileHeader: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
};
|
||||
|
||||
const tileIconWrap: React.CSSProperties = {
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: INK.iconWrapBg, color: INK.ink,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const tileTitle: React.CSSProperties = {
|
||||
fontFamily: INK.fontSerif,
|
||||
fontSize: "1.05rem", fontWeight: 400,
|
||||
color: INK.ink, letterSpacing: "-0.02em",
|
||||
margin: 0, lineHeight: 1.2,
|
||||
};
|
||||
|
||||
const tileSubtitle: React.CSSProperties = {
|
||||
fontSize: 12, color: INK.muted, marginTop: 3,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileBody: React.CSSProperties = {
|
||||
display: "flex", flexDirection: "column", gap: 14, flex: 1, minHeight: 0,
|
||||
};
|
||||
|
||||
const tileMetaRow: React.CSSProperties = {
|
||||
display: "flex", gap: 28,
|
||||
};
|
||||
|
||||
const metricLabel: React.CSSProperties = {
|
||||
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const fileList: React.CSSProperties = {
|
||||
listStyle: "none", padding: 0, margin: 0,
|
||||
display: "flex", flexDirection: "column",
|
||||
border: `1px solid ${INK.border}`, borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
background: "#fdfcfa",
|
||||
};
|
||||
|
||||
const fileRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "8px 12px",
|
||||
borderTop: `1px solid ${INK.border}`,
|
||||
fontSize: 12.5, color: INK.ink,
|
||||
};
|
||||
|
||||
const fileIconWrap: React.CSSProperties = {
|
||||
color: INK.stone, display: "flex", alignItems: "center",
|
||||
};
|
||||
|
||||
const fileName: React.CSSProperties = {
|
||||
flex: 1, minWidth: 0, overflow: "hidden",
|
||||
textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
fontFamily: INK.fontMono, fontSize: 12,
|
||||
};
|
||||
|
||||
const fileType: React.CSSProperties = {
|
||||
fontSize: 10, color: INK.stone, fontWeight: 500,
|
||||
textTransform: "uppercase", letterSpacing: "0.08em",
|
||||
flexShrink: 0, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileMore: React.CSSProperties = {
|
||||
fontSize: 11.5, color: INK.muted, paddingLeft: 4,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const liveUrlRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "10px 12px",
|
||||
background: "#fdfcfa",
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const liveUrlLabel: React.CSSProperties = {
|
||||
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
flexShrink: 0, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const liveUrlValue: React.CSSProperties = {
|
||||
flex: 1, minWidth: 0,
|
||||
fontSize: 12, color: INK.ink, fontWeight: 500,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
fontFamily: INK.fontMono,
|
||||
};
|
||||
|
||||
const liveUrlOpen: React.CSSProperties = {
|
||||
width: 24, height: 24, borderRadius: 6,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: INK.ink, background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`, flexShrink: 0,
|
||||
textDecoration: "none",
|
||||
};
|
||||
@@ -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 page–style 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 }: {
|
||||
@@ -715,13 +741,6 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
|
||||
>
|
||||
Approve & commit
|
||||
</button>
|
||||
<a
|
||||
href="https://theia.vibnai.com"
|
||||
target="_blank" rel="noreferrer"
|
||||
style={{ padding: "7px 16px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", textDecoration: "none", display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
Open in Theia →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -926,7 +945,7 @@ function TerminalPanel({ appName }: { appName: string }) {
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, padding: "16px 24px", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "0.78rem", color: "#6b6560", lineHeight: 1.6, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", maxWidth: 340 }}>
|
||||
{appName
|
||||
? `Live shell into the ${appName} container via xterm.js + Theia PTY — coming in Phase 4.`
|
||||
? `Live shell into the ${appName} container — coming in Phase 4.`
|
||||
: "Select an app from the left, then open a live shell into its container."}
|
||||
</div>
|
||||
</div>
|
||||
@@ -956,7 +975,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 +1066,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 +1091,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 +1277,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 +1298,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 +1368,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" }}>
|
||||
|
||||
{/* ── 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={{ 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 });
|
||||
}}
|
||||
/>
|
||||
)) : (
|
||||
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No apps yet</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>
|
||||
{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>
|
||||
<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 +1483,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 +1598,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 +1619,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>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ interface Project {
|
||||
status?: string;
|
||||
giteaRepoUrl?: string;
|
||||
giteaRepo?: string;
|
||||
theiaWorkspaceUrl?: string;
|
||||
coolifyDeployUrl?: string;
|
||||
customDomain?: string;
|
||||
prd?: string;
|
||||
@@ -70,7 +69,7 @@ export default function DeploymentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl);
|
||||
const hasDeploy = Boolean(project?.coolifyDeployUrl);
|
||||
const hasRepo = Boolean(project?.giteaRepoUrl);
|
||||
const hasPRD = Boolean(project?.prd);
|
||||
|
||||
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
|
||||
81
app/[workspace]/project/[projectId]/(workspace)/layout.tsx
Normal file
81
app/[workspace]/project/[projectId]/(workspace)/layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { ProjectShell } from "@/components/layout/project-shell";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-justine-jakarta",
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className={plusJakarta.variable} style={{ height: "100%", minHeight: "100dvh" }}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||
|
||||
export default function MvpSetupArchitectPage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const base = `/${workspace}/project/${projectId}`;
|
||||
return (
|
||||
<MvpSetupStepPlaceholder
|
||||
title="Architect"
|
||||
subtitle="Lock in discovery — stack choices, surfaces, and what we’re shipping."
|
||||
body="Use Task to run discovery phases and save answers. When you’re ready, continue to Design."
|
||||
primaryHref={`${base}/tasks`}
|
||||
primaryLabel="Open Task"
|
||||
nextHref={`${base}/mvp-setup/design`}
|
||||
nextLabel="Continue to Design"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupDescribeView } from "@/components/project-main/MvpSetupDescribeView";
|
||||
|
||||
export default function MvpSetupDescribePage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
return <MvpSetupDescribeView projectId={projectId} workspace={workspace} />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||
|
||||
export default function MvpSetupDesignPage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const base = `/${workspace}/project/${projectId}`;
|
||||
return (
|
||||
<MvpSetupStepPlaceholder
|
||||
title="Design"
|
||||
subtitle="Pick feel, color, and layout — we’ll apply it across your product surfaces."
|
||||
body="The full design studio lives on the Design tab. When it looks right, move on to how you’ll grow."
|
||||
primaryHref={`${base}/design`}
|
||||
primaryLabel="Open Design"
|
||||
nextHref={`${base}/mvp-setup/website`}
|
||||
nextLabel="Continue to Website"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { MvpSetupLayoutClient } from "@/components/project-main/MvpSetupLayoutClient";
|
||||
|
||||
export default async function MvpSetupWizardLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
return (
|
||||
<MvpSetupLayoutClient workspace={workspace} projectId={projectId}>
|
||||
{children}
|
||||
</MvpSetupLayoutClient>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||
|
||||
export default function MvpSetupWebsitePage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const base = `/${workspace}/project/${projectId}`;
|
||||
return (
|
||||
<MvpSetupStepPlaceholder
|
||||
title="Website"
|
||||
subtitle="Voice, topics, and marketing style — what people see before they sign up."
|
||||
body="Tune growth messaging on the Grow tab. Then review everything and kick off your MVP build."
|
||||
primaryHref={`${base}/growth`}
|
||||
primaryLabel="Open Grow"
|
||||
nextHref={`${base}/mvp-setup/launch`}
|
||||
nextLabel="Review & launch"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { BuildMvpJustineV2 } from "@/components/project-main/BuildMvpJustineV2";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
interface SurfaceEntry {
|
||||
id: string;
|
||||
lockedTheme?: string;
|
||||
}
|
||||
|
||||
export default function MvpSetupLaunchPage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const router = useRouter();
|
||||
const [productName, setProductName] = useState("Your product");
|
||||
const [giteaRepo, setGiteaRepo] = useState<string | undefined>();
|
||||
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const p = d.project;
|
||||
if (p) {
|
||||
setProductName(p.productName || p.name || "Your product");
|
||||
setGiteaRepo(p.giteaRepo);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch(`/api/projects/${projectId}/design-surfaces`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const ids: string[] = d.surfaces ?? [];
|
||||
const themes: Record<string, string> = d.surfaceThemes ?? {};
|
||||
setSurfaces(ids.map(id => ({ id, lockedTheme: themes[id] })));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const webappSurface = surfaces.find(s => s.id === "webapp");
|
||||
const marketingSurface = surfaces.find(s => s.id === "marketing");
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "hidden", background: JM.inputBg }}>
|
||||
<BuildMvpJustineV2
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
projectName={productName}
|
||||
giteaRepo={giteaRepo}
|
||||
accentLabel={webappSurface?.lockedTheme}
|
||||
websiteStyle={marketingSurface?.lockedTheme}
|
||||
onSwitchToPreview={() => {
|
||||
router.push(`/${workspace}/project/${projectId}/build?section=preview`, { scroll: false });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Root: no sidebar — launch step uses full Justine chrome; wizard steps use (wizard)/layout. */
|
||||
export default function MvpSetupRootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function MvpSetupIndexPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
redirect(`/${workspace}/project/${projectId}/mvp-setup/describe`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
|
||||
|
||||
export default function RunPage() {
|
||||
return <ProjectInfraPanel routeBase="run" navGroupLabel="Run" />;
|
||||
}
|
||||
507
app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx
Normal file
507
app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { useParams } 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 TasksPage() {
|
||||
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 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 tasks…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const reqStatus = prd
|
||||
? "Complete"
|
||||
: doneCount > 0
|
||||
? `In progress · ${doneCount}/${sections.length} sections`
|
||||
: "Not started";
|
||||
const archStatus = architecture
|
||||
? "Complete"
|
||||
: prd
|
||||
? "Ready to generate"
|
||||
: "Blocked — finish requirements first";
|
||||
|
||||
const taskCardBase: CSSProperties = {
|
||||
flex: "1 1 240px",
|
||||
maxWidth: 320,
|
||||
textAlign: "left" as const,
|
||||
padding: "14px 16px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
transition: "border-color 0.12s, box-shadow 0.12s",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
|
||||
<header style={{ marginBottom: 24, maxWidth: 720 }}>
|
||||
<h1 style={{
|
||||
fontFamily: "var(--font-lora), ui-serif, serif",
|
||||
fontSize: "1.35rem",
|
||||
fontWeight: 500,
|
||||
color: "#1a1a1a",
|
||||
margin: "0 0 8px",
|
||||
}}>
|
||||
Tasks
|
||||
</h1>
|
||||
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.55, margin: 0 }}>
|
||||
Work is tracked as tasks—similar in spirit to agent task boards like{" "}
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui" target="_blank" rel="noopener noreferrer" style={{ color: "#4a4640" }}>
|
||||
Claude Agent Teams UI
|
||||
</a>
|
||||
. Your <strong>product requirements (PRD)</strong> is the first task; technical architecture is the next once requirements are captured.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Task selector — PRD is a task; architecture is a follow-on task */}
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("prd")}
|
||||
style={{
|
||||
...taskCardBase,
|
||||
border: activeTab === "prd" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
|
||||
background: activeTab === "prd" ? "#faf8f5" : "#fff",
|
||||
boxShadow: activeTab === "prd" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Product requirements
|
||||
</div>
|
||||
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
|
||||
PRD · {reqStatus}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => architecture && setActiveTab("architecture")}
|
||||
disabled={!architecture}
|
||||
style={{
|
||||
...taskCardBase,
|
||||
border: activeTab === "architecture" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
|
||||
background: activeTab === "architecture" ? "#faf8f5" : "#fff",
|
||||
boxShadow: activeTab === "architecture" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
|
||||
opacity: architecture ? 1 : 0.72,
|
||||
cursor: architecture ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
|
||||
{archStatus}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next step banner — PRD done but no architecture yet */}
|
||||
{prd && !architecture && activeTab === "prd" && (
|
||||
<div style={{
|
||||
marginBottom: 24, padding: "18px 22px",
|
||||
background: "#1a1a1a", borderRadius: 10,
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||
Next: Generate technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||
</div>
|
||||
{archError && (
|
||||
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateArchitecture}
|
||||
disabled={archGenerating}
|
||||
style={{
|
||||
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||
background: archGenerating ? "#4a4640" : "#fff",
|
||||
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||
fontSize: "0.82rem", fontWeight: 700,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: archGenerating ? "default" : "pointer",
|
||||
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
{archGenerating && (
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||
}} />
|
||||
)}
|
||||
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture tab */}
|
||||
{activeTab === "architecture" && architecture && (
|
||||
<ArchitectureView arch={architecture} />
|
||||
)}
|
||||
|
||||
{/* PRD tab — finalized */}
|
||||
{activeTab === "prd" && prd && (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||
Product Requirements
|
||||
</h3>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||
PRD complete
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||
padding: "28px 32px", lineHeight: 1.8,
|
||||
fontSize: "0.88rem", color: "#2a2824",
|
||||
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
{prd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||
{activeTab === "prd" && !prd && (
|
||||
/* ── Section progress view ── */
|
||||
<div style={{ maxWidth: 680 }}>
|
||||
{/* Progress bar */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 16,
|
||||
padding: "16px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
||||
}}>
|
||||
{totalPct}%
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 2,
|
||||
width: `${totalPct}%`, background: "#1a1a1a",
|
||||
transition: "width 0.6s ease",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||
{doneCount}/{sections.length} sections
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
padding: "14px 18px", marginBottom: 6,
|
||||
background: "#fff", borderRadius: 10,
|
||||
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
||||
animationDelay: `${i * 0.04}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Status icon */}
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.65rem", fontWeight: 700,
|
||||
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
||||
}}>
|
||||
{s.isDone ? "✓" : "○"}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
flex: 1, fontSize: "0.84rem",
|
||||
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
||||
fontWeight: s.isDone ? 500 : 400,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
|
||||
{s.isDone && s.savedPhase && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#2e7d32", background: "#2e7d3210",
|
||||
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
||||
}}>
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
{!s.isDone && !s.phaseId && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#b5b0a6", padding: "2px 7px",
|
||||
}}>
|
||||
generated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable phase data */}
|
||||
{s.isDone && s.savedPhase && (
|
||||
<PhaseDataCard phase={s.savedPhase} />
|
||||
)}
|
||||
|
||||
{/* Pending hint */}
|
||||
{!s.isDone && (
|
||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||
{s.phaseId
|
||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||
: "Will be generated when PRD is finalized"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{doneCount === 0 && (
|
||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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}</>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
40
app/api/debug/prisma/route.ts
Normal file
40
app/api/debug/prisma/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Dev-only: verifies Prisma can connect (NextAuth adapter needs this after Google redirects back).
|
||||
* Open GET /api/debug/prisma while running next dev.
|
||||
*/
|
||||
export async function GET() {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const hasUrl = Boolean(process.env.DATABASE_URL?.trim() || process.env.POSTGRES_URL?.trim());
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
databaseUrlConfigured: hasUrl,
|
||||
hint: "Prisma connects; if auth still fails, check Google client id/secret and terminal [next-auth] logs.",
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
const publicHost = /Can't reach database server at `([\d.]+):(\d+)`/.exec(message);
|
||||
const hint = publicHost
|
||||
? `No TCP route to Postgres at ${publicHost[1]}:${publicHost[2]} from this machine. In Coolify: confirm the DB service publishes that host port and Postgres listens on 0.0.0.0. On the cloud firewall (e.g. GCP), allow inbound TCP ${publicHost[2]} from your IP (or use VPN). Test: nc -zv ${publicHost[1]} ${publicHost[2]} or psql. Then npm run db:push from vibn-frontend.`
|
||||
: "If the URL uses a Coolify internal hostname, it only works inside Docker. Otherwise check DATABASE_URL, firewall, and run npm run db:push.";
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
databaseUrlConfigured: hasUrl,
|
||||
message,
|
||||
hint,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -49,7 +48,6 @@ async function buildKnowledgeContext(projectId: string, email: string): Promise<
|
||||
const architecture = d.architecture as Record<string, unknown> | null ?? null;
|
||||
const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? [];
|
||||
const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? '';
|
||||
const theiaUrl = (d.theiaWorkspaceUrl as string) ?? '';
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -65,14 +63,13 @@ Operating principles:
|
||||
- Be brief. No preamble, no "Great question!".
|
||||
- You decide the technical approach — never ask the founder to choose.
|
||||
- Be honest when you're uncertain or when data isn't available.
|
||||
- Do NOT spawn agents on the protected platform repos (vibn-frontend, theia-code-os, vibn-agent-runner, vibn-api, master-ai).`);
|
||||
- Do NOT spawn agents on the protected platform repos (vibn-frontend, vibn-agent-runner, vibn-api, master-ai).`);
|
||||
|
||||
// Project identity
|
||||
lines.push(`\n## Project: ${name}`);
|
||||
if (vision) lines.push(`Vision: ${vision}`);
|
||||
if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`);
|
||||
if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`);
|
||||
if (theiaUrl) lines.push(`Theia IDE: ${theiaUrl}`);
|
||||
|
||||
// Architecture document
|
||||
if (architecture) {
|
||||
@@ -129,7 +126,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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -114,7 +113,6 @@ export async function POST(
|
||||
p.giteaRepo ? `Gitea repo: ${p.giteaRepo}` : null,
|
||||
p.coolifyAppUuid ? `Coolify app UUID: ${p.coolifyAppUuid}` : null,
|
||||
p.deploymentUrl ? `Live URL: ${p.deploymentUrl}` : null,
|
||||
p.theiaWorkspaceUrl ? `IDE: ${p.theiaWorkspaceUrl}` : null,
|
||||
].filter(Boolean);
|
||||
projectContext = lines.join("\n");
|
||||
}
|
||||
@@ -190,7 +188,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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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_chat_threads WHERE project_id = $1 AND scope = $2`,
|
||||
[projectId, scope]
|
||||
);
|
||||
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]
|
||||
);
|
||||
return rows[0]?.messages ?? [];
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const rows = await query<{ id: string; data: any }>(`
|
||||
SELECT p.id, p.data
|
||||
FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2
|
||||
LIMIT 1
|
||||
`, [projectId, session.user.email]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const project = rows[0].data;
|
||||
|
||||
if (project.theiaWorkspaceUrl) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
workspaceUrl: project.theiaWorkspaceUrl,
|
||||
message: 'Workspace already provisioned',
|
||||
});
|
||||
}
|
||||
|
||||
const slug = project.slug;
|
||||
if (!slug) {
|
||||
return NextResponse.json({ error: 'Project has no slug — cannot provision workspace' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Provision Cloud Run workspace
|
||||
const workspace = await provisionTheiaWorkspace(slug, projectId, project.giteaRepo ?? null);
|
||||
|
||||
// Save URL back to project record
|
||||
await query(`
|
||||
UPDATE fs_projects
|
||||
SET data = data || jsonb_build_object(
|
||||
'theiaWorkspaceUrl', $1::text,
|
||||
'theiaAppUuid', $2::text
|
||||
)
|
||||
WHERE id = $3
|
||||
`, [workspace.serviceUrl, workspace.serviceName, projectId]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
workspaceUrl: workspace.serviceUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[POST /api/projects/:id/workspace] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to provision workspace', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { randomUUID } from 'crypto';
|
||||
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
|
||||
import { pushTurborepoScaffold } from '@/lib/scaffold';
|
||||
import { createMonorepoAppService } from '@/lib/coolify';
|
||||
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
|
||||
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
|
||||
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
||||
|
||||
@@ -208,24 +207,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 3. Provision dedicated Theia workspace
|
||||
// ──────────────────────────────────────────────
|
||||
let theiaWorkspaceUrl: string | null = null;
|
||||
let theiaAppUuid: string | null = null;
|
||||
let theiaError: string | null = null;
|
||||
|
||||
try {
|
||||
const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo);
|
||||
theiaWorkspaceUrl = workspace.serviceUrl;
|
||||
theiaAppUuid = workspace.serviceName;
|
||||
console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`);
|
||||
} catch (err) {
|
||||
theiaError = err instanceof Error ? err.message : String(err);
|
||||
console.error('[API] Theia workspace provisioning failed (non-fatal):', theiaError);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 4. Save project record
|
||||
// 3. Save project record
|
||||
// ──────────────────────────────────────────────
|
||||
const projectData = {
|
||||
id: projectId,
|
||||
@@ -262,10 +244,6 @@ export async function POST(request: Request) {
|
||||
giteaSshUrl,
|
||||
giteaWebhookId,
|
||||
giteaError,
|
||||
// Theia workspace
|
||||
theiaWorkspaceUrl,
|
||||
theiaAppUuid,
|
||||
theiaError,
|
||||
// Context snapshot (kept fresh by webhooks)
|
||||
contextSnapshot: null,
|
||||
// Coolify project — one per VIBN project, scopes all app services + DBs
|
||||
@@ -344,8 +322,6 @@ export async function POST(request: Request) {
|
||||
? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl }
|
||||
: null,
|
||||
giteaError: giteaError ?? undefined,
|
||||
theiaWorkspaceUrl,
|
||||
theiaError: theiaError ?? undefined,
|
||||
isImport: !!githubRepoUrl,
|
||||
analysisJobId: analysisJobId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { prewarmWorkspace } from '@/lib/cloud-run-workspace';
|
||||
|
||||
/**
|
||||
* POST /api/projects/prewarm
|
||||
* Body: { urls: string[] }
|
||||
*
|
||||
* Fires warm-up requests to Cloud Run workspace URLs so containers
|
||||
* are running by the time the user clicks "Open IDE". Server-side
|
||||
* to avoid CORS issues with run.app domains.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { urls } = await req.json() as { urls: string[] };
|
||||
if (!Array.isArray(urls) || urls.length === 0) {
|
||||
return NextResponse.json({ warmed: 0 });
|
||||
}
|
||||
|
||||
// Fire all prewarm pings in parallel — intentionally not awaited
|
||||
Promise.allSettled(urls.map(url => prewarmWorkspace(url))).catch(() => {});
|
||||
|
||||
return NextResponse.json({ warmed: urls.length });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* GET /api/theia-auth
|
||||
*
|
||||
* Traefik ForwardAuth endpoint for Theia IDE domains.
|
||||
*
|
||||
* Handles two cases:
|
||||
* 1. theia.vibnai.com — shared IDE: any authenticated user may access
|
||||
* 2. {slug}.ide.vibnai.com — per-project IDE: only the project owner may access
|
||||
*
|
||||
* Traefik calls this URL for every request to those Theia domains, forwarding
|
||||
* the user's Cookie header via authRequestHeaders. We validate the NextAuth
|
||||
* database session directly in Postgres (avoids Prisma / authOptions build-time
|
||||
* issues under --network host).
|
||||
*
|
||||
* Returns:
|
||||
* 200 — valid session (and owner check passed), Traefik lets the request through
|
||||
* 302 — no/expired session, redirect browser to Vibn login
|
||||
* 403 — authenticated but not the project owner
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const APP_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com';
|
||||
const THEIA_URL = 'https://theia.vibnai.com';
|
||||
const IDE_SUFFIX = '.ide.vibnai.com';
|
||||
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
'__Secure-next-auth.session-token',
|
||||
'next-auth.session-token',
|
||||
];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// ── 1. Extract session token ──────────────────────────────────────────────
|
||||
let sessionToken: string | null = null;
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const val = request.cookies.get(name)?.value;
|
||||
if (val) { sessionToken = val; break; }
|
||||
}
|
||||
|
||||
if (!sessionToken) return redirectToLogin(request);
|
||||
|
||||
// ── 2. Validate session in Postgres ──────────────────────────────────────
|
||||
let userEmail: string | null = null;
|
||||
let userName: string | null = null;
|
||||
let userId: string | null = null;
|
||||
|
||||
try {
|
||||
const rows = await query<{ email: string; name: string; user_id: string }>(
|
||||
`SELECT u.email, u.name, s.user_id
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.session_token = $1
|
||||
AND s.expires > NOW()
|
||||
LIMIT 1`,
|
||||
[sessionToken],
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
userEmail = rows[0].email;
|
||||
userName = rows[0].name;
|
||||
userId = rows[0].user_id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[theia-auth] DB error:', err);
|
||||
return redirectToLogin(request);
|
||||
}
|
||||
|
||||
if (!userEmail || !userId) return redirectToLogin(request);
|
||||
|
||||
// ── 3. Per-project ownership check for *.ide.vibnai.com ──────────────────
|
||||
const forwardedHost = request.headers.get('x-forwarded-host') ?? '';
|
||||
|
||||
if (forwardedHost.endsWith(IDE_SUFFIX)) {
|
||||
const slug = forwardedHost.slice(0, -IDE_SUFFIX.length);
|
||||
|
||||
try {
|
||||
const rows = await query<{ user_id: string }>(
|
||||
`SELECT user_id FROM fs_projects WHERE slug = $1 LIMIT 1`,
|
||||
[slug],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Unknown project slug — deny
|
||||
return new NextResponse('Workspace not found', { status: 403 });
|
||||
}
|
||||
|
||||
const ownerUserId = rows[0].user_id;
|
||||
if (ownerUserId !== userId) {
|
||||
// Authenticated but not the owner
|
||||
return new NextResponse('Access denied — this workspace belongs to another user', { status: 403 });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[theia-auth] project ownership check error:', err);
|
||||
return redirectToLogin(request);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Allow — pass user identity headers to Theia ───────────────────────
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'X-Auth-Email': userEmail,
|
||||
'X-Auth-Name': userName ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function redirectToLogin(request: NextRequest): NextResponse {
|
||||
// Use THEIA_URL as the callbackUrl so the user lands back on Theia after login.
|
||||
// (X-Forwarded-Host points to vibnai.com via Traefik, not the original Theia domain.)
|
||||
const loginUrl = `${APP_URL}/auth?callbackUrl=${encodeURIComponent(THEIA_URL)}`;
|
||||
return NextResponse.redirect(loginUrl, { status: 302 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,23 +16,17 @@ function AuthPageInner() {
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated" && session?.user?.email) {
|
||||
const callbackUrl = searchParams.get("callbackUrl");
|
||||
// Only follow external callbackUrls we control (Theia subdomain)
|
||||
if (callbackUrl && callbackUrl.startsWith("https://theia.vibnai.com")) {
|
||||
window.location.href = callbackUrl;
|
||||
} else {
|
||||
const workspace = deriveWorkspace(session.user.email);
|
||||
router.push(`/${workspace}/projects`);
|
||||
}
|
||||
}
|
||||
}, [status, session, router, searchParams]);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,57 +1,96 @@
|
||||
"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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
{/* 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
|
||||
return (
|
||||
<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>
|
||||
|
||||
{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"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
className="justine-auth-btn-google"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<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"
|
||||
@@ -61,7 +100,7 @@ export default function NextAuthComponent() {
|
||||
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"
|
||||
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
|
||||
@@ -69,16 +108,83 @@ export default function NextAuthComponent() {
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
{isLoading ? "Signing in..." : "Continue with Google"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isLoading ? "Signing in…" : "Continue with Google"}
|
||||
</button>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
By continuing, you agree to our Terms of Service and Privacy Policy.
|
||||
{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();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-justine] * {
|
||||
/* Only Justine’s 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;
|
||||
|
||||
204
app/styles/justine/02-signup.css
Normal file
204
app/styles/justine/02-signup.css
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* From justine/02_signup.html — scoped to [data-justine-auth] only.
|
||||
*/
|
||||
|
||||
[data-justine-auth] {
|
||||
--ink: #1a1a1a;
|
||||
--mid: #6b7280;
|
||||
--muted: #9ca3af;
|
||||
--border: #e5e7eb;
|
||||
--white: #ffffff;
|
||||
--soft: #f5f3ff;
|
||||
--hover: #fafaff;
|
||||
--sans: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
|
||||
--serif: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
|
||||
}
|
||||
|
||||
[data-justine-auth].justine-auth-root {
|
||||
font-family: var(--sans);
|
||||
background: linear-gradient(to bottom, #fafafa, #f5f3ff);
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
[data-justine-auth] .f {
|
||||
font-family: var(--serif);
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav {
|
||||
background: rgba(250, 250, 250, 0.95);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 40px;
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
[data-justine-auth] .justine-auth-nav {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #2e2a5e, #4338ca);
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav-logo span {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav-wordmark {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav-aside {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav-aside a {
|
||||
color: #6366f1;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-nav-aside a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-inner {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 10px 30px rgba(30, 27, 75, 0.05);
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-title {
|
||||
font-size: 23px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-sub {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 22px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-btn-google {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
border-radius: 10px;
|
||||
padding: 11px;
|
||||
font-family: var(--sans);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-btn-google:hover:not(:disabled) {
|
||||
border-color: #6366f1;
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-btn-google:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-legal {
|
||||
text-align: center;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-legal a {
|
||||
color: var(--muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-legal a:hover {
|
||||
color: var(--mid);
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: #6366f1;
|
||||
animation: justine-auth-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes justine-auth-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-justine-auth] .justine-auth-loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
370
app/styles/justine/03-dashboard.css
Normal file
370
app/styles/justine/03-dashboard.css
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* From justine/03_dashboard.html, scoped under [data-justine-dashboard].
|
||||
* Top bar uses .jd-topnav. Root layout: .justine-dashboard-root
|
||||
*/
|
||||
|
||||
|
||||
[data-justine-dashboard] *{box-sizing:border-box;margin:0;padding:0;}
|
||||
[data-justine-dashboard]{
|
||||
--ink:#1A1A1A; --mid:#6B7280; --muted:#9CA3AF; --border:#E5E7EB;
|
||||
--cream:#FAFAFF; --paper:#F5F3FF; --white:#FFFFFF;
|
||||
--indigo:#6366F1; --indigo-dim:rgba(99,102,241,0.08); --indigo-border:rgba(99,102,241,0.18);
|
||||
--green:#059669; --green-dim:#D1FAE5;
|
||||
--amber:#F59E0B; --amber-dim:#FFFBEB; --amber-border:#FDE68A;
|
||||
--serif:var(--font-justine-jakarta),"Plus Jakarta Sans",sans-serif; --sans:var(--font-justine-jakarta),"Plus Jakarta Sans",sans-serif;
|
||||
}
|
||||
[data-justine-dashboard].justine-dashboard-root{font-family:var(--sans);background:#FAFAFA;min-height:100vh;display:flex;flex-direction:column;height:100vh;max-height:100vh;overflow:hidden;color:var(--ink);}
|
||||
[data-justine-dashboard] .f{font-family:var(--serif);}
|
||||
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:0.35;}}
|
||||
|
||||
/* ── App shell ── */
|
||||
.app-shell{display:flex;flex:1;overflow:hidden;}
|
||||
|
||||
/* ── Left panel ── */
|
||||
.proj-nav{width:256px;flex-shrink:0;background:#EDECEA;border-right:1px solid rgba(0,0,0,0.08);display:flex;flex-direction:column;overflow:hidden;}
|
||||
|
||||
/* ── Nav item buttons (Dashboard, Clients, Invoices…) ── */
|
||||
.nav-item-btn{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:8px;border:none;background:transparent;cursor:pointer;width:100%;text-align:left;transition:background 0.18s ease;margin-bottom:1px;font-family:var(--sans);}
|
||||
.nav-item-btn:hover{background:rgba(0,0,0,0.06);}
|
||||
.nav-item-btn.active{background:rgba(99,102,241,0.12);}
|
||||
.nav-icon{width:28px;height:28px;border-radius:7px;background:rgba(99,102,241,0.12);color:#3730A3;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;transition:background 0.18s ease;}
|
||||
.nav-item-btn.active .nav-icon{background:var(--indigo-dim);}
|
||||
.nav-label{font-size:13px;font-weight:500;color:var(--ink);line-height:1.3;}
|
||||
.nav-item-btn.active .nav-label{color:var(--indigo);font-weight:600;}
|
||||
.nav-sub{font-size:10.5px;color:var(--muted);}
|
||||
.nav-group-label{font-size:10px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);padding:0 8px;margin-bottom:4px;}
|
||||
|
||||
/* ── Project rows ── */
|
||||
.proj-list{max-height:168px;overflow-y:auto;padding:4px 8px 8px;flex-shrink:0;}
|
||||
.proj-row{display:flex;align-items:flex-start;gap:10px;padding:9px 10px;border-radius:10px;cursor:pointer;border:1px solid transparent;transition:background 0.18s ease,border-color 0.18s ease;margin-bottom:2px;}
|
||||
.proj-edit-btn{opacity:0;transition:opacity 0.18s ease;background:none;border:none;cursor:pointer;padding:3px;border-radius:5px;display:flex;align-items:center;color:var(--muted);flex-shrink:0;margin-left:2px;}
|
||||
.proj-row:hover .proj-edit-btn{opacity:1;}
|
||||
.proj-edit-btn:hover{color:var(--indigo);background:var(--indigo-dim);}
|
||||
|
||||
/* ── Project edit popover ── */
|
||||
#proj-edit-popover{position:fixed;display:none;background:var(--white);border:1px solid var(--border);border-radius:12px;padding:14px;z-index:300;box-shadow:0 8px 28px rgba(0,0,0,0.14);width:206px;}
|
||||
#proj-edit-name{width:100%;box-sizing:border-box;border:1px solid var(--border);border-radius:6px;padding:6px 9px;font-family:var(--sans);font-size:13px;color:var(--ink);background:var(--white);outline:none;transition:border-color 0.15s;}
|
||||
#proj-edit-name:focus{border-color:var(--indigo);}
|
||||
.color-swatch{width:24px;height:24px;border-radius:50%;cursor:pointer;border:2.5px solid transparent;transition:transform 0.12s ease,border-color 0.12s ease;}
|
||||
.color-swatch:hover{transform:scale(1.18);}
|
||||
.color-swatch.active{border-color:var(--ink);}
|
||||
.proj-row:hover{background:rgba(0,0,0,0.05);}
|
||||
.proj-row.active{background:rgba(99,102,241,0.12);border-color:var(--indigo-border);}
|
||||
.proj-icon{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:12px;font-weight:700;color:var(--white);flex-shrink:0;}
|
||||
.proj-row-name{font-size:12.5px;font-weight:600;color:var(--ink);display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-bottom:2px;}
|
||||
.proj-row-metric{font-size:11.5px;color:var(--mid);margin-bottom:1px;font-weight:500;}
|
||||
.proj-row-time{font-size:10px;color:var(--muted);}
|
||||
.alert-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);flex-shrink:0;margin-top:5px;}
|
||||
|
||||
/* ── Status pills ── */
|
||||
.pill{display:inline-flex;align-items:center;gap:3px;font-size:10px;font-weight:600;padding:2px 7px;border-radius:4px;white-space:nowrap;}
|
||||
.pill-live{background:var(--green-dim);color:var(--green);}
|
||||
.pill-building{background:#EDE9FE;color:#4338CA;}
|
||||
.pill-draft{background:#F3F4F6;color:var(--mid);}
|
||||
.dot-live{width:5px;height:5px;border-radius:50%;background:var(--green);display:inline-block;flex-shrink:0;}
|
||||
.dot-building{width:5px;height:5px;border-radius:50%;background:#6366F1;display:inline-block;flex-shrink:0;animation:pulse 1.8s ease infinite;}
|
||||
|
||||
/* ── Search ── */
|
||||
.nav-search-wrap{position:relative;}
|
||||
.nav-search{width:100%;border:1px solid rgba(0,0,0,0.1);border-radius:8px;padding:7px 10px 7px 30px;font-family:var(--sans);font-size:12px;color:var(--ink);background:rgba(0,0,0,0.05);outline:none;transition:border-color 0.18s ease,background 0.18s ease;}
|
||||
.nav-search:focus{border-color:var(--indigo);background:rgba(255,255,255,0.7);}
|
||||
.nav-search::placeholder{color:var(--muted);}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-primary{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:var(--white);border:none;border-radius:8px;padding:10px 20px;font-family:var(--sans);font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 4px 14px rgba(30,27,75,0.14);transition:box-shadow 0.2s,transform 0.15s;white-space:nowrap;}
|
||||
.btn-primary:hover{box-shadow:0 6px 20px rgba(30,27,75,0.22);transform:translateY(-1px);}
|
||||
.btn-secondary{background:var(--white);color:var(--ink);border:1px solid var(--border);border-radius:8px;padding:9px 18px;font-family:var(--sans);font-size:13px;font-weight:500;cursor:pointer;transition:border-color 0.15s,background 0.15s,color 0.15s;white-space:nowrap;}
|
||||
.btn-secondary:hover{border-color:var(--indigo);background:var(--cream);color:var(--indigo);}
|
||||
.btn-ghost{background:none;color:var(--mid);border:none;font-family:var(--sans);font-size:12px;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background 0.12s,color 0.12s;white-space:nowrap;}
|
||||
.btn-ghost:hover{background:var(--cream);color:var(--ink);}
|
||||
.btn-amber{background:var(--amber-dim);color:#92400E;border:1px solid var(--amber-border);border-radius:8px;padding:9px 16px;font-family:var(--sans);font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background 0.15s;}
|
||||
.btn-amber:hover{background:#FEF3C7;}
|
||||
|
||||
/* ── Nav footer ── */
|
||||
.nav-footer{padding:12px 14px;border-top:1px solid var(--border);flex-shrink:0;}
|
||||
|
||||
/* ── Workspace ── */
|
||||
.workspace{flex:1;overflow-y:auto;background:linear-gradient(160deg,#F4F3F0 0%,#EDE9FA 100%);}
|
||||
.ws-inner{max-width:1140px;padding:32px 36px;margin:0 auto;}
|
||||
.ws-section{display:none;}
|
||||
.ws-section.active{display:block;}
|
||||
|
||||
/* ── Workspace header ── */
|
||||
.ws-header{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--border);}
|
||||
.ws-header-left{flex:1;min-width:0;}
|
||||
.ws-header-identity{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:7px;}
|
||||
.proj-name-heading{border-radius:6px;padding:2px 5px;margin:-2px -5px;}
|
||||
.proj-name-input{font-size:22px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;font-family:var(--serif);border:none;border-bottom:2px solid var(--indigo);background:transparent;outline:none;padding:2px 0;width:auto;min-width:60px;max-width:280px;}
|
||||
.client-card-header{position:relative;}
|
||||
.client-edit-btn{position:absolute;top:12px;right:12px;opacity:0;transition:opacity 0.18s ease;background:none;border:none;cursor:pointer;padding:4px;border-radius:5px;display:flex;align-items:center;color:var(--muted);}
|
||||
.client-card-header:hover .client-edit-btn{opacity:1;}
|
||||
.client-edit-btn:hover{color:var(--indigo);background:var(--indigo-dim);}
|
||||
#client-edit-popover{position:fixed;display:none;background:var(--white);border:1px solid var(--border);border-radius:12px;padding:14px;z-index:300;box-shadow:0 8px 28px rgba(0,0,0,0.14);width:220px;}
|
||||
.client-edit-field{width:100%;box-sizing:border-box;border:1px solid var(--border);border-radius:6px;padding:6px 9px;font-family:var(--sans);font-size:13px;color:var(--ink);background:var(--white);outline:none;transition:border-color 0.15s;margin-bottom:8px;}
|
||||
.client-edit-field:last-of-type{margin-bottom:0;}
|
||||
.client-edit-field:focus{border-color:var(--indigo);}
|
||||
.ws-header-desc{font-size:13px;color:var(--mid);padding-left:48px;line-height:1.5;}
|
||||
.ws-header-actions{display:flex;gap:8px;align-items:center;flex-shrink:0;padding-top:4px;}
|
||||
|
||||
/* ── Priority card ── */
|
||||
.priority-card{background:var(--white);border:1px solid #E0E7FF;border-left:4px solid var(--indigo);border-radius:12px;padding:22px 24px;display:flex;align-items:flex-start;gap:18px;margin-bottom:24px;box-shadow:0 2px 16px rgba(99,102,241,0.07);}
|
||||
.priority-icon{width:42px;height:42px;border-radius:10px;background:var(--indigo-dim);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;margin-top:2px;}
|
||||
.priority-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--indigo);margin-bottom:5px;}
|
||||
.priority-title{font-size:17px;font-weight:700;color:var(--ink);margin-bottom:7px;letter-spacing:-0.01em;}
|
||||
.priority-desc{font-size:13px;color:var(--mid);line-height:1.65;margin-bottom:16px;}
|
||||
|
||||
/* ── Metric card states ── */
|
||||
.metric-card-up{border-color:#6EE7B7!important;background:#ECFDF5!important;}
|
||||
.metric-card-up .metric-label{color:#065F46!important;}
|
||||
.metric-card-up .metric-value{color:#065F46!important;}
|
||||
.metric-card-down{border-color:var(--amber-border)!important;background:var(--amber-dim)!important;}
|
||||
.metric-card-down .metric-label{color:#92400E!important;}
|
||||
.metric-card-down .metric-value{color:#92400E!important;}
|
||||
.metric-card-flat{border-color:#BFDBFE!important;background:#EFF6FF!important;}
|
||||
.metric-card-flat .metric-label{color:#1E40AF!important;}
|
||||
.metric-card-flat .metric-value{color:#1E40AF!important;}
|
||||
/* Dark mode overrides */
|
||||
[data-justine-dashboard][data-theme="dark"] .metric-card-up{border-color:rgba(5,150,105,0.35)!important;background:rgba(5,150,105,0.12)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .metric-card-up .metric-label,[data-justine-dashboard][data-theme="dark"] .metric-card-up .metric-value{color:#6EE7B7!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .metric-card-down{border-color:rgba(245,158,11,0.30)!important;background:rgba(245,158,11,0.12)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .metric-card-down .metric-label,[data-justine-dashboard][data-theme="dark"] .metric-card-down .metric-value{color:#FDE68A!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .metric-card-flat{border-color:rgba(59,130,246,0.30)!important;background:rgba(59,130,246,0.10)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .metric-card-flat .metric-label,[data-justine-dashboard][data-theme="dark"] .metric-card-flat .metric-value{color:#93C5FD!important;}
|
||||
|
||||
/* ── Metrics ── */
|
||||
.metrics-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px;}
|
||||
.metric-card{background:var(--white);border:1px solid #E0DDD8;border-radius:10px;padding:16px 18px;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
|
||||
.metric-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);margin-bottom:6px;}
|
||||
.metric-value{font-family:var(--serif);font-size:24px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:4px;}
|
||||
.metric-sub{font-size:11.5px;color:var(--mid);}
|
||||
.trend-up{font-size:11.5px;color:var(--green);font-weight:600;}
|
||||
.trend-down{font-size:11.5px;color:var(--amber);font-weight:500;}
|
||||
.trend-neutral{font-size:11.5px;color:#3B82F6;font-weight:500;}
|
||||
[data-justine-dashboard][data-theme="dark"] .trend-neutral{color:#93C5FD;}
|
||||
|
||||
/* ── Progress ── */
|
||||
.progress-bar{height:5px;background:#E5E7EB;border-radius:3px;overflow:hidden;margin-top:10px;}
|
||||
.progress-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,#6366F1,#8B5CF6);}
|
||||
.progress-fill-gray{height:100%;border-radius:3px;background:linear-gradient(90deg,#9CA3AF,#6B7280);}
|
||||
|
||||
/* ── Content cards ── */
|
||||
.cards-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||
.content-card{background:var(--white);border:1px solid #E0DDD8;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
|
||||
.card-head{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;}
|
||||
.card-title{font-size:13px;font-weight:600;color:var(--ink);}
|
||||
.card-body{padding:16px 20px;}
|
||||
|
||||
/* ── Rows inside cards ── */
|
||||
.health-row{display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||
.health-row:last-child{border-bottom:none;padding-bottom:0;}
|
||||
.health-icon{font-size:15px;width:22px;text-align:center;flex-shrink:0;margin-top:1px;}
|
||||
.health-title{font-size:13px;font-weight:500;color:var(--ink);margin-bottom:2px;}
|
||||
.health-sub{font-size:11.5px;color:var(--muted);}
|
||||
.activity-row{display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||
.activity-row:last-child{border-bottom:none;padding-bottom:0;}
|
||||
.activity-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;margin-top:5px;}
|
||||
.fin-row{display:flex;align-items:center;justify-content:space-between;padding:9px 0;border-bottom:1px solid var(--border);}
|
||||
.fin-row:last-child{border-bottom:none;}
|
||||
.milestone-row{display:flex;align-items:flex-start;gap:12px;padding:11px 0;border-bottom:1px solid var(--border);}
|
||||
.milestone-row:last-child{border-bottom:none;padding-bottom:0;}
|
||||
.milestone-row.is-current{background:rgba(99,102,241,0.04);border-radius:8px;padding:11px 10px;margin:2px -10px;border-left:3px solid var(--indigo);border-bottom:none;padding-left:7px;}
|
||||
.m-check{width:20px;height:20px;border-radius:50%;border:2px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;margin-top:1px;}
|
||||
.m-check.done{background:var(--green);border-color:var(--green);color:var(--white);}
|
||||
.m-check.current{background:var(--indigo);border-color:var(--indigo);color:var(--white);animation:pulse 2s ease infinite;}
|
||||
.m-check.pending{opacity:0.35;}
|
||||
.setup-row{display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||
.setup-row:last-child{border-bottom:none;padding-bottom:0;}
|
||||
.s-check{width:18px;height:18px;border-radius:5px;border:2px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;margin-top:1px;}
|
||||
.s-check.done{background:var(--indigo);border-color:var(--indigo);color:var(--white);}
|
||||
.rec-block{padding-bottom:14px;margin-bottom:14px;border-bottom:1px solid var(--border);}
|
||||
.rec-block:last-child{padding-bottom:0;margin-bottom:0;border-bottom:none;}
|
||||
|
||||
/* ════════════════════════════════════
|
||||
DASHBOARD LANDING PAGE styles
|
||||
════════════════════════════════════ */
|
||||
.dash-section-title{font-family:var(--serif);font-size:14px;font-weight:600;color:var(--ink);margin-bottom:13px;letter-spacing:-0.01em;}
|
||||
|
||||
|
||||
/* Attention cards */
|
||||
.attn-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:36px;}
|
||||
.attn-card{border-radius:12px;padding:22px;border:1px solid transparent;display:flex;flex-direction:column;}
|
||||
.attn-card-amber{background:#FFFBEB;border-color:#FDE68A;}
|
||||
.attn-card-indigo{background:#EDE9FE;border-color:#DDD6FE;}
|
||||
.attn-card-slate{background:#F8FAFC;border-color:#E2E8F0;}
|
||||
.attn-value{font-family:var(--serif);font-size:30px;font-weight:700;letter-spacing:-0.03em;margin-bottom:5px;}
|
||||
.attn-value-amber{color:#92400E;}
|
||||
.attn-value-indigo{color:#3730A3;}
|
||||
.attn-value-slate{color:#334155;}
|
||||
.attn-desc{font-size:13px;line-height:1.5;flex:1;}
|
||||
.attn-desc-amber{color:#78350F;}
|
||||
.attn-desc-indigo{color:#3730A3;}
|
||||
.attn-desc-slate{color:#475569;}
|
||||
.attn-cta{margin-top:18px;font-size:12.5px;font-weight:600;cursor:pointer;background:none;border:none;padding:0;display:inline-flex;align-items:center;gap:5px;transition:gap 0.15s;font-family:var(--sans);}
|
||||
.attn-cta:hover{gap:9px;}
|
||||
.attn-cta-amber{color:#92400E;}
|
||||
.attn-cta-indigo{color:#4338CA;}
|
||||
.attn-cta-slate{color:#475569;}
|
||||
|
||||
/* Snapshot cards */
|
||||
.snap-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:36px;}
|
||||
.snap-card{background:var(--white);border:1px solid #E0DDD8;border-radius:10px;padding:16px 18px;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
|
||||
.snap-value{font-family:var(--serif);font-size:24px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:4px;}
|
||||
.snap-label{font-size:11.5px;color:var(--muted);}
|
||||
|
||||
/* Performance cards */
|
||||
.perf-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:36px;}
|
||||
.perf-card{background:var(--white);border:1px solid #E0DDD8;border-radius:12px;padding:22px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
|
||||
.perf-value{font-family:var(--serif);font-size:32px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;margin-bottom:4px;}
|
||||
.perf-change{font-size:12px;font-weight:600;margin-bottom:3px;}
|
||||
.perf-sublabel{font-size:11.5px;color:var(--muted);}
|
||||
.perf-chart-wrap{margin-top:18px;}
|
||||
|
||||
/* Article cards */
|
||||
.article-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
|
||||
.article-card{background:var(--white);border:1px solid #E0DDD8;border-radius:12px;padding:22px;display:flex;flex-direction:column;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
|
||||
.article-emoji{font-size:22px;margin-bottom:12px;}
|
||||
.article-title{font-family:var(--serif);font-size:15px;font-weight:600;color:var(--ink);margin-bottom:7px;}
|
||||
.article-desc{font-size:12.5px;color:var(--mid);line-height:1.6;flex:1;}
|
||||
.article-cta{margin-top:14px;font-size:12px;font-weight:600;color:var(--indigo);cursor:pointer;background:none;border:none;padding:0;font-family:var(--sans);display:inline-flex;align-items:center;gap:4px;transition:gap 0.15s;}
|
||||
.article-cta:hover{gap:7px;}
|
||||
|
||||
/* ── Section table (Clients, Invoices, Costs) ── */
|
||||
.sec-table{width:100%;border-collapse:collapse;}
|
||||
.sec-table th{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);padding:10px 16px;border-bottom:2px solid var(--border);text-align:left;background:#FAFAFA;}
|
||||
.sec-table td{font-size:13px;padding:13px 16px;border-bottom:1px solid var(--border);color:var(--ink);vertical-align:middle;}
|
||||
.sec-table tr:last-child td{border-bottom:none;}
|
||||
.sec-table tbody tr:hover td{background:var(--cream);}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(15,14,26,0.45);display:none;align-items:center;justify-content:center;z-index:100;backdrop-filter:blur(2px);}
|
||||
.modal-overlay.open{display:flex;}
|
||||
.modal-card{background:var(--white);border-radius:16px;padding:28px;width:100%;max-width:420px;box-shadow:0 24px 64px rgba(30,27,75,0.18);}
|
||||
.modal-input{width:100%;border:1px solid var(--border);border-radius:8px;padding:10px 13px;font-family:var(--sans);font-size:14px;color:var(--ink);background:#FAFAFA;outline:none;transition:border-color 0.15s;}
|
||||
.modal-input:focus{border-color:var(--indigo);}
|
||||
.modal-input::placeholder{color:var(--muted);}
|
||||
.for-card{flex:1;border:1px solid var(--border);border-radius:9px;padding:14px;cursor:pointer;text-align:center;background:#FAFAFA;transition:all 0.15s;}
|
||||
.for-card:hover,.for-card.sel{border-color:var(--indigo);background:var(--cream);}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media(max-width:860px){
|
||||
.attn-grid,.perf-grid,.article-grid{grid-template-columns:1fr 1fr;}
|
||||
.snap-grid{grid-template-columns:1fr 1fr;}
|
||||
.cards-grid{grid-template-columns:1fr;}
|
||||
.ws-header{flex-direction:column;gap:12px;}
|
||||
.ws-header-actions{flex-wrap:wrap;}
|
||||
.ws-inner{padding:24px 20px;}
|
||||
}
|
||||
@media(max-width:600px){
|
||||
.proj-nav{display:none!important;}
|
||||
.workspace{padding-bottom:64px;}
|
||||
.attn-grid,.perf-grid,.article-grid,.snap-grid{grid-template-columns:1fr;}
|
||||
.ws-inner{padding:20px 16px;}
|
||||
.ws-header-actions{gap:6px;width:100%;}
|
||||
.ws-header-actions .btn-secondary,.ws-header-actions .btn-primary{font-size:12px;padding:10px 8px;flex:1;text-align:center;justify-content:center;}
|
||||
.dash-header-actions{flex-direction:column;gap:6px!important;padding-top:0!important;}
|
||||
.dash-header-actions .btn-secondary,.dash-header-actions .btn-primary{font-size:11.5px;padding:7px 12px;width:100%;}
|
||||
.clients-grid{grid-template-columns:1fr!important;}
|
||||
.content-card{overflow-x:auto;overflow-y:visible;-webkit-overflow-scrolling:touch;}
|
||||
.sec-table{min-width:460px;}
|
||||
.mob-col-hide{display:none!important;}
|
||||
.mob-hide{display:none!important;}
|
||||
}
|
||||
|
||||
/* ── Mobile bottom tab bar ── */
|
||||
.mob-tab-bar{display:none;position:fixed;bottom:0;left:0;right:0;height:60px;background:var(--white);border-top:1px solid var(--border);align-items:stretch;z-index:200;padding-bottom:env(safe-area-inset-bottom);}
|
||||
.mob-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:none;background:transparent;cursor:pointer;font-family:var(--sans);font-size:10px;font-weight:500;color:var(--muted);padding:8px 4px;transition:color 0.15s;}
|
||||
.mob-tab.active{color:var(--indigo);}
|
||||
.mob-tab svg{flex-shrink:0;transition:transform 0.15s;}
|
||||
.mob-tab.active svg{transform:scale(1.1);}
|
||||
@media(max-width:600px){.mob-tab-bar{display:flex;}}
|
||||
|
||||
/* ── Mobile project cards ── */
|
||||
.mob-proj-card{background:var(--white);border:1px solid var(--border);border-radius:14px;padding:16px;display:flex;align-items:center;gap:14px;cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s;}
|
||||
.mob-proj-card:hover{border-color:var(--indigo);box-shadow:0 2px 12px rgba(99,102,241,0.1);}
|
||||
|
||||
/* ── Dark mode: mobile tab bar ── */
|
||||
[data-justine-dashboard][data-theme="dark"] .mob-tab-bar{background:#212840;border-top-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .mob-tab{color:#6A7490;}
|
||||
[data-justine-dashboard][data-theme="dark"] .mob-tab.active{color:#A5B4FC;}
|
||||
[data-justine-dashboard][data-theme="dark"] .mob-proj-card{background:#2A3250;border-color:#3A4260;}
|
||||
|
||||
/* ── Dark mode ── */
|
||||
/* Surface hierarchy: body #1A1F2E → nav #212840 → cards #2A3250 → borders #3A4260 */
|
||||
[data-justine-dashboard][data-theme="dark"]{
|
||||
--ink:#ECE9F5; --mid:#9AA3BC; --muted:#6A7490; --indigo:#A5B4FC;
|
||||
--border:#3A4260; --cream:#2A3250; --paper:#242B48; --white:#2A3250;
|
||||
--indigo-dim:rgba(99,102,241,0.18); --indigo-border:rgba(99,102,241,0.35);
|
||||
--green-dim:rgba(5,150,105,0.18);
|
||||
--amber-dim:rgba(245,158,11,0.14); --amber-border:rgba(245,158,11,0.30);
|
||||
}
|
||||
[data-justine-dashboard][data-theme="dark"].justine-dashboard-root{background:#1A1F2E;}
|
||||
[data-justine-dashboard][data-theme="dark"] .jd-topnav{background:rgba(26,31,46,0.97)!important;border-bottom-color:#3A4260!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .jd-topnav .f,[data-justine-dashboard][data-theme="dark"] .jd-topnav span{color:var(--ink)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .proj-nav{background:#212840;border-right-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .proj-row:hover{background:rgba(255,255,255,0.06);}
|
||||
[data-justine-dashboard][data-theme="dark"] .proj-row.active{background:rgba(99,102,241,0.18);}
|
||||
[data-justine-dashboard][data-theme="dark"] .proj-row:hover .proj-edit-btn{color:#A5B4FC;}
|
||||
[data-justine-dashboard][data-theme="dark"] .proj-edit-btn:hover{color:#A5B4FC!important;background:rgba(165,180,252,0.15)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] #proj-edit-popover{background:#2A3250;border-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] #proj-edit-name{background:#1E2640;border-color:#3A4260;color:#ECE9F5;}
|
||||
[data-justine-dashboard][data-theme="dark"] #proj-edit-name:focus{border-color:#A5B4FC;}
|
||||
[data-justine-dashboard][data-theme="dark"] .color-swatch.active{border-color:#ECE9F5;}
|
||||
[data-justine-dashboard][data-theme="dark"] .client-edit-btn{color:#6A7490;}
|
||||
[data-justine-dashboard][data-theme="dark"] .client-card-header:hover .client-edit-btn{color:#A5B4FC;}
|
||||
[data-justine-dashboard][data-theme="dark"] .client-edit-btn:hover{color:#A5B4FC!important;background:rgba(165,180,252,0.15)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] #client-edit-popover{background:#2A3250;border-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .client-edit-field{background:#1E2640;border-color:#3A4260;color:#ECE9F5;}
|
||||
[data-justine-dashboard][data-theme="dark"] .client-edit-field:focus{border-color:#A5B4FC;}
|
||||
[data-justine-dashboard][data-theme="dark"] .nav-item-btn:hover{background:rgba(255,255,255,0.07);}
|
||||
[data-justine-dashboard][data-theme="dark"] .nav-item-btn.active{background:rgba(99,102,241,0.18);}
|
||||
[data-justine-dashboard][data-theme="dark"] .nav-icon{background:#A5B4FC;color:#1E1B6E;}
|
||||
[data-justine-dashboard][data-theme="dark"] #nav-dashboard{background:rgba(255,255,255,0.08)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] #nav-dashboard:hover{background:rgba(255,255,255,0.12)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .nav-search{background:rgba(255,255,255,0.07);border-color:#3A4260;color:var(--ink);}
|
||||
[data-justine-dashboard][data-theme="dark"] .nav-search:focus{background:rgba(255,255,255,0.10);}
|
||||
[data-justine-dashboard][data-theme="dark"] .workspace{background:linear-gradient(160deg,#1C2235 0%,#212840 100%);}
|
||||
[data-justine-dashboard][data-theme="dark"] .ws-header{border-bottom-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .snap-card,[data-justine-dashboard][data-theme="dark"] .perf-card,[data-justine-dashboard][data-theme="dark"] .article-card,
|
||||
[data-justine-dashboard][data-theme="dark"] .metric-card,[data-justine-dashboard][data-theme="dark"] .content-card{border-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .card-head{border-bottom-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .health-row,[data-justine-dashboard][data-theme="dark"] .activity-row,[data-justine-dashboard][data-theme="dark"] .fin-row,
|
||||
[data-justine-dashboard][data-theme="dark"] .milestone-row,[data-justine-dashboard][data-theme="dark"] .setup-row,[data-justine-dashboard][data-theme="dark"] .rec-block{border-bottom-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .attn-card-amber{background:rgba(245,158,11,0.12);border-color:rgba(245,158,11,0.30);}
|
||||
[data-justine-dashboard][data-theme="dark"] .attn-card-indigo{background:rgba(99,102,241,0.15);border-color:rgba(99,102,241,0.32);}
|
||||
[data-justine-dashboard][data-theme="dark"] .attn-card-slate{background:#242B48;border-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .attn-value-slate{color:#B0BAD0;}
|
||||
[data-justine-dashboard][data-theme="dark"] .attn-desc-slate{color:#8A96B0;}
|
||||
[data-justine-dashboard][data-theme="dark"] .attn-cta-slate{color:#8A96B0;}
|
||||
[data-justine-dashboard][data-theme="dark"] .pill-building{background:rgba(99,102,241,0.22);color:#A5B4FC;}
|
||||
[data-justine-dashboard][data-theme="dark"] .pill-draft{background:rgba(255,255,255,0.10);color:var(--mid);}
|
||||
[data-justine-dashboard][data-theme="dark"] .progress-bar{background:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .btn-primary{color:#FFFFFF;}
|
||||
[data-justine-dashboard][data-theme="dark"] .proj-icon{color:#FFFFFF;}
|
||||
[data-justine-dashboard][data-theme="dark"] .btn-amber{color:#FDE68A;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="color:#92400E"]{color:#FDE68A!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="color:#4338CA"]{color:var(--indigo)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="color:#6366F1"]{color:var(--indigo)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] .btn-secondary{background:#2A3250;border-color:#3A4260;color:var(--ink);}
|
||||
[data-justine-dashboard][data-theme="dark"] .btn-secondary:hover{background:#323C5E;border-color:var(--indigo);}
|
||||
[data-justine-dashboard][data-theme="dark"] .btn-ghost:hover{background:#323C5E;color:var(--ink);}
|
||||
[data-justine-dashboard][data-theme="dark"] .sec-table th{background:#212840;border-bottom-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .sec-table td{border-bottom-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .sec-table tbody tr:hover td{background:#242B48;}
|
||||
[data-justine-dashboard][data-theme="dark"] .modal-overlay{background:rgba(10,12,24,0.65);}
|
||||
[data-justine-dashboard][data-theme="dark"] .modal-input{background:#212840;border-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .for-card{background:#212840;border-color:#3A4260;}
|
||||
[data-justine-dashboard][data-theme="dark"] .for-card:hover,[data-justine-dashboard][data-theme="dark"] .for-card.sel{background:#2A3250;}
|
||||
/* Inline-style hardcode overrides */
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="background:#EDE9FE"]{background:rgba(165,180,252,0.45)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="background:#FAFAFA"]{background:#212840!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="background:#F3F4F6"]{background:rgba(255,255,255,0.08)!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="background:#E5E7EB"]{background:#3A4260!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="border-color:#E5E7EB"]{border-color:#3A4260!important;}
|
||||
[data-justine-dashboard][data-theme="dark"] [style*="background:#F8FAFC"]{background:#242B48!important;}
|
||||
|
||||
/* Scrollbars */
|
||||
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar{width:6px;height:6px;}
|
||||
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar-track{background:#1A1F2E;}
|
||||
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar-thumb{background:#3A4260;border-radius:3px;}
|
||||
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar-thumb:hover{background:#5865A0;}
|
||||
[data-justine-dashboard][data-theme="dark"] *{scrollbar-color:#3A4260 #1A1F2E;scrollbar-width:thin;}
|
||||
@@ -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({
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAtlas) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 22, animation: "enter 0.3s ease both" }}>
|
||||
{/* Avatar */}
|
||||
<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,142 +526,345 @@ 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: "100%", maxWidth: JV.chatFeedMaxWidth, margin: "0 auto" }}>
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 12,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 11, background: "#1a1a1a",
|
||||
width: 44, height: 44, borderRadius: 14, background: JM.primaryGradient,
|
||||
boxShadow: JM.primaryShadow,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, color: "#fff",
|
||||
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: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>Vibn</p>
|
||||
<p style={{ fontSize: "0.78rem", color: "#a09a90", maxWidth: 260, lineHeight: 1.5 }}>
|
||||
Your product strategist. Let's define what you're building.
|
||||
<p 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"
|
||||
<div style={{
|
||||
flex: 1, minHeight: 0, position: "relative", display: "flex", flexDirection: "column",
|
||||
}}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={syncScrollFab}
|
||||
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",
|
||||
flex: 1, overflowY: "auto", paddingTop: 24, paddingBottom: 16,
|
||||
...feedPad,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = "#8a8478")}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = "#d0ccc4")}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<div style={{ maxWidth: JV.chatFeedMaxWidth, margin: "0 auto", width: "100%" }}>
|
||||
{visibleMessages.map((msg, i) => (
|
||||
<MessageRow key={i} msg={msg} userInitial={userInitial} projectId={projectId} workspace={workspace} />
|
||||
<MessageRow key={i} msg={msg} projectId={projectId} workspace={workspace} />
|
||||
))}
|
||||
{isStreaming && <TypingIndicator />}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
{showScrollFab && (
|
||||
<button
|
||||
type="button"
|
||||
title="Scroll to latest"
|
||||
onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
|
||||
style={{
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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={{ 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 32px 8px", display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<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: "5px 12px", borderRadius: 20,
|
||||
border: "1px solid #e0dcd4",
|
||||
background: "#fff", color: "#6b6560",
|
||||
fontSize: "0.73rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
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 = "#f0ece4";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "#c8c4bc";
|
||||
(e.currentTarget as HTMLElement).style.color = "#1a1a1a";
|
||||
(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 = "#fff";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "#e0dcd4";
|
||||
(e.currentTarget as HTMLElement).style.color = "#6b6560";
|
||||
(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>
|
||||
)}
|
||||
|
||||
{/* 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",
|
||||
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="Describe your thinking..."
|
||||
rows={1}
|
||||
placeholder="Reply…"
|
||||
rows={2}
|
||||
disabled={isStreaming}
|
||||
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,
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<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
|
||||
onClick={() => setIsStreaming(false)}
|
||||
type="button"
|
||||
title="Focus composer"
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
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,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: JM.mid,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 10, height: 10, background: "#8a8478", borderRadius: 2, display: "inline-block" }} />
|
||||
<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 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",
|
||||
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",
|
||||
flexShrink: 0, transition: "all 0.15s",
|
||||
boxShadow: input.trim() ? JM.primaryShadow : "none",
|
||||
transition: "opacity 0.15s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (input.trim()) (e.currentTarget.style.opacity = "0.8"); }}
|
||||
onMouseEnter={e => { if (input.trim()) (e.currentTarget.style.opacity = "0.92"); }}
|
||||
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
|
||||
>
|
||||
Send
|
||||
@@ -582,5 +873,8 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
586
components/justine/JustineWorkspaceProjectsDashboard.tsx
Normal file
586
components/justine/JustineWorkspaceProjectsDashboard.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ProjectCreationModal } from "@/components/project-creation-modal";
|
||||
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const justineJakarta = Plus_Jakarta_Sans({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
variable: "--font-justine-jakarta",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
interface ProjectWithStats {
|
||||
id: string;
|
||||
productName: string;
|
||||
productVision?: string;
|
||||
status?: string;
|
||||
updatedAt: string | null;
|
||||
stats: { sessions: number; costs: number };
|
||||
}
|
||||
|
||||
const ICON_BG = ["#6366F1", "#8B5CF6", "#06B6D4", "#EC4899", "#9CA3AF"];
|
||||
|
||||
function timeAgo(dateStr?: string | null): string {
|
||||
if (!dateStr) return "—";
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return "—";
|
||||
const diff = (Date.now() - date.getTime()) / 1000;
|
||||
if (diff < 60) return "just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
const days = Math.floor(diff / 86400);
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
function greetingName(session: { user?: { name?: string | null; email?: string | null } } | null): string {
|
||||
const n = session?.user?.name?.trim();
|
||||
if (n) return n.split(/\s+/)[0] ?? "there";
|
||||
const e = session?.user?.email;
|
||||
if (e) return e.split("@")[0] ?? "there";
|
||||
return "there";
|
||||
}
|
||||
|
||||
function greetingPrefix(): string {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return "Good morning";
|
||||
if (h < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status?: string }) {
|
||||
if (status === "live") {
|
||||
return (
|
||||
<span className="pill pill-live">
|
||||
<span className="dot-live" />
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "building") {
|
||||
return (
|
||||
<span className="pill pill-building">
|
||||
<span className="dot-building" />
|
||||
Building
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="pill pill-draft">Defining</span>;
|
||||
}
|
||||
|
||||
export function JustineWorkspaceProjectsDashboard({ workspace }: { workspace: string }) {
|
||||
const { data: session, status: authStatus } = useSession();
|
||||
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const t = localStorage.getItem("jd-dashboard-theme");
|
||||
if (t === "dark") setTheme("dark");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === "light" ? "dark" : "light";
|
||||
try {
|
||||
localStorage.setItem("jd-dashboard-theme", next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
setProjects(data.projects ?? []);
|
||||
} catch {
|
||||
/* silent */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isClientDevProjectBypass()) {
|
||||
void fetchProjects();
|
||||
return;
|
||||
}
|
||||
if (authStatus === "authenticated") fetchProjects();
|
||||
else if (authStatus === "unauthenticated") setLoading(false);
|
||||
}, [authStatus, fetchProjects]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return projects;
|
||||
return projects.filter((p) => p.productName.toLowerCase().includes(q));
|
||||
}, [projects, search]);
|
||||
|
||||
const liveN = projects.filter((p) => p.status === "live").length;
|
||||
const buildingN = projects.filter((p) => p.status === "building").length;
|
||||
const totalCosts = projects.reduce((s, p) => s + (p.stats?.costs ?? 0), 0);
|
||||
|
||||
const userInitial =
|
||||
session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
|
||||
const displayName = session?.user?.name?.trim() || session?.user?.email?.split("@")[0] || "Account";
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const res = await fetch("/api/projects/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId: projectToDelete.id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Project deleted");
|
||||
setProjectToDelete(null);
|
||||
fetchProjects();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.error || "Failed to delete");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const firstName = greetingName(session);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-justine-dashboard
|
||||
data-theme={theme === "dark" ? "dark" : undefined}
|
||||
className={`${justineJakarta.variable} justine-dashboard-root`}
|
||||
>
|
||||
<nav
|
||||
className="jd-topnav"
|
||||
style={{
|
||||
background: "rgba(250,250,250,0.97)",
|
||||
borderBottom: "1px solid #E5E7EB",
|
||||
height: 56,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 24px",
|
||||
flexShrink: 0,
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 27,
|
||||
height: 27,
|
||||
background: "linear-gradient(135deg,#2E2A5E,#4338CA)",
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span className="f" style={{ fontSize: 13, fontWeight: 700, color: "#FFFFFF" }}>
|
||||
V
|
||||
</span>
|
||||
</div>
|
||||
<span className="f" style={{ fontSize: 17, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.02em" }}>
|
||||
vibn
|
||||
</span>
|
||||
</Link>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<button type="button" className="btn-secondary mob-hide" onClick={toggleTheme} style={{ fontSize: 12.5, padding: "7px 14px" }}>
|
||||
{theme === "dark" ? "☀️ Light" : "🌙 Dark"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/${workspace}/settings`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
cursor: "pointer",
|
||||
padding: "5px 8px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
fontFamily: "var(--sans)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 29,
|
||||
height: 29,
|
||||
borderRadius: "50%",
|
||||
background: "#6366F1",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 11,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{userInitial}
|
||||
</div>
|
||||
<div className="mob-hide" style={{ textAlign: "left" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)", lineHeight: 1.2 }}>{displayName}</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Workspace · {workspace}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="app-shell">
|
||||
<aside className="proj-nav">
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{ padding: "10px 8px 8px" }}>
|
||||
<Link
|
||||
href={`/${workspace}/projects`}
|
||||
id="nav-dashboard"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 11,
|
||||
padding: "11px 12px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: "rgba(255,255,255,0.55)",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
fontFamily: "var(--sans)",
|
||||
transition: "background 0.18s ease",
|
||||
backdropFilter: "blur(4px)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: "linear-gradient(135deg,#4338CA,#6366F1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 15,
|
||||
flexShrink: 0,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
⬟
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>Dashboard</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Overview</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "10px 8px 8px", marginTop: 2 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 4px", marginBottom: 8 }}>
|
||||
<div className="nav-group-label" style={{ margin: 0 }}>
|
||||
Projects
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(true)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
color: "var(--indigo)",
|
||||
padding: "0 4px",
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
}}
|
||||
title="New project"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="nav-search-wrap">
|
||||
<svg
|
||||
style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M8.5 3a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 8.5a6.5 6.5 0 1111.436 4.23l3.857 3.857a.75.75 0 01-1.06 1.06l-3.857-3.857A6.5 6.5 0 012 8.5z"
|
||||
fill="#9CA3AF"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
className="nav-search"
|
||||
type="search"
|
||||
placeholder="Search projects…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proj-list">
|
||||
{loading && (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 24 }}>
|
||||
<Loader2 style={{ width: 22, height: 22, color: "var(--muted)" }} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && projects.length === 0 && (
|
||||
<div style={{ padding: "20px 8px 12px", textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: "var(--indigo-dim)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto 10px",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
✦
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", marginBottom: 4 }}>No projects yet</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Start building your first product with vibn.
|
||||
</div>
|
||||
<button type="button" className="btn-primary" style={{ fontSize: 11.5, padding: "7px 14px", width: "100%" }} onClick={() => setShowNew(true)}>
|
||||
+ Create first project
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && projects.length > 0 && filtered.length === 0 && (
|
||||
<div style={{ padding: "16px 12px", textAlign: "center", fontSize: 12, color: "var(--muted)" }}>No projects match your search.</div>
|
||||
)}
|
||||
{!loading &&
|
||||
filtered.map((p, i) => (
|
||||
<div key={p.id} className="proj-row">
|
||||
<Link
|
||||
href={`/${workspace}/project/${p.id}`}
|
||||
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "flex-start", gap: 10, textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
<div
|
||||
className="proj-icon"
|
||||
style={{
|
||||
background: ICON_BG[i % ICON_BG.length],
|
||||
}}
|
||||
>
|
||||
{(p.productName[0] ?? "P").toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="proj-row-name">
|
||||
{p.productName} <StatusPill status={p.status} />
|
||||
</div>
|
||||
<div className="proj-row-metric" style={{ fontWeight: 400, color: "var(--muted)" }}>
|
||||
{p.productVision ? `${p.productVision.slice(0, 42)}${p.productVision.length > 42 ? "…" : ""}` : "Personal"}
|
||||
</div>
|
||||
<div className="proj-row-time">{timeAgo(p.updatedAt)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="proj-edit-btn"
|
||||
title="Delete project"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setProjectToDelete(p);
|
||||
}}
|
||||
>
|
||||
<Trash2 style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: "12px 8px 16px", display: "flex", flexDirection: "column" }}>
|
||||
<div className="nav-group-label" style={{ marginBottom: 4 }}>
|
||||
Workspace
|
||||
</div>
|
||||
<Link href={`/${workspace}/activity`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
|
||||
<div className="nav-icon">↗</div>
|
||||
<div>
|
||||
<div className="nav-label">Activity</div>
|
||||
<div className="nav-sub">Timeline & runs</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={`/${workspace}/settings`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
|
||||
<div className="nav-icon">⚙</div>
|
||||
<div className="nav-label">Settings</div>
|
||||
</Link>
|
||||
<div style={{ height: 1, background: "var(--border)", margin: "8px 4px" }} />
|
||||
<div className="nav-group-label" style={{ marginBottom: 4 }}>
|
||||
Account
|
||||
</div>
|
||||
<button type="button" className="nav-item-btn" onClick={() => toast.message("Help — docs coming soon.")}>
|
||||
<div className="nav-icon">?</div>
|
||||
<div className="nav-label">Help</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="workspace">
|
||||
<div id="ws-dashboard" className="ws-section active">
|
||||
<div className="ws-inner">
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20, marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 className="f" style={{ fontSize: 28, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.03em", marginBottom: 7 }}>
|
||||
{greetingPrefix()}, {firstName}.
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.5 }}>
|
||||
Open a project from the sidebar or start a new one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="dash-header-actions" style={{ display: "flex", gap: 9, alignItems: "center", flexShrink: 0, paddingTop: 4 }}>
|
||||
<button type="button" className="btn-primary" onClick={() => setShowNew(true)}>
|
||||
+ New project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && projects.length === 0 && (
|
||||
<div style={{ marginBottom: 36 }}>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--white)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 16,
|
||||
padding: "48px 36px",
|
||||
textAlign: "center",
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg,rgba(99,102,241,0.12),rgba(139,92,246,0.12))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto 20px",
|
||||
fontSize: 26,
|
||||
}}
|
||||
>
|
||||
✦
|
||||
</div>
|
||||
<h2 className="f" style={{ fontSize: 20, fontWeight: 700, color: "var(--ink)", marginBottom: 8, letterSpacing: "-0.02em" }}>
|
||||
Build your first product
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.6, marginBottom: 24 }}>
|
||||
Describe your idea, and vibn will architect, design, and help you ship it — no code required.
|
||||
</p>
|
||||
<button type="button" className="btn-primary" style={{ padding: "11px 28px", fontSize: 14 }} onClick={() => setShowNew(true)}>
|
||||
+ Start a new project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dash-section-title">Portfolio snapshot</div>
|
||||
<div className="snap-grid">
|
||||
<div className="snap-card">
|
||||
<div className="snap-value">{projects.length}</div>
|
||||
<div className="snap-label">Active projects</div>
|
||||
</div>
|
||||
<div className="snap-card">
|
||||
<div className="snap-value" style={{ color: "var(--green)" }}>
|
||||
{liveN}
|
||||
</div>
|
||||
<div className="snap-label">Live products</div>
|
||||
</div>
|
||||
<div className="snap-card">
|
||||
<div className="snap-value" style={{ color: "#4338CA" }}>
|
||||
{buildingN}
|
||||
</div>
|
||||
<div className="snap-label">Building now</div>
|
||||
</div>
|
||||
<div className="snap-card" style={{ borderColor: "var(--amber-border)", background: "var(--amber-dim)" }}>
|
||||
<div className="snap-value" style={{ color: "#92400E" }}>
|
||||
{totalCosts > 0 ? `$${totalCosts.toFixed(2)}` : "—"}
|
||||
</div>
|
||||
<div className="snap-label" style={{ color: "#B45309" }}>
|
||||
API spend (est.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ProjectCreationModal
|
||||
open={showNew}
|
||||
onOpenChange={(open) => {
|
||||
setShowNew(open);
|
||||
if (!open) fetchProjects();
|
||||
}}
|
||||
workspace={workspace}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!projectToDelete} onOpenChange={(open) => !open && setProjectToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{projectToDelete?.productName}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove the project record. Sessions will be preserved but unlinked. The Gitea repo will not be deleted automatically.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-red-600 hover:bg-red-700">
|
||||
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
|
||||
Delete project
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" :
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<FieldLabel>Project name</FieldLabel>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="e.g. Foxglove, Meridian, OpsAI…"
|
||||
onKeyDown={e => { if (e.key === "Enter" && canCreate) handleCreate(); }}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
components/project-creation/modal-theme.ts
Normal file
40
components/project-creation/modal-theme.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** Tokens aligned with justine/03_dashboard.html (new project modal + .btn-primary). */
|
||||
export const JM = {
|
||||
overlay: "rgba(15,14,26,0.45)",
|
||||
cardShadow: "0 24px 64px rgba(30,27,75,0.18)",
|
||||
ink: "#1A1A1A",
|
||||
mid: "#6B7280",
|
||||
muted: "#9CA3AF",
|
||||
border: "#E5E7EB",
|
||||
cream: "#FAFAFF",
|
||||
inputBg: "#FAFAFA",
|
||||
indigo: "#6366F1",
|
||||
fontSans: 'var(--font-justine-jakarta), "Plus Jakarta Sans", ui-sans-serif, sans-serif',
|
||||
/** Headings use same face as Justine `.f` on dashboard */
|
||||
fontDisplay: 'var(--font-justine-jakarta), "Plus Jakarta Sans", ui-sans-serif, sans-serif',
|
||||
primaryGradient: "linear-gradient(135deg,#2E2A5E,#4338CA)",
|
||||
primaryShadow: "0 4px 14px rgba(30,27,75,0.14)",
|
||||
primaryShadowHover: "0 6px 20px rgba(30,27,75,0.22)",
|
||||
cardMaxWidth: 420,
|
||||
} as const;
|
||||
|
||||
/** Overview / describe chat — justine/05_describe.html */
|
||||
export const JV = {
|
||||
chatColumnBg: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||
prdPanelBg: "#F5F3FF",
|
||||
bubbleAiBg: "#F0F4FF",
|
||||
bubbleAiBorder: "#E0E7FF",
|
||||
bubbleUserBg: "#6366F1",
|
||||
bubbleUserColor: "#FFFFFF",
|
||||
inputWrapBg: "#FAFAFA",
|
||||
accentSoft: "#A5B4FC",
|
||||
violetTint: "#EDE9FE",
|
||||
/** Centered “studio” feed — main chat column */
|
||||
chatFeedMaxWidth: 720,
|
||||
/** User bubble (right rail) — soft tint, not flat indigo */
|
||||
userBubbleBg: "#EDE9FE",
|
||||
userBubbleBorder: "#E0E7FF",
|
||||
composerSurface: "#FFFFFF",
|
||||
composerRadius: 22,
|
||||
composerShadow: "0 4px 28px rgba(30, 27, 75, 0.08)",
|
||||
} as const;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
286
components/project-main/BuildLivePlanPanel.tsx
Normal file
286
components/project-main/BuildLivePlanPanel.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { JM, JV } from "@/components/project-creation/modal-theme";
|
||||
import { PRD_PLAN_SECTIONS, isSectionFilled } from "@/lib/prd-sections";
|
||||
import {
|
||||
type ChatContextRef,
|
||||
contextRefKey,
|
||||
} from "@/lib/chat-context-refs";
|
||||
|
||||
interface SavedPhase {
|
||||
phase: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
data: Record<string, unknown>;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
export function BuildLivePlanPanel({
|
||||
projectId,
|
||||
workspace,
|
||||
chatContextRefs,
|
||||
onAddSectionRef,
|
||||
compactHeader,
|
||||
}: {
|
||||
projectId: string;
|
||||
workspace: string;
|
||||
chatContextRefs: ChatContextRef[];
|
||||
onAddSectionRef: (label: string, phaseId: string | null) => void;
|
||||
/** When true, hide subtitle to save space in narrow tabs */
|
||||
compactHeader?: boolean;
|
||||
}) {
|
||||
const [prdText, setPrdText] = useState<string | null>(null);
|
||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
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]) => {
|
||||
setPrdText(projectData?.project?.prd ?? null);
|
||||
setSavedPhases(phaseData?.phases ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const t = setInterval(refresh, 8000);
|
||||
return () => clearInterval(t);
|
||||
}, [refresh]);
|
||||
|
||||
const savedPhaseIds = useMemo(() => new Set(savedPhases.map(p => p.phase)), [savedPhases]);
|
||||
const phaseMap = useMemo(() => new Map(savedPhases.map(p => [p.phase, p])), [savedPhases]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let firstOpenIndex = -1;
|
||||
const list = PRD_PLAN_SECTIONS.map((s, index) => {
|
||||
const done = isSectionFilled(s.phaseId, savedPhaseIds);
|
||||
if (!done && firstOpenIndex < 0) firstOpenIndex = index;
|
||||
return {
|
||||
...s,
|
||||
done,
|
||||
active: !done && index === firstOpenIndex,
|
||||
pending: !done && index > firstOpenIndex,
|
||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||
};
|
||||
});
|
||||
return list;
|
||||
}, [savedPhaseIds, phaseMap]);
|
||||
|
||||
const doneCount = rows.filter(r => r.done).length;
|
||||
const tasksHref = `/${workspace}/project/${projectId}/tasks`;
|
||||
|
||||
const attached = useCallback(
|
||||
(label: string) => chatContextRefs.some(r => r.kind === "section" && r.label === label),
|
||||
[chatContextRefs]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: JV.prdPanelBg,
|
||||
color: JM.muted,
|
||||
fontSize: 13,
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
Loading plan…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (prdText) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
background: JV.prdPanelBg,
|
||||
borderLeft: `1px solid ${JM.border}`,
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "16px 16px 12px", borderBottom: `1px solid ${JM.border}`, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 700, color: JM.muted, textTransform: "uppercase", letterSpacing: "0.06em" }}>
|
||||
Your plan
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: JM.ink, marginTop: 4 }}>PRD ready</div>
|
||||
{!compactHeader && (
|
||||
<div style={{ fontSize: 12, color: JM.muted, marginTop: 4, lineHeight: 1.45 }}>
|
||||
Full document saved — open Task to edit or keep refining in chat.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 16, overflow: "auto" }}>
|
||||
<Link
|
||||
href={tasksHref}
|
||||
style={{
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
padding: "11px 14px",
|
||||
borderRadius: 10,
|
||||
background: JM.primaryGradient,
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
boxShadow: JM.primaryShadow,
|
||||
}}
|
||||
>
|
||||
View full PRD →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
background: JV.prdPanelBg,
|
||||
borderLeft: `1px solid ${JM.border}`,
|
||||
fontFamily: JM.fontSans,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes buildPlanFadeUp {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ padding: "16px 16px 10px", borderBottom: `1px solid ${JM.border}`, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 700, color: JM.muted, textTransform: "uppercase", letterSpacing: "0.06em" }}>
|
||||
Your plan
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: JM.ink, marginTop: 4 }}>Fills as you chat</div>
|
||||
{!compactHeader && (
|
||||
<div style={{ fontSize: 12, color: JM.muted, marginTop: 4, lineHeight: 1.45 }}>
|
||||
Tap a section to attach it — your next message prioritizes it.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 12 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: JM.indigo, minWidth: 40 }}>{Math.round((doneCount / rows.length) * 100)}%</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ height: 4, borderRadius: 2, background: "#e0e7ff" }}>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
borderRadius: 2,
|
||||
width: `${(doneCount / rows.length) * 100}%`,
|
||||
background: JM.primaryGradient,
|
||||
transition: "width 0.5s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: JM.muted, whiteSpace: "nowrap" }}>
|
||||
{doneCount}/{rows.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px 8px" }}>
|
||||
{rows.map((r, i) => {
|
||||
const isAttached = attached(r.label);
|
||||
const hint = r.done && r.savedPhase?.summary
|
||||
? r.savedPhase.summary.slice(0, 120) + (r.savedPhase.summary.length > 120 ? "…" : "")
|
||||
: r.active
|
||||
? "Answer in chat — this block updates when the phase saves."
|
||||
: "Tap to attach — chat uses this section as context.";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onAddSectionRef(r.label, r.phaseId)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "11px 12px",
|
||||
marginBottom: 8,
|
||||
borderRadius: 9,
|
||||
border: r.active ? `1px solid ${JV.bubbleAiBorder}` : `1px solid ${JM.border}`,
|
||||
borderLeftWidth: r.active ? 3 : 1,
|
||||
borderLeftColor: r.active ? JM.indigo : JM.border,
|
||||
background: r.active ? "#fafaff" : r.done ? "#fff" : "#fff",
|
||||
opacity: r.pending && !r.done ? 0.55 : 1,
|
||||
borderStyle: r.pending && !r.done ? "dashed" : "solid",
|
||||
cursor: "pointer",
|
||||
boxShadow: r.active ? "0 0 0 3px rgba(99,102,241,0.08), 0 2px 12px rgba(99,102,241,0.07)" : "0 1px 8px rgba(99,102,241,0.05)",
|
||||
animation: `buildPlanFadeUp 0.35s ease ${i * 0.02}s both`,
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: r.done ? JM.mid : JM.muted }}>
|
||||
{r.label}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||
{r.done && <span style={{ fontSize: 10, color: JM.indigo, fontWeight: 700 }}>✓</span>}
|
||||
{isAttached && <span style={{ fontSize: 9, fontWeight: 600, color: JM.indigo, background: JV.violetTint, padding: "2px 6px", borderRadius: 4 }}>Attached</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.5, color: r.done ? JM.ink : JM.muted, marginTop: 4 }}>{hint}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "10px 14px 14px",
|
||||
borderTop: `1px solid ${JM.border}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={tasksHref}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "10px 14px",
|
||||
borderRadius: 9,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: JM.indigo,
|
||||
background: "#eef2ff",
|
||||
border: `1px solid ${JV.bubbleAiBorder}`,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Open requirements view
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function addSectionContextRef(
|
||||
prev: ChatContextRef[],
|
||||
label: string,
|
||||
phaseId: string | null
|
||||
): ChatContextRef[] {
|
||||
const next: ChatContextRef = { kind: "section", label, phaseId };
|
||||
const k = contextRefKey(next);
|
||||
if (prev.some(r => contextRefKey(r) === k)) return prev;
|
||||
return [...prev, next];
|
||||
}
|
||||
1068
components/project-main/BuildMvpJustineV2.tsx
Normal file
1068
components/project-main/BuildMvpJustineV2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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'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 */}
|
||||
{allDone && (
|
||||
<div style={{ padding: "12px 16px", borderTop: "1px solid #e8e4dc", flexShrink: 0 }}>
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/prd`}
|
||||
{/* 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={{
|
||||
display: "block", textAlign: "center",
|
||||
padding: "9px 0", borderRadius: 7,
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
fontSize: "0.78rem", fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
flex: 1, minWidth: 0,
|
||||
border: "none", background: "transparent",
|
||||
fontSize: 12, fontFamily: JM.fontSans,
|
||||
color: JM.ink, outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={sortAlpha ? "Sort: document order" : "Sort: A–Z"}
|
||||
onClick={() => setSortAlpha(s => !s)}
|
||||
style={{
|
||||
border: "none", background: sortAlpha ? JV.violetTint : "transparent",
|
||||
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
|
||||
}}
|
||||
>
|
||||
Generate PRD →
|
||||
<ArrowUpDown size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={pendingOnly ? "Show all sections" : "Pending only"}
|
||||
onClick={() => setPendingOnly(p => !p)}
|
||||
style={{
|
||||
border: "none", background: pendingOnly ? JV.violetTint : "transparent",
|
||||
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
|
||||
}}
|
||||
>
|
||||
<Filter size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scope + group (Tasks tab only shows group pills; Phases tab locks grouping) */}
|
||||
<div style={{
|
||||
padding: "8px 10px 10px",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
background: "#F7F5FC",
|
||||
}}>
|
||||
<select
|
||||
value={phaseScope}
|
||||
onChange={e => setPhaseScope(e.target.value)}
|
||||
aria-label="Filter by discovery phase"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${JM.border}`,
|
||||
background: "#fff",
|
||||
fontSize: 12,
|
||||
fontFamily: JM.fontSans,
|
||||
color: JM.ink,
|
||||
marginBottom: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<option value="all">All sections</option>
|
||||
{DISCOVERY_PHASES.map(p => (
|
||||
<option key={p} value={p}>{PHASE_DISPLAY[p]}</option>
|
||||
))}
|
||||
</select>
|
||||
{sidebarTab === "tasks" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: JM.muted, fontFamily: JM.fontSans }}>
|
||||
Group by
|
||||
</span>
|
||||
{([
|
||||
{ id: "none" as const, label: "None" },
|
||||
{ id: "phase" as const, label: "Phase" },
|
||||
{ id: "status" as const, label: "Status" },
|
||||
]).map(opt => {
|
||||
const on = groupBy === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setGroupBy(opt.id)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${on ? JM.indigo : JM.border}`,
|
||||
background: on ? JV.violetTint : "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: on ? 600 : 500,
|
||||
color: on ? JM.indigo : JM.mid,
|
||||
fontFamily: JM.fontSans,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{sidebarTab === "phases" && (
|
||||
<div style={{ fontSize: 11, color: JM.muted, fontFamily: JM.fontSans }}>
|
||||
Grouped by discovery phase
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress summary */}
|
||||
<div style={{
|
||||
padding: "10px 12px",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
background: "#F4F2FA",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ height: 3, background: "#E0E7FF", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 99,
|
||||
background: JM.primaryGradient,
|
||||
width: `${Math.round((completedSections / totalSections) * 100)}%`,
|
||||
transition: "width 0.4s ease",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: JM.muted, marginTop: 6, fontFamily: JM.fontSans }}>
|
||||
{completedSections} of {totalSections} sections · Requirements task
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: JM.indigo, marginTop: 5, fontFamily: JM.fontSans, opacity: 0.9 }}>
|
||||
Click a section row or phase header to attach it to your next message.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", background: "#F4F2FA" }}>
|
||||
{(() => {
|
||||
const rows = filteredSections;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "28px 16px",
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
No sections match your search or filters.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderRow = (label: string, phase: string | null, key: string) => {
|
||||
const isDone = sectionDone(phase, savedPhaseIds, allDone);
|
||||
const phaseSlug = phase ? phase.replace(/_/g, "-") : "prd";
|
||||
const phaseLine = phase ? PHASE_DISPLAY[phase] ?? phase : "PRD";
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
title="Add this section to chat context for Vibn"
|
||||
onClick={() => addSectionToChat(label, phase)}
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderBottom: `1px solid rgba(229,231,235,0.85)`,
|
||||
borderTop: "none",
|
||||
borderLeft: "none",
|
||||
borderRight: "none",
|
||||
display: "flex", gap: 10, alignItems: "flex-start",
|
||||
background: isDone ? "rgba(237,233,254,0.55)" : "transparent",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: "50%", flexShrink: 0, marginTop: 1,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: isDone ? JM.indigo : "#fff",
|
||||
border: isDone ? "none" : `1.5px solid ${JM.border}`,
|
||||
color: isDone ? "#fff" : "transparent",
|
||||
fontFamily: JM.fontSans,
|
||||
}}>
|
||||
{isDone ? "✓" : ""}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: JM.ink,
|
||||
lineHeight: 1.3,
|
||||
fontFamily: JM.fontSans,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: JM.indigo,
|
||||
fontFamily: JM.fontSans,
|
||||
}}>
|
||||
{phaseSlug}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: isDone ? "#059669" : JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}>
|
||||
{isDone ? "Done" : "Pending"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: JM.muted,
|
||||
marginTop: 3,
|
||||
fontFamily: JM.fontSans,
|
||||
lineHeight: 1.35,
|
||||
}}>
|
||||
Discovery · {phaseLine}
|
||||
{!isDone ? " · complete in chat" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
if (effectiveGroupBy === "none") {
|
||||
return rows.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`));
|
||||
}
|
||||
|
||||
if (effectiveGroupBy === "phase") {
|
||||
const byPhase = new Map<string, typeof rows>();
|
||||
for (const r of rows) {
|
||||
const pk = r.phase ?? "null";
|
||||
if (!byPhase.has(pk)) byPhase.set(pk, []);
|
||||
byPhase.get(pk)!.push(r);
|
||||
}
|
||||
const order = [...DISCOVERY_PHASES, "null"];
|
||||
return order.flatMap(pk => {
|
||||
const list = byPhase.get(pk);
|
||||
if (!list?.length) return [];
|
||||
const header = pk === "null" ? "Final" : PHASE_DISPLAY[pk] ?? pk;
|
||||
const phaseClickable = pk !== "null";
|
||||
return [
|
||||
phaseClickable ? (
|
||||
<button
|
||||
key={`h-${pk}`}
|
||||
type="button"
|
||||
title={`Add discovery phase "${header}" to chat context`}
|
||||
onClick={() => addPhaseToChat(pk, header)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "8px 12px 6px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
border: "none",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={`h-${pk}`}
|
||||
style={{
|
||||
padding: "8px 12px 4px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
),
|
||||
...list.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`)),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const doneRows = rows.filter(r => sectionDone(r.phase, savedPhaseIds, allDone));
|
||||
const todoRows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
|
||||
const statusBlocks: ReactNode[] = [];
|
||||
if (todoRows.length > 0) {
|
||||
statusBlocks.push(
|
||||
<div
|
||||
key="h-todo"
|
||||
style={{
|
||||
padding: "8px 12px 4px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
}}
|
||||
>
|
||||
To do
|
||||
</div>
|
||||
);
|
||||
todoRows.forEach(r => {
|
||||
statusBlocks.push(renderRow(r.label, r.phase, `todo-${r.label}-${r.index}`));
|
||||
});
|
||||
}
|
||||
if (doneRows.length > 0) {
|
||||
statusBlocks.push(
|
||||
<div
|
||||
key="h-done"
|
||||
style={{
|
||||
padding: "8px 12px 4px",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
fontFamily: JM.fontSans,
|
||||
background: "#EDE9FE",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</div>
|
||||
);
|
||||
doneRows.forEach(r => {
|
||||
statusBlocks.push(renderRow(r.label, r.phase, `done-${r.label}-${r.index}`));
|
||||
});
|
||||
}
|
||||
return statusBlocks;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{allDone && (
|
||||
<div style={{ padding: "10px 12px", borderTop: `1px solid ${JM.border}`, flexShrink: 0, background: "#FAF8FF" }}>
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/tasks`}
|
||||
style={{
|
||||
display: "block", textAlign: "center",
|
||||
padding: "10px 0", borderRadius: 8,
|
||||
background: JM.primaryGradient,
|
||||
color: "#fff",
|
||||
fontSize: 12, fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontFamily: JM.fontSans,
|
||||
boxShadow: JM.primaryShadow,
|
||||
}}
|
||||
>
|
||||
Open Tasks →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
129
components/project-main/MvpSetupDescribeView.tsx
Normal file
129
components/project-main/MvpSetupDescribeView.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { JM, JV } from "@/components/project-creation/modal-theme";
|
||||
import { AtlasChat } from "@/components/AtlasChat";
|
||||
import {
|
||||
BuildLivePlanPanel,
|
||||
addSectionContextRef,
|
||||
} from "@/components/project-main/BuildLivePlanPanel";
|
||||
import {
|
||||
type ChatContextRef,
|
||||
contextRefKey,
|
||||
} from "@/lib/chat-context-refs";
|
||||
|
||||
export function MvpSetupDescribeView({ projectId, workspace }: { projectId: string; workspace: string }) {
|
||||
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
|
||||
const [tab, setTab] = useState<"chat" | "plan">("chat");
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 900px)");
|
||||
const apply = () => setNarrow(mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
}, []);
|
||||
|
||||
const removeChatContextRef = useCallback((key: string) => {
|
||||
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
|
||||
}, []);
|
||||
|
||||
const addPlanSectionToChat = useCallback((label: string, phaseId: string | null) => {
|
||||
setChatContextRefs(prev => addSectionContextRef(prev, label, phaseId));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, background: JV.chatColumnBg }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "18px 28px 14px",
|
||||
background: "#fff",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: JM.ink, marginBottom: 3, fontFamily: JM.fontDisplay }}>
|
||||
Describe
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: JM.muted }}>
|
||||
Tell Vibn about your idea — your plan fills in on the right as you go.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{narrow && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderBottom: `1px solid ${JM.border}`,
|
||||
background: "#EEF0FF",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{(["chat", "plan"] as const).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "11px 8px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
fontWeight: tab === id ? 600 : 500,
|
||||
color: tab === id ? JM.indigo : JM.muted,
|
||||
borderBottom: tab === id ? `2px solid ${JM.indigo}` : "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
{id === "chat" ? "Chat" : "Your plan"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: narrow && tab !== "chat" ? "none" : "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AtlasChat
|
||||
projectId={projectId}
|
||||
conversationScope="overview"
|
||||
contextEmptyLabel="Plan"
|
||||
emptyStateHint="Answer Vibn’s questions — each phase you complete updates your plan."
|
||||
chatContextRefs={chatContextRefs}
|
||||
onRemoveChatContextRef={removeChatContextRef}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: narrow ? undefined : 308,
|
||||
flex: narrow && tab === "plan" ? 1 : undefined,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
display: narrow && tab !== "plan" ? "none" : "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<div style={{ flex: 1, background: JV.prdPanelBg }} />}>
|
||||
<BuildLivePlanPanel
|
||||
projectId={projectId}
|
||||
workspace={workspace}
|
||||
chatContextRefs={chatContextRefs}
|
||||
onAddSectionRef={addPlanSectionToChat}
|
||||
compactHeader={narrow}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
components/project-main/MvpSetupLayoutClient.tsx
Normal file
174
components/project-main/MvpSetupLayoutClient.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
const BUILD_LEFT_BG = "#faf8f5";
|
||||
const BUILD_LEFT_BORDER = "#e8e4dc";
|
||||
|
||||
export function MvpSetupLayoutClient({
|
||||
workspace,
|
||||
projectId,
|
||||
children,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const base = `/${workspace}/project/${projectId}/mvp-setup`;
|
||||
|
||||
const steps = [
|
||||
{ href: `${base}/describe`, label: "Describe", sub: "Your idea", suffix: "/describe" },
|
||||
{ href: `${base}/architect`, label: "Architect", sub: "Discovery", suffix: "/architect" },
|
||||
{ href: `${base}/design`, label: "Design", sub: "Look & feel", suffix: "/design" },
|
||||
{ href: `${base}/website`, label: "Website", sub: "Grow", suffix: "/website" },
|
||||
{ href: `${base}/launch`, label: "Build MVP", sub: "Review & launch", suffix: "/launch" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
fontFamily: JM.fontSans,
|
||||
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 200,
|
||||
flexShrink: 0,
|
||||
borderRight: `1px solid ${BUILD_LEFT_BORDER}`,
|
||||
background: "#fff",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "18px 12px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "0 6px", marginBottom: 20 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: JM.primaryGradient,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>V</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: JM.ink, letterSpacing: "-0.02em" }}>MVP setup</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: JM.muted, paddingLeft: 34 }}>New product flow</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: JM.muted,
|
||||
padding: "0 6px",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Steps
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
{steps.map(step => {
|
||||
const active = pathname.includes(`${base}${step.suffix}`);
|
||||
return (
|
||||
<Link
|
||||
key={step.suffix}
|
||||
href={step.href}
|
||||
scroll={false}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
padding: "9px 10px",
|
||||
borderRadius: 8,
|
||||
textDecoration: "none",
|
||||
background: active ? "#fafaff" : "transparent",
|
||||
border: active ? `1px solid rgba(99,102,241,0.2)` : "1px solid transparent",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: active ? JM.primaryGradient : "#e5e7eb",
|
||||
color: active ? "#fff" : JM.muted,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{active ? "▲" : "○"}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: active ? 600 : 500, color: JM.ink }}>{step.label}</div>
|
||||
<div style={{ fontSize: 10, color: JM.muted }}>{step.sub}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: `1px solid ${BUILD_LEFT_BORDER}`, marginTop: 14, paddingTop: 12, flexShrink: 0 }}>
|
||||
<Link
|
||||
href={`/${workspace}/projects`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
background: "#eef2ff",
|
||||
border: "1px solid #e0e7ff",
|
||||
borderRadius: 8,
|
||||
padding: "9px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: JM.indigo,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Save & go to dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/build`}
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: 10,
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: JM.muted,
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
Open Build workspace →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
components/project-main/MvpSetupStepPlaceholder.tsx
Normal file
76
components/project-main/MvpSetupStepPlaceholder.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
export function MvpSetupStepPlaceholder({
|
||||
title,
|
||||
subtitle,
|
||||
body,
|
||||
primaryHref,
|
||||
primaryLabel,
|
||||
nextHref,
|
||||
nextLabel,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
body: string;
|
||||
primaryHref: string;
|
||||
primaryLabel: string;
|
||||
nextHref: string;
|
||||
nextLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
padding: "28px 32px",
|
||||
fontFamily: JM.fontSans,
|
||||
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 520 }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: JM.ink, margin: "0 0 8px", fontFamily: JM.fontDisplay }}>
|
||||
{title}
|
||||
</h1>
|
||||
<p style={{ fontSize: 13.5, color: JM.muted, margin: "0 0 24px", lineHeight: 1.55 }}>{subtitle}</p>
|
||||
<p style={{ fontSize: 14, color: JM.ink, lineHeight: 1.65, margin: "0 0 28px" }}>{body}</p>
|
||||
<Link
|
||||
href={primaryHref}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "12px 22px",
|
||||
borderRadius: 10,
|
||||
background: JM.primaryGradient,
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
boxShadow: JM.primaryShadow,
|
||||
marginRight: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href={nextHref}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "12px 18px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${JM.border}`,
|
||||
color: JM.indigo,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{nextLabel} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
358
components/project-main/ProjectInfraPanel.tsx
Normal file
358
components/project-main/ProjectInfraPanel.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
export type ProjectInfraRouteBase = "run" | "infrastructure";
|
||||
|
||||
export interface ProjectInfraPanelProps {
|
||||
routeBase: ProjectInfraRouteBase;
|
||||
/** Uppercase rail heading (e.g. Run vs Infrastructure) */
|
||||
navGroupLabel: string;
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InfraApp {
|
||||
name: string;
|
||||
domain?: string | null;
|
||||
coolifyServiceUuid?: string | null;
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
apps?: InfraApp[];
|
||||
}
|
||||
|
||||
// ── Tab definitions ───────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ id: "builds", label: "Builds", icon: "⬡" },
|
||||
{ id: "databases", label: "Databases", icon: "◫" },
|
||||
{ id: "services", label: "Services", icon: "◎" },
|
||||
{ id: "environment", label: "Environment", icon: "≡" },
|
||||
{ id: "domains", label: "Domains", icon: "◬" },
|
||||
{ id: "logs", label: "Logs", icon: "≈" },
|
||||
] as const;
|
||||
|
||||
type TabId = typeof TABS[number]["id"];
|
||||
|
||||
// ── Shared empty state ────────────────────────────────────────────────────────
|
||||
|
||||
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
padding: 60, textAlign: "center", gap: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "1.5rem", color: "#b5b0a6",
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 8, padding: "8px 18px",
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
|
||||
opacity: 0.4, cursor: "default",
|
||||
}}>
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builds tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function BuildsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="⬡"
|
||||
title="No deployments yet"
|
||||
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Deployed Apps
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}>⬡</span>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
|
||||
{app.domain && (
|
||||
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Databases tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DatabasesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◫"
|
||||
title="Databases"
|
||||
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Services tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ServicesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◎"
|
||||
title="Services"
|
||||
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Environment tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function EnvironmentTab() {
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Environment Variables & Secrets
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
overflow: "hidden", marginBottom: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "10px 18px", background: "#faf8f5",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
|
||||
letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
}}>
|
||||
<span>Key</span><span>Value</span><span />
|
||||
</div>
|
||||
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
|
||||
<div key={k} style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}>••••••••</span>
|
||||
<button type="button" style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||
<button type="button" style={{
|
||||
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
|
||||
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
|
||||
cursor: "pointer", width: "100%",
|
||||
}}>
|
||||
+ Add variable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
|
||||
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Domains tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DomainsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = (project?.apps ?? []).filter(a => a.domain);
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Domains & SSL
|
||||
</div>
|
||||
{apps.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
|
||||
{app.domain}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
|
||||
padding: "32px 24px", textAlign: "center", marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
|
||||
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" style={{
|
||||
background: "#1a1a1a", color: "#fff", border: "none",
|
||||
borderRadius: 8, padding: "9px 20px",
|
||||
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
|
||||
opacity: 0.5,
|
||||
}}>
|
||||
+ Add domain
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logs tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function LogsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="≈"
|
||||
title="No logs yet"
|
||||
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 900 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Runtime Logs
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
|
||||
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
|
||||
lineHeight: 1.6, minHeight: 200,
|
||||
}}>
|
||||
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
|
||||
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function ProjectInfraPanelInner({ routeBase, navGroupLabel }: ProjectInfraPanelProps) {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
|
||||
const [project, setProject] = useState<ProjectData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/apps`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const setTab = (id: TabId) => {
|
||||
router.push(`/${workspace}/project/${projectId}/${routeBase}?tab=${id}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: JM.fontSans, overflow: "hidden" }}>
|
||||
|
||||
<div style={{
|
||||
width: 190, flexShrink: 0,
|
||||
borderRight: "1px solid #e8e4dc",
|
||||
background: "#faf8f5",
|
||||
display: "flex", flexDirection: "column",
|
||||
padding: "16px 8px",
|
||||
gap: 2,
|
||||
overflow: "auto",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
padding: "0 8px 10px",
|
||||
}}>
|
||||
{navGroupLabel}
|
||||
</div>
|
||||
{TABS.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setTab(tab.id)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 9,
|
||||
padding: "7px 10px", borderRadius: 6,
|
||||
background: active ? "#f0ece4" : "transparent",
|
||||
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
|
||||
color: active ? "#1a1a1a" : "#6b6560",
|
||||
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
|
||||
transition: "background 0.1s",
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{activeTab === "builds" && <BuildsTab project={project} />}
|
||||
{activeTab === "databases" && <DatabasesTab />}
|
||||
{activeTab === "services" && <ServicesTab />}
|
||||
{activeTab === "environment" && <EnvironmentTab />}
|
||||
{activeTab === "domains" && <DomainsTab project={project} />}
|
||||
{activeTab === "logs" && <LogsTab project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectInfraPanel(props: ProjectInfraPanelProps) {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: JM.muted, fontFamily: JM.fontSans, fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<ProjectInfraPanelInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
18
docker-compose.local-db.yml
Normal file
18
docker-compose.local-db.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Local-only Postgres for `next dev` + NextAuth (Prisma). Not for production.
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.local-db.yml up -d
|
||||
# DATABASE_URL=postgresql://vibn:vibn@localhost:5433/vibn npx prisma db push
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_USER: vibn
|
||||
POSTGRES_PASSWORD: vibn
|
||||
POSTGRES_DB: vibn
|
||||
volumes:
|
||||
- vibn_pg_local:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
vibn_pg_local:
|
||||
@@ -6,7 +6,7 @@
|
||||
* 2. Per-workspace bearer API key (`Authorization: Bearer vibn_sk_...`)
|
||||
*
|
||||
* Either way we resolve a `WorkspacePrincipal` that is scoped to one
|
||||
* workspace. Routes that touch Coolify/Gitea/Theia must call
|
||||
* workspace. Routes that touch Coolify/Gitea must call
|
||||
* `requireWorkspacePrincipal()` and use `principal.workspace` to fetch
|
||||
* the right Coolify Project / Gitea org.
|
||||
*/
|
||||
|
||||
49
lib/chat-context-refs.ts
Normal file
49
lib/chat-context-refs.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* References attached from the overview sidebar so the user can point Vibn at a
|
||||
* PRD section or discovery phase. Sent with chat; not shown in stored user text.
|
||||
*/
|
||||
export type ChatContextRef =
|
||||
| { kind: "section"; label: string; phaseId: string | null }
|
||||
| { kind: "phase"; label: string; phaseId: string }
|
||||
| { kind: "app"; label: string; path: string };
|
||||
|
||||
export function contextRefKey(r: ChatContextRef): string {
|
||||
if (r.kind === "section") return `s:${r.label}`;
|
||||
if (r.kind === "phase") return `p:${r.phaseId}`;
|
||||
return `a:${r.path}`;
|
||||
}
|
||||
|
||||
export function augmentAtlasMessage(
|
||||
message: string,
|
||||
refs: ChatContextRef[] | null | undefined
|
||||
): string {
|
||||
const list = refs?.filter(Boolean) ?? [];
|
||||
if (!list.length) return message;
|
||||
const lines = list.map(r =>
|
||||
r.kind === "section"
|
||||
? `- PRD section: "${r.label}"${r.phaseId ? ` (discovery phase: ${r.phaseId})` : ""}`
|
||||
: r.kind === "phase"
|
||||
? `- Discovery phase: "${r.label}" (id: ${r.phaseId})`
|
||||
: `- Monorepo workspace / package: "${r.label}" (path: ${r.path})`
|
||||
);
|
||||
return `[The user attached these project references for this message. Prioritize them in your reply:\n${lines.join("\n")}\n]\n\n${message}`;
|
||||
}
|
||||
|
||||
export function parseContextRefs(raw: unknown): ChatContextRef[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: ChatContextRef[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const o = item as Record<string, unknown>;
|
||||
if (o.kind === "section" && typeof o.label === "string") {
|
||||
const phaseId: string | null =
|
||||
o.phaseId === null ? null : typeof o.phaseId === "string" ? o.phaseId : null;
|
||||
out.push({ kind: "section", label: o.label, phaseId });
|
||||
} else if (o.kind === "phase" && typeof o.label === "string" && typeof o.phaseId === "string") {
|
||||
out.push({ kind: "phase", label: o.label, phaseId: o.phaseId });
|
||||
} else if (o.kind === "app" && typeof o.label === "string" && typeof o.path === "string") {
|
||||
out.push({ kind: "app", label: o.label, path: o.path });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Cloud Run Workspace Provisioning
|
||||
*
|
||||
* Provisions a dedicated Theia IDE instance per Vibn project using
|
||||
* Google Cloud Run. Each workspace:
|
||||
* - Gets its own Cloud Run service: theia-{slug}
|
||||
* - Scales to zero when idle (zero cost when not in use)
|
||||
* - Starts in ~5-15s from cached image on demand
|
||||
* - Is accessible at the Cloud Run URL stored on the project record
|
||||
* - Auth is enforced by our Vibn session before the URL is revealed
|
||||
*/
|
||||
|
||||
import { GoogleAuth, JWT } from 'google-auth-library';
|
||||
|
||||
const PROJECT_ID = 'master-ai-484822';
|
||||
const REGION = 'northamerica-northeast1';
|
||||
const IMAGE = `${REGION}-docker.pkg.dev/${PROJECT_ID}/vibn-ide/theia:latest`;
|
||||
const VIBN_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com';
|
||||
|
||||
const CLOUD_RUN_API = `https://run.googleapis.com/v2/projects/${PROJECT_ID}/locations/${REGION}/services`;
|
||||
const SCOPES = ['https://www.googleapis.com/auth/cloud-platform'];
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
// Prefer an explicit service account key (avoids GCE metadata scope limitations).
|
||||
// Stored as base64 to survive Docker ARG/ENV special-character handling.
|
||||
const keyB64 = process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64;
|
||||
if (keyB64) {
|
||||
const keyJson = Buffer.from(keyB64, 'base64').toString('utf-8');
|
||||
const key = JSON.parse(keyJson) as {
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
};
|
||||
const jwt = new JWT({
|
||||
email: key.client_email,
|
||||
key: key.private_key,
|
||||
scopes: SCOPES,
|
||||
});
|
||||
const token = await jwt.getAccessToken();
|
||||
if (!token.token) throw new Error('Failed to get GCP access token from service account key');
|
||||
return token.token as string;
|
||||
}
|
||||
|
||||
// Fall back to ADC (works locally or on GCE with cloud-platform scope)
|
||||
const auth = new GoogleAuth({ scopes: SCOPES });
|
||||
const client = await auth.getClient();
|
||||
const token = await client.getAccessToken();
|
||||
if (!token.token) throw new Error('Failed to get GCP access token');
|
||||
return token.token;
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
serviceUrl: string; // https://theia-{slug}-xxx.run.app
|
||||
serviceName: string; // theia-{slug}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Cloud Run service for a Vibn project workspace.
|
||||
* The service scales to zero when idle and starts on first request.
|
||||
*/
|
||||
export async function provisionTheiaWorkspace(
|
||||
slug: string,
|
||||
projectId: string,
|
||||
giteaRepo: string | null,
|
||||
): Promise<ProvisionResult> {
|
||||
const token = await getAccessToken();
|
||||
const serviceName = `theia-${slug}`.slice(0, 49); // Cloud Run max 49 chars
|
||||
|
||||
// Cloud Run v2: name must be empty in the body — it's passed via ?serviceId= in the URL
|
||||
const serviceBody = {
|
||||
template: {
|
||||
scaling: {
|
||||
minInstanceCount: 0, // scale to zero when idle
|
||||
maxInstanceCount: 1, // one instance per workspace
|
||||
},
|
||||
containers: [{
|
||||
image: IMAGE,
|
||||
ports: [{ containerPort: 3000 }],
|
||||
resources: {
|
||||
limits: { cpu: '1', memory: '2Gi' },
|
||||
cpuIdle: true, // only allocate CPU during requests
|
||||
},
|
||||
env: [
|
||||
{ name: 'VIBN_PROJECT_ID', value: projectId },
|
||||
{ name: 'VIBN_PROJECT_SLUG', value: slug },
|
||||
{ name: 'VIBN_API_URL', value: VIBN_URL },
|
||||
{ name: 'GITEA_REPO', value: giteaRepo ?? '' },
|
||||
{ name: 'GITEA_API_URL', value: process.env.GITEA_API_URL ?? 'https://git.vibnai.com' },
|
||||
// Token lets the startup script clone and push to the project's repo
|
||||
{ name: 'GITEA_TOKEN', value: process.env.GITEA_API_TOKEN ?? '' },
|
||||
// Gemini API key — needed by startup.sh to configure AI features in Theia
|
||||
{ name: 'GOOGLE_API_KEY', value: process.env.GOOGLE_API_KEY ?? '' },
|
||||
],
|
||||
// 5 minute startup timeout — Theia needs time to initialise
|
||||
startupProbe: {
|
||||
httpGet: { path: '/', port: 3000 },
|
||||
failureThreshold: 30,
|
||||
periodSeconds: 10,
|
||||
},
|
||||
}],
|
||||
// Keep container alive for 15 minutes of idle before scaling to zero
|
||||
timeout: '900s',
|
||||
},
|
||||
ingress: 'INGRESS_TRAFFIC_ALL',
|
||||
};
|
||||
|
||||
const createRes = await fetch(`${CLOUD_RUN_API}?serviceId=${serviceName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(serviceBody),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.text();
|
||||
|
||||
// 409 = service already exists — fetch its URL instead of failing
|
||||
if (createRes.status === 409) {
|
||||
console.log(`[workspace] Cloud Run service already exists: ${serviceName} — fetching existing URL`);
|
||||
const serviceUrl = await waitForServiceUrl(serviceName, token);
|
||||
await allowUnauthenticated(serviceName, token);
|
||||
console.log(`[workspace] Linked to existing service: ${serviceName} → ${serviceUrl}`);
|
||||
return { serviceUrl, serviceName };
|
||||
}
|
||||
|
||||
throw new Error(`Cloud Run create service failed (${createRes.status}): ${body}`);
|
||||
}
|
||||
|
||||
// Make service publicly accessible (auth handled by Vibn before URL is revealed)
|
||||
await allowUnauthenticated(serviceName, token);
|
||||
|
||||
// Poll until the service URL is available (usually 10-30s)
|
||||
const serviceUrl = await waitForServiceUrl(serviceName, token);
|
||||
|
||||
console.log(`[workspace] Cloud Run service ready: ${serviceName} → ${serviceUrl}`);
|
||||
return { serviceUrl, serviceName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants allUsers invoker access so the service URL works without GCP auth.
|
||||
* Vibn controls access by only sharing the URL with the project owner.
|
||||
*/
|
||||
async function allowUnauthenticated(serviceName: string, token: string): Promise<void> {
|
||||
await fetch(`${CLOUD_RUN_API}/${serviceName}:setIamPolicy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
policy: {
|
||||
bindings: [{ role: 'roles/run.invoker', members: ['allUsers'] }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls until Cloud Run reports the service URL (service is ready).
|
||||
*/
|
||||
async function waitForServiceUrl(serviceName: string, token: string, maxWaitMs = 60_000): Promise<string> {
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const res = await fetch(`${CLOUD_RUN_API}/${serviceName}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const svc = await res.json() as { urls?: string[] };
|
||||
if (svc.urls?.[0]) return svc.urls[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Return expected URL pattern even if polling timed out
|
||||
return `https://${serviceName}-${PROJECT_ID.slice(-6)}.${REGION}.run.app`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a warm-up request to a workspace so the container is ready
|
||||
* before the user clicks "Open IDE". Call this on user login.
|
||||
*/
|
||||
export async function prewarmWorkspace(serviceUrl: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${serviceUrl}/`, { signal: AbortSignal.timeout(5000) });
|
||||
} catch {
|
||||
// Ignore — fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Cloud Run workspace service.
|
||||
*/
|
||||
export async function deleteTheiaWorkspace(serviceName: string): Promise<void> {
|
||||
const token = await getAccessToken();
|
||||
await fetch(`${CLOUD_RUN_API}/${serviceName}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Coolify Workspace Provisioning
|
||||
*
|
||||
* Provisions a dedicated Theia IDE instance per Vibn project using the
|
||||
* Coolify Docker image application API. Each workspace is:
|
||||
* - Hosted at {slug}.ide.vibnai.com
|
||||
* - Protected by the vibn-auth ForwardAuth (project-owner only)
|
||||
* - Running ghcr.io/eclipse-theia/theia-blueprint/theia-ide:latest
|
||||
*/
|
||||
|
||||
const COOLIFY_URL = process.env.COOLIFY_URL ?? 'http://34.19.250.135:8000';
|
||||
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
|
||||
|
||||
// Coolify resource IDs (stable — tied to the Vibn server/project setup)
|
||||
const COOLIFY_PROJECT_UUID = 'f4owwggokksgw0ogo0844os0'; // "Vibn" project
|
||||
const COOLIFY_ENVIRONMENT = 'production';
|
||||
const COOLIFY_SERVER_UUID = 'jws4g4cgssss4cw48s488woc'; // localhost (Coolify host)
|
||||
|
||||
const THEIA_IMAGE_NAME = 'ghcr.io/eclipse-theia/theia-blueprint/theia-ide';
|
||||
const THEIA_IMAGE_TAG = 'latest';
|
||||
const THEIA_PORT = '3000';
|
||||
const IDE_DOMAIN_SUFFIX = '.ide.vibnai.com';
|
||||
|
||||
function coolifyHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the newline-separated Traefik label string that Coolify stores
|
||||
* as custom_labels. We add vibn-auth@file to the HTTPS router middleware
|
||||
* chain after Coolify's generated labels.
|
||||
*
|
||||
* Router naming convention observed in Coolify:
|
||||
* https-0-{uuid} → the TLS router for the app
|
||||
*/
|
||||
function buildCustomLabels(appUuid: string): string {
|
||||
const routerName = `https-0-${appUuid}`;
|
||||
return [
|
||||
'traefik.enable=true',
|
||||
`traefik.http.routers.${routerName}.middlewares=vibn-auth@file,gzip`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
appUuid: string;
|
||||
workspaceUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Coolify Docker-image application for a Vibn project Theia workspace.
|
||||
* Sets the vibn-auth ForwardAuth middleware so only the project owner can access it.
|
||||
*/
|
||||
export async function provisionTheiaWorkspace(
|
||||
slug: string,
|
||||
projectId: string,
|
||||
giteaRepo: string | null,
|
||||
): Promise<ProvisionResult> {
|
||||
const workspaceUrl = `https://${slug}${IDE_DOMAIN_SUFFIX}`;
|
||||
const appName = `theia-${slug}`;
|
||||
|
||||
// ── Step 1: Create the app ────────────────────────────────────────────────
|
||||
const createRes = await fetch(`${COOLIFY_URL}/api/v1/applications/dockerimage`, {
|
||||
method: 'POST',
|
||||
headers: coolifyHeaders(),
|
||||
body: JSON.stringify({
|
||||
project_uuid: COOLIFY_PROJECT_UUID,
|
||||
environment_name: COOLIFY_ENVIRONMENT,
|
||||
server_uuid: COOLIFY_SERVER_UUID,
|
||||
docker_registry_image_name: THEIA_IMAGE_NAME,
|
||||
docker_registry_image_tag: THEIA_IMAGE_TAG,
|
||||
name: appName,
|
||||
description: `Theia IDE for Vibn project ${slug}`,
|
||||
ports_exposes: THEIA_PORT,
|
||||
domains: workspaceUrl,
|
||||
instant_deploy: false, // we deploy after patching labels
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.text();
|
||||
throw new Error(`Coolify create app failed (${createRes.status}): ${body}`);
|
||||
}
|
||||
|
||||
const { uuid: appUuid } = await createRes.json() as { uuid: string };
|
||||
|
||||
// ── Step 2: Patch with vibn-auth Traefik labels ───────────────────────────
|
||||
const patchRes = await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}`, {
|
||||
method: 'PATCH',
|
||||
headers: coolifyHeaders(),
|
||||
body: JSON.stringify({
|
||||
custom_labels: buildCustomLabels(appUuid),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!patchRes.ok) {
|
||||
console.warn(`[workspace] PATCH labels failed (${patchRes.status}) — continuing`);
|
||||
}
|
||||
|
||||
// ── Step 3: Set environment variables ────────────────────────────────────
|
||||
const giteaBaseUrl = process.env.GITEA_URL ?? 'https://git.vibnai.com';
|
||||
const giteaToken = process.env.GITEA_TOKEN ?? '';
|
||||
// Authenticated clone URL so Theia can git clone on startup
|
||||
const giteaCloneUrl = giteaRepo
|
||||
? `https://${giteaToken ? `oauth2:${giteaToken}@` : ''}${giteaBaseUrl.replace(/^https?:\/\//, '')}/${giteaRepo}.git`
|
||||
: '';
|
||||
|
||||
const envVars = [
|
||||
{ key: 'VIBN_PROJECT_ID', value: projectId, is_preview: false },
|
||||
{ key: 'VIBN_PROJECT_SLUG', value: slug, is_preview: false },
|
||||
{ key: 'GITEA_REPO', value: giteaRepo ?? '', is_preview: false },
|
||||
{ key: 'GITEA_CLONE_URL', value: giteaCloneUrl, is_preview: false },
|
||||
{ key: 'GITEA_API_URL', value: giteaBaseUrl, is_preview: false },
|
||||
// Theia opens this path as its workspace root
|
||||
{ key: 'THEIA_WORKSPACE_ROOT', value: `/home/theia/${slug}`, is_preview: false },
|
||||
];
|
||||
|
||||
await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}/envs/bulk`, {
|
||||
method: 'POST',
|
||||
headers: coolifyHeaders(),
|
||||
body: JSON.stringify({ data: envVars }),
|
||||
});
|
||||
|
||||
// ── Step 4: Deploy ────────────────────────────────────────────────────────
|
||||
await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}/start`, {
|
||||
method: 'POST',
|
||||
headers: coolifyHeaders(),
|
||||
});
|
||||
|
||||
console.log(`[workspace] Provisioned ${appName} → ${workspaceUrl} (uuid: ${appUuid})`);
|
||||
|
||||
return { appUuid, workspaceUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a provisioned Theia workspace from Coolify.
|
||||
*/
|
||||
export async function deleteTheiaWorkspace(appUuid: string): Promise<void> {
|
||||
await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}`, {
|
||||
method: 'DELETE',
|
||||
headers: coolifyHeaders(),
|
||||
});
|
||||
}
|
||||
@@ -408,6 +408,16 @@ export async function setApplicationDomains(
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
});
|
||||
// Coolify API: send `domains` (NOT `fqdn`). The controller maps it to
|
||||
// the DB's `fqdn` column internally, but only when the destination
|
||||
// server has `proxy.type=TRAEFIK` (or CADDY) AND `is_build_server=false`
|
||||
// — i.e. when Server::isProxyShouldRun() returns true. If either is
|
||||
// misconfigured, the controller silently drops the field (PATCH returns
|
||||
// 200, fqdn unchanged). We hit this on the missinglettr-test app on
|
||||
// 2026-04-22; the underlying server had proxy.type=null and
|
||||
// is_build_server=true. Fix is in Coolify server-config (UI/DB), not
|
||||
// the client. Sending `fqdn` directly is rejected with 422 ("This
|
||||
// field is not allowed").
|
||||
return updateApplication(uuid, {
|
||||
domains: normalized.join(','),
|
||||
force_domain_override: opts.forceOverride ?? true,
|
||||
|
||||
10
lib/dev-bypass.ts
Normal file
10
lib/dev-bypass.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Local dev: treat the app as signed-in for data fetching when bypass is on.
|
||||
* Server: lib/auth/session-server.ts must use the same env flag.
|
||||
*/
|
||||
export function isClientDevProjectBypass(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
process.env.NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH === "true"
|
||||
);
|
||||
}
|
||||
@@ -420,10 +420,11 @@ export async function registerDomain(
|
||||
const regUsername = input.regUsername ?? generateHandle(input.domain);
|
||||
const regPassword = input.regPassword ?? generateRandomPassword();
|
||||
|
||||
const nameservers =
|
||||
const nameservers = (
|
||||
input.nameservers && input.nameservers.length >= 2
|
||||
? input.nameservers
|
||||
: defaultNameservers(cfg.mode);
|
||||
: defaultNameservers(cfg.mode)
|
||||
).map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
|
||||
|
||||
const contactSet = {
|
||||
owner: input.contact,
|
||||
@@ -492,6 +493,10 @@ export async function updateDomainNameservers(
|
||||
if (!nameservers || nameservers.length < 2) {
|
||||
throw new Error('At least two nameservers are required');
|
||||
}
|
||||
// OpenSRS rejects FQDN-style nameservers (with trailing dot). Cloud DNS
|
||||
// returns NS records as `ns-cloud-eX.googledomains.com.` so we must strip
|
||||
// the trailing dot before handing them to the registrar.
|
||||
const normalized = nameservers.map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
|
||||
const cfg = configFromEnv(overrides);
|
||||
const parsed = await rawCall(
|
||||
'advanced_update_nameservers',
|
||||
@@ -499,7 +504,7 @@ export async function updateDomainNameservers(
|
||||
{
|
||||
domain,
|
||||
op_type: 'assign',
|
||||
assign_ns: nameservers,
|
||||
assign_ns: normalized,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
33
lib/prd-sections.ts
Normal file
33
lib/prd-sections.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Shared PRD plan sections (discovery phases → document headings). */
|
||||
|
||||
export const DISCOVERY_PHASE_ORDER = [
|
||||
"big_picture",
|
||||
"users_personas",
|
||||
"features_scope",
|
||||
"business_model",
|
||||
"screens_data",
|
||||
"risks_questions",
|
||||
] as const;
|
||||
|
||||
export type DiscoveryPhaseId = (typeof DISCOVERY_PHASE_ORDER)[number];
|
||||
|
||||
export const PRD_PLAN_SECTIONS = [
|
||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" as const },
|
||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" as const },
|
||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" as const },
|
||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" as const },
|
||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" as const },
|
||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" as const },
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" as const },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" as const },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" as const },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" as const },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" as const },
|
||||
] as const;
|
||||
|
||||
/** Matches legacy PrdContent: sections without a phase are never marked done in the checklist. */
|
||||
export function isSectionFilled(phaseId: string | null, savedPhaseIds: Set<string>): boolean {
|
||||
if (phaseId === null) return false;
|
||||
return savedPhaseIds.has(phaseId);
|
||||
}
|
||||
21
marketing/components/justine/JustineAuthShell.tsx
Normal file
21
marketing/components/justine/JustineAuthShell.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/** Compact nav from justine/02_signup.html — use inside [data-justine-auth] + 02-signup.css */
|
||||
export function JustineAuthShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<nav className="justine-auth-nav" aria-label="Auth">
|
||||
<Link href="/" className="justine-auth-nav-brand">
|
||||
<div className="justine-auth-nav-logo">
|
||||
<span className="f">V</span>
|
||||
</div>
|
||||
<span className="justine-auth-nav-wordmark f">vibn</span>
|
||||
</Link>
|
||||
<span className="justine-auth-nav-aside">
|
||||
New to vibn? <Link href="/">View homepage</Link>
|
||||
</span>
|
||||
</nav>
|
||||
<div className="justine-auth-main">{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(); }}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { JustineNav } from "./JustineNav";
|
||||
export { JustineFooter } from "./JustineFooter";
|
||||
export { JustineHomePage } from "./JustineHomePage";
|
||||
export { JustineAuthShell } from "./JustineAuthShell";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user