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";
import { Copy, Key } from "lucide-react";
import { Key } from "lucide-react";
import {
THEME,
PageHeader,
EmptyState,
} from "@/components/project/dashboard-ui";
export default function ApiPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
API & Webhooks
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Connect external services to your application.
</p>
</div>
<div style={{ maxWidth: 860, margin: "0 auto" }}>
<PageHeader
title="API Keys"
subtitle="Manage authentication keys for your application's public API."
/>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
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>
<EmptyState
icon={<Key size={22} />}
title="API management coming soon"
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."
/>
</div>
</div>
);

View File

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

View File

@@ -8,11 +8,16 @@ import {
ChevronDown,
ChevronRight,
Database,
CircleDot,
} from "lucide-react";
import { DatabaseTableTree } from "@/components/project/database-table-tree";
import { TableViewer } from "@/components/project/table-viewer";
import { useAnatomy } from "@/components/project/use-anatomy";
import {
THEME,
PageHeader,
Card,
StatusDot,
} from "@/components/project/dashboard-ui";
type Selection = {
kind: "table";
@@ -46,52 +51,214 @@ export default function DataTablesPage() {
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
<div style={grid}>
<div
style={{
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
}}
>
<div style={{ maxWidth: 1400, margin: "0 auto" }}>
<PageHeader
title="Data / Tables"
subtitle="Explore the raw schema and rows in your project databases."
/>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
gap: 28,
alignItems: "stretch",
}}
>
{/* ── Left rail ── */}
<section style={leftCol}>
<section
style={{
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
{showLoading && (
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
<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 && (
<Inline>
<AlertCircle size={13} /> {error}
</Inline>
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.danger,
fontSize: "0.875rem",
}}
>
<AlertCircle size={15} /> {error}
</div>
</Card>
)}
{anatomy && (
<RailGroup title="Databases" count={activeDatabases.length}>
<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>
</header>
<div
style={{ display: "flex", flexDirection: "column", gap: 10 }}
>
{activeDatabases.length === 0 && (
<RailEmpty>
<div
style={{
padding: "10px 12px",
fontSize: "0.74rem",
color: THEME.muted,
border: `1px dashed ${THEME.borderSoft}`,
borderRadius: 8,
lineHeight: 1.6,
}}
>
No databases yet.
<span style={nudge}>
<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>
</RailEmpty>
</div>
)}
{activeDatabases.map((db) => {
return (
<article key={db.uuid} style={codebaseTile}>
<div style={tileHeader}>
<span style={chevronCell}>
<ChevronDown size={13} style={{ color: INK.mid }} />
<article
key={db.uuid}
style={{
background: THEME.cardBg,
border: `1px solid ${THEME.borderSoft}`,
borderRadius: 10,
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "12px 14px",
background: "transparent",
border: "none",
font: "inherit",
color: "inherit",
}}
>
<span
style={{
width: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<ChevronDown
size={13}
style={{ color: THEME.mid }}
/>
</span>
<Database
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
style={{ color: THEME.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
<div style={tileLabel}>{db.name}</div>
<div style={tileHint}>{db.type}</div>
<div
style={{ minWidth: 0, textAlign: "left", flex: 1 }}
>
<div
style={{
fontSize: "0.85rem",
fontWeight: 600,
color: THEME.ink,
marginBottom: 2,
}}
>
{db.name}
</div>
<CircleDot
size={9}
style={{ color: statusColor(db.status), flexShrink: 0 }}
<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={tileBody}>
<div
style={{
padding: "8px 10px 12px",
borderTop: `1px solid ${THEME.borderSoft}`,
}}
>
<DatabaseTableTree
projectId={projectId}
dbUuid={db.uuid}
@@ -117,14 +284,25 @@ export default function DataTablesPage() {
</article>
);
})}
</RailGroup>
</div>
</div>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection)}</h3>
<div style={panel}>
<aside
style={{ minWidth: 0, display: "flex", flexDirection: "column" }}
>
<Card
padding={16}
style={{
flex: 1,
minHeight: "calc(100vh - 150px)",
display: "flex",
flexDirection: "column",
padding: 16,
}}
>
{selection?.kind === "table" && (
<TableViewer
projectId={projectId}
@@ -134,239 +312,25 @@ export default function DataTablesPage() {
/>
)}
{!selection && (
<Empty>Select a table on the left to preview data.</Empty>
)}
</div>
</aside>
</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,
color: THEME.muted,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
Select a table on the left to preview data.
</div>
)}
</Card>
</aside>
</div>
</div>
</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";
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() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 8000 });
const live = anatomy?.hosting.live ?? [];
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
}}
>
<div style={{ marginBottom: 32 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
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 style={{ maxWidth: 860, margin: "0 auto" }}>
<PageHeader
title="Domains"
subtitle="Public URLs for your deployed apps. To add a custom domain, ask the AI in chat — DNS + TLS are wired automatically."
/>
<div
style={{
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>
{loading && !anatomy ? (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 16,
color: THEME.mid,
fontSize: "0.875rem",
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
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>
<Loader2 size={15} className="animate-spin" /> Loading
</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>
<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>
</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>
</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
// ──────────────────────────────────────────────────
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({
icon,
title,
@@ -492,13 +483,13 @@ function EmptySection({
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
ink: "#111827",
mid: "#4b5563",
muted: "#9ca3af",
border: "#e5e7eb",
borderSoft: "#f3f4f6",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const;
const GREEN = "#10b981";
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,
minHeight: 0,
height: "100vh",
background: "#faf8f5",
background: "#f9fafb",
overflow: "hidden",
};

View File

@@ -1,97 +1,217 @@
"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() {
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 (
<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" }}
>
Logs
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
View application and server logs.
</p>
</div>
<div
style={{
background: "#18181b",
borderRadius: 12,
overflow: "hidden",
color: "#e4e4e7",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #3f3f46",
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexDirection: "column",
}}
>
<div
style={{
maxWidth: 1000,
margin: "0 auto",
width: "100%",
flex: 1,
display: "flex",
flexDirection: "column",
}}
>
<PageHeader
title="Runtime Logs"
subtitle="Container stdout/stderr for your deployed apps."
/>
{loading && !anatomy ? (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#27272a",
borderRadius: 6,
padding: "4px 10px",
width: 300,
color: THEME.mid,
fontSize: "0.875rem",
}}
>
<Search size={14} color="#a1a1aa" />
<input
type="text"
placeholder="Filter logs..."
style={{
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.8rem",
width: "100%",
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={{
padding: "16px",
fontSize: "0.85rem",
lineHeight: 1.6,
height: 400,
overflowY: "auto",
flex: 1,
overflow: "auto",
padding: 16,
background: "#0a0a0a",
borderBottomLeftRadius: THEME.radius,
borderBottomRightRadius: THEME.radius,
}}
>
<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>
<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>
);

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

View File

@@ -21,6 +21,16 @@ import {
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import useSWR from "swr";
import {
THEME,
PageHeader,
Card,
SectionHeader,
PrimaryButton,
SecondaryButton,
Badge,
StatusDot,
} from "@/components/project/dashboard-ui";
// ──────────────────────────────────────────────────
// Types & Fetcher
@@ -88,26 +98,49 @@ export default function PlanTab() {
const showLoading = !plan && !error;
return (
<div style={pageWrap}>
{showLoading && (
<div style={centeredMsg}>
<Loader2
size={16}
className="animate-spin"
style={{ color: INK.muted }}
<div
style={{
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
}}
>
<div style={{ maxWidth: 1400, margin: "0 auto" }}>
<PageHeader
title="Plan & Specs"
subtitle="Your product brief, execution plan, and AI instructions."
/>
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
Loading plan
</span>
{showLoading && (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.mid,
fontSize: "0.875rem",
}}
>
<Loader2 size={15} className="animate-spin" /> Loading plan
</div>
</Card>
)}
{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>
<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>
)}
{plan && (
@@ -184,6 +217,7 @@ export default function PlanTab() {
</div>
)}
</div>
</div>
);
}
@@ -210,15 +244,15 @@ function RailItem({
onClick={() => onClick(id)}
style={{
...flatTile,
background: isActive ? INK.cardBg : "transparent",
borderColor: isActive ? INK.border : "transparent",
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.02)" : "none",
color: isActive ? INK.ink : INK.muted,
background: isActive ? THEME.cardBg : "transparent",
borderColor: isActive ? THEME.border : "transparent",
boxShadow: isActive ? THEME.shadow : "none",
color: isActive ? THEME.ink : THEME.muted,
}}
>
{React.cloneElement(icon, {
size: 15,
color: isActive ? INK.ink : INK.muted,
color: isActive ? THEME.ink : THEME.muted,
} as React.SVGProps<SVGSVGElement> & { size?: number | string })}
<span style={{ fontSize: "0.85rem", fontWeight: isActive ? 600 : 500 }}>
{label}
@@ -279,43 +313,71 @@ function ObjectivePanel({
};
return (
<div style={panel}>
<div style={panelHeader}>
<Card
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>
<h2 style={panelTitle}>Product Brief</h2>
<p style={panelDesc}>
<h2
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.
</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{saving && (
<span style={{ fontSize: "0.75rem", color: INK.muted }}>
Saving...
</span>
)}
{!editing && (
<button onClick={() => setEditing(true)} style={actionBtn}>
<Pencil size={14} /> Edit Objective
</button>
<SecondaryButton
onClick={() => setEditing(true)}
icon={<Pencil size={14} />}
>
Edit Objective
</SecondaryButton>
)}
{editing && (
<>
<button
<PrimaryButton
onClick={() => save(draft)}
disabled={!dirty || saving}
className="btn-primary"
>
Save Changes
</button>
<button onClick={cancel} className="btn-ghost">
Cancel
</button>
{saving ? "Saving…" : "Save Changes"}
</PrimaryButton>
<SecondaryButton onClick={cancel}>Cancel</SecondaryButton>
</>
)}
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
padding: "0 32px 32px",
}}
>
<div style={editorContainer}>
{editing ? (
<>
@@ -333,7 +395,9 @@ function ObjectivePanel({
type="button"
onClick={() => setEditorView("preview")}
style={
editorView === "preview" ? editorTabActive : editorTabInactive
editorView === "preview"
? editorTabActive
: editorTabInactive
}
>
<BookOpen size={14} /> Preview
@@ -374,7 +438,7 @@ function ObjectivePanel({
</ReactMarkdown>
</div>
) : (
<div style={{ color: INK.muted, fontStyle: "italic" }}>
<div style={{ color: THEME.muted, fontStyle: "italic" }}>
Nothing to preview yet.
</div>
)}
@@ -390,7 +454,7 @@ function ObjectivePanel({
</ReactMarkdown>
</div>
) : (
<div style={{ color: INK.muted, fontStyle: "italic" }}>
<div style={{ color: THEME.muted, fontStyle: "italic" }}>
No objective set. Click Edit to add one.
</div>
)}
@@ -398,6 +462,7 @@ function ObjectivePanel({
)}
</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];
return (
<div style={panel}>
<Card
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
padding={32}
>
{content ? (
<div
className="markdown-prose"
@@ -423,40 +496,44 @@ function DocumentPanel({ plan, docId }: { plan: Plan; docId: string }) {
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<>
<div style={panelHeader}>
<div>
<h2 style={panelTitle}>{docConfig.label}</h2>
<p style={panelDesc}>This document is currently empty.</p>
</div>
</div>
<div style={emptyBox}>
{React.cloneElement(
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
<div
style={{
fontSize: "0.85rem",
maxWidth: 300,
margin: "8px 0 0",
lineHeight: 1.5,
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: THEME.muted,
gap: 12,
}}
>
This document is generated and maintained by the AI agent. Chat
with your agent to update the scope and blueprint.
<div
style={{
color: THEME.muted,
display: "flex",
transform: "scale(1.5)",
}}
>
{docConfig.icon}
</div>
<div style={{ textAlign: "center", marginTop: 8 }}>
<h3
style={{
fontSize: "1.1rem",
fontWeight: 600,
color: THEME.ink,
margin: "0 0 4px 0",
}}
>
{docConfig.label}
</h3>
<p style={{ fontSize: "0.875rem", margin: 0 }}>
This document is currently empty. Ask the AI to draft it.
</p>
</div>
</>
)}
</div>
)}
</Card>
);
}
@@ -507,43 +584,38 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
};
const TaskCard = ({ t }: { t: Plan["tasks"][number] }) => (
<div style={taskCard}>
<Card
padding={16}
style={{ display: "flex", flexDirection: "column", gap: 8 }}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 12,
width: "100%",
overflow: "hidden",
}}
>
<div
style={{
...taskStatusDot,
borderColor:
<div style={{ marginTop: 4 }}>
<StatusDot
status={
t.status === "done"
? "#10b981"
? "success"
: t.status === "open"
? INK.muted
: "#f59e0b",
background: t.status === "done" ? "#10b981" : "transparent",
}}
>
{t.status === "done" && (
<Check size={10} color="#fff" strokeWidth={3} />
)}
? "neutral"
: "warning"
}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.95rem",
color: INK.ink,
color: THEME.ink,
textDecoration: t.status === "done" ? "line-through" : "none",
opacity: t.status === "done" ? 0.6 : 1,
wordWrap: "break-word",
overflowWrap: "break-word",
hyphens: "auto",
}}
>
{t.title}
@@ -552,12 +624,10 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
<div
style={{
fontSize: "0.8rem",
color: INK.muted,
color: THEME.muted,
marginTop: 4,
lineHeight: 1.4,
wordWrap: "break-word",
overflowWrap: "break-word",
hyphens: "auto",
}}
>
{t.description}
@@ -565,43 +635,66 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
)}
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<span style={taskBadge}>
<div
style={{ display: "flex", justifyContent: "flex-end", marginTop: 12 }}
>
<Badge
color={
t.status === "done"
? "success"
: t.status === "open"
? "default"
: "warning"
}
>
{t.status === "open"
? "Queued"
: t.status === "done"
? "Completed"
: "In Progress"}
</span>
</div>
</Badge>
</div>
</Card>
);
return (
<div
style={{
...panel,
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
}}
>
<div style={panelHeader}>
<div>
<h2 style={panelTitle}>Execution Plan</h2>
<p style={panelDesc}>
<h2
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.
</p>
</div>
<button
className="btn-primary"
<PrimaryButton
onClick={handleDelegate}
disabled={delegating || openTasks.length === 0}
style={{ background: INK.ink, color: "#fff" }}
>
{delegating ? "Starting Jarvis..." : "Delegate Build"}
</button>
</PrimaryButton>
</div>
<div
@@ -673,23 +766,6 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
// 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 = {
display: "grid",
gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
@@ -712,14 +788,6 @@ const rightCol: React.CSSProperties = {
flexDirection: "column",
};
const centeredMsg: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "24px 0",
justifyContent: "center",
};
const railGroup: React.CSSProperties = {
display: "flex",
flexDirection: "column",
@@ -735,7 +803,7 @@ const railGroupTitle: React.CSSProperties = {
fontWeight: 700,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
color: THEME.muted,
};
const railItems: React.CSSProperties = {
display: "flex",
@@ -757,58 +825,11 @@ const flatTile: React.CSSProperties = {
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 = {
border: `1px solid ${INK.border}`,
border: `1px solid ${THEME.border}`,
borderRadius: 10,
overflow: "hidden",
background: INK.cardBg,
background: THEME.cardBg,
boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
display: "flex",
flexDirection: "column",
@@ -817,8 +838,8 @@ const editorContainer: React.CSSProperties = {
};
const editorTabs: React.CSSProperties = {
display: "flex",
background: INK.bgHover,
borderBottom: `1px solid ${INK.borderSoft}`,
background: THEME.subtleBg,
borderBottom: `1px solid ${THEME.borderSoft}`,
padding: "8px 16px",
gap: 16,
flexShrink: 0,
@@ -829,10 +850,10 @@ const editorTabActive: React.CSSProperties = {
gap: 6,
padding: "6px 12px",
borderRadius: 6,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
background: THEME.cardBg,
border: `1px solid ${THEME.borderSoft}`,
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
color: INK.ink,
color: THEME.ink,
fontWeight: 500,
fontSize: "0.85rem",
cursor: "pointer",
@@ -842,7 +863,7 @@ const editorTabInactive: React.CSSProperties = {
background: "transparent",
border: "1px solid transparent",
boxShadow: "none",
color: INK.muted,
color: THEME.muted,
};
const textAreaStyle: React.CSSProperties = {
@@ -856,7 +877,7 @@ const textAreaStyle: React.CSSProperties = {
outline: "none",
resize: "none",
fontFamily: "var(--font-sans)",
color: INK.ink,
color: THEME.ink,
display: "block",
boxSizing: "border-box",
margin: 0,
@@ -869,26 +890,6 @@ const previewAreaStyle: React.CSSProperties = {
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 = {
flex: 1,
minWidth: 300,
@@ -905,15 +906,15 @@ const kanbanColHeader: React.CSSProperties = {
const kanbanColTitle: React.CSSProperties = {
fontSize: "0.95rem",
fontWeight: 600,
color: INK.ink,
color: THEME.ink,
margin: 0,
};
const kanbanCount: React.CSSProperties = {
fontSize: "0.75rem",
background: INK.borderSoft,
background: THEME.borderSoft,
padding: "2px 8px",
borderRadius: 12,
color: INK.muted,
color: THEME.muted,
fontWeight: 600,
};
const kanbanList: React.CSSProperties = {
@@ -925,50 +926,18 @@ const kanbanList: React.CSSProperties = {
paddingBottom: 24,
};
const taskCard: React.CSSProperties = {
border: `1px solid ${INK.border}`,
const emptyBoxSmall: React.CSSProperties = {
padding: 32,
textAlign: "center",
border: `1px dashed ${THEME.border}`,
borderRadius: 8,
padding: 16,
background: INK.cardBg,
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,
color: THEME.muted,
fontSize: "0.85rem",
};
// Global styles
const styleTag = `
.btn-primary, .btn-secondary, .btn-ghost {
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 { font-size: 0.85rem; color: #111827; }
.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 h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; }

View File

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

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
// ──────────────────────────────────────────────────
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({
icon,
title,
@@ -505,13 +496,13 @@ function EmptySection({
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
ink: "#111827",
mid: "#4b5563",
muted: "#9ca3af",
border: "#e5e7eb",
borderSoft: "#f3f4f6",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const;
const GREEN = "#10b981";
const AMBER = "#f59e0b";

View File

@@ -1,99 +1,150 @@
"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() {
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 (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
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 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}>
App Name
</label>
<input
type="text"
defaultValue="CampCore"
style={{
padding: "10px 14px",
border: "1px solid #e4e4e7",
borderRadius: 8,
fontSize: "0.9rem",
outline: "none",
}}
<div style={{ maxWidth: 860, margin: "0 auto" }}>
<PageHeader
title="App Settings"
subtitle="General configuration for your application."
/>
</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>
<SectionHeader title="General" />
<Card>
{loading ? (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
alignItems: "center",
gap: 8,
color: THEME.mid,
fontSize: "0.875rem",
}}
>
<div>
<h3
style={{
fontSize: "1rem",
fontWeight: 600,
margin: "0 0 4px 0",
color: "#ef4444",
}}
>
Delete Application
</h3>
<p style={{ fontSize: "0.85rem", color: "#71717a", margin: 0 }}>
Permanently delete this app and all of its data.
</p>
<Loader2 size={15} className="animate-spin" /> Loading
</div>
<button
style={{
background: "#fee2e2",
color: "#ef4444",
border: "1px solid #fca5a5",
borderRadius: 8,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 600,
cursor: "pointer",
}}
) : error ? (
<div style={{ color: THEME.danger, fontSize: "0.875rem" }}>
{error}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<TextField
label="App Name"
value={name}
onChange={setName}
placeholder="My app"
/>
<TextField
label="Description"
multiline
rows={3}
value={description}
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} />
)
}
>
Delete App
</button>
{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>

View File

@@ -1,246 +1,33 @@
"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() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
}}
>
<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" }}
>
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 }}>
<button
style={{
background: "#fff",
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 style={{ maxWidth: 860, margin: "0 auto" }}>
<PageHeader
title="Users"
subtitle="Manage the end-users of your application."
/>
<EmptyState
icon={<Users size={22} />}
title="User management coming soon"
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)."
/>
</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>
);

View File

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