diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx index 90c82a2c..3084a541 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx @@ -3,8 +3,13 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { - Loader2, AlertCircle, ChevronDown, ChevronRight, - Box, Container, CircleDot, + Loader2, + AlertCircle, + ChevronDown, + ChevronRight, + Box, + Container, + CircleDot, } from "lucide-react"; import { GiteaFileTree } from "@/components/project/gitea-file-tree"; import { GiteaFileViewer } from "@/components/project/gitea-file-viewer"; @@ -23,10 +28,7 @@ import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; * part of the product surface. */ -type Selection = - | { type: "file"; codebaseId: string; path: string } - | { type: "image"; uuid: string } - | null; +type Selection = { type: "file"; codebaseId: string; path: string } | null; export default function CodeTab() { const params = useParams(); @@ -34,32 +36,14 @@ export default function CodeTab() { const { anatomy, loading, error } = useAnatomy(projectId); const codebases = anatomy?.product.codebases ?? null; - const images = anatomy?.product.images ?? null; - const reason = anatomy?.codebasesReason; + const reason = anatomy?.codebasesReason; - const [expanded, setExpanded] = useState>(new Set()); const [selection, setSelection] = useState(null); - useEffect(() => { - if (codebases && codebases[0]) { - setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev)); - } - }, [codebases]); - useEffect(() => { setSelection(null); - setExpanded(new Set()); }, [projectId]); - const toggleCodebase = (id: string) => { - setExpanded(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - const showLoading = loading && !anatomy; return ( @@ -68,97 +52,78 @@ export default function CodeTab() { {/* ── Left rail ── */}
{showLoading && ( - Loading… + + Loading… + )} {error && !showLoading && ( - {error} + + {error} + )} {anatomy && ( <> - {/* Codebases */} - + {/* Code Files */} + {codebases && codebases.length === 0 && ( - {reason === "no_repo" - ? <>No codebase yet. Try: "Start building my app" - : <>Repo is empty — push a first commit. Try: "Scaffold a Next.js app"} + {reason === "no_repo" ? ( + <> + No codebase yet.{" "} + + Try: "Start building my app" + + + ) : ( + <> + Repo is empty — push a first commit.{" "} + + Try: "Scaffold a Next.js app" + + + )} )} - {codebases?.map(cb => { - const isOpen = expanded.has(cb.id); + {codebases?.map((cb) => { return (
- - {isOpen && ( -
- - setSelection({ type: "file", codebaseId: cb.id, path: p }) - } - /> -
- )} + +
+ + setSelection({ + type: "file", + codebaseId: cb.id, + path: p, + }) + } + /> +
); })}
- - {/* Images */} - - {images && images.length === 0 && ( - - Self-hosted tools (Twenty CRM, n8n, Plausible…) you run appear here. - Try: "Install Twenty CRM for my project" - - )} - {images?.map(img => ( - - ))} - )}
@@ -170,12 +135,7 @@ export default function CodeTab() { {selection?.type === "file" && ( )} - {selection?.type === "image" && anatomy && ( - - )} - {!selection && ( - Pick a codebase file or an image on the left. - )} + {!selection && Pick a codebase file on the left.} @@ -183,39 +143,19 @@ export default function CodeTab() { ); } -// ────────────────────────────────────────────────── -// Image details (right pane) -// ────────────────────────────────────────────────── - -function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) { - const img = anatomy.product.images.find(i => i.uuid === uuid); - if (!img) return This image is no longer in the project.; - const live = anatomy.hosting.live.find(l => l.uuid === uuid); - - return ( -
- - - - - {live?.fqdn && ( - - )} -
- ); -} - // ────────────────────────────────────────────────── // Bits // ────────────────────────────────────────────────── function RailGroup({ - title, count, children, -}: { title: string; count: number; children: React.ReactNode }) { + title, + count, + children, +}: { + title: string; + count: number; + children: React.ReactNode; +}) { return (
@@ -232,16 +172,28 @@ function RailEmpty({ children }: { children: React.ReactNode }) { } function DetailRow({ - label, value, dot, href, -}: { label: string; value: string; dot?: string; href?: string }) { + label, + value, + dot, + href, +}: { + label: string; + value: string; + dot?: string; + href?: string; +}) { return (
{label} {dot && } {href ? ( - {value} - ) : value} + + {value} + + ) : ( + value + )}
); @@ -249,11 +201,19 @@ function DetailRow({ function Inline({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -261,10 +221,18 @@ function Inline({ children }: { children: React.ReactNode }) { function Empty({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -275,7 +243,7 @@ function Empty({ children }: { children: React.ReactNode }) { function paneHeading(s: Selection): string { if (!s) return "Preview"; if (s.type === "file") return `Preview · ${shortPath(s.path)}`; - return "Image"; + return "Preview"; } function shortPath(p: string) { const parts = p.split("/"); @@ -286,7 +254,8 @@ function statusColor(status: string) { const s = status.toLowerCase(); if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; - if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b"; + if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) + return "#c5392b"; return "#a09a90"; } @@ -318,79 +287,156 @@ const grid: React.CSSProperties = { alignItems: "stretch", }; const leftCol: React.CSSProperties = { - minWidth: 0, display: "flex", flexDirection: "column", gap: 18, + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: 18, }; const rightCol: React.CSSProperties = { - minWidth: 0, display: "flex", flexDirection: "column", + minWidth: 0, + display: "flex", + flexDirection: "column", }; const heading: React.CSSProperties = { - fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em", - textTransform: "uppercase", color: INK.muted, margin: "0 0 14px", + fontSize: "0.72rem", + fontWeight: 600, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, + margin: "0 0 14px", +}; +const railGroup: React.CSSProperties = { + display: "flex", + flexDirection: "column", }; -const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" }; const railGroupHeader: React.CSSProperties = { - display: "flex", alignItems: "center", justifyContent: "space-between", + display: "flex", + alignItems: "center", + justifyContent: "space-between", padding: "0 4px 8px", }; const railGroupTitle: React.CSSProperties = { - fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", - textTransform: "uppercase", color: INK.muted, + fontSize: "0.68rem", + fontWeight: 600, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, }; const countPill: React.CSSProperties = { - fontSize: "0.7rem", fontWeight: 600, color: INK.mid, - padding: "1px 7px", borderRadius: 999, background: "#f3eee4", + fontSize: "0.7rem", + fontWeight: 600, + color: INK.mid, + padding: "1px 7px", + borderRadius: 999, + background: "#f3eee4", +}; +const railItems: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 10, }; -const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 }; const railEmpty: React.CSSProperties = { - padding: "10px 12px", fontSize: "0.74rem", color: INK.muted, - border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, + padding: "10px 12px", + fontSize: "0.74rem", + color: INK.muted, + border: `1px dashed ${INK.borderSoft}`, + borderRadius: 8, lineHeight: 1.6, }; const nudge: React.CSSProperties = { - display: "block", marginTop: 6, fontStyle: "normal", - background: "#f3eee4", borderRadius: 4, padding: "3px 8px", - fontSize: "0.72rem", color: "#7a6a50", + display: "block", + marginTop: 6, + fontStyle: "normal", + background: "#f3eee4", + borderRadius: 4, + padding: "3px 8px", + fontSize: "0.72rem", + color: "#7a6a50", }; const flatTile: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 10, - width: "100%", padding: "12px 14px", - background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, - cursor: "pointer", font: "inherit", color: "inherit", + display: "flex", + alignItems: "center", + gap: 10, + width: "100%", + padding: "12px 14px", + background: INK.cardBg, + border: `1px solid ${INK.borderSoft}`, + borderRadius: 10, + cursor: "pointer", + font: "inherit", + color: "inherit", transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", }; const codebaseTile: React.CSSProperties = { - background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden", + background: INK.cardBg, + border: `1px solid ${INK.borderSoft}`, + borderRadius: 10, + overflow: "hidden", }; const tileHeader: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 8, width: "100%", - padding: "12px 14px", background: "transparent", border: "none", - cursor: "pointer", font: "inherit", color: "inherit", + display: "flex", + alignItems: "center", + gap: 8, + width: "100%", + padding: "12px 14px", + background: "transparent", + border: "none", + font: "inherit", + color: "inherit", }; const tileLabel: React.CSSProperties = { - fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2, + fontSize: "0.85rem", + fontWeight: 600, + color: INK.ink, + marginBottom: 2, +}; +const tileHint: React.CSSProperties = { + fontSize: "0.74rem", + color: INK.mid, + lineHeight: 1.4, }; -const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 }; const tileBody: React.CSSProperties = { - padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`, + padding: "8px 10px 12px", + borderTop: `1px solid ${INK.borderSoft}`, }; const chevronCell: React.CSSProperties = { - width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, + width: 14, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, }; const panel: React.CSSProperties = { - background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, - padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", + background: INK.cardBg, + border: `1px solid ${INK.border}`, + borderRadius: 10, + padding: 16, + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", }; const detailRow: React.CSSProperties = { - display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 4px", + borderBottom: `1px solid ${INK.borderSoft}`, }; const detailLabel: React.CSSProperties = { - fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em", - textTransform: "uppercase", color: INK.muted, + fontSize: "0.72rem", + fontWeight: 600, + letterSpacing: "0.06em", + textTransform: "uppercase", + color: INK.muted, }; const detailValue: React.CSSProperties = { - fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center", + fontSize: "0.85rem", + color: INK.ink, + display: "inline-flex", + alignItems: "center", }; const detailLink: React.CSSProperties = { - color: INK.ink, textDecoration: "underline", + color: INK.ink, + textDecoration: "underline", }; diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx index 126d5cbc..ff1abb32 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/overview/page.tsx @@ -1,683 +1,378 @@ "use client"; -import { useState } from "react"; +import { useAnatomy } from "@/components/project/use-anatomy"; import { useParams } from "next/navigation"; -import { - Loader2, - AlertCircle, - ExternalLink, - Globe, - RefreshCw, - CircleDot, - ChevronDown, - ChevronRight, - Copy, - Check, - Terminal, - Server, -} from "lucide-react"; -import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; +import { ExternalLink, Copy, Users, EyeOff, LayoutGrid } from "lucide-react"; -/** - * Hosting tab — user-facing: "Is my thing live? How do I reach it?" - * - * One endpoint = one card. Each card shows: - * - Live URL (open in new tab) - * - Status dot + plain-language status - * - Redeploy button - * - Domain(s) list - * - Last build (time + status) - * - Expandable recent logs - * - * No master-detail split — with 1-3 services the overhead isn't worth it. - * Previews (dev server URLs) shown below in a secondary section. - */ - -// ────────────────────────────────────────────────── -// Types -// ────────────────────────────────────────────────── - -type LiveItem = Anatomy["hosting"]["live"][number]; -type Preview = Anatomy["hosting"]["previews"][number]; - -// ────────────────────────────────────────────────── -// Main component -// ────────────────────────────────────────────────── - -export default function OverviewTab() { +export default function OverviewPage() { const params = useParams(); const projectId = params.projectId as string; - const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 }); - const showLoading = loading && !anatomy; + const workspace = params.workspace as string; + const { anatomy } = useAnatomy(projectId, { pollMs: 8000 }); + + const activeProjectName = anatomy?.product?.codebases?.[0]?.label ?? "App"; + + // Try to find the primary live URL + const primaryLive = anatomy?.hosting?.live?.find((l) => !!l.fqdn); + const displayUrl = primaryLive ? `https://${primaryLive.fqdn}` : null; return ( -
- {showLoading && ( -
- - - Loading… - +
+ {/* Header card */} +
+
+
+ +
+
+

+ {activeProjectName} +

+

+ A comprehensive operating system for your users, integrating + discovery, registration, operations, and growth automation into + one unified platform. +

+
+ Created a few days ago +
+
- )} - {error && !showLoading && ( -
- - {error} -
- )} - {anatomy && ( - <> - {/* ── Live endpoints ── */} -
- - {anatomy.hosting.live.length === 0 ? ( - } - title="Nothing deployed yet" - hint="Ask the AI to deploy your app and it will appear here." - promptSuggestion="Deploy my app to production" - /> - ) : ( -
- {anatomy.hosting.live.map((item) => ( - - ))} -
- )} -
- - {/* ── Previews ── */} - {anatomy.hosting.previews.length > 0 && ( -
- -
- {anatomy.hosting.previews.map((p) => ( - - ))} -
-
+
+ {displayUrl ? ( + + Open App + + ) : ( + )} - - )} -
- ); -} -// ────────────────────────────────────────────────── -// Live card -// ────────────────────────────────────────────────── + +
+
-function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) { - const [deploying, setDeploying] = useState(false); - const [logsOpen, setLogsOpen] = useState(false); - const [logs, setLogs] = useState(null); - const [logsLoading, setLogsLoading] = useState(false); - const [copied, setCopied] = useState(false); - - const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null; - const phase = classifyPhase(item.status); - const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item); - - const redeploy = async () => { - if (deploying) return; - setDeploying(true); - try { - await fetch(`/api/mcp`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "apps.deploy", - params: { uuid: item.uuid, projectId }, - }), - }); - } finally { - setTimeout(() => setDeploying(false), 3000); - } - }; - - const openLogs = async () => { - if (!logsOpen) { - setLogsOpen(true); - setLogsLoading(true); - try { - const r = await fetch(`/api/mcp`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "apps.logs", - params: { uuid: item.uuid, lines: 60 }, - }), - }); - const d = await r.json(); - setLogs( - typeof d.result === "string" - ? d.result - : JSON.stringify(d.result ?? d.error, null, 2), - ); - } catch { - setLogs("Failed to load logs."); - } finally { - setLogsLoading(false); - } - } else { - setLogsOpen(false); - } - }; - - const copyUrl = () => { - if (!primaryUrl) return; - navigator.clipboard.writeText(primaryUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- {/* ── Card header ── */} -
+
+ {/* App Visibility */}
+
+
+

+ App Visibility +

+

+ Control who can access your application +

+
+
+ + +
+ + {/* Invite Users */} +
+
+
+

+ Invite Users +

+

+ Grow your user base by inviting others +

+
+ +
+ +
+ + +
+
+
+ + {/* Move to Workspace */} +
+
+

+ Move to Workspace +

+

+ Move this app to another workspace +

+
+ +
+ + {/* Platform Badge */} +
+
+

+ Platform Badge +

+

+ The "Built with Vibn" badge is currently visible on your app. +

+
+
-
- -
-
- - {/* ── Status line ── */} -
- - {statusLabel} - - {item.lastBuild && ( - - · Last build {item.lastBuild.status}{" "} - {formatRelative(item.lastBuild.finishedAt)} - - )} -
- - {/* ── Live URL ── */} - {primaryUrl ? ( -
- - - {primaryUrl} - - - -
- ) : ( -
- - - No domain attached — ask the AI to add one. - -
- )} - - {/* ── Extra domains ── */} - {item.domains.length > 1 && ( -
- {item.domains.slice(1).map((d) => ( - - {d}{" "} - - - ))} -
- )} - - {/* ── Logs toggle ── */} -
- - - {logsOpen && ( -
- {logsLoading ? ( - - Loading… - - ) : ( -
{logs || "(no logs)"}
- )} -
- )}
); } - -// ────────────────────────────────────────────────── -// Preview row -// ────────────────────────────────────────────────── - -function PreviewRow({ preview }: { preview: Preview }) { - const running = preview.state === "running"; - return ( -
-
- - - {preview.name} - - - port {preview.port} - - {preview.url && running && ( - - )} -
-
- ); -} - -// ────────────────────────────────────────────────── -// Helpers -// ────────────────────────────────────────────────── - -type Phase = "up" | "deploying" | "down" | "unknown"; - -function classifyPhase(status: string | undefined): Phase { - const s = (status ?? "").toLowerCase(); - if (!s || s === "unknown") return "unknown"; - if (/^(running|healthy)/.test(s)) return "up"; - if ( - /^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test( - s, - ) - ) - return "deploying"; - if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down"; - return "unknown"; -} - -function phaseDisplay( - phase: Phase, - item: LiveItem, -): { color: string; label: string } { - if (item.inFlightBuild) - return { - color: AMBER, - label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`, - }; - switch (phase) { - case "up": - return { color: GREEN, label: "Live" }; - case "deploying": - return { color: AMBER, label: "Starting…" }; - case "down": - return { color: DANGER, label: "Down" }; - default: - return { color: INK.muted, label: "Unknown" }; - } -} - -function formatRelative(iso: string | undefined) { - if (!iso) return ""; - const ms = Date.now() - new Date(iso).getTime(); - if (Number.isNaN(ms)) return ""; - const min = Math.floor(ms / 60_000); - if (min < 1) return "just now"; - if (min < 60) return `${min}m ago`; - const hr = Math.floor(min / 60); - if (hr < 24) return `${hr}h ago`; - return `${Math.floor(hr / 24)}d ago`; -} - -// ────────────────────────────────────────────────── -// Sub-components -// ────────────────────────────────────────────────── - -function SectionHeader({ title, count }: { title: string; count: number }) { - return ( -
- {title} - {count} -
- ); -} - -function EmptySection({ - icon, - title, - hint, - promptSuggestion, -}: { - icon: React.ReactNode; - title: string; - hint: string; - promptSuggestion?: string; -}) { - return ( -
-
{icon}
-
- {title} -
-
- {hint} -
- {promptSuggestion && ( -
- - Try asking: - - - "{promptSuggestion}" - -
- )} -
- ); -} - -// ────────────────────────────────────────────────── -// Tokens -// ────────────────────────────────────────────────── - -const INK = { - ink: "#1a1a1a", - mid: "#5f5e5a", - muted: "#a09a90", - border: "#e8e4dc", - borderSoft: "#efebe1", - cardBg: "#fff", - fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', -} as const; -const GREEN = "#10b981"; -const AMBER = "#f59e0b"; -const DANGER = "#ef4444"; - -// ────────────────────────────────────────────────── -// Styles -// ────────────────────────────────────────────────── - -const pageWrap: React.CSSProperties = { - padding: "28px 48px 64px", - fontFamily: INK.fontSans, - color: INK.ink, - maxWidth: 860, -}; -const centeredMsg: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 10, - padding: "24px 0", -}; -const sectionHeader: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 8, - marginBottom: 14, -}; -const sectionTitle: React.CSSProperties = { - fontSize: "0.68rem", - fontWeight: 700, - letterSpacing: "0.12em", - textTransform: "uppercase", - color: INK.muted, -}; -const countPill: React.CSSProperties = { - fontSize: "0.7rem", - fontWeight: 600, - color: INK.mid, - padding: "1px 7px", - borderRadius: 999, - background: "#f3eee4", -}; -const card: React.CSSProperties = { - background: INK.cardBg, - border: `1px solid ${INK.border}`, - borderRadius: 10, - padding: "18px 20px", -}; -const cardHeader: React.CSSProperties = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 12, - marginBottom: 6, -}; -const cardTitle: React.CSSProperties = { - fontSize: "0.95rem", - fontWeight: 700, - color: INK.ink, -}; -const statusLine: React.CSSProperties = { - fontSize: "0.8rem", - color: INK.mid, - marginBottom: 12, - display: "flex", - alignItems: "center", - gap: 6, - flexWrap: "wrap", -}; -const urlRow: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 8, - background: "#f8f5f0", - borderRadius: 6, - padding: "8px 12px", - marginBottom: 2, -}; -const urlLink: React.CSSProperties = { - fontSize: "0.85rem", - color: INK.ink, - textDecoration: "none", - flex: 1, - minWidth: 0, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - display: "inline-flex", - alignItems: "center", - gap: 4, -}; -const actionBtn: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - gap: 6, - padding: "6px 12px", - border: `1px solid ${INK.border}`, - borderRadius: 6, - background: "#fff", - cursor: "pointer", - font: "inherit", - fontSize: "0.78rem", - fontWeight: 600, - color: INK.mid, - transition: "background 0.1s, border-color 0.1s", -}; -const iconBtn: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 26, - height: 26, - border: "none", - background: "transparent", - cursor: "pointer", - color: INK.muted, - borderRadius: 4, - flexShrink: 0, -}; -const logsToggleBtn: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - gap: 6, - fontSize: "0.75rem", - fontWeight: 600, - color: INK.mid, - background: "none", - border: "none", - cursor: "pointer", - font: "inherit", - padding: 0, -}; -const logsBox: React.CSSProperties = { - marginTop: 10, - background: "#1a1a1a", - borderRadius: 6, - padding: "12px 14px", - maxHeight: 320, - overflowY: "auto", -}; -const logsPre: React.CSSProperties = { - margin: 0, - fontFamily: "ui-monospace, monospace", - fontSize: "0.72rem", - color: "#d4d0c8", - lineHeight: 1.6, - whiteSpace: "pre-wrap", - wordBreak: "break-all", -}; - -const emptyBox: React.CSSProperties = { - border: `1px dashed ${INK.border}`, - borderRadius: 10, - padding: "36px 28px", - textAlign: "center", - display: "flex", - flexDirection: "column", - alignItems: "center", -}; -const promptChip: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - background: "#f3eee4", - borderRadius: 6, - padding: "6px 12px", - fontSize: "0.8rem", -}; - -function sourcePill(source: "repo" | "image"): React.CSSProperties { - const isRepo = source === "repo"; - return { - fontSize: "0.62rem", - fontWeight: 700, - letterSpacing: "0.08em", - textTransform: "uppercase", - color: isRepo ? "#2e6d2e" : "#3b5a78", - background: isRepo ? "#eaf3e8" : "#e9eff5", - padding: "1px 6px", - borderRadius: 4, - flexShrink: 0, - }; -} diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx index d20453e9..a76f5e7c 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx @@ -1,1035 +1,119 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { - Loader2, AlertCircle, Database, KeyRound, CircleDot, - ShieldCheck, Mail, CreditCard, Sparkles, HardDrive, - ExternalLink, Pencil, RotateCw, - ChevronDown, ChevronRight, -} from "lucide-react"; -import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; -import { DatabaseTableTree } from "@/components/project/database-table-tree"; -import { TableViewer } from "@/components/project/table-viewer"; - -/** - * Infrastructure tab — supporting plumbing the product runs on. - * - * Six fixed sub-areas, always visible (even when empty) so a founder - * learns the model on a brand-new project: - * - * Databases · Auth · Email · Payments · Models · Storage · Secrets - * - * Categories explicitly NOT shown (yet): - * - SMS, Analytics, Search, Monitoring — removed Apr 29 2026 to keep - * the surface focused on what's actually wired today. Add back when - * each gets real UX (not just env-var detection). - * - * Storage is special: it's the workspace's bundled GCS-via-HMAC bucket - * (S3-compatible) — not a third-party detection. Every workspace gets - * one auto-provisioned, so the tile is always there with status. - */ - -type ProviderCategory = Anatomy["infrastructure"]["providers"][number]["category"]; - -type CategoryKey = - | "databases" - | "auth" | "email" | "payments" | "llm" - | "storage" - | "secrets"; - -type Selection = - | { kind: "category"; category: CategoryKey } - | { kind: "database"; uuid: string } - | { kind: "table"; dbUuid: string; schema: string; name: string } - | { kind: "provider"; id: string } - | { kind: "storage" } - | { kind: "secrets"; resourceUuid: string } - | null; - -interface CategoryDef { - key: CategoryKey; - label: string; - icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; - blurb: string; - examples: string[]; - /** Vendor → external dashboard URL */ - dashboards?: Record; -} - -const INFRA_NUDGE = { - databases: 'No database yet. Try: "Add a Postgres database to my project"', - auth: 'No auth provider connected. Try: "Add Google OAuth to my app"', - email: 'No email provider. Try: "Set up email sending with Resend"', - payments: 'No payment provider. Try: "Connect Stripe to my project"', - llm: 'No LLM connected. Try: "Add an OpenAI key to this project"', - secrets: 'No secrets stored yet. Try: "Add my Stripe secret key"', -} as const; - -const CATEGORIES: CategoryDef[] = [ - { - key: "databases", label: "Databases", icon: Database, - blurb: "Postgres, Redis, Mongo, ClickHouse — anything your app reads or writes from.", - examples: ["PostgreSQL", "Redis", "MongoDB", "ClickHouse", "MySQL"], - }, - { - key: "auth", label: "Auth", icon: ShieldCheck, - blurb: "Identity, sessions, SSO. Detected from env-var keys (CLERK_*, AUTH0_*, NEXTAUTH_*…).", - examples: ["Clerk", "Auth0", "Supabase Auth", "NextAuth", "WorkOS", "SuperTokens"], - dashboards: { Clerk: "https://dashboard.clerk.com", Auth0: "https://manage.auth0.com", "Supabase Auth": "https://supabase.com/dashboard", WorkOS: "https://dashboard.workos.com" }, - }, - { - key: "email", label: "Email", icon: Mail, - blurb: "Transactional + outbound email. SMS is intentionally not here yet.", - examples: ["Resend", "Postmark", "SendGrid", "Mailgun", "AWS SES", "Loops"], - dashboards: { Resend: "https://resend.com/emails", Postmark: "https://account.postmarkapp.com", SendGrid: "https://app.sendgrid.com", Mailgun: "https://app.mailgun.com", "AWS SES": "https://console.aws.amazon.com/ses" }, - }, - { - key: "payments", label: "Payments", icon: CreditCard, - blurb: "Stripe / Paddle / LemonSqueezy. Connect Stripe to enable webhook + checkout setup (coming soon).", - examples: ["Stripe", "LemonSqueezy", "Paddle"], - dashboards: { Stripe: "https://dashboard.stripe.com", LemonSqueezy: "https://app.lemonsqueezy.com", Paddle: "https://vendors.paddle.com" }, - }, - { - key: "llm", label: "Models", icon: Sparkles, - blurb: "AI models the project calls. BYOK (Bring Your Own Key) lets you use your own provider keys instead of platform defaults.", - examples: ["OpenAI", "Anthropic", "Google AI", "Mistral", "Cohere", "Groq", "OpenRouter"], - dashboards: { OpenAI: "https://platform.openai.com", Anthropic: "https://console.anthropic.com", "Google AI": "https://aistudio.google.com", Mistral: "https://console.mistral.ai", Groq: "https://console.groq.com", OpenRouter: "https://openrouter.ai" }, - }, - { - key: "storage", label: "Storage", icon: HardDrive, - blurb: "Vibn provisions an S3-compatible bucket per workspace. Every project in the workspace shares it.", - examples: ["GCS bucket (S3-compatible)"], - }, - { - key: "secrets", label: "Secrets", icon: KeyRound, - blurb: "Every env var across every app + service. Values are never read here; rotate via the AI chat.", - examples: [], - }, -]; - -function categoryDef(key: CategoryKey): CategoryDef { - return CATEGORIES.find(c => c.key === key)!; -} - -export default function SecurityTab() { - const params = useParams(); - const projectId = params.projectId as string; - const { anatomy, loading, error } = useAnatomy(projectId); - - const [selection, setSelection] = useState(null); - /** Which database tiles are expanded inline to show their tables. */ - const [expandedDbs, setExpandedDbs] = useState>(new Set()); - - const toggleDb = (uuid: string) => { - setExpandedDbs(prev => { - const next = new Set(prev); - if (next.has(uuid)) next.delete(uuid); else next.add(uuid); - return next; - }); - }; - - // Auto-expand the first database tile once anatomy lands so the user - // sees tables immediately on a project that has one DB. - useEffect(() => { - if (anatomy && anatomy.infrastructure.databases[0] && expandedDbs.size === 0) { - setExpandedDbs(new Set([anatomy.infrastructure.databases[0].uuid])); - } - }, [anatomy, expandedDbs.size]); - - const showLoading = loading && !anatomy; +import { Shield, Settings } from "lucide-react"; +export default function SecurityPage() { return ( -
-
- {/* ── Left rail ── */} -
- {showLoading && ( - Loading… - )} - {error && !showLoading && ( - {error} - )} - - {anatomy && CATEGORIES.map(def => ( - - ))} -
- - {/* ── Right pane ── */} - -
-
- ); -} - -// ────────────────────────────────────────────────── -// Left-rail group per category -// ────────────────────────────────────────────────── - -function CategoryRail({ - def, anatomy, selection, onSelect, - projectId, expandedDbs, onToggleDb, -}: { - def: CategoryDef; - anatomy: Anatomy; - selection: Selection; - onSelect: (s: Selection) => void; - projectId: string; - expandedDbs: Set; - onToggleDb: (uuid: string) => void; -}) { - const Icon = def.icon; - const headerActive = selection?.kind === "category" && selection.category === def.key; - - // Storage is a single-tile category backed by workspace state - if (def.key === "storage") { - const s = anatomy.infrastructure.bundledStorage; - const present = s.status !== "unprovisioned"; - const active = selection?.kind === "storage"; - return ( -
- onSelect({ kind: "category", category: def.key })} /> - {present && ( -
- -
- )} -
- ); - } - - // Databases render as expandable cards (like Codebases on Product) so - // the table list lives inline; click a table to preview rows on the right. - if (def.key === "databases") { - const dbs = anatomy.infrastructure.databases; - return ( -
- onSelect({ kind: "category", category: def.key })} /> - {dbs.length > 0 && ( -
- {dbs.map(db => { - const open = expandedDbs.has(db.uuid); - const tileSelected = - selection?.kind === "database" && selection.uuid === db.uuid; - const selectedTable = - selection?.kind === "table" && selection.dbUuid === db.uuid - ? { schema: selection.schema, name: selection.name } - : undefined; - - return ( -
- - {open && ( -
- - onSelect({ kind: "table", dbUuid: db.uuid, schema, name }) - } - /> -
- )} -
- ); - })} -
- )} -
- ); - } - - const items = itemsForCategory(def.key, anatomy); - const count = - def.key === "secrets" - ? anatomy.infrastructure.secrets.total - : items.length; - - return ( -
- ); -} + +
-function CategoryHeader({ - def, count, active, onClick, -}: { - def: CategoryDef; count: number; active: boolean; onClick: () => void; -}) { - const Icon = def.icon; - return ( - - ); -} - -function itemsForCategory(key: CategoryKey, a: Anatomy): unknown[] { - if (key === "databases") return a.infrastructure.databases; - if (key === "secrets") return a.infrastructure.secrets.byResource; - if (key === "storage") return []; - return a.infrastructure.providers.filter(p => p.category === key); -} - -function renderRailItem( - key: CategoryKey, - item: unknown, - selection: Selection, - onSelect: (s: Selection) => void -) { - if (key === "databases") { - const db = item as Anatomy["infrastructure"]["databases"][number]; - const active = selection?.kind === "database" && selection.uuid === db.uuid; - return ( - - ); - } - if (key === "secrets") { - const r = item as Anatomy["infrastructure"]["secrets"]["byResource"][number]; - const active = selection?.kind === "secrets" && selection.resourceUuid === r.resourceUuid; - return ( - - ); - } - // Provider - const p = item as Anatomy["infrastructure"]["providers"][number]; - const active = selection?.kind === "provider" && selection.id === p.id; - const totalKeys = p.attachments.reduce((n, a) => n + a.keys.length, 0); - return ( - - ); -} - -// ────────────────────────────────────────────────── -// Right-pane content -// ────────────────────────────────────────────────── - -function Overview({ - anatomy, onJump, -}: { anatomy: Anatomy; onJump: (s: Selection) => void }) { - const dbCount = anatomy.infrastructure.databases.length; - const providerCount = anatomy.infrastructure.providers.length; - const secretsCount = anatomy.infrastructure.secrets.total; - const storage = anatomy.infrastructure.bundledStorage; - - return ( -
-
- dbCount && onJump({ kind: "database", uuid: anatomy.infrastructure.databases[0].uuid })} - /> - providerCount && onJump({ kind: "provider", id: anatomy.infrastructure.providers[0].id })} - /> - onJump({ kind: "storage" })} - /> - secretsCount && onJump({ kind: "secrets", resourceUuid: anatomy.infrastructure.secrets.byResource[0].resourceUuid })} - /> +

