fix(dashboard): remove Analytics, Marketing, Security, Integrations + fix build

- Deleted unused/stub routes: Security, Analytics, Marketing (+SEO/Social), Integrations
- Removed these routes from the Dashboard Sidebar menu
- Fixed Next.js build errors caused by duplicate component declarations (SectionHeader, KvRow) in overview, hosting, services, and infrastructure by relying fully on the unified dashboard-ui kit.
This commit is contained in:
2026-06-13 11:13:37 -07:00
parent eb198e2d4d
commit 9092b9e549
21 changed files with 2460 additions and 2927 deletions

View File

@@ -1,75 +0,0 @@
"use client";
import { BarChart2 } from "lucide-react";
export default function AnalyticsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Analytics
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Track traffic, usage, and events.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px dashed #e4e4e7",
borderRadius: 12,
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginTop: 32,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<BarChart2 size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
No data available yet
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Once your app is live and receiving traffic, your analytics metrics
will appear here.
</p>
</div>
</div>
);
}

View File

@@ -1,157 +1,33 @@
"use client"; "use client";
import { Copy, Key } from "lucide-react"; import { Key } from "lucide-react";
import {
THEME,
PageHeader,
EmptyState,
} from "@/components/project/dashboard-ui";
export default function ApiPage() { export default function ApiPage() {
return ( return (
<div <div
style={{ style={{
padding: "32px 48px", minHeight: "100vh",
fontFamily: '"Outfit", "Inter", sans-serif', background: THEME.canvasGradient,
color: "#18181b", fontFamily: THEME.font,
maxWidth: 900, padding: "36px 48px",
}} }}
> >
<div style={{ marginBottom: 24 }}> <div style={{ maxWidth: 860, margin: "0 auto" }}>
<h1 <PageHeader
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }} title="API Keys"
> subtitle="Manage authentication keys for your application's public API."
API & Webhooks />
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Connect external services to your application.
</p>
</div>
<div <EmptyState
style={{ icon={<Key size={22} />}
background: "#fff", title="API management coming soon"
border: "1px solid #e4e4e7", hint="Built-in API key generation and request routing is in development. If your app already issues its own API keys, you can manage them directly in your database via the Data tab."
borderRadius: 12, />
padding: "24px",
marginBottom: 32,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: "0 0 16px 0" }}>
REST API Endpoint
</h2>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
flex: 1,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "10px 16px",
fontSize: "0.9rem",
color: "#71717a",
display: "flex",
alignItems: "center",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
<span style={{ color: "#18181b" }}>
https://api.steadfast-camp-core-flow.vibn.app/v1
</span>
</div>
<button
style={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<Copy size={16} color="#71717a" />
</button>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
API Keys
</h2>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Generate Key
</button>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
padding: "16px 0",
borderBottom: "1px solid #e4e4e7",
}}
>
<div
style={{
width: 40,
height: 40,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Key size={16} color="#18181b" />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Production Key
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Created 2 days ago
</div>
</div>
<div
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.85rem",
color: "#71717a",
background: "#fafafa",
padding: "4px 8px",
borderRadius: 6,
border: "1px solid #e4e4e7",
}}
>
pk_live_*******************
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -264,13 +264,13 @@ function statusColor(status: string) {
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
const INK = { const INK = {
ink: "#1a1a1a", ink: "#111827",
mid: "#5f5e5a", mid: "#4b5563",
muted: "#a09a90", muted: "#9ca3af",
border: "#e8e4dc", border: "#e5e7eb",
borderSoft: "#efebe1", borderSoft: "#f3f4f6",
cardBg: "#fff", cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const; } as const;
const pageWrap: React.CSSProperties = { const pageWrap: React.CSSProperties = {

View File

@@ -8,11 +8,16 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Database, Database,
CircleDot,
} from "lucide-react"; } from "lucide-react";
import { DatabaseTableTree } from "@/components/project/database-table-tree"; import { DatabaseTableTree } from "@/components/project/database-table-tree";
import { TableViewer } from "@/components/project/table-viewer"; import { TableViewer } from "@/components/project/table-viewer";
import { useAnatomy } from "@/components/project/use-anatomy"; import { useAnatomy } from "@/components/project/use-anatomy";
import {
THEME,
PageHeader,
Card,
StatusDot,
} from "@/components/project/dashboard-ui";
type Selection = { type Selection = {
kind: "table"; kind: "table";
@@ -46,327 +51,286 @@ export default function DataTablesPage() {
const showLoading = loading && !anatomy; const showLoading = loading && !anatomy;
return ( return (
<div style={pageWrap}> <div
<div style={grid}> style={{
{/* ── Left rail ── */} minHeight: "100vh",
<section style={leftCol}> background: THEME.canvasGradient,
{showLoading && ( fontFamily: THEME.font,
<Inline> padding: "36px 48px",
<Loader2 size={13} className="animate-spin" /> Loading }}
</Inline> >
)} <div style={{ maxWidth: 1400, margin: "0 auto" }}>
{error && !showLoading && ( <PageHeader
<Inline> title="Data / Tables"
<AlertCircle size={13} /> {error} subtitle="Explore the raw schema and rows in your project databases."
</Inline> />
)}
{anatomy && ( <div
<RailGroup title="Databases" count={activeDatabases.length}> style={{
{activeDatabases.length === 0 && ( display: "grid",
<RailEmpty> gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
No databases yet. gap: 28,
<span style={nudge}> alignItems: "stretch",
Try: &quot;Add a Postgres database to my project&quot; }}
>
{/* ── Left rail ── */}
<section
style={{
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
{showLoading && (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.mid,
fontSize: "0.875rem",
}}
>
<Loader2 size={15} className="animate-spin" /> Loading
</div>
</Card>
)}
{error && !showLoading && (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.danger,
fontSize: "0.875rem",
}}
>
<AlertCircle size={15} /> {error}
</div>
</Card>
)}
{anatomy && (
<div style={{ display: "flex", flexDirection: "column" }}>
<header
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 4px 8px",
}}
>
<h3
style={{
fontSize: "0.68rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: THEME.muted,
margin: 0,
}}
>
Databases
</h3>
<span
style={{
fontSize: "0.7rem",
fontWeight: 600,
color: THEME.mid,
padding: "1px 7px",
borderRadius: 999,
background: THEME.borderSoft,
}}
>
{activeDatabases.length}
</span> </span>
</RailEmpty> </header>
)} <div
{activeDatabases.map((db) => { style={{ display: "flex", flexDirection: "column", gap: 10 }}
return ( >
<article key={db.uuid} style={codebaseTile}> {activeDatabases.length === 0 && (
<div style={tileHeader}> <div
<span style={chevronCell}> style={{
<ChevronDown size={13} style={{ color: INK.mid }} /> padding: "10px 12px",
fontSize: "0.74rem",
color: THEME.muted,
border: `1px dashed ${THEME.borderSoft}`,
borderRadius: 8,
lineHeight: 1.6,
}}
>
No databases yet.
<span
style={{
display: "block",
marginTop: 6,
fontStyle: "normal",
background: THEME.borderSoft,
borderRadius: 4,
padding: "3px 8px",
fontSize: "0.72rem",
color: THEME.mid,
}}
>
Try: &quot;Add a Postgres database to my project&quot;
</span> </span>
<Database
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{db.name}</div>
<div style={tileHint}>{db.type}</div>
</div>
<CircleDot
size={9}
style={{ color: statusColor(db.status), flexShrink: 0 }}
/>
</div> </div>
<div style={tileBody}> )}
<DatabaseTableTree {activeDatabases.map((db) => {
projectId={projectId} return (
dbUuid={db.uuid} <article
selectedTable={ key={db.uuid}
selection?.kind === "table" && style={{
selection.dbUuid === db.uuid background: THEME.cardBg,
? { border: `1px solid ${THEME.borderSoft}`,
schema: selection.schema, borderRadius: 10,
name: selection.name, overflow: "hidden",
} }}
: undefined >
} <div
onSelectTable={({ schema, name }) => style={{
setSelection({ display: "flex",
kind: "table", alignItems: "center",
dbUuid: db.uuid, gap: 8,
schema, width: "100%",
name, padding: "12px 14px",
}) background: "transparent",
} border: "none",
/> font: "inherit",
</div> color: "inherit",
</article> }}
); >
})} <span
</RailGroup> style={{
)} width: 14,
</section> display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<ChevronDown
size={13}
style={{ color: THEME.mid }}
/>
</span>
<Database
size={13}
style={{ color: THEME.mid, flexShrink: 0 }}
/>
<div
style={{ minWidth: 0, textAlign: "left", flex: 1 }}
>
<div
style={{
fontSize: "0.85rem",
fontWeight: 600,
color: THEME.ink,
marginBottom: 2,
}}
>
{db.name}
</div>
<div
style={{
fontSize: "0.74rem",
color: THEME.mid,
lineHeight: 1.4,
}}
>
{db.type}
</div>
</div>
<StatusDot
status={
db.status.includes("running") ||
db.status.includes("healthy")
? "success"
: "neutral"
}
/>
</div>
<div
style={{
padding: "8px 10px 12px",
borderTop: `1px solid ${THEME.borderSoft}`,
}}
>
<DatabaseTableTree
projectId={projectId}
dbUuid={db.uuid}
selectedTable={
selection?.kind === "table" &&
selection.dbUuid === db.uuid
? {
schema: selection.schema,
name: selection.name,
}
: undefined
}
onSelectTable={({ schema, name }) =>
setSelection({
kind: "table",
dbUuid: db.uuid,
schema,
name,
})
}
/>
</div>
</article>
);
})}
</div>
</div>
)}
</section>
{/* ── Right pane ── */} {/* ── Right pane ── */}
<aside style={rightCol}> <aside
<h3 style={heading}>{paneHeading(selection)}</h3> style={{ minWidth: 0, display: "flex", flexDirection: "column" }}
<div style={panel}> >
{selection?.kind === "table" && ( <Card
<TableViewer padding={16}
projectId={projectId} style={{
dbUuid={selection.dbUuid} flex: 1,
schema={selection.schema} minHeight: "calc(100vh - 150px)",
table={selection.name} display: "flex",
/> flexDirection: "column",
)} padding: 16,
{!selection && ( }}
<Empty>Select a table on the left to preview data.</Empty> >
)} {selection?.kind === "table" && (
</div> <TableViewer
</aside> projectId={projectId}
dbUuid={selection.dbUuid}
schema={selection.schema}
table={selection.name}
/>
)}
{!selection && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: THEME.muted,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
Select a table on the left to preview data.
</div>
)}
</Card>
</aside>
</div>
</div> </div>
</div> </div>
); );
} }
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
function RailEmpty({ children }: { children: React.ReactNode }) {
return <div style={railEmpty}>{children}</div>;
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: INK.mid,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
}}
>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: INK.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
</div>
);
}
function paneHeading(s: Selection): string {
if (!s) return "Preview";
if (s.kind === "table")
return `Preview · ${s.schema === "public" ? s.name : `${s.schema}.${s.name}`}`;
return "Preview";
}
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";
}
// ──────────────────────────────────────────────────
// 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: 18,
};
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: "0 4px 8px",
};
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: 10,
};
const railEmpty: React.CSSProperties = {
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",
};
const codebaseTile: React.CSSProperties = {
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",
font: "inherit",
color: "inherit",
};
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 tileBody: 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 panel: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 16,
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
};

View File

