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