+ Check the security of your app +

+

+ Review your configuration, identify potential risks, and learn how to + strengthen your app's protection +

+
); } - -function CategoryDetail({ - def, anatomy, onJump, -}: { def: CategoryDef; anatomy: Anatomy; onJump: (s: Selection) => void }) { - const items = itemsForCategory(def.key, anatomy); - const count = - def.key === "secrets" ? anatomy.infrastructure.secrets.total : - def.key === "storage" ? (anatomy.infrastructure.bundledStorage.status === "unprovisioned" ? 0 : 1) : - items.length; - - // Per-category functional CTAs (no explainer prose). - let actionRow: React.ReactNode = null; - if (def.key === "payments" && !items.length) { - actionRow = ( - - ); - } else if (def.key === "storage") { - actionRow = ( - - ); - } - - return ( -
- {actionRow} - -
- - Connected{" "} - ({count}) - - {count === 0 ? ( -
- {INFRA_NUDGE[def.key as keyof typeof INFRA_NUDGE] ?? "None yet."} -
- ) : def.key === "secrets" ? ( -
- {anatomy.infrastructure.secrets.byResource.map(r => ( -
- {r.resourceName} - {r.count} keys · {r.resourceKind} -
- ))} -
- ) : def.key === "databases" ? ( -
- {anatomy.infrastructure.databases.map(db => ( -
- {db.name} - {db.type} · {db.status} -
- ))} -
- ) : def.key === "storage" ? ( -
-
- Workspace bucket - {anatomy.infrastructure.bundledStorage.bucketName ?? "—"} -
-
- ) : ( -
- {(items as Anatomy["infrastructure"]["providers"]).map(p => ( -
- {p.vendor} - - {p.attachments.length} resource{p.attachments.length === 1 ? "" : "s"} - -
- ))} -
- )} -
-
- ); -} - -function DatabaseDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) { - const db = anatomy.infrastructure.databases.find(d => d.uuid === uuid); - if (!db) return This database is no longer in the project.; - - return ( -
- - - - {db.publicPort != null && } - {db.internalAddress && } - -
- Connection env -
- {db.consumerEnvKey}={""} -
-
-
- ); -} - -function ProviderDetail({ id, anatomy }: { id: string; anatomy: Anatomy }) { - const p = anatomy.infrastructure.providers.find(x => x.id === id); - if (!p) return This provider is no longer detected.; - const def = categoryDef(p.category); - const dashboard = def.dashboards?.[p.vendor]; - const totalKeys = p.attachments.reduce((n, a) => n + a.keys.length, 0); - - // Stripe-specific connection callout - const isStripe = p.vendor === "Stripe"; - - return ( -
- - - - - {dashboard && ( - - Open {p.vendor} dashboard - - )} - -
- Detected here - {p.attachments.map(att => ( -
-
- {att.resourceName} {att.resourceKind} -
-
- {att.keys.map(k => {k})} -
-
- ))} -
-
- ); -} - -function StorageDetail({ anatomy }: { anatomy: Anatomy }) { - const s = anatomy.infrastructure.bundledStorage; - - if (s.status === "unprovisioned") { - return No bucket provisioned yet.; - } - - return ( -
- - {s.bucketName && } - {s.region && } - {s.hmacAccessId && } - -
- Connection env -
- -{`STORAGE_ENDPOINT=https://storage.googleapis.com -STORAGE_REGION=${s.region ?? "auto"} -STORAGE_BUCKET=${s.bucketName ?? ""} -STORAGE_ACCESS_KEY_ID=${s.hmacAccessId ?? ""} -STORAGE_SECRET_ACCESS_KEY=`} - -
-
- - {s.errorMessage && ( -
- - {s.errorMessage} -
- )} -
- ); -} - -function SecretsDetail({ - resourceUuid, anatomy, -}: { resourceUuid: string; anatomy: Anatomy }) { - const r = anatomy.infrastructure.secrets.byResource.find(x => x.resourceUuid === resourceUuid); - if (!r) return This resource is no longer in the project.; - - // Group keys by detected provider so the user sees Stripe / Resend / - // OpenAI bunched together with an Other catch-all for unrecognised keys. - const providerByKey = new Map(); - for (const p of anatomy.infrastructure.providers) { - const att = p.attachments.find(a => a.resourceUuid === resourceUuid); - if (!att) continue; - for (const k of att.keys) { - providerByKey.set(k, { vendor: p.vendor, category: categoryDef(p.category as CategoryKey).label }); - } - } - - const groups = new Map(); - for (const k of r.keys) { - const tag = providerByKey.get(k); - const groupKey = tag ? `${tag.vendor}` : "Other"; - if (!groups.has(groupKey)) { - groups.set(groupKey, { label: tag ? `${tag.vendor} · ${tag.category}` : "Other (project-defined)", keys: [] }); - } - groups.get(groupKey)!.keys.push(k); - } - - return ( -
- - - - -
- Keys -
- {[...groups.values()].map(g => ( -
-
{g.label}
- {g.keys.map(k => ( -
- - {k} - -
- - -
-
- ))} -
- ))} -
-
-
- ); -} - -// ────────────────────────────────────────────────── -// Tiny helpers -// ────────────────────────────────────────────────── - -function paneHeading(s: Selection, a: Anatomy | null): string { - if (!a) return "Details"; - if (!s) return "Overview"; - if (s.kind === "category") return `About · ${categoryDef(s.category).label}`; - if (s.kind === "database") return `Database · ${a.infrastructure.databases.find(x => x.uuid === s.uuid)?.name ?? ""}`; - if (s.kind === "table") return `Preview · ${s.schema === "public" ? s.name : `${s.schema}.${s.name}`}`; - if (s.kind === "provider") return `Provider · ${a.infrastructure.providers.find(x => x.id === s.id)?.vendor ?? ""}`; - if (s.kind === "storage") return "Storage · Workspace bucket"; - if (s.kind === "secrets") return `Secrets · ${a.infrastructure.secrets.byResource.find(x => x.resourceUuid === s.resourceUuid)?.resourceName ?? ""}`; - return "Details"; -} - -function statusColor(status: string) { - const s = (status ?? "").toLowerCase(); - if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; - if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; - if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b"; - return "#a09a90"; -} -function storageColor(status: string) { - if (status === "ready") return "#2e7d32"; - if (status === "pending" || status === "partial") return "#d4a04a"; - if (status === "error") return "#c5392b"; - return "#a09a90"; -} - -function tileButtonStyle(active: boolean): React.CSSProperties { - return { - ...railItem, - borderColor: active ? INK.ink : INK.borderSoft, - boxShadow: active ? `0 0 0 1px ${INK.ink}` : "none", - background: active ? "#fffdf8" : INK.cardBg, - }; -} - -function KvRow({ - label, value, dot, mono, -}: { label: string; value: string; dot?: string; mono?: boolean }) { - return ( -
- {label} - - {dot && } - - {value} - - -
- ); -} - -function SectionTitle({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function Para({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) { - return

{children}

; -} - -function Inline({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -function Empty({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -function OverviewStat({ - label, value, onClick, -}: { label: string; value: number; onClick?: () => void }) { - return ( - - ); -} - -// ────────────────────────────────────────────────── -// Tokens -// ────────────────────────────────────────────────── - -const INK = { - ink: "#1a1a1a", - mid: "#5f5e5a", - muted: "#a09a90", - border: "#e8e4dc", - borderSoft: "#efebe1", - cardBg: "#fff", - fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', -} as const; - -const pageWrap: React.CSSProperties = { - padding: "28px 48px 48px", - fontFamily: INK.fontSans, - color: INK.ink, -}; -const grid: React.CSSProperties = { - display: "grid", - gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)", - gap: 28, - maxWidth: 1400, - margin: "0 auto", - alignItems: "stretch", -}; -const leftCol: React.CSSProperties = { - minWidth: 0, display: "flex", flexDirection: "column", gap: 14, -}; -const rightCol: React.CSSProperties = { - minWidth: 0, display: "flex", flexDirection: "column", -}; -const heading: React.CSSProperties = { - fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em", - textTransform: "uppercase", color: INK.muted, margin: "0 0 14px", -}; -const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" }; -const railGroupHeader: React.CSSProperties = { - display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "6px 8px", borderRadius: 6, - cursor: "pointer", font: "inherit", color: "inherit", -}; -const railGroupTitle: React.CSSProperties = { - fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", - textTransform: "uppercase", color: INK.muted, -}; -const countPill: React.CSSProperties = { - fontSize: "0.7rem", fontWeight: 600, color: INK.mid, - padding: "1px 7px", borderRadius: 999, background: "#f3eee4", -}; -const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 6, marginTop: 4 }; -const railItem: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 10, - width: "100%", padding: "10px 12px", - border: `1px solid ${INK.borderSoft}`, borderRadius: 8, - cursor: "pointer", font: "inherit", color: "inherit", - transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s", -}; -const railEmptyButton: React.CSSProperties = { - width: "100%", textAlign: "left", - padding: "8px 12px", fontSize: "0.74rem", color: INK.muted, - background: "transparent", - border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, - lineHeight: 1.45, cursor: "pointer", marginTop: 4, - fontFamily: "inherit", -}; -const tileBody: React.CSSProperties = { minWidth: 0, textAlign: "left", flex: 1 }; -const dbCard: React.CSSProperties = { - background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, - borderRadius: 10, overflow: "hidden", -}; -const dbCardHeader: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 8, width: "100%", - padding: "10px 12px", background: "transparent", border: "none", - cursor: "pointer", font: "inherit", color: "inherit", -}; -const dbCardBody: React.CSSProperties = { - padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`, -}; -const chevronCell: React.CSSProperties = { - width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, -}; -const tileLabel: React.CSSProperties = { - fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2, -}; -const tileHint: React.CSSProperties = { - fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4, textTransform: "capitalize", -}; -const iconStyle: React.CSSProperties = { color: INK.mid, flexShrink: 0 }; -const panel: React.CSSProperties = { - background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, - padding: 18, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", - overflowY: "auto", -}; -const detailRow: React.CSSProperties = { - display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "10px 4px", borderBottom: `1px solid ${INK.borderSoft}`, -}; -const detailLabel: React.CSSProperties = { - fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em", - textTransform: "uppercase", color: INK.muted, -}; -const detailValue: React.CSSProperties = { - fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center", -}; -const sectionTitle: React.CSSProperties = { - fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.08em", - textTransform: "uppercase", color: INK.muted, marginBottom: 8, -}; -const para: React.CSSProperties = { - margin: 0, fontSize: "0.85rem", color: INK.ink, lineHeight: 1.55, -}; -const overviewGrid: React.CSSProperties = { - display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 10, -}; -const overviewStat: React.CSSProperties = { - display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 4, - padding: "14px 16px", border: `1px solid ${INK.borderSoft}`, borderRadius: 10, - background: INK.cardBg, color: INK.ink, font: "inherit", -}; -const overviewStatValue: React.CSSProperties = { - fontSize: "1.6rem", fontWeight: 600, lineHeight: 1, -}; -const overviewStatLabel: React.CSSProperties = { - fontSize: "0.7rem", color: INK.muted, letterSpacing: "0.08em", textTransform: "uppercase", -}; -const chipRow: React.CSSProperties = { display: "flex", flexWrap: "wrap", gap: 6 }; -const chip: React.CSSProperties = { - fontSize: "0.74rem", color: INK.mid, - padding: "3px 9px", borderRadius: 999, - background: "#fafaf6", border: `1px solid ${INK.borderSoft}`, -}; -const listBox: React.CSSProperties = { - display: "flex", flexDirection: "column", - border: `1px solid ${INK.borderSoft}`, borderRadius: 8, overflow: "hidden", -}; -const listRow: React.CSSProperties = { - display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "10px 12px", fontSize: "0.82rem", - borderBottom: `1px solid ${INK.borderSoft}`, -}; -const emptyBox: React.CSSProperties = { - padding: "12px 14px", fontSize: "0.82rem", color: INK.mid, - background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, - lineHeight: 1.5, -}; -const errorBox: React.CSSProperties = { - padding: "12px 14px", fontSize: "0.82rem", color: "#7a1f15", - background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8, - lineHeight: 1.5, -}; -const codeBox: React.CSSProperties = { - padding: "10px 12px", - background: "#fafaf6", - border: `1px solid ${INK.borderSoft}`, borderRadius: 8, - overflowX: "auto", - whiteSpace: "pre", -}; -const code: React.CSSProperties = { - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', - fontSize: "0.78rem", color: INK.ink, whiteSpace: "pre", -}; -const inlineCode: React.CSSProperties = { - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', - fontSize: "0.78rem", padding: "1px 5px", - background: "#fafaf6", border: `1px solid ${INK.borderSoft}`, borderRadius: 4, -}; -const dashboardLink: React.CSSProperties = { - display: "inline-flex", alignItems: "center", gap: 6, - padding: "8px 12px", borderRadius: 8, - background: INK.ink, color: "#fff", fontSize: "0.82rem", fontWeight: 600, - textDecoration: "none", alignSelf: "flex-start", -}; -const ctaButton: React.CSSProperties = { - alignSelf: "flex-start", - padding: "8px 14px", borderRadius: 8, - background: INK.ink, color: "#fff", fontSize: "0.82rem", fontWeight: 600, - border: "none", cursor: "pointer", font: "inherit", -}; -const attachmentBlock: React.CSSProperties = { - padding: "10px 12px", marginTop: 8, - background: "#fafaf6", borderRadius: 8, -}; -const attachmentHeader: React.CSSProperties = { - fontSize: "0.82rem", fontWeight: 600, color: INK.ink, marginBottom: 6, - display: "flex", alignItems: "center", gap: 8, -}; -const attachmentBadge: React.CSSProperties = { - fontSize: "0.62rem", fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", - color: INK.mid, background: "#ece6da", padding: "1px 6px", borderRadius: 4, -}; -const keyList: React.CSSProperties = { - display: "flex", flexWrap: "wrap", gap: 4, -}; -const keyChip: React.CSSProperties = { - fontSize: "0.7rem", color: INK.mid, padding: "2px 6px", - background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 4, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', -}; -const secretGroupHeader: React.CSSProperties = { - padding: "8px 12px", fontSize: "0.7rem", color: INK.muted, - letterSpacing: "0.08em", textTransform: "uppercase", - background: "#fafaf6", -}; -const secretRow: React.CSSProperties = { - display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "8px 12px", - borderTop: `1px solid ${INK.borderSoft}`, -}; -const secretActions: React.CSSProperties = { - display: "flex", gap: 4, -}; -const iconButton: React.CSSProperties = { - display: "inline-flex", alignItems: "center", justifyContent: "center", - width: 24, height: 24, padding: 0, - background: "transparent", border: `1px solid ${INK.borderSoft}`, - borderRadius: 4, cursor: "not-allowed", color: INK.muted, - font: "inherit", -}; diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx index 08957ce6..29a691dc 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx @@ -1,233 +1,6 @@ -"use client"; +import { redirect } from "next/navigation"; -import { useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { Settings, Trash2, AlertTriangle, Loader2, ArrowLeft } from "lucide-react"; -import { WorkspaceKeysPanel } from "@/components/workspace/WorkspaceKeysPanel"; -import Link from "next/link"; - -/** - * Project settings page. - * Accessible via the gear icon in the project header. - * - * Sections: - * - General (name, description — future) - * - Danger zone: delete project - */ - -export default function ProjectSettingsPage() { - const params = useParams(); - const router = useRouter(); - const projectId = params.projectId as string; - const workspace = params.workspace as string; - - const [deletePhase, setDeletePhase] = useState<"idle" | "confirm" | "deleting" | "done">("idle"); - const [confirmInput, setConfirmInput] = useState(""); - const [deleteError, setDeleteError] = useState(null); - - const projectBackUrl = `/${workspace}/project/${projectId}/plan`; - - const handleDelete = async () => { - if (deletePhase === "idle") { - setDeletePhase("confirm"); - return; - } - if (deletePhase !== "confirm") return; - if (confirmInput.toLowerCase() !== "delete") return; - - setDeletePhase("deleting"); - setDeleteError(null); - - try { - const r = await fetch("/api/projects/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId }), - }); - const d = await r.json(); - if (!r.ok) throw new Error(d.error || "Delete failed"); - setDeletePhase("done"); - setTimeout(() => router.push(`/${workspace}/projects`), 1500); - } catch (e) { - setDeleteError(e instanceof Error ? e.message : String(e)); - setDeletePhase("confirm"); - } - }; - - return ( -
- {/* Back link */} - - Back to project - - -

- Project settings -

- -
- - {/* ── Danger zone ── */} -
-

- - Danger zone -

- -
-
-
-
Delete this project
-
- Removes all project data from Vibn. Coolify services and databases - are not automatically stopped — use the chat to clean those - up first, or remove them from Coolify directly. -
-
- - {deletePhase === "idle" && ( - - )} - - {deletePhase === "confirm" && ( -
-
- Type delete to confirm -
-
- setConfirmInput(e.target.value)} - onKeyDown={e => e.key === "Enter" && confirmInput.toLowerCase() === "delete" && handleDelete()} - placeholder="delete" - style={confirmInput_} - /> - - -
- {deleteError && ( -
{deleteError}
- )} -
- )} - - {deletePhase === "deleting" && ( - - )} - - {deletePhase === "done" && ( -
- Project deleted. Redirecting… -
- )} -
-
-
-
- ); +export default async function SettingsPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) { + const { workspace, projectId } = await params; + redirect(`/${workspace}/project/${projectId}/settings/app`); } - -// ────────────────────────────────────────────────── -// Tokens -// ────────────────────────────────────────────────── - -const DANGER = "#c5392b"; - -const INK = { - ink: "#1a1a1a", - mid: "#5f5e5a", - muted: "#a09a90", - border: "#e8e4dc", - borderSoft: "#efebe1", - fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', -} as const; - -// ────────────────────────────────────────────────── -// Styles -// ────────────────────────────────────────────────── - -const pageWrap: React.CSSProperties = { - padding: "28px 48px 64px", - fontFamily: INK.fontSans, - color: INK.ink, - maxWidth: 720, -}; -const backLink: React.CSSProperties = { - display: "inline-flex", alignItems: "center", gap: 6, - fontSize: "0.8rem", color: INK.mid, textDecoration: "none", - marginBottom: 24, -}; -const pageTitle: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 10, - fontSize: "1.25rem", fontWeight: 700, color: INK.ink, - marginBottom: 36, marginTop: 0, -}; -const dangerSection: React.CSSProperties = { marginTop: 32 }; -const sectionTitle: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 8, - fontSize: "0.72rem", fontWeight: 700, letterSpacing: "0.12em", - textTransform: "uppercase", color: INK.muted, - marginBottom: 12, -}; -const dangerCard: React.CSSProperties = { - border: `1px solid #f0cac5`, - borderRadius: 10, - background: "#fffaf9", -}; -const dangerCardBody: React.CSSProperties = { - padding: "18px 20px", - display: "flex", - alignItems: "flex-start", - justifyContent: "space-between", - gap: 24, - flexWrap: "wrap", -}; -const dangerItemTitle: React.CSSProperties = { - fontWeight: 600, fontSize: "0.9rem", color: INK.ink, marginBottom: 4, -}; -const dangerItemDesc: React.CSSProperties = { - fontSize: "0.8rem", color: INK.mid, lineHeight: 1.55, maxWidth: 380, -}; -const dangerBtn: React.CSSProperties = { - display: "inline-flex", alignItems: "center", gap: 6, - padding: "7px 14px", border: `1px solid ${DANGER}`, - borderRadius: 6, background: "#fff", cursor: "pointer", - font: "inherit", fontSize: "0.8rem", fontWeight: 600, color: DANGER, - whiteSpace: "nowrap", flexShrink: 0, -}; -const cancelBtn: React.CSSProperties = { - display: "inline-flex", alignItems: "center", - padding: "7px 12px", border: `1px solid ${INK.border}`, - borderRadius: 6, background: "#fff", cursor: "pointer", - font: "inherit", fontSize: "0.8rem", color: INK.mid, - whiteSpace: "nowrap", -}; -const confirmBox: React.CSSProperties = { display: "flex", flexDirection: "column" }; -const confirmInput_: React.CSSProperties = { - padding: "7px 10px", - border: `1px solid ${DANGER}`, - borderRadius: 6, - font: "inherit", - fontSize: "0.85rem", - outline: "none", - width: 100, -}; diff --git a/vibn-frontend/components/project/dashboard-sidebar.tsx b/vibn-frontend/components/project/dashboard-sidebar.tsx index 6fc70fe0..2672083b 100644 --- a/vibn-frontend/components/project/dashboard-sidebar.tsx +++ b/vibn-frontend/components/project/dashboard-sidebar.tsx @@ -18,8 +18,12 @@ import { ChevronDown, ChevronRight, Users, + HardDrive, + Blocks, } from "lucide-react"; +import { useAnatomy } from "@/components/project/use-anatomy"; + export function DashboardSidebar({ workspace, projectId, @@ -39,23 +43,40 @@ export function DashboardSidebar({ Record >({ settings: true, + data: true, }); const [searchQuery, setSearchQuery] = useState(""); + const { anatomy } = useAnatomy(projectId); + const databases = anatomy?.infrastructure?.databases ?? []; + if (isPreview) { return <>{children}; } - const toggleSection = (section: string) => { - setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); + const handleSectionClick = (segment: string) => { + if (!expandedSections[segment]) { + setExpandedSections((prev) => ({ ...prev, [segment]: true })); + } }; const menuItems = [ { segment: "overview", label: "Overview", Icon: LayoutGrid }, { segment: "plan", label: "Plan & Specs", Icon: ClipboardList }, { segment: "code", label: "Code", Icon: Code2 }, - { segment: "data", label: "Data", Icon: Database }, + { + segment: "data", + label: "Data", + Icon: Database, + hasChildren: true, + children: databases.map((db) => ({ + segment: `data/tables?db=${db.uuid}`, + label: db.name, + })), + }, + { segment: "storage", label: "Storage", Icon: HardDrive }, + { segment: "services", label: "Services", Icon: Blocks }, { segment: "users", label: "Auth / Users", Icon: Users }, { segment: "integrations", label: "Integrations", Icon: Plug }, { segment: "security", label: "Security", Icon: ShieldCheck }, @@ -67,6 +88,17 @@ export function DashboardSidebar({ Icon: BarChart2, badge: "Soon", }, + { + segment: "marketing", + label: "Marketing", + Icon: BarChart2, + badge: "New", + hasChildren: true, + children: [ + { segment: "marketing/seo", label: "SEO & GEO" }, + { segment: "marketing/social", label: "Social content" }, + ], + }, { segment: "settings", label: "Settings", @@ -170,9 +202,10 @@ export function DashboardSidebar({ }} onClick={() => { if (item.hasChildren) { - toggleSection(item.segment); - } else { - // Navigate via link logic would happen here, we'll wrap the icon/label in a link + setExpandedSections((prev) => ({ + ...prev, + [item.segment]: !prev[item.segment], + })); } }} > @@ -245,28 +278,41 @@ export function DashboardSidebar({ }} > {item.children.map((child) => { - const isChildActive = - pathname === `${projectBase}/${child.segment}`; + const href = child.segment.includes("?") + ? `${projectBase}/${child.segment.split("?")[0]}?${child.segment.split("?")[1]}` + : `${projectBase}/${child.segment}`; + let isChildActive = false; + if (child.segment.includes("?")) { + const [basePath, searchStr] = child.segment.split("?"); + isChildActive = + pathname === `${projectBase}/${basePath}` && + (typeof window !== "undefined" + ? window.location.search.includes(searchStr) + : false); + } else { + isChildActive = + pathname === `${projectBase}/${child.segment}`; + } + return ( {child.label} diff --git a/vibn-frontend/components/project/project-icon-rail.tsx b/vibn-frontend/components/project/project-icon-rail.tsx index 86db64f3..d2e452b8 100644 --- a/vibn-frontend/components/project/project-icon-rail.tsx +++ b/vibn-frontend/components/project/project-icon-rail.tsx @@ -37,6 +37,9 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) { fontSize: "0.75rem", fontWeight: 500, borderRadius: 6, + display: "flex", + alignItems: "center", + height: 24, // Explicitly match device toggles height textDecoration: "none", background: isPreviewActive ? "#ffffff" : "transparent", color: isPreviewActive ? "#18181b" : "#71717a", @@ -53,6 +56,9 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) { fontSize: "0.75rem", fontWeight: 500, borderRadius: 6, + display: "flex", + alignItems: "center", + height: 24, // Explicitly match device toggles height textDecoration: "none", background: !isPreviewActive ? "#ffffff" : "transparent", color: !isPreviewActive ? "#18181b" : "#71717a", @@ -401,12 +407,11 @@ const bar: React.CSSProperties = { alignItems: "center", flex: 1, minWidth: 0, - height: "56px", // Explicitly set height + height: "100%", // Inherit height from parent padding: "0 16px", gap: 12, boxSizing: "border-box", - background: "#fafafa", - borderBottom: "1px solid #e4e4e7", + background: "#faf8f5", fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif', }; diff --git a/vibn-frontend/components/vibn-chat/chat-panel.tsx b/vibn-frontend/components/vibn-chat/chat-panel.tsx index 9f96cd34..f8a7254c 100644 --- a/vibn-frontend/components/vibn-chat/chat-panel.tsx +++ b/vibn-frontend/components/vibn-chat/chat-panel.tsx @@ -1027,16 +1027,11 @@ export function ChatPanel({ ); const [showThreads, setShowThreads] = useState(false); const [mcpToken, setMcpToken] = useState(null); - const [isChatMinimized, setIsChatMinimized] = useState(() => { - if (typeof window === "undefined") return false; - return !window.location.pathname.includes("/preview"); - }); + const [isChatMinimized, setIsChatMinimized] = useState(false); // Auto-minimize when navigating to dashboard, auto-open when navigating to preview useEffect(() => { - if (typeof window !== "undefined") { - setIsChatMinimized(!window.location.pathname.includes("/preview")); - } + setIsChatMinimized(!pathname.includes("/preview")); }, [pathname]); const messagesEndRef = useRef(null); @@ -2101,12 +2096,9 @@ export function ChatPanel({ }} onInput={(e) => { const el = e.currentTarget; - const newlines = (el.value.match(/\n/g) || []).length; - if ((el as any).lastNewlines !== newlines) { - (el as any).lastNewlines = newlines; - el.style.height = "auto"; - el.style.height = Math.min(el.scrollHeight, 240) + "px"; - } + // Only resize if height actually changed + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 240) + "px"; }} />