@@ -1,227 +1,189 @@
"use client"; "use client";
import { Copy } from "lucide-react"; import { useParams } from "next/navigation";
import { useState } from "react";
import { Globe, ExternalLink, Copy, Check, Loader2 } from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
import {
THEME,
PageHeader,
Card,
EmptyState,
} from "@/components/project/dashboard-ui";
type LiveApp = Anatomy["hosting"]["live"][number];
// All public URLs for an app: its fqdn(s) (Coolify can store a comma-joined
// list) plus any attached custom domains, de-duplicated.
function urlsFor(app: LiveApp): string[] {
const out = new Set<string>();
(app.fqdn ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.forEach((u) => out.add(u));
(app.domains ?? []).forEach((d) =>
out.add(d.startsWith("http") ? d : `https://${d}`),
);
return [...out];
}
export default function DomainsPage() { export default function DomainsPage() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 8000 });
const live = anatomy?.hosting.live ?? [];
return ( return (
<div <div
style={{ style={{
padding: "32px 48px", minHeight: "100vh",
fontFamily: '"Outfit", "Inter", sans-serif', background: THEME.canvasGradient,
color: "#18181b", fontFamily: THEME.font,
maxWidth: 900, padding: "36px 48px",
}} }}
> >
<div style={{ marginBottom: 32 }}> <div style={{ maxWidth: 860, margin: "0 auto" }}>
<h1 <PageHeader
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }} title="Domains"
> subtitle="Public URLs for your deployed apps. To add a custom domain, ask the AI in chat — DNS + TLS are wired automatically."
Domains />
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Buy, connect and manage your domains.{" "}
<a href="#" style={{ color: "#18181b", textDecoration: "underline" }}>
Learn more
</a>
</p>
</div>
<div {loading && !anatomy ? (
style={{ <Card>
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 32,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
Built-in URL
</h2>
</div>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Edit URL
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
flex: 1,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "10px 16px",
fontSize: "0.9rem",
color: "#71717a",
display: "flex",
alignItems: "center",
}}
>
<span style={{ color: "#18181b", fontWeight: 500 }}>
steadfast-camp-core-flow
</span>
.vibn.app
</div>
<button
style={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<Copy size={16} color="#71717a" />
</button>
</div>
</div>
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 16px 0" }}>
Custom domains
</h2>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
borderStyle: "dashed",
padding: "48px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginBottom: 32,
}}
>
<h3
style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 8px 0" }}
>
Want to use your domain?
</h3>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 400,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Custom domains are available on our Builder plan and above. Upgrade to
continue working to this app.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
View Plans
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 16,
}}
>
<div>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
marginBottom: 16, color: THEME.mid,
fontSize: "0.875rem",
}} }}
> >
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}> <Loader2 size={15} className="animate-spin" /> Loading
Email domain
</h2>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "#f97316",
border: "1px solid #ffedd5",
background: "#fff7ed",
padding: "2px 6px",
borderRadius: 4,
}}
>
Builder+
</span>
</div>
<div
style={{
fontSize: "0.95rem",
fontWeight: 500,
color: "#18181b",
marginBottom: 4,
}}
>
no-reply@notifications.vibn.app
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Sender Name: App
</div> </div>
</Card>
) : live.length === 0 ? (
<EmptyState
icon={<Globe size={22} />}
title="No deployed apps yet"
hint="Once you deploy an app, its URL and any custom domains will appear here."
/>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{live.map((app) => (
<DomainCard key={app.uuid} app={app} />
))}
</div> </div>
<button )}
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
color: "#71717a",
}}
>
Use your custom domain
</button>
</div>
</div> </div>
</div> </div>
); );
} }
function DomainCard({ app }: { app: LiveApp }) {
const urls = urlsFor(app);
return (
<Card padding={0}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "14px 20px",
borderBottom: urls.length ? `1px solid ${THEME.borderSoft}` : "none",
}}
>
<span
style={{ fontSize: "0.95rem", fontWeight: 600, color: THEME.ink }}
>
{app.name}
</span>
<span style={{ fontSize: "0.78rem", color: THEME.muted }}>
{app.sourceLabel}
</span>
</div>
{urls.length === 0 ? (
<div
style={{
padding: "14px 20px",
fontSize: "0.85rem",
color: THEME.mid,
fontStyle: "italic",
}}
>
No domain assigned yet still deploying.
</div>
) : (
urls.map((url, i) => (
<DomainRow key={url} url={url} last={i === urls.length - 1} />
))
)}
</Card>
);
}
function DomainRow({ url, last }: { url: string; last: boolean }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard?.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 20px",
borderBottom: last ? "none" : `1px solid ${THEME.borderSoft}`,
}}
>
<Globe size={14} style={{ color: THEME.muted, flexShrink: 0 }} />
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
fontSize: "0.85rem",
color: THEME.ink,
textDecoration: "none",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
}}
>
{url.replace(/^https?:\/\//, "")}
</a>
<a
href={url}
target="_blank"
rel="noreferrer"
title="Open"
style={{ color: THEME.muted, display: "inline-flex", flexShrink: 0 }}
>
<ExternalLink size={14} />
</a>
<button
onClick={copy}
title="Copy URL"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: copied ? "#16a34a" : THEME.muted,
display: "inline-flex",
padding: 0,
flexShrink: 0,
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
);
}

View File

@@ -427,15 +427,6 @@ function formatRelative(iso: string | undefined) {
// Sub-components // Sub-components
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({ function EmptySection({
icon, icon,
title, title,
@@ -492,13 +483,13 @@ function EmptySection({
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
const INK = { const INK = {
ink: "#1a1a1a", ink: "#111827",
mid: "#5f5e5a", mid: "#4b5563",
muted: "#a09a90", muted: "#9ca3af",
border: "#e8e4dc", border: "#e5e7eb",
borderSoft: "#efebe1", borderSoft: "#f3f4f6",
cardBg: "#fff", cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const; } as const;
const GREEN = "#10b981"; const GREEN = "#10b981";
const AMBER = "#f59e0b"; const AMBER = "#f59e0b";

View File

@@ -1,223 +0,0 @@
"use client";
import { Diamond } from "lucide-react";
export default function IntegrationsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
position: "relative",
height: "calc(100vh - 100px)",
overflow: "hidden",
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Integrations
</h1>
</div>
<div style={{ filter: "blur(4px)", opacity: 0.5, pointerEvents: "none" }}>
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "8px 24px",
fontSize: "0.85rem",
fontWeight: 500,
}}
>
My Integrations
</button>
<button
style={{
background: "#f4f4f5",
border: "none",
color: "#71717a",
borderRadius: 8,
padding: "8px 24px",
fontSize: "0.85rem",
fontWeight: 500,
}}
>
Browse
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 32,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div
style={{
width: 40,
height: 40,
background: "#f4f4f5",
borderRadius: 8,
}}
></div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Stripe</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Sell products or subscriptions and get paid online.
</div>
</div>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 16px",
fontSize: "0.85rem",
}}
>
Manage
</button>
</div>
</div>
<h2
style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 16px 0" }}
>
Connectors
</h2>
<p style={{ fontSize: "0.9rem", color: "#71717a", marginBottom: 24 }}>
Connect your app to popular services.
</p>
<div
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
marginBottom: 16,
}}
>
<div
style={{
width: 32,
height: 32,
background: "#f4f4f5",
borderRadius: 8,
}}
></div>
</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Connector {i}
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Connect with external service for app data.
</div>
</div>
))}
</div>
</div>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255, 255, 255, 0.3)",
}}
>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 16,
padding: "32px 48px",
display: "flex",
flexDirection: "column",
alignItems: "center",
boxShadow: "0 10px 25px rgba(0,0,0,0.05)",
maxWidth: 440,
}}
>
<div
style={{
width: 40,
height: 40,
background: "#fff7ed",
border: "1px solid #ffedd5",
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 20,
}}
>
<Diamond size={18} color="#f97316" />
</div>
<h2
style={{
fontSize: "1.1rem",
fontWeight: 600,
margin: "0 0 12px 0",
}}
>
Unlock this feature
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
This feature is only available on the Builder plan or higher.
Upgrade to continue working without limits.
</p>
<button
style={{
background: "#f97316",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Upgrade
</button>
</div>
</div>
</div>
);
}

View File

@@ -42,6 +42,6 @@ const pageWrap: React.CSSProperties = {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
height: "100vh", height: "100vh",
background: "#faf8f5", background: "#f9fafb",
overflow: "hidden", overflow: "hidden",
}; };

View File

