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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: "Add a Postgres database to my project"
|
||||
</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",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,6 @@ const pageWrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
height: "100vh",
|
||||
background: "#faf8f5",
|
||||
background: "#f9fafb",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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: "Start building my app"</span></>
|
||||
: <>Repo is empty — push a first commit. <span style={nudge}>Try: "Scaffold a Next.js app"</span></>}
|
||||
{reason === "no_repo" ? (
|
||||
<>
|
||||
No codebase yet.{" "}
|
||||
<span style={nudge}>
|
||||
Try: "Start building my app"
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Repo is empty — push a first commit.{" "}
|
||||
<span style={nudge}>
|
||||
Try: "Scaffold a Next.js app"
|
||||
</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: "Install Twenty CRM for my project"</span>
|
||||
Self-hosted tools (Twenty CRM, n8n, Plausible…) you run
|
||||
appear here.
|
||||
<span style={nudge}>
|
||||
Try: "Install Twenty CRM for my project"
|
||||
</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",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user