@@ -1,97 +1,217 @@
"use client"; "use client";
import { Search } from "lucide-react"; import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { Activity, Loader2, RefreshCw } from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
import {
THEME,
PageHeader,
Card,
EmptyState,
SecondaryButton,
} from "@/components/project/dashboard-ui";
type LiveApp = Anatomy["hosting"]["live"][number];
export default function LogsPage() { export default function LogsPage() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 8000 });
const live = anatomy?.hosting.live ?? [];
const [activeUuid, setActiveUuid] = useState<string | null>(null);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
// Auto-select first app if none selected
useEffect(() => {
if (live.length > 0 && !activeUuid) {
setActiveUuid(live[0].uuid);
}
}, [live, activeUuid]);
const fetchLogs = async (uuid: string) => {
setLogsLoading(true);
try {
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.logs",
params: { uuid, lines: 100 },
}),
});
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. Is the container running?");
} finally {
setLogsLoading(false);
}
};
// Fetch when active app changes
useEffect(() => {
if (activeUuid) fetchLogs(activeUuid);
}, [activeUuid]);
return ( return (
<div <div
style={{ style={{
padding: "32px 48px", minHeight: "100vh",
fontFamily: '"Outfit", "Inter", sans-serif', background: THEME.canvasGradient,
color: "#18181b", fontFamily: THEME.font,
maxWidth: 900, padding: "36px 48px",
display: "flex",
flexDirection: "column",
}} }}
> >
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Logs
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
View application and server logs.
</p>
</div>
<div <div
style={{ style={{
background: "#18181b", maxWidth: 1000,
borderRadius: 12, margin: "0 auto",
overflow: "hidden", width: "100%",
color: "#e4e4e7", flex: 1,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", display: "flex",
flexDirection: "column",
}} }}
> >
<div <PageHeader
style={{ title="Runtime Logs"
padding: "12px 16px", subtitle="Container stdout/stderr for your deployed apps."
borderBottom: "1px solid #3f3f46", />
display: "flex",
justifyContent: "space-between", {loading && !anatomy ? (
alignItems: "center", <Card>
}} <div
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#27272a",
borderRadius: 6,
padding: "4px 10px",
width: 300,
}}
>
<Search size={14} color="#a1a1aa" />
<input
type="text"
placeholder="Filter logs..."
style={{ style={{
border: "none", display: "flex",
outline: "none", alignItems: "center",
background: "transparent", gap: 8,
fontSize: "0.8rem", color: THEME.mid,
width: "100%", fontSize: "0.875rem",
color: "#fff",
}} }}
/> >
<Loader2 size={15} className="animate-spin" /> Loading
</div>
</Card>
) : live.length === 0 ? (
<EmptyState
icon={<Activity size={22} />}
title="No apps running"
hint="Once you deploy an app, its runtime logs will appear here."
/>
) : (
<div style={{ display: "flex", gap: 16, flex: 1, minHeight: 0 }}>
{/* App Picker Column */}
<div
style={{
width: 220,
flexShrink: 0,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{live.map((app) => (
<button
key={app.uuid}
onClick={() => setActiveUuid(app.uuid)}
style={{
textAlign: "left",
padding: "10px 14px",
background:
activeUuid === app.uuid ? THEME.subtleBg : THEME.cardBg,
border: `1px solid ${activeUuid === app.uuid ? THEME.border : "transparent"}`,
borderRadius: THEME.radiusSm,
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: 500,
color: activeUuid === app.uuid ? THEME.ink : THEME.mid,
transition: "all 0.15s ease",
}}
>
{app.name}
</button>
))}
</div>
{/* Log Viewer Column */}
<Card
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
padding={0}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderBottom: `1px solid ${THEME.borderSoft}`,
}}
>
<span
style={{
fontSize: "0.85rem",
fontWeight: 600,
color: THEME.ink,
}}
>
{live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
</span>
<SecondaryButton
icon={
logsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)
}
onClick={() => activeUuid && fetchLogs(activeUuid)}
disabled={logsLoading}
>
Refresh
</SecondaryButton>
</div>
<div
style={{
flex: 1,
overflow: "auto",
padding: 16,
background: "#0a0a0a",
borderBottomLeftRadius: THEME.radius,
borderBottomRightRadius: THEME.radius,
}}
>
<pre
style={{
margin: 0,
fontSize: "0.8rem",
color: "#e5e5e5",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{logsLoading && !logs
? "Loading..."
: logs || "No logs available."}
</pre>
</div>
</Card>
</div> </div>
</div> )}
<div
style={{
padding: "16px",
fontSize: "0.85rem",
lineHeight: 1.6,
height: 400,
overflowY: "auto",
}}
>
<div style={{ display: "flex", gap: 16 }}>
<span style={{ color: "#71717a" }}>14:32:01</span>
<span style={{ color: "#10b981" }}>[info]</span>
<span>Server started on port 3000</span>
</div>
<div style={{ display: "flex", gap: 16 }}>
<span style={{ color: "#71717a" }}>14:32:05</span>
<span style={{ color: "#10b981" }}>[info]</span>
<span>Database connected successfully</span>
</div>
<div style={{ display: "flex", gap: 16 }}>
<span style={{ color: "#71717a" }}>14:45:12</span>
<span style={{ color: "#3b82f6" }}>[http]</span>
<span>GET /api/users 200 OK - 45ms</span>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +0,0 @@
import { redirect } from "next/navigation";
export default async function MarketingPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
const { workspace, projectId } = await params;
redirect(`/${workspace}/project/${projectId}/marketing/seo`);
}

View File

@@ -1,366 +0,0 @@
"use client";
import { ListFilter } from "lucide-react";
export default function SeoPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
}}
>
<div>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
SEO & GEO
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Improve how your app appears in search results and AI answers.
</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span
style={{ fontSize: "0.9rem", color: "#18181b", fontWeight: 500 }}
>
Enable SEO for this app
</span>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
</div>
<div
style={{
display: "flex",
gap: 24,
marginBottom: 24,
borderBottom: "1px solid #e4e4e7",
}}
>
<button
style={{
background: "none",
border: "none",
borderBottom: "2px solid #18181b",
padding: "8px 0",
fontSize: "0.9rem",
fontWeight: 500,
color: "#18181b",
cursor: "pointer",
}}
>
Overview
</button>
<button
style={{
background: "none",
border: "none",
padding: "8px 0",
fontSize: "0.9rem",
fontWeight: 500,
color: "#71717a",
cursor: "pointer",
}}
>
Meta tags
</button>
<button
style={{
background: "none",
border: "none",
padding: "8px 0",
fontSize: "0.9rem",
fontWeight: 500,
color: "#71717a",
cursor: "pointer",
}}
>
Advanced Settings
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "48px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginBottom: 32,
}}
>
<div
style={{
width: 40,
height: 40,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
}}
>
<ListFilter size={18} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 8px 0" }}
>
Run an SEO & GEO scan
</h2>
<p
style={{
fontSize: "0.9rem",
color: "#71717a",
textAlign: "center",
maxWidth: 400,
margin: "0 0 24px 0",
}}
>
Scan your app for SEO basics and GEO details. Get a prioritized
checklist to fix issues in minutes.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Run Scan
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
AI Assistant Discovery
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Help AI search engines understand and recommend your app
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#e4e4e7",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
left: 2,
top: 2,
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
}}
></div>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Generate robots.txt
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Off: serve your deployed public/robots.txt if shipped, otherwise
return 404.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Generate sitemap.xml
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Off: serve your deployed public/sitemap.xml if shipped, otherwise
return 404.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "20px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div style={{ maxWidth: 600 }}>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Auto-generate per-page breadcrumbs
</div>
<div
style={{ fontSize: "0.85rem", color: "#71717a", lineHeight: 1.5 }}
>
Build a fresh BreadcrumbList for each route instead of using the
same persisted list site-wide. Turn off if you hand crafted your
breadcrumb schema and want it served verbatim.
</div>
</div>
<div
style={{
width: 44,
height: 24,
background: "#18181b",
borderRadius: 12,
position: "relative",
cursor: "pointer",
flexShrink: 0,
}}
>
<div
style={{
width: 20,
height: 20,
background: "#fff",
borderRadius: "50%",
position: "absolute",
right: 2,
top: 2,
}}
></div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,89 +0,0 @@
"use client";
import { Share2 } from "lucide-react";
export default function SocialPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Social Content
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage social sharing campaigns and meta tags.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px dashed #e4e4e7",
borderRadius: 12,
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginTop: 32,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<Share2 size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
Social Campaign Manager
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Automatically generate and schedule social media content across
platforms based on your app's pages.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Connect Social Accounts
</button>
</div>
</div>
);
}

View File

@@ -16,7 +16,15 @@ import {
Terminal, Terminal,
Server, Server,
} from "lucide-react"; } from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy"; import {
THEME,
PageHeader,
Card,
SectionHeader,
EmptyState,
Badge,
SecondaryButton,
} from "@/components/project/dashboard-ui";
/** /**
* Hosting tab — user-facing: "Is my thing live? How do I reach it?" * Hosting tab — user-facing: "Is my thing live? How do I reach it?"
@@ -51,67 +59,96 @@ export default function OverviewTab() {
const showLoading = loading && !anatomy; const showLoading = loading && !anatomy;
return ( return (
<div style={pageWrap}> <div
{showLoading && ( style={{
<div style={centeredMsg}> minHeight: "100vh",
<Loader2 background: THEME.canvasGradient,
size={16} fontFamily: THEME.font,
className="animate-spin" padding: "36px 48px",
style={{ color: INK.muted }} }}
/> >
<span style={{ color: INK.muted, fontSize: "0.85rem" }}> <div style={{ maxWidth: 860, margin: "0 auto" }}>
Loading <PageHeader
</span> title="Overview"
</div> subtitle="Your live deployments and development previews."
)} />
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: DANGER }} />
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
</div>
)}
{anatomy && ( {showLoading && (
<> <Card>
{/* ── Live endpoints ── */} <div
<section> style={{
<SectionHeader title="Live" count={anatomy.hosting.live.length} /> display: "flex",
{anatomy.hosting.live.length === 0 ? ( alignItems: "center",
<EmptySection gap: 8,
icon={<Server size={20} style={{ color: INK.muted }} />} color: THEME.mid,
title="Nothing deployed yet" fontSize: "0.875rem",
hint="Ask the AI to deploy your app and it will appear here." }}
promptSuggestion="Deploy my app to production" >
/> <Loader2 size={15} className="animate-spin" /> Loading
) : ( </div>
<div </Card>
style={{ display: "flex", flexDirection: "column", gap: 16 }} )}
> {error && !showLoading && (
{anatomy.hosting.live.map((item) => ( <Card>
<LiveCard key={item.uuid} item={item} projectId={projectId} /> <div
))} style={{
</div> display: "flex",
)} alignItems: "center",
</section> gap: 8,
color: THEME.danger,
fontSize: "0.875rem",
}}
>
<AlertCircle size={15} /> {error}
</div>
</Card>
)}
{/* ── Previews ── */} {anatomy && (
{anatomy.hosting.previews.length > 0 && ( <>
<section style={{ marginTop: 40 }}> {/* ── Live endpoints ── */}
<SectionHeader <section>
title="Dev Previews" <SectionHeader title="Live" count={anatomy.hosting.live.length} />
count={anatomy.hosting.previews.length} {anatomy.hosting.live.length === 0 ? (
/> <EmptyState
<div icon={<Server size={22} />}
style={{ display: "flex", flexDirection: "column", gap: 10 }} title="Nothing deployed yet"
> hint="Ask the AI to deploy your app and it will appear here."
{anatomy.hosting.previews.map((p) => ( />
<PreviewRow key={p.id} preview={p} /> ) : (
))} <div
</div> style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{anatomy.hosting.live.map((item) => (
<LiveCard
key={item.uuid}
item={item}
projectId={projectId}
/>
))}
</div>
)}
</section> </section>
)}
</> {/* ── Previews ── */}
)} {anatomy.hosting.previews.length > 0 && (
<section style={{ marginTop: 40 }}>
<SectionHeader
title="Dev Previews"
count={anatomy.hosting.previews.length}
/>
<div
style={{ display: "flex", flexDirection: "column", gap: 10 }}
>
{anatomy.hosting.previews.map((p) => (
<PreviewRow key={p.id} preview={p} />
))}
</div>
</section>
)}
</>
)}
</div>
</div> </div>
); );
} }
@@ -130,6 +167,14 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null; const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
const phase = classifyPhase(item.status); const phase = classifyPhase(item.status);
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item); const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
const statusTheme =
phase === "healthy"
? "success"
: phase === "building"
? "warning"
: phase === "failed"
? "danger"
: "neutral";
const redeploy = async () => { const redeploy = async () => {
if (deploying) return; if (deploying) return;
@@ -185,77 +230,91 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
}; };
return ( return (
<div style={card}> <Card padding={16}>
{/* ── Card header ── */} <div
<div style={cardHeader}> style={{
<div display: "flex",
style={{ alignItems: "center",
display: "flex", justifyContent: "space-between",
alignItems: "center", gap: 16,
gap: 10, marginBottom: 12,
minWidth: 0, }}
flex: 1, >
}} <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
> <StatusDot status={statusTheme} />
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} /> <span style={{ fontSize: "1rem", fontWeight: 600, color: THEME.ink }}>
<span style={cardTitle}>{item.name}</span> {item.name}
<span style={sourcePill(item.source)}>
{item.source === "repo" ? "built" : "image"}
</span> </span>
<Badge color="default">
{item.source === "repo" ? "built" : "image"}
</Badge>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <SecondaryButton
<button onClick={redeploy}
onClick={redeploy} disabled={deploying}
disabled={deploying} icon={
style={actionBtn} deploying ? (
title="Redeploy now" <Loader2 size={14} className="animate-spin" />
>
{deploying ? (
<Loader2 size={13} className="animate-spin" />
) : ( ) : (
<RefreshCw size={13} /> <RefreshCw size={14} />
)} )
{deploying ? "Deploying…" : "Redeploy"} }
</button> >
</div> {deploying ? "Deploying…" : "Redeploy"}
</SecondaryButton>
</div> </div>
{/* ── Status line ── */} <div style={{ fontSize: "0.85rem", marginBottom: 16 }}>
<div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}> <span style={{ color: statusColor, fontWeight: 600 }}>
{statusLabel} {statusLabel}
</span> </span>
{item.lastBuild && ( {item.lastBuild && (
<span style={{ color: INK.muted }}> <span style={{ color: THEME.muted, marginLeft: 6 }}>
· Last build {item.lastBuild.status}{" "} · Last build {item.lastBuild.status}{" "}
{formatRelative(item.lastBuild.finishedAt)} {formatRelative(item.lastBuild.finishedAt)}
</span> </span>
)} )}
</div> </div>
{/* ── Live URL ── */}
{primaryUrl ? ( {primaryUrl ? (
<div style={urlRow}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} /> <Globe size={14} style={{ color: "#059669", flexShrink: 0 }} />
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}> <a
href={primaryUrl}
target="_blank"
rel="noreferrer"
style={{
fontSize: "0.875rem",
color: THEME.ink,
textDecoration: "underline",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
{primaryUrl} {primaryUrl}
</a> </a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} /> <ExternalLink size={12} style={{ color: THEME.muted }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL"> <button
{copied ? ( onClick={copyUrl}
<Check size={12} style={{ color: "#2e7d32" }} /> style={{
) : ( background: "transparent",
<Copy size={12} /> border: "none",
)} cursor: "pointer",
color: copied ? "#059669" : THEME.muted,
display: "flex",
padding: 4,
}}
title="Copy URL"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button> </button>
</div> </div>
) : ( ) : (
<div style={urlRow}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} /> <Globe size={14} style={{ color: THEME.muted, flexShrink: 0 }} />
<span <span
style={{ style={{
color: INK.muted, color: THEME.muted,
fontSize: "0.82rem", fontSize: "0.85rem",
fontStyle: "italic", fontStyle: "italic",
}} }}
> >
@@ -264,15 +323,14 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
</div> </div>
)} )}
{/* ── Extra domains ── */}
{item.domains.length > 1 && ( {item.domains.length > 1 && (
<div <div
style={{ style={{
paddingLeft: 23, paddingLeft: 22,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 4, gap: 4,
marginTop: 4, marginTop: 6,
}} }}
> >
{item.domains.slice(1).map((d) => ( {item.domains.slice(1).map((d) => (
@@ -281,45 +339,78 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
href={`https://${d}`} href={`https://${d}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }} style={{
fontSize: "0.8rem",
color: THEME.mid,
textDecoration: "none",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
> >
{d}{" "} {d} <ExternalLink size={10} style={{ display: "inline" }} />
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a> </a>
))} ))}
</div> </div>
)} )}
{/* ── Logs toggle ── */}
<div <div
style={{ style={{
marginTop: 14, marginTop: 16,
borderTop: `1px solid ${INK.borderSoft}`, borderTop: `1px solid ${THEME.borderSoft}`,
paddingTop: 10, paddingTop: 12,
}} }}
> >
<button onClick={openLogs} style={logsToggleBtn}> <button
onClick={openLogs}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "transparent",
border: "none",
color: THEME.mid,
fontSize: "0.8rem",
fontWeight: 500,
cursor: "pointer",
padding: 0,
}}
>
<Terminal size={12} /> <Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"} {logsOpen ? "Hide logs" : "Show recent logs"}
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button> </button>
{logsOpen && ( {logsOpen && (
<div style={logsBox}> <div
style={{
marginTop: 10,
background: "#0a0a0a",
borderRadius: THEME.radiusSm,
padding: 12,
maxHeight: 300,
overflowY: "auto",
}}
>
{logsLoading ? ( {logsLoading ? (
<span style={{ color: INK.muted, fontSize: "0.8rem" }}> <span style={{ color: THEME.muted, fontSize: "0.8rem" }}>
Loading Loading
</span> </span>
) : ( ) : (
<pre style={logsPre}>{logs || "(no logs)"}</pre> <pre
style={{
margin: 0,
fontSize: "0.75rem",
color: "#e5e5e5",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
whiteSpace: "pre-wrap",
}}
>
{logs || "(no logs)"}
</pre>
)} )}
</div> </div>
)} )}
</div> </div>
</div> </Card>
); );
} }
@@ -330,43 +421,46 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
function PreviewRow({ preview }: { preview: Preview }) { function PreviewRow({ preview }: { preview: Preview }) {
const running = preview.state === "running"; const running = preview.state === "running";
return ( return (
<div style={{ ...card, padding: "12px 16px" }}> <Card
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> padding={16}
<CircleDot style={{ display: "flex", alignItems: "center", gap: 12 }}
size={10} >
style={{ color: running ? "#10b981" : INK.muted, flexShrink: 0 }} <StatusDot status={running ? "success" : "neutral"} />
/> <span style={{ fontSize: "0.95rem", fontWeight: 600, color: THEME.ink }}>
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}> {preview.name}
{preview.name} </span>
</span> <span style={{ fontSize: "0.8rem", color: THEME.muted }}>
<span style={{ fontSize: "0.75rem", color: INK.mid }}> port {preview.port}
port {preview.port} </span>
</span> {preview.url && running && (
{preview.url && running && ( <div
<div style={{
marginLeft: "auto",
display: "flex",
gap: 8,
alignItems: "center",
}}
>
<a
href={preview.url}
target="_blank"
rel="noreferrer"
style={{ style={{
marginLeft: "auto", fontSize: "0.85rem",
display: "flex", color: THEME.ink,
gap: 8, textDecoration: "underline",
alignItems: "center", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}} }}
> >
<a {preview.url.replace(/^https?:\/\//, "")}{" "}
href={preview.url} <ExternalLink
target="_blank" size={12}
rel="noreferrer" style={{ display: "inline", verticalAlign: "middle" }}
style={{ ...urlLink, marginLeft: 0 }} />
> </a>
{preview.url.replace(/^https?:\/\//, "")}{" "} </div>
<ExternalLink )}
size={10} </Card>
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
</div>
)}
</div>
</div>
); );
} }
@@ -427,15 +521,6 @@ function formatRelative(iso: string | undefined) {
// Sub-components // Sub-components
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({ function EmptySection({
icon, icon,
title, title,
@@ -492,13 +577,13 @@ function EmptySection({
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
const INK = { const INK = {
ink: "#1a1a1a", ink: "#111827",
mid: "#5f5e5a", mid: "#4b5563",
muted: "#a09a90", muted: "#9ca3af",
border: "#e8e4dc", border: "#e5e7eb",
borderSoft: "#efebe1", borderSoft: "#f3f4f6",
cardBg: "#fff", cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const; } as const;
const GREEN = "#10b981"; const GREEN = "#10b981";
const AMBER = "#f59e0b"; const AMBER = "#f59e0b";

View File

@@ -21,6 +21,16 @@ import {
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import useSWR from "swr"; import useSWR from "swr";
import {
THEME,
PageHeader,
Card,
SectionHeader,
PrimaryButton,
SecondaryButton,
Badge,
StatusDot,
} from "@/components/project/dashboard-ui";
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Types & Fetcher // Types & Fetcher
@@ -88,101 +98,125 @@ export default function PlanTab() {
const showLoading = !plan && !error; const showLoading = !plan && !error;
return ( return (
<div style={pageWrap}> <div
{showLoading && ( style={{
<div style={centeredMsg}> minHeight: "100vh",
<Loader2 background: THEME.canvasGradient,
size={16} fontFamily: THEME.font,
className="animate-spin" padding: "36px 48px",
style={{ color: INK.muted }} }}
/> >
<span style={{ color: INK.muted, fontSize: "0.85rem" }}> <div style={{ maxWidth: 1400, margin: "0 auto" }}>
Loading plan <PageHeader
</span> title="Plan & Specs"
</div> subtitle="Your product brief, execution plan, and AI instructions."
)} />
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: "#d93025" }} />
<span style={{ fontSize: "0.85rem", color: "#d93025" }}>
{error.message || "Failed to load plan"}
</span>
</div>
)}
{plan && ( {showLoading && (
<div style={grid}> <Card>
{/* ── Left Rail (Master Index) ── */} <div
<section style={leftCol}> style={{
<div style={railGroup}> display: "flex",
<div style={railGroupHeader}> alignItems: "center",
<h3 style={railGroupTitle}>Scope</h3> gap: 8,
</div> color: THEME.mid,
<div style={railItems}> fontSize: "0.875rem",
<RailItem }}
id="objective" >
label="Product Brief" <Loader2 size={15} className="animate-spin" /> Loading plan
icon={<Target />}
selectedId={selectedId}
onClick={setSelectedId}
/>
</div>
</div> </div>
</Card>
)}
{error && !showLoading && (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.danger,
fontSize: "0.875rem",
}}
>
<AlertCircle size={15} /> {error.message || "Failed to load plan"}
</div>
</Card>
)}
<div style={railGroup}> {plan && (
<div style={railGroupHeader}> <div style={grid}>
<h3 style={railGroupTitle}>Blueprint</h3> {/* ── Left Rail (Master Index) ── */}
</div> <section style={leftCol}>
<div style={railItems}> <div style={railGroup}>
{BLUEPRINT_DOCS.map((doc) => ( <div style={railGroupHeader}>
<h3 style={railGroupTitle}>Scope</h3>
</div>
<div style={railItems}>
<RailItem <RailItem
key={doc.id} id="objective"
id={doc.id} label="Product Brief"
label={doc.label} icon={<Target />}
icon={doc.icon}
selectedId={selectedId} selectedId={selectedId}
onClick={setSelectedId} onClick={setSelectedId}
/> />
))} </div>
</div> </div>
</div>
<div style={railGroup}> <div style={railGroup}>
<div style={railGroupHeader}> <div style={railGroupHeader}>
<h3 style={railGroupTitle}>Delegate to AI</h3> <h3 style={railGroupTitle}>Blueprint</h3>
</div>
<div style={railItems}>
{BLUEPRINT_DOCS.map((doc) => (
<RailItem
key={doc.id}
id={doc.id}
label={doc.label}
icon={doc.icon}
selectedId={selectedId}
onClick={setSelectedId}
/>
))}
</div>
</div> </div>
<div style={railItems}>
<RailItem <div style={railGroup}>
id="kanban" <div style={railGroupHeader}>
label="Execution Plan" <h3 style={railGroupTitle}>Delegate to AI</h3>
icon={<Play />} </div>
selectedId={selectedId} <div style={railItems}>
onClick={setSelectedId} <RailItem
id="kanban"
label="Execution Plan"
icon={<Play />}
selectedId={selectedId}
onClick={setSelectedId}
/>
</div>
</div>
</section>
{/* ── Right Rail (Detail Viewer) ── */}
<section style={rightCol}>
{selectedId === "objective" && (
<ObjectivePanel
plan={plan}
projectId={projectId}
onChange={setPlan}
/> />
</div> )}
</div>
</section>
{/* ── Right Rail (Detail Viewer) ── */} {BLUEPRINT_DOCS.some((d) => d.id === selectedId) && (
<section style={rightCol}> <DocumentPanel plan={plan} docId={selectedId} />
{selectedId === "objective" && ( )}
<ObjectivePanel
plan={plan}
projectId={projectId}
onChange={setPlan}
/>
)}
{BLUEPRINT_DOCS.some((d) => d.id === selectedId) && ( {selectedId === "kanban" && (
<DocumentPanel plan={plan} docId={selectedId} /> <DelegatePanel plan={plan} projectId={projectId} />
)} )}
</section>
{selectedId === "kanban" && ( </div>
<DelegatePanel plan={plan} projectId={projectId} /> )}
)} </div>
</section>
</div>
)}
</div> </div>
); );
} }
@@ -210,15 +244,15 @@ function RailItem({
onClick={() => onClick(id)} onClick={() => onClick(id)}
style={{ style={{
...flatTile, ...flatTile,
background: isActive ? INK.cardBg : "transparent", background: isActive ? THEME.cardBg : "transparent",
borderColor: isActive ? INK.border : "transparent", borderColor: isActive ? THEME.border : "transparent",
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.02)" : "none", boxShadow: isActive ? THEME.shadow : "none",
color: isActive ? INK.ink : INK.muted, color: isActive ? THEME.ink : THEME.muted,
}} }}
> >
{React.cloneElement(icon, { {React.cloneElement(icon, {
size: 15, size: 15,
color: isActive ? INK.ink : INK.muted, color: isActive ? THEME.ink : THEME.muted,
} as React.SVGProps<SVGSVGElement> & { size?: number | string })} } as React.SVGProps<SVGSVGElement> & { size?: number | string })}
<span style={{ fontSize: "0.85rem", fontWeight: isActive ? 600 : 500 }}> <span style={{ fontSize: "0.85rem", fontWeight: isActive ? 600 : 500 }}>
{label} {label}
@@ -279,125 +313,156 @@ function ObjectivePanel({
}; };
return ( return (
<div style={panel}> <Card
<div style={panelHeader}> style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
padding={0}
>
<div
style={{
padding: "24px 32px",
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div> <div>
<h2 style={panelTitle}>Product Brief</h2> <h2
<p style={panelDesc}> style={{
fontSize: "1.25rem",
fontWeight: 600,
color: THEME.ink,
margin: "0 0 6px 0",
}}
>
Product Brief
</h2>
<p style={{ margin: 0, fontSize: "0.875rem", color: THEME.muted }}>
The high-level business case and elevator pitch. The high-level business case and elevator pitch.
</p> </p>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{saving && (
<span style={{ fontSize: "0.75rem", color: INK.muted }}>
Saving...
</span>
)}
{!editing && ( {!editing && (
<button onClick={() => setEditing(true)} style={actionBtn}> <SecondaryButton
<Pencil size={14} /> Edit Objective onClick={() => setEditing(true)}
</button> icon={<Pencil size={14} />}
>
Edit Objective
</SecondaryButton>
)} )}
{editing && ( {editing && (
<> <>
<button <PrimaryButton
onClick={() => save(draft)} onClick={() => save(draft)}
disabled={!dirty || saving} disabled={!dirty || saving}
className="btn-primary"
> >
Save Changes {saving ? "Saving…" : "Save Changes"}
</button> </PrimaryButton>
<button onClick={cancel} className="btn-ghost"> <SecondaryButton onClick={cancel}>Cancel</SecondaryButton>
Cancel
</button>
</> </>
)} )}
</div> </div>
</div> </div>
<div style={editorContainer}> <div
{editing ? ( style={{
<> flex: 1,
<div style={editorTabs}> display: "flex",
<button flexDirection: "column",
type="button" minHeight: 0,
onClick={() => setEditorView("write")} padding: "0 32px 32px",
style={ }}
editorView === "write" ? editorTabActive : editorTabInactive >
} <div style={editorContainer}>
> {editing ? (
<FileText size={14} /> Write <>
</button> <div style={editorTabs}>
<button <button
type="button" type="button"
onClick={() => setEditorView("preview")} onClick={() => setEditorView("write")}
style={ style={
editorView === "preview" ? editorTabActive : editorTabInactive editorView === "write" ? editorTabActive : editorTabInactive
} }
>
<BookOpen size={14} /> Preview
</button>
<div style={{ flex: 1 }} />
{dirty && (
<span
style={{
fontSize: "0.75rem",
color: "#f97316",
display: "flex",
alignItems: "center",
fontWeight: 500,
}}
> >
Unsaved <FileText size={14} /> Write
</span> </button>
)} <button
</div> type="button"
onClick={() => setEditorView("preview")}
{editorView === "write" ? ( style={
<textarea editorView === "preview"
value={draft} ? editorTabActive
onChange={(e) => { : editorTabInactive
setDraft(e.target.value); }
setDirty(true); >
}} <BookOpen size={14} /> Preview
style={textAreaStyle} </button>
placeholder="Describe the business objective..." <div style={{ flex: 1 }} />
spellCheck {dirty && (
/> <span
) : ( style={{
<div style={previewAreaStyle}> fontSize: "0.75rem",
{draft.trim() ? ( color: "#f97316",
<div className="markdown-prose"> display: "flex",
<ReactMarkdown remarkPlugins={[remarkGfm]}> alignItems: "center",
{draft} fontWeight: 500,
</ReactMarkdown> }}
</div> >
) : ( Unsaved
<div style={{ color: INK.muted, fontStyle: "italic" }}> </span>
Nothing to preview yet.
</div>
)} )}
</div> </div>
)}
</> {editorView === "write" ? (
) : ( <textarea
<div style={previewAreaStyle}> value={draft}
{draft.trim() ? ( onChange={(e) => {
<div className="markdown-prose"> setDraft(e.target.value);
<ReactMarkdown remarkPlugins={[remarkGfm]}> setDirty(true);
{draft} }}
</ReactMarkdown> style={textAreaStyle}
</div> placeholder="Describe the business objective..."
) : ( spellCheck
<div style={{ color: INK.muted, fontStyle: "italic" }}> />
No objective set. Click Edit to add one. ) : (
</div> <div style={previewAreaStyle}>
)} {draft.trim() ? (
</div> <div className="markdown-prose">
)} <ReactMarkdown remarkPlugins={[remarkGfm]}>
{draft}
</ReactMarkdown>
</div>
) : (
<div style={{ color: THEME.muted, fontStyle: "italic" }}>
Nothing to preview yet.
</div>
)}
</div>
)}
</>
) : (
<div style={previewAreaStyle}>
{draft.trim() ? (
<div className="markdown-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{draft}
</ReactMarkdown>
</div>
) : (
<div style={{ color: THEME.muted, fontStyle: "italic" }}>
No objective set. Click Edit to add one.
</div>
)}
</div>
)}
</div>
</div> </div>
</div> </Card>
); );
} }
@@ -414,7 +479,15 @@ function DocumentPanel({ plan, docId }: { plan: Plan; docId: string }) {
const content = plan.blueprint?.[docId as keyof typeof plan.blueprint]; const content = plan.blueprint?.[docId as keyof typeof plan.blueprint];
return ( return (
<div style={panel}> <Card
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
padding={32}
>
{content ? ( {content ? (
<div <div
className="markdown-prose" className="markdown-prose"
@@ -423,40 +496,44 @@ function DocumentPanel({ plan, docId }: { plan: Plan; docId: string }) {
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div> </div>
) : ( ) : (
<> <div
<div style={panelHeader}> style={{
<div> flex: 1,
<h2 style={panelTitle}>{docConfig.label}</h2> display: "flex",
<p style={panelDesc}>This document is currently empty.</p> flexDirection: "column",
</div> alignItems: "center",
justifyContent: "center",
color: THEME.muted,
gap: 12,
}}
>
<div
style={{
color: THEME.muted,
display: "flex",
transform: "scale(1.5)",
}}
>
{docConfig.icon}
</div> </div>
<div style={emptyBox}> <div style={{ textAlign: "center", marginTop: 8 }}>
{React.cloneElement( <h3
docConfig.icon as React.ReactElement,
{
size: 32,
color: INK.muted,
style: { marginBottom: 16, opacity: 0.5 },
} as React.SVGProps<SVGSVGElement> & { size?: number | string },
)}
<p style={{ fontWeight: 500, margin: 0, color: INK.ink }}>
Not generated yet.
</p>
<p
style={{ style={{
fontSize: "0.85rem", fontSize: "1.1rem",
maxWidth: 300, fontWeight: 600,
margin: "8px 0 0", color: THEME.ink,
lineHeight: 1.5, margin: "0 0 4px 0",
}} }}
> >
This document is generated and maintained by the AI agent. Chat {docConfig.label}
with your agent to update the scope and blueprint. </h3>
<p style={{ fontSize: "0.875rem", margin: 0 }}>
This document is currently empty. Ask the AI to draft it.
</p> </p>
</div> </div>
</> </div>
)} )}
</div> </Card>
); );
} }
@@ -507,43 +584,38 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
}; };
const TaskCard = ({ t }: { t: Plan["tasks"][number] }) => ( const TaskCard = ({ t }: { t: Plan["tasks"][number] }) => (
<div style={taskCard}> <Card
padding={16}
style={{ display: "flex", flexDirection: "column", gap: 8 }}
>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "flex-start",
gap: 12, gap: 12,
width: "100%", width: "100%",
overflow: "hidden",
}} }}
> >
<div <div style={{ marginTop: 4 }}>
style={{ <StatusDot
...taskStatusDot, status={
borderColor:
t.status === "done" t.status === "done"
? "#10b981" ? "success"
: t.status === "open" : t.status === "open"
? INK.muted ? "neutral"
: "#f59e0b", : "warning"
background: t.status === "done" ? "#10b981" : "transparent", }
}} />
>
{t.status === "done" && (
<Check size={10} color="#fff" strokeWidth={3} />
)}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div <div
style={{ style={{
fontWeight: 600, fontWeight: 600,
fontSize: "0.95rem", fontSize: "0.95rem",
color: INK.ink, color: THEME.ink,
textDecoration: t.status === "done" ? "line-through" : "none", textDecoration: t.status === "done" ? "line-through" : "none",
opacity: t.status === "done" ? 0.6 : 1, opacity: t.status === "done" ? 0.6 : 1,
wordWrap: "break-word", wordWrap: "break-word",
overflowWrap: "break-word",
hyphens: "auto",
}} }}
> >
{t.title} {t.title}
@@ -552,12 +624,10 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
<div <div
style={{ style={{
fontSize: "0.8rem", fontSize: "0.8rem",
color: INK.muted, color: THEME.muted,
marginTop: 4, marginTop: 4,
lineHeight: 1.4, lineHeight: 1.4,
wordWrap: "break-word", wordWrap: "break-word",
overflowWrap: "break-word",
hyphens: "auto",
}} }}
> >
{t.description} {t.description}
@@ -565,43 +635,66 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
)} )}
</div> </div>
</div> </div>
<div style={{ display: "flex", justifyContent: "flex-end" }}> <div
<span style={taskBadge}> style={{ display: "flex", justifyContent: "flex-end", marginTop: 12 }}
>
<Badge
color={
t.status === "done"
? "success"
: t.status === "open"
? "default"
: "warning"
}
>
{t.status === "open" {t.status === "open"
? "Queued" ? "Queued"
: t.status === "done" : t.status === "done"
? "Completed" ? "Completed"
: "In Progress"} : "In Progress"}
</span> </Badge>
</div> </div>
</div> </Card>
); );
return ( return (
<div <div
style={{ style={{
...panel, display: "flex",
background: "transparent", flexDirection: "column",
border: "none", flex: 1,
boxShadow: "none", minHeight: 0,
padding: 0,
}} }}
> >
<div style={panelHeader}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
}}
>
<div> <div>
<h2 style={panelTitle}>Execution Plan</h2> <h2
<p style={panelDesc}> style={{
fontSize: "1.25rem",
fontWeight: 600,
color: THEME.ink,
margin: "0 0 6px 0",
}}
>
Execution Plan
</h2>
<p style={{ margin: 0, fontSize: "0.875rem", color: THEME.muted }}>
The prioritized roadmap for the AI background runner to execute. The prioritized roadmap for the AI background runner to execute.
</p> </p>
</div> </div>
<button <PrimaryButton
className="btn-primary"
onClick={handleDelegate} onClick={handleDelegate}
disabled={delegating || openTasks.length === 0} disabled={delegating || openTasks.length === 0}
style={{ background: INK.ink, color: "#fff" }}
> >
{delegating ? "Starting Jarvis..." : "Delegate Build"} {delegating ? "Starting Jarvis..." : "Delegate Build"}
</button> </PrimaryButton>
</div> </div>
<div <div
@@ -673,23 +766,6 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
// Styles (Mapped to infrastructure/product design language) // Styles (Mapped to infrastructure/product design language)
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
bgHover: "#fafaf6",
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 = { const grid: React.CSSProperties = {
display: "grid", display: "grid",
gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)", gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
@@ -712,14 +788,6 @@ const rightCol: React.CSSProperties = {
flexDirection: "column", flexDirection: "column",
}; };
const centeredMsg: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "24px 0",
justifyContent: "center",
};
const railGroup: React.CSSProperties = { const railGroup: React.CSSProperties = {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -735,7 +803,7 @@ const railGroupTitle: React.CSSProperties = {
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.12em", letterSpacing: "0.12em",
textTransform: "uppercase", textTransform: "uppercase",
color: INK.muted, color: THEME.muted,
}; };
const railItems: React.CSSProperties = { const railItems: React.CSSProperties = {
display: "flex", display: "flex",
@@ -757,58 +825,11 @@ const flatTile: React.CSSProperties = {
textAlign: "left", textAlign: "left",
}; };
const panel: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 32,
flex: 1,
minHeight: "calc(100vh - 150px)",
display: "flex",
flexDirection: "column",
boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
};
const panelHeader: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
flexShrink: 0,
};
const panelTitle: React.CSSProperties = {
fontSize: "1.2rem",
fontWeight: 600,
margin: 0,
color: INK.ink,
};
const panelDesc: React.CSSProperties = {
color: INK.muted,
fontSize: "0.85rem",
margin: "4px 0 0",
};
const actionBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 16px",
border: `1px solid ${INK.border}`,
borderRadius: 8,
background: "#fff",
cursor: "pointer",
font: "inherit",
fontSize: "0.85rem",
fontWeight: 500,
color: INK.ink,
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
};
const editorContainer: React.CSSProperties = { const editorContainer: React.CSSProperties = {
border: `1px solid ${INK.border}`, border: `1px solid ${THEME.border}`,
borderRadius: 10, borderRadius: 10,
overflow: "hidden", overflow: "hidden",
background: INK.cardBg, background: THEME.cardBg,
boxShadow: "0 1px 3px rgba(0,0,0,0.02)", boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -817,8 +838,8 @@ const editorContainer: React.CSSProperties = {
}; };
const editorTabs: React.CSSProperties = { const editorTabs: React.CSSProperties = {
display: "flex", display: "flex",
background: INK.bgHover, background: THEME.subtleBg,
borderBottom: `1px solid ${INK.borderSoft}`, borderBottom: `1px solid ${THEME.borderSoft}`,
padding: "8px 16px", padding: "8px 16px",
gap: 16, gap: 16,
flexShrink: 0, flexShrink: 0,
@@ -829,10 +850,10 @@ const editorTabActive: React.CSSProperties = {
gap: 6, gap: 6,
padding: "6px 12px", padding: "6px 12px",
borderRadius: 6, borderRadius: 6,
background: INK.cardBg, background: THEME.cardBg,
border: `1px solid ${INK.borderSoft}`, border: `1px solid ${THEME.borderSoft}`,
boxShadow: "0 1px 2px rgba(0,0,0,0.04)", boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
color: INK.ink, color: THEME.ink,
fontWeight: 500, fontWeight: 500,
fontSize: "0.85rem", fontSize: "0.85rem",
cursor: "pointer", cursor: "pointer",
@@ -842,7 +863,7 @@ const editorTabInactive: React.CSSProperties = {
background: "transparent", background: "transparent",
border: "1px solid transparent", border: "1px solid transparent",
boxShadow: "none", boxShadow: "none",
color: INK.muted, color: THEME.muted,
}; };
const textAreaStyle: React.CSSProperties = { const textAreaStyle: React.CSSProperties = {
@@ -856,7 +877,7 @@ const textAreaStyle: React.CSSProperties = {
outline: "none", outline: "none",
resize: "none", resize: "none",
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
color: INK.ink, color: THEME.ink,
display: "block", display: "block",
boxSizing: "border-box", boxSizing: "border-box",
margin: 0, margin: 0,
@@ -869,26 +890,6 @@ const previewAreaStyle: React.CSSProperties = {
overflowY: "auto", overflowY: "auto",
}; };
const emptyBox: React.CSSProperties = {
border: `1px dashed ${INK.borderSoft}`,
borderRadius: 10,
padding: "48px 32px",
textAlign: "center",
color: INK.muted,
display: "flex",
flexDirection: "column",
alignItems: "center",
margin: "auto 0",
};
const emptyBoxSmall: React.CSSProperties = {
padding: 32,
textAlign: "center",
border: `1px dashed ${INK.border}`,
borderRadius: 8,
color: INK.muted,
fontSize: "0.85rem",
};
const kanbanCol: React.CSSProperties = { const kanbanCol: React.CSSProperties = {
flex: 1, flex: 1,
minWidth: 300, minWidth: 300,
@@ -905,15 +906,15 @@ const kanbanColHeader: React.CSSProperties = {
const kanbanColTitle: React.CSSProperties = { const kanbanColTitle: React.CSSProperties = {
fontSize: "0.95rem", fontSize: "0.95rem",
fontWeight: 600, fontWeight: 600,
color: INK.ink, color: THEME.ink,
margin: 0, margin: 0,
}; };
const kanbanCount: React.CSSProperties = { const kanbanCount: React.CSSProperties = {
fontSize: "0.75rem", fontSize: "0.75rem",
background: INK.borderSoft, background: THEME.borderSoft,
padding: "2px 8px", padding: "2px 8px",
borderRadius: 12, borderRadius: 12,
color: INK.muted, color: THEME.muted,
fontWeight: 600, fontWeight: 600,
}; };
const kanbanList: React.CSSProperties = { const kanbanList: React.CSSProperties = {
@@ -925,50 +926,18 @@ const kanbanList: React.CSSProperties = {
paddingBottom: 24, paddingBottom: 24,
}; };
const taskCard: React.CSSProperties = { const emptyBoxSmall: React.CSSProperties = {
border: `1px solid ${INK.border}`, padding: 32,
textAlign: "center",
border: `1px dashed ${THEME.border}`,
borderRadius: 8, borderRadius: 8,
padding: 16, color: THEME.muted,
background: INK.cardBg, fontSize: "0.85rem",
display: "flex",
flexDirection: "column",
gap: 8,
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
};
const taskStatusDot: React.CSSProperties = {
width: 16,
height: 16,
borderRadius: "50%",
marginTop: 2,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
const taskBadge: React.CSSProperties = {
fontSize: "0.75rem",
color: INK.muted,
background: INK.bgHover,
padding: "4px 8px",
borderRadius: 4,
fontWeight: 500,
}; };
// Global styles // Global styles
const styleTag = ` const styleTag = `
.btn-primary, .btn-secondary, .btn-ghost { .markdown-prose { font-size: 0.85rem; color: #111827; }
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
cursor: pointer; transition: all 0.15s ease; border: 1px solid transparent;
}
.btn-primary { background: #1a1a1a; color: white; }
.btn-primary:hover { background: #333; }
.btn-secondary { background: #fff; border-color: #e8e4dc; color: #1a1a1a; }
.btn-secondary:hover { background: #fafaf6; }
.btn-ghost { background: transparent; color: #a09a90; }
.btn-ghost:hover { background: #fafaf6; color: #1a1a1a; }
.markdown-prose { font-size: 0.85rem; color: #1a1a1a; }
.markdown-prose h1 { font-size: 1.25rem; font-weight: 700; margin-top: 0; } .markdown-prose h1 { font-size: 1.25rem; font-weight: 700; margin-top: 0; }
.markdown-prose h2 { font-size: 1.15rem; font-weight: 600; margin-top: 1.5rem; } .markdown-prose h2 { font-size: 1.15rem; font-weight: 600; margin-top: 1.5rem; }
.markdown-prose h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; } .markdown-prose h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; }

View File

@@ -3,8 +3,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
Loader2, AlertCircle, ChevronDown, ChevronRight, Loader2,
Box, Container, CircleDot, AlertCircle,
ChevronDown,
ChevronRight,
Box,
Container,
CircleDot,
} from "lucide-react"; } from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree"; import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer"; import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
@@ -34,15 +39,17 @@ export default function ProductTab() {
const { anatomy, loading, error } = useAnatomy(projectId); const { anatomy, loading, error } = useAnatomy(projectId);
const codebases = anatomy?.product.codebases ?? null; const codebases = anatomy?.product.codebases ?? null;
const images = anatomy?.product.images ?? null; const images = anatomy?.product.images ?? null;
const reason = anatomy?.codebasesReason; const reason = anatomy?.codebasesReason;
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selection, setSelection] = useState<Selection>(null); const [selection, setSelection] = useState<Selection>(null);
useEffect(() => { useEffect(() => {
if (codebases && codebases[0]) { if (codebases && codebases[0]) {
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev)); setExpanded((prev) =>
prev.size === 0 ? new Set([codebases[0].id]) : prev,
);
} }
}, [codebases]); }, [codebases]);
@@ -52,7 +59,7 @@ export default function ProductTab() {
}, [projectId]); }, [projectId]);
const toggleCodebase = (id: string) => { const toggleCodebase = (id: string) => {
setExpanded(prev => { setExpanded((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
@@ -68,10 +75,14 @@ export default function ProductTab() {
{/* ── Left rail ── */} {/* ── Left rail ── */}
<section style={leftCol}> <section style={leftCol}>
{showLoading && ( {showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline> <Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)} )}
{error && !showLoading && ( {error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline> <Inline>
<AlertCircle size={13} /> {error}
</Inline>
)} )}
{anatomy && ( {anatomy && (
@@ -80,12 +91,24 @@ export default function ProductTab() {
<RailGroup title="Codebases" count={codebases?.length ?? 0}> <RailGroup title="Codebases" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && ( {codebases && codebases.length === 0 && (
<RailEmpty> <RailEmpty>
{reason === "no_repo" {reason === "no_repo" ? (
? <>No codebase yet. <span style={nudge}>Try: &quot;Start building my app&quot;</span></> <>
: <>Repo is empty push a first commit. <span style={nudge}>Try: &quot;Scaffold a Next.js app&quot;</span></>} No codebase yet.{" "}
<span style={nudge}>
Try: &quot;Start building my app&quot;
</span>
</>
) : (
<>
Repo is empty push a first commit.{" "}
<span style={nudge}>
Try: &quot;Scaffold a Next.js app&quot;
</span>
</>
)}
</RailEmpty> </RailEmpty>
)} )}
{codebases?.map(cb => { {codebases?.map((cb) => {
const isOpen = expanded.has(cb.id); const isOpen = expanded.has(cb.id);
return ( return (
<article key={cb.id} style={codebaseTile}> <article key={cb.id} style={codebaseTile}>
@@ -96,11 +119,19 @@ export default function ProductTab() {
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<span style={chevronCell}> <span style={chevronCell}>
{isOpen {isOpen ? (
? <ChevronDown size={13} style={{ color: INK.mid }} /> <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />} ) : (
<ChevronRight
size={13}
style={{ color: INK.mid }}
/>
)}
</span> </span>
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} /> <Box
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left" }}> <div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div> <div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>} {cb.hint && <div style={tileHint}>{cb.hint}</div>}
@@ -112,12 +143,17 @@ export default function ProductTab() {
projectId={projectId} projectId={projectId}
rootPath={cb.path} rootPath={cb.path}
selectedPath={ selectedPath={
selection?.type === "file" && selection.codebaseId === cb.id selection?.type === "file" &&
selection.codebaseId === cb.id
? selection.path ? selection.path
: undefined : undefined
} }
onSelectFile={(p) => onSelectFile={(p) =>
setSelection({ type: "file", codebaseId: cb.id, path: p }) setSelection({
type: "file",
codebaseId: cb.id,
path: p,
})
} }
/> />
</div> </div>
@@ -131,31 +167,62 @@ export default function ProductTab() {
<RailGroup title="Images" count={images?.length ?? 0}> <RailGroup title="Images" count={images?.length ?? 0}>
{images && images.length === 0 && ( {images && images.length === 0 && (
<RailEmpty> <RailEmpty>
Self-hosted tools (Twenty CRM, n8n, Plausible) you run appear here. Self-hosted tools (Twenty CRM, n8n, Plausible) you run
<span style={nudge}>Try: &quot;Install Twenty CRM for my project&quot;</span> appear here.
<span style={nudge}>
Try: &quot;Install Twenty CRM for my project&quot;
</span>
</RailEmpty> </RailEmpty>
)} )}
{images?.map(img => ( {images?.map((img) => (
<button <button
key={img.uuid} key={img.uuid}
type="button" type="button"
onClick={() => setSelection({ type: "image", uuid: img.uuid })} onClick={() =>
setSelection({ type: "image", uuid: img.uuid })
}
style={{ style={{
...flatTile, ...flatTile,
borderColor: selection?.type === "image" && selection.uuid === img.uuid ? INK.ink : INK.borderSoft, borderColor:
boxShadow: selection?.type === "image" && selection.uuid === img.uuid ? `0 0 0 1px ${INK.ink}` : "none", selection?.type === "image" &&
background: selection?.type === "image" && selection.uuid === img.uuid ? "#fffdf8" : INK.cardBg, selection.uuid === img.uuid
? INK.ink
: INK.borderSoft,
boxShadow:
selection?.type === "image" &&
selection.uuid === img.uuid
? `0 0 0 1px ${INK.ink}`
: "none",
background:
selection?.type === "image" &&
selection.uuid === img.uuid
? "#fffdf8"
: INK.cardBg,
}} }}
aria-pressed={selection?.type === "image" && selection.uuid === img.uuid} aria-pressed={
selection?.type === "image" && selection.uuid === img.uuid
}
> >
<Container size={13} style={{ color: INK.mid, flexShrink: 0 }} /> <Container
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}> <div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{img.name}</div> <div style={tileLabel}>{img.name}</div>
<div style={tileHint}> <div style={tileHint}>
{img.image}{img.version ? `:${img.version}` : ""} {img.image}
{img.version ? `:${img.version}` : ""}
</div> </div>
</div> </div>
{img.status && <CircleDot size={9} style={{ color: statusColor(img.status), flexShrink: 0 }} />} {img.status && (
<CircleDot
size={9}
style={{
color: statusColor(img.status),
flexShrink: 0,
}}
/>
)}
</button> </button>
))} ))}
</RailGroup> </RailGroup>
@@ -188,22 +255,26 @@ export default function ProductTab() {
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) { function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
const img = anatomy.product.images.find(i => i.uuid === uuid); const img = anatomy.product.images.find((i) => i.uuid === uuid);
if (!img) return <Empty>This image is no longer in the project.</Empty>; if (!img) return <Empty>This image is no longer in the project.</Empty>;
const live = anatomy.hosting.live.find(l => l.uuid === uuid); const live = anatomy.hosting.live.find((l) => l.uuid === uuid);
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}> <div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
<DetailRow label="Image" value={img.image} /> <DetailRow label="Image" value={img.image} />
<DetailRow label="Version" value={img.version || "latest"} /> <DetailRow label="Version" value={img.version || "latest"} />
<DetailRow label="Type" value={img.serviceType ?? "—"} /> <DetailRow label="Type" value={img.serviceType ?? "—"} />
<DetailRow <DetailRow
label="Status" label="Status"
value={img.status ?? "unknown"} value={img.status ?? "unknown"}
dot={statusColor(img.status ?? "")} dot={statusColor(img.status ?? "")}
/> />
{live?.fqdn && ( {live?.fqdn && (
<DetailRow label="URL" value={live.fqdn} href={`https://${live.fqdn}`} /> <DetailRow
label="URL"
value={live.fqdn}
href={`https://${live.fqdn}`}
/>
)} )}
</div> </div>
); );
@@ -214,8 +285,14 @@ function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
function RailGroup({ function RailGroup({
title, count, children, title,
}: { title: string; count: number; children: React.ReactNode }) { count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return ( return (
<div style={railGroup}> <div style={railGroup}>
<header style={railGroupHeader}> <header style={railGroupHeader}>
@@ -232,16 +309,28 @@ function RailEmpty({ children }: { children: React.ReactNode }) {
} }
function DetailRow({ function DetailRow({
label, value, dot, href, label,
}: { label: string; value: string; dot?: string; href?: string }) { value,
dot,
href,
}: {
label: string;
value: string;
dot?: string;
href?: string;
}) {
return ( return (
<div style={detailRow}> <div style={detailRow}>
<span style={detailLabel}>{label}</span> <span style={detailLabel}>{label}</span>
<span style={detailValue}> <span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />} {dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? ( {href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a> <a href={href} target="_blank" rel="noreferrer" style={detailLink}>
) : value} {value}
</a>
) : (
value
)}
</span> </span>
</div> </div>
); );
@@ -249,11 +338,19 @@ function DetailRow({
function Inline({ children }: { children: React.ReactNode }) { function Inline({ children }: { children: React.ReactNode }) {
return ( return (
<div style={{ <div
display: "flex", alignItems: "center", gap: 8, style={{
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid, display: "flex",
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8, alignItems: "center",
}}> gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: INK.mid,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
}}
>
{children} {children}
</div> </div>
); );
@@ -261,10 +358,18 @@ function Inline({ children }: { children: React.ReactNode }) {
function Empty({ children }: { children: React.ReactNode }) { function Empty({ children }: { children: React.ReactNode }) {
return ( return (
<div style={{ <div
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", style={{
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center", flex: 1,
}}> display: "flex",
alignItems: "center",
justifyContent: "center",
color: INK.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children} {children}
</div> </div>
); );
@@ -286,7 +391,8 @@ function statusColor(status: string) {
const s = status.toLowerCase(); const s = status.toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32"; if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a"; 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"; return "#a09a90";
} }
@@ -295,13 +401,13 @@ function statusColor(status: string) {
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
const INK = { const INK = {
ink: "#1a1a1a", ink: "#111827",
mid: "#5f5e5a", mid: "#4b5563",
muted: "#a09a90", muted: "#9ca3af",
border: "#e8e4dc", border: "#e5e7eb",
borderSoft: "#efebe1", borderSoft: "#f3f4f6",
cardBg: "#fff", cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const; } as const;
const pageWrap: React.CSSProperties = { const pageWrap: React.CSSProperties = {
@@ -318,79 +424,157 @@ const grid: React.CSSProperties = {
alignItems: "stretch", alignItems: "stretch",
}; };
const leftCol: React.CSSProperties = { const leftCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column", gap: 18, minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 18,
}; };
const rightCol: React.CSSProperties = { const rightCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column", minWidth: 0,
display: "flex",
flexDirection: "column",
}; };
const heading: React.CSSProperties = { const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em", fontSize: "0.72rem",
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px", 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 = { const railGroupHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 4px 8px", padding: "0 4px 8px",
}; };
const railGroupTitle: React.CSSProperties = { const railGroupTitle: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", fontSize: "0.68rem",
textTransform: "uppercase", color: INK.muted, fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
}; };
const countPill: React.CSSProperties = { const countPill: React.CSSProperties = {
fontSize: "0.7rem", fontWeight: 600, color: INK.mid, fontSize: "0.7rem",
padding: "1px 7px", borderRadius: 999, background: "#f3eee4", 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 = { const railEmpty: React.CSSProperties = {
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted, padding: "10px 12px",
border: `1px dashed ${INK.borderSoft}`, borderRadius: 8, fontSize: "0.74rem",
color: INK.muted,
border: `1px dashed ${INK.borderSoft}`,
borderRadius: 8,
lineHeight: 1.6, lineHeight: 1.6,
}; };
const nudge: React.CSSProperties = { const nudge: React.CSSProperties = {
display: "block", marginTop: 6, fontStyle: "normal", display: "block",
background: "#f3eee4", borderRadius: 4, padding: "3px 8px", marginTop: 6,
fontSize: "0.72rem", color: "#7a6a50", fontStyle: "normal",
background: "#f3eee4",
borderRadius: 4,
padding: "3px 8px",
fontSize: "0.72rem",
color: "#7a6a50",
}; };
const flatTile: React.CSSProperties = { const flatTile: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10, display: "flex",
width: "100%", padding: "12px 14px", alignItems: "center",
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, gap: 10,
cursor: "pointer", font: "inherit", color: "inherit", 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", transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
}; };
const codebaseTile: React.CSSProperties = { 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 = { const tileHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8, width: "100%", display: "flex",
padding: "12px 14px", background: "transparent", border: "none", alignItems: "center",
cursor: "pointer", font: "inherit", color: "inherit", gap: 8,
width: "100%",
padding: "12px 14px",
background: "transparent",
border: "none",
cursor: "pointer",
font: "inherit",
color: "inherit",
}; };
const tileLabel: React.CSSProperties = { 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 = { 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 = { 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 = { const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, background: INK.cardBg,
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column", border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 16,
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}; };
const detailRow: React.CSSProperties = { const detailRow: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex",
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`, alignItems: "center",
justifyContent: "space-between",
padding: "12px 4px",
borderBottom: `1px solid ${INK.borderSoft}`,
}; };
const detailLabel: React.CSSProperties = { const detailLabel: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em", fontSize: "0.72rem",
textTransform: "uppercase", color: INK.muted, fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: INK.muted,
}; };
const detailValue: React.CSSProperties = { 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 = { const detailLink: React.CSSProperties = {
color: INK.ink, textDecoration: "underline", color: INK.ink,
textDecoration: "underline",
}; };

View File

@@ -1,119 +0,0 @@
"use client";
import { Shield, Settings } from "lucide-react";
export default function SecurityPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
}}
>
<div>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Security
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage your permissions and security rules.{" "}
<a
href="#"
style={{ color: "#18181b", textDecoration: "underline" }}
>
Learn more
</a>
</p>
</div>
<button
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<Settings size={16} color="#18181b" />
</button>
</div>
<div
style={{
background: "#fff",
border: "1px dashed #e4e4e7",
borderRadius: 12,
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
marginTop: 32,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<Shield size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
Check the security of your app
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Review your configuration, identify potential risks, and learn how to
strengthen your app's protection
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Check Security
</button>
</div>
</div>
);
}

View File

@@ -440,15 +440,6 @@ function formatRelative(iso: string | undefined) {
// Sub-components // Sub-components
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({ function EmptySection({
icon, icon,
title, title,
@@ -505,13 +496,13 @@ function EmptySection({
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
const INK = { const INK = {
ink: "#1a1a1a", ink: "#111827",
mid: "#5f5e5a", mid: "#4b5563",
muted: "#a09a90", muted: "#9ca3af",
border: "#e8e4dc", border: "#e5e7eb",
borderSoft: "#efebe1", borderSoft: "#f3f4f6",
cardBg: "#fff", cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const; } as const;
const GREEN = "#10b981"; const GREEN = "#10b981";
const AMBER = "#f59e0b"; const AMBER = "#f59e0b";

View File

@@ -1,99 +1,150 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
THEME,
PageHeader,
Card,
SettingCard,
SectionHeader,
PrimaryButton,
TextField,
} from "@/components/project/dashboard-ui";
export default function AppSettingsPage() { export default function AppSettingsPage() {
const params = useParams();
const projectId = params.projectId as string;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// ── Load real project metadata ──
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch(`/api/projects/${projectId}`, {
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
if (cancelled) return;
setName(d.project?.name ?? "");
setDescription(d.project?.description ?? "");
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [projectId]);
// ── Persist via PATCH ──
async function save() {
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, description }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
toast.success("App settings saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
}
return ( return (
<div <div
style={{ style={{
padding: "32px 48px", minHeight: "100vh",
fontFamily: '"Outfit", "Inter", sans-serif', background: THEME.canvasGradient,
color: "#18181b", fontFamily: THEME.font,
maxWidth: 900, padding: "36px 48px",
}} }}
> >
<div style={{ marginBottom: 24 }}> <div style={{ maxWidth: 860, margin: "0 auto" }}>
<h1 <PageHeader
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }} title="App Settings"
> subtitle="General configuration for your application."
App Settings />
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
General configuration for your application.
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}> <SectionHeader title="General" />
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <Card>
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}> {loading ? (
App Name <div
</label>
<input
type="text"
defaultValue="CampCore"
style={{
padding: "10px 14px",
border: "1px solid #e4e4e7",
borderRadius: 8,
fontSize: "0.9rem",
outline: "none",
}}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}>
Description
</label>
<textarea
rows={3}
defaultValue="A comprehensive operating system for camp and youth activity providers."
style={{
padding: "10px 14px",
border: "1px solid #e4e4e7",
borderRadius: 8,
fontSize: "0.9rem",
outline: "none",
resize: "none",
}}
/>
</div>
<div style={{ borderTop: "1px solid #e4e4e7", margin: "12px 0" }}></div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div>
<h3
style={{ style={{
fontSize: "1rem", display: "flex",
fontWeight: 600, alignItems: "center",
margin: "0 0 4px 0", gap: 8,
color: "#ef4444", color: THEME.mid,
fontSize: "0.875rem",
}} }}
> >
Delete Application <Loader2 size={15} className="animate-spin" /> Loading
</h3> </div>
<p style={{ fontSize: "0.85rem", color: "#71717a", margin: 0 }}> ) : error ? (
Permanently delete this app and all of its data. <div style={{ color: THEME.danger, fontSize: "0.875rem" }}>
</p> {error}
</div> </div>
<button ) : (
style={{ <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
background: "#fee2e2", <TextField
color: "#ef4444", label="App Name"
border: "1px solid #fca5a5", value={name}
borderRadius: 8, onChange={setName}
padding: "8px 16px", placeholder="My app"
fontSize: "0.85rem", />
fontWeight: 600, <TextField
cursor: "pointer", label="Description"
}} multiline
> rows={3}
Delete App value={description}
</button> onChange={setDescription}
placeholder="What does this app do?"
/>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<PrimaryButton
onClick={save}
disabled={saving}
icon={
saving ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Save size={15} />
)
}
>
{saving ? "Saving…" : "Save changes"}
</PrimaryButton>
</div>
</div>
)}
</Card>
{/* Danger zone — there is no project-delete endpoint, so we don't fake a
button that does nothing. Deletion is handled through the AI chat. */}
<div style={{ marginTop: 32 }}>
<SectionHeader title="Danger zone" />
<SettingCard
danger
title="Delete Application"
description="Permanently deletes this app and all of its data. To delete it, ask the AI in chat — this is intentionally not a one-click action."
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,246 +1,33 @@
"use client"; "use client";
import { Search, ChevronDown, ListFilter } from "lucide-react"; import { Users } from "lucide-react";
import {
THEME,
PageHeader,
EmptyState,
} from "@/components/project/dashboard-ui";
export default function UsersPage() { export default function UsersPage() {
return ( return (
<div <div
style={{ style={{
padding: "32px 48px", minHeight: "100vh",
fontFamily: '"Outfit", "Inter", sans-serif', background: THEME.canvasGradient,
color: "#18181b", fontFamily: THEME.font,
maxWidth: 900, padding: "36px 48px",
}} }}
> >
<div <div style={{ maxWidth: 860, margin: "0 auto" }}>
style={{ <PageHeader
display: "flex", title="Users"
justifyContent: "space-between", subtitle="Manage the end-users of your application."
alignItems: "flex-start", />
marginBottom: 24,
}}
>
<div>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Users
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage the app's users and their roles
</p>
</div>
<div style={{ display: "flex", gap: 12 }}>
<button
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<ListFilter size={16} color="#18181b" />
</button>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "0 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
height: 36,
}}
>
Invite User
</button>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}> <EmptyState
<button icon={<Users size={22} />}
style={{ title="User management coming soon"
background: "#fff", hint="A built-in user directory is in development. In the meantime, you can view your users directly in your database via the Data tab, or in your connected Auth provider's dashboard (e.g. Clerk, NextAuth)."
border: "1px solid #e4e4e7", />
borderRadius: 8,
padding: "8px 48px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
}}
>
Users
</button>
<button
style={{
background: "#f4f4f5",
border: "none",
color: "#71717a",
borderRadius: 8,
padding: "8px 48px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Pending requests
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
overflow: "hidden",
}}
>
<div
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
Users
</h2>
<div style={{ display: "flex", gap: 12 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
width: 240,
}}
>
<Search size={14} color="#a1a1aa" />
<input
type="text"
placeholder="Search by Email or Name"
style={{
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.8rem",
width: "100%",
}}
/>
</div>
<button
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
fontWeight: 500,
cursor: "pointer",
}}
>
all roles <ChevronDown size={14} />
</button>
</div>
</div>
<table
style={{
width: "100%",
borderCollapse: "collapse",
textAlign: "left",
fontSize: "0.85rem",
}}
>
<thead>
<tr
style={{
background: "#fafafa",
borderBottom: "1px solid #e4e4e7",
}}
>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "40%",
}}
>
Name
</th>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "20%",
}}
>
Role
</th>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "40%",
}}
>
Email
</th>
</tr>
</thead>
<tbody>
<tr>
<td
style={{ padding: "16px", borderBottom: "1px solid #e4e4e7" }}
>
<div style={{ fontWeight: 500, color: "#18181b" }}>
Mark Henderson
</div>
<div
style={{ color: "#71717a", fontSize: "0.8rem", marginTop: 2 }}
>
Owner
</div>
</td>
<td
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
fontWeight: 500,
}}
>
admin
</td>
<td
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
color: "#18181b",
}}
>
markhenderson1977@gmail.com
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
); );

View File

@@ -8,10 +8,7 @@ import {
LayoutGrid, LayoutGrid,
ClipboardList, ClipboardList,
Database, Database,
BarChart2,
Globe, Globe,
Plug,
ShieldCheck,
Code2, Code2,
Terminal, Terminal,
Settings, Settings,
@@ -24,6 +21,15 @@ import {
import { useAnatomy } from "@/components/project/use-anatomy"; import { useAnatomy } from "@/components/project/use-anatomy";
type MenuItem = {
segment: string;
label: string;
Icon: React.ElementType;
badge?: string;
hasChildren?: boolean;
children?: { segment: string; label: string }[];
};
export function DashboardSidebar({ export function DashboardSidebar({
workspace, workspace,
projectId, projectId,
@@ -61,7 +67,7 @@ export function DashboardSidebar({
} }
}; };
const menuItems = [ const menuItems: MenuItem[] = [
{ segment: "overview", label: "Overview", Icon: LayoutGrid }, { segment: "overview", label: "Overview", Icon: LayoutGrid },
{ segment: "plan", label: "Plan & Specs", Icon: ClipboardList }, { segment: "plan", label: "Plan & Specs", Icon: ClipboardList },
{ segment: "code", label: "Code", Icon: Code2 }, { segment: "code", label: "Code", Icon: Code2 },
@@ -78,27 +84,8 @@ export function DashboardSidebar({
{ segment: "storage", label: "Storage", Icon: HardDrive }, { segment: "storage", label: "Storage", Icon: HardDrive },
{ segment: "services", label: "Services", Icon: Blocks }, { segment: "services", label: "Services", Icon: Blocks },
{ segment: "users", label: "Auth / Users", Icon: Users }, { segment: "users", label: "Auth / Users", Icon: Users },
{ segment: "integrations", label: "Integrations", Icon: Plug },
{ segment: "security", label: "Security", Icon: ShieldCheck },
{ segment: "logs", label: "Logs", Icon: Terminal }, { segment: "logs", label: "Logs", Icon: Terminal },
{ segment: "domains", label: "Domains", Icon: Globe }, { segment: "domains", label: "Domains", Icon: Globe },
{
segment: "analytics",
label: "Analytics",
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", segment: "settings",
label: "Settings", label: "Settings",
@@ -125,7 +112,7 @@ export function DashboardSidebar({
<div <div
style={{ style={{
width: 250, width: 250,
borderRight: "1px solid #e4e4e7", borderRight: "1px solid #e5e7eb",
background: "#ffffff", background: "#ffffff",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -194,10 +181,10 @@ export function DashboardSidebar({
cursor: "pointer", cursor: "pointer",
background: background:
isMainActive && !item.hasChildren isMainActive && !item.hasChildren
? "#eff6ff" ? "#f3f4f6"
: "transparent", : "transparent",
color: color:
isMainActive && !item.hasChildren ? "#1d4ed8" : "#52525b", isMainActive && !item.hasChildren ? "#111827" : "#4b5563",
transition: "all 0.1s ease", transition: "all 0.1s ease",
}} }}
onClick={() => { onClick={() => {
@@ -248,8 +235,8 @@ export function DashboardSidebar({
{item.badge && ( {item.badge && (
<span <span
style={{ style={{
background: "#eef2ff", background: "#f3f4f6",
color: "#4f46e5", color: "#4b5563",
fontSize: "0.65rem", fontSize: "0.65rem",
fontWeight: 600, fontWeight: 600,
padding: "2px 6px", padding: "2px 6px",
@@ -331,7 +318,8 @@ export function DashboardSidebar({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
overflow: "auto", overflow: "auto",
background: "#fff", background:
"radial-gradient(120% 80% at 50% 0%, #ffffff 0%, #f9fafb 52%, #f3f4f6 100%)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}