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";
|
"use client";
|
||||||
|
|
||||||
import { Copy, Key } from "lucide-react";
|
import { Key } from "lucide-react";
|
||||||
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
EmptyState,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
export default function ApiPage() {
|
export default function ApiPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "32px 48px",
|
minHeight: "100vh",
|
||||||
fontFamily: '"Outfit", "Inter", sans-serif',
|
background: THEME.canvasGradient,
|
||||||
color: "#18181b",
|
fontFamily: THEME.font,
|
||||||
maxWidth: 900,
|
padding: "36px 48px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ maxWidth: 860, margin: "0 auto" }}>
|
||||||
<h1
|
<PageHeader
|
||||||
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
|
title="API Keys"
|
||||||
>
|
subtitle="Manage authentication keys for your application's public API."
|
||||||
API & Webhooks
|
/>
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
|
|
||||||
Connect external services to your application.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<EmptyState
|
||||||
style={{
|
icon={<Key size={22} />}
|
||||||
background: "#fff",
|
title="API management coming soon"
|
||||||
border: "1px solid #e4e4e7",
|
hint="Built-in API key generation and request routing is in development. If your app already issues its own API keys, you can manage them directly in your database via the Data tab."
|
||||||
borderRadius: 12,
|
/>
|
||||||
padding: "24px",
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: "0 0 16px 0" }}>
|
|
||||||
REST API Endpoint
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: "#fafafa",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "10px 16px",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "#71717a",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "#18181b" }}>
|
|
||||||
https://api.steadfast-camp-core-flow.vibn.app/v1
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy size={16} color="#71717a" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
|
|
||||||
API Keys
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: "#18181b",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "8px 16px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generate Key
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
padding: "16px 0",
|
|
||||||
borderBottom: "1px solid #e4e4e7",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
background: "#fafafa",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Key size={16} color="#18181b" />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
Production Key
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
|
|
||||||
Created 2 days ago
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
color: "#71717a",
|
|
||||||
background: "#fafafa",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
pk_live_*******************
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -264,13 +264,13 @@ function statusColor(status: string) {
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const INK = {
|
const INK = {
|
||||||
ink: "#1a1a1a",
|
ink: "#111827",
|
||||||
mid: "#5f5e5a",
|
mid: "#4b5563",
|
||||||
muted: "#a09a90",
|
muted: "#9ca3af",
|
||||||
border: "#e8e4dc",
|
border: "#e5e7eb",
|
||||||
borderSoft: "#efebe1",
|
borderSoft: "#f3f4f6",
|
||||||
cardBg: "#fff",
|
cardBg: "#fff",
|
||||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const pageWrap: React.CSSProperties = {
|
const pageWrap: React.CSSProperties = {
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Database,
|
Database,
|
||||||
CircleDot,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DatabaseTableTree } from "@/components/project/database-table-tree";
|
import { DatabaseTableTree } from "@/components/project/database-table-tree";
|
||||||
import { TableViewer } from "@/components/project/table-viewer";
|
import { TableViewer } from "@/components/project/table-viewer";
|
||||||
import { useAnatomy } from "@/components/project/use-anatomy";
|
import { useAnatomy } from "@/components/project/use-anatomy";
|
||||||
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
Card,
|
||||||
|
StatusDot,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
type Selection = {
|
type Selection = {
|
||||||
kind: "table";
|
kind: "table";
|
||||||
@@ -46,327 +51,286 @@ export default function DataTablesPage() {
|
|||||||
const showLoading = loading && !anatomy;
|
const showLoading = loading && !anatomy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={pageWrap}>
|
<div
|
||||||
<div style={grid}>
|
style={{
|
||||||
{/* ── Left rail ── */}
|
minHeight: "100vh",
|
||||||
<section style={leftCol}>
|
background: THEME.canvasGradient,
|
||||||
{showLoading && (
|
fontFamily: THEME.font,
|
||||||
<Inline>
|
padding: "36px 48px",
|
||||||
<Loader2 size={13} className="animate-spin" /> Loading…
|
}}
|
||||||
</Inline>
|
>
|
||||||
)}
|
<div style={{ maxWidth: 1400, margin: "0 auto" }}>
|
||||||
{error && !showLoading && (
|
<PageHeader
|
||||||
<Inline>
|
title="Data / Tables"
|
||||||
<AlertCircle size={13} /> {error}
|
subtitle="Explore the raw schema and rows in your project databases."
|
||||||
</Inline>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{anatomy && (
|
<div
|
||||||
<RailGroup title="Databases" count={activeDatabases.length}>
|
style={{
|
||||||
{activeDatabases.length === 0 && (
|
display: "grid",
|
||||||
<RailEmpty>
|
gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
|
||||||
No databases yet.
|
gap: 28,
|
||||||
<span style={nudge}>
|
alignItems: "stretch",
|
||||||
Try: "Add a Postgres database to my project"
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Left rail ── */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showLoading && (
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
color: THEME.mid,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader2 size={15} className="animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{error && !showLoading && (
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
color: THEME.danger,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={15} /> {error}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{anatomy && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 4px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: THEME.muted,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Databases
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: THEME.mid,
|
||||||
|
padding: "1px 7px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: THEME.borderSoft,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeDatabases.length}
|
||||||
</span>
|
</span>
|
||||||
</RailEmpty>
|
</header>
|
||||||
)}
|
<div
|
||||||
{activeDatabases.map((db) => {
|
style={{ display: "flex", flexDirection: "column", gap: 10 }}
|
||||||
return (
|
>
|
||||||
<article key={db.uuid} style={codebaseTile}>
|
{activeDatabases.length === 0 && (
|
||||||
<div style={tileHeader}>
|
<div
|
||||||
<span style={chevronCell}>
|
style={{
|
||||||
<ChevronDown size={13} style={{ color: INK.mid }} />
|
padding: "10px 12px",
|
||||||
|
fontSize: "0.74rem",
|
||||||
|
color: THEME.muted,
|
||||||
|
border: `1px dashed ${THEME.borderSoft}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No databases yet.
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginTop: 6,
|
||||||
|
fontStyle: "normal",
|
||||||
|
background: THEME.borderSoft,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 8px",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: THEME.mid,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try: "Add a Postgres database to my project"
|
||||||
</span>
|
</span>
|
||||||
<Database
|
|
||||||
size={13}
|
|
||||||
style={{ color: INK.mid, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
|
||||||
<div style={tileLabel}>{db.name}</div>
|
|
||||||
<div style={tileHint}>{db.type}</div>
|
|
||||||
</div>
|
|
||||||
<CircleDot
|
|
||||||
size={9}
|
|
||||||
style={{ color: statusColor(db.status), flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={tileBody}>
|
)}
|
||||||
<DatabaseTableTree
|
{activeDatabases.map((db) => {
|
||||||
projectId={projectId}
|
return (
|
||||||
dbUuid={db.uuid}
|
<article
|
||||||
selectedTable={
|
key={db.uuid}
|
||||||
selection?.kind === "table" &&
|
style={{
|
||||||
selection.dbUuid === db.uuid
|
background: THEME.cardBg,
|
||||||
? {
|
border: `1px solid ${THEME.borderSoft}`,
|
||||||
schema: selection.schema,
|
borderRadius: 10,
|
||||||
name: selection.name,
|
overflow: "hidden",
|
||||||
}
|
}}
|
||||||
: undefined
|
>
|
||||||
}
|
<div
|
||||||
onSelectTable={({ schema, name }) =>
|
style={{
|
||||||
setSelection({
|
display: "flex",
|
||||||
kind: "table",
|
alignItems: "center",
|
||||||
dbUuid: db.uuid,
|
gap: 8,
|
||||||
schema,
|
width: "100%",
|
||||||
name,
|
padding: "12px 14px",
|
||||||
})
|
background: "transparent",
|
||||||
}
|
border: "none",
|
||||||
/>
|
font: "inherit",
|
||||||
</div>
|
color: "inherit",
|
||||||
</article>
|
}}
|
||||||
);
|
>
|
||||||
})}
|
<span
|
||||||
</RailGroup>
|
style={{
|
||||||
)}
|
width: 14,
|
||||||
</section>
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
style={{ color: THEME.mid }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Database
|
||||||
|
size={13}
|
||||||
|
style={{ color: THEME.mid, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ minWidth: 0, textAlign: "left", flex: 1 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: THEME.ink,
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{db.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.74rem",
|
||||||
|
color: THEME.mid,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{db.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusDot
|
||||||
|
status={
|
||||||
|
db.status.includes("running") ||
|
||||||
|
db.status.includes("healthy")
|
||||||
|
? "success"
|
||||||
|
: "neutral"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 10px 12px",
|
||||||
|
borderTop: `1px solid ${THEME.borderSoft}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseTableTree
|
||||||
|
projectId={projectId}
|
||||||
|
dbUuid={db.uuid}
|
||||||
|
selectedTable={
|
||||||
|
selection?.kind === "table" &&
|
||||||
|
selection.dbUuid === db.uuid
|
||||||
|
? {
|
||||||
|
schema: selection.schema,
|
||||||
|
name: selection.name,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSelectTable={({ schema, name }) =>
|
||||||
|
setSelection({
|
||||||
|
kind: "table",
|
||||||
|
dbUuid: db.uuid,
|
||||||
|
schema,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── Right pane ── */}
|
{/* ── Right pane ── */}
|
||||||
<aside style={rightCol}>
|
<aside
|
||||||
<h3 style={heading}>{paneHeading(selection)}</h3>
|
style={{ minWidth: 0, display: "flex", flexDirection: "column" }}
|
||||||
<div style={panel}>
|
>
|
||||||
{selection?.kind === "table" && (
|
<Card
|
||||||
<TableViewer
|
padding={16}
|
||||||
projectId={projectId}
|
style={{
|
||||||
dbUuid={selection.dbUuid}
|
flex: 1,
|
||||||
schema={selection.schema}
|
minHeight: "calc(100vh - 150px)",
|
||||||
table={selection.name}
|
display: "flex",
|
||||||
/>
|
flexDirection: "column",
|
||||||
)}
|
padding: 16,
|
||||||
{!selection && (
|
}}
|
||||||
<Empty>Select a table on the left to preview data.</Empty>
|
>
|
||||||
)}
|
{selection?.kind === "table" && (
|
||||||
</div>
|
<TableViewer
|
||||||
</aside>
|
projectId={projectId}
|
||||||
|
dbUuid={selection.dbUuid}
|
||||||
|
schema={selection.schema}
|
||||||
|
table={selection.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!selection && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: THEME.muted,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
padding: "32px 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select a table on the left to preview data.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Bits
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function RailGroup({
|
|
||||||
title,
|
|
||||||
count,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
count: number;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div style={railGroup}>
|
|
||||||
<header style={railGroupHeader}>
|
|
||||||
<span style={railGroupTitle}>{title}</span>
|
|
||||||
<span style={countPill}>{count}</span>
|
|
||||||
</header>
|
|
||||||
<div style={railItems}>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RailEmpty({ children }: { children: React.ReactNode }) {
|
|
||||||
return <div style={railEmpty}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inline({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "12px 14px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
color: INK.mid,
|
|
||||||
background: INK.cardBg,
|
|
||||||
border: `1px solid ${INK.borderSoft}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Empty({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: INK.mid,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
padding: "32px 16px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function paneHeading(s: Selection): string {
|
|
||||||
if (!s) return "Preview";
|
|
||||||
if (s.kind === "table")
|
|
||||||
return `Preview · ${s.schema === "public" ? s.name : `${s.schema}.${s.name}`}`;
|
|
||||||
return "Preview";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusColor(status: string) {
|
|
||||||
const s = (status ?? "").toLowerCase();
|
|
||||||
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
|
|
||||||
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
|
|
||||||
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy"))
|
|
||||||
return "#c5392b";
|
|
||||||
return "#a09a90";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Tokens
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const INK = {
|
|
||||||
ink: "#1a1a1a",
|
|
||||||
mid: "#5f5e5a",
|
|
||||||
muted: "#a09a90",
|
|
||||||
border: "#e8e4dc",
|
|
||||||
borderSoft: "#efebe1",
|
|
||||||
cardBg: "#fff",
|
|
||||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const pageWrap: React.CSSProperties = {
|
|
||||||
padding: "28px 48px 48px",
|
|
||||||
fontFamily: INK.fontSans,
|
|
||||||
color: INK.ink,
|
|
||||||
};
|
|
||||||
const grid: React.CSSProperties = {
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
|
|
||||||
gap: 28,
|
|
||||||
maxWidth: 1400,
|
|
||||||
margin: "0 auto",
|
|
||||||
alignItems: "stretch",
|
|
||||||
};
|
|
||||||
const leftCol: React.CSSProperties = {
|
|
||||||
minWidth: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 18,
|
|
||||||
};
|
|
||||||
const rightCol: React.CSSProperties = {
|
|
||||||
minWidth: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
};
|
|
||||||
const heading: React.CSSProperties = {
|
|
||||||
fontSize: "0.72rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.12em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: INK.muted,
|
|
||||||
margin: "0 0 14px",
|
|
||||||
};
|
|
||||||
const railGroup: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
};
|
|
||||||
const railGroupHeader: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "0 4px 8px",
|
|
||||||
};
|
|
||||||
const railGroupTitle: React.CSSProperties = {
|
|
||||||
fontSize: "0.68rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.12em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: INK.muted,
|
|
||||||
};
|
|
||||||
const countPill: React.CSSProperties = {
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: INK.mid,
|
|
||||||
padding: "1px 7px",
|
|
||||||
borderRadius: 999,
|
|
||||||
background: "#f3eee4",
|
|
||||||
};
|
|
||||||
const railItems: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 10,
|
|
||||||
};
|
|
||||||
const railEmpty: React.CSSProperties = {
|
|
||||||
padding: "10px 12px",
|
|
||||||
fontSize: "0.74rem",
|
|
||||||
color: INK.muted,
|
|
||||||
border: `1px dashed ${INK.borderSoft}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
};
|
|
||||||
const nudge: React.CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
marginTop: 6,
|
|
||||||
fontStyle: "normal",
|
|
||||||
background: "#f3eee4",
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 8px",
|
|
||||||
fontSize: "0.72rem",
|
|
||||||
color: "#7a6a50",
|
|
||||||
};
|
|
||||||
const codebaseTile: React.CSSProperties = {
|
|
||||||
background: INK.cardBg,
|
|
||||||
border: `1px solid ${INK.borderSoft}`,
|
|
||||||
borderRadius: 10,
|
|
||||||
overflow: "hidden",
|
|
||||||
};
|
|
||||||
const tileHeader: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
width: "100%",
|
|
||||||
padding: "12px 14px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
font: "inherit",
|
|
||||||
color: "inherit",
|
|
||||||
};
|
|
||||||
const tileLabel: React.CSSProperties = {
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: INK.ink,
|
|
||||||
marginBottom: 2,
|
|
||||||
};
|
|
||||||
const tileHint: React.CSSProperties = {
|
|
||||||
fontSize: "0.74rem",
|
|
||||||
color: INK.mid,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
textTransform: "capitalize",
|
|
||||||
};
|
|
||||||
const tileBody: React.CSSProperties = {
|
|
||||||
padding: "8px 10px 12px",
|
|
||||||
borderTop: `1px solid ${INK.borderSoft}`,
|
|
||||||
};
|
|
||||||
const chevronCell: React.CSSProperties = {
|
|
||||||
width: 14,
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
const panel: React.CSSProperties = {
|
|
||||||
background: INK.cardBg,
|
|
||||||
border: `1px solid ${INK.border}`,
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 16,
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,227 +1,189 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Copy } from "lucide-react";
|
import { useParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Globe, ExternalLink, Copy, Check, Loader2 } from "lucide-react";
|
||||||
|
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
||||||
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
|
type LiveApp = Anatomy["hosting"]["live"][number];
|
||||||
|
|
||||||
|
// All public URLs for an app: its fqdn(s) (Coolify can store a comma-joined
|
||||||
|
// list) plus any attached custom domains, de-duplicated.
|
||||||
|
function urlsFor(app: LiveApp): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
(app.fqdn ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((u) => out.add(u));
|
||||||
|
(app.domains ?? []).forEach((d) =>
|
||||||
|
out.add(d.startsWith("http") ? d : `https://${d}`),
|
||||||
|
);
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 8000 });
|
||||||
|
const live = anatomy?.hosting.live ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "32px 48px",
|
minHeight: "100vh",
|
||||||
fontFamily: '"Outfit", "Inter", sans-serif',
|
background: THEME.canvasGradient,
|
||||||
color: "#18181b",
|
fontFamily: THEME.font,
|
||||||
maxWidth: 900,
|
padding: "36px 48px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 32 }}>
|
<div style={{ maxWidth: 860, margin: "0 auto" }}>
|
||||||
<h1
|
<PageHeader
|
||||||
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
|
title="Domains"
|
||||||
>
|
subtitle="Public URLs for your deployed apps. To add a custom domain, ask the AI in chat — DNS + TLS are wired automatically."
|
||||||
Domains
|
/>
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
|
|
||||||
Buy, connect and manage your domains.{" "}
|
|
||||||
<a href="#" style={{ color: "#18181b", textDecoration: "underline" }}>
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{loading && !anatomy ? (
|
||||||
style={{
|
<Card>
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: "24px",
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
|
|
||||||
Built-in URL
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: "#fafafa",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "10px 16px",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "#71717a",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "#18181b", fontWeight: 500 }}>
|
|
||||||
steadfast-camp-core-flow
|
|
||||||
</span>
|
|
||||||
.vibn.app
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy size={16} color="#71717a" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 16px 0" }}>
|
|
||||||
Custom domains
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 12,
|
|
||||||
borderStyle: "dashed",
|
|
||||||
padding: "48px 32px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{ fontSize: "1.1rem", fontWeight: 600, margin: "0 0 8px 0" }}
|
|
||||||
>
|
|
||||||
Want to use your domain?
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.95rem",
|
|
||||||
color: "#71717a",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: 400,
|
|
||||||
margin: "0 0 24px 0",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Custom domains are available on our Builder plan and above. Upgrade to
|
|
||||||
continue working to this app.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: "#18181b",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "10px 24px",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View Plans
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 16,
|
color: THEME.mid,
|
||||||
|
fontSize: "0.875rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
|
<Loader2 size={15} className="animate-spin" /> Loading…
|
||||||
Email domain
|
|
||||||
</h2>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#f97316",
|
|
||||||
border: "1px solid #ffedd5",
|
|
||||||
background: "#fff7ed",
|
|
||||||
padding: "2px 6px",
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Builder+
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.95rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "#18181b",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
no-reply@notifications.vibn.app
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
|
|
||||||
Sender Name: App
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : live.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Globe size={22} />}
|
||||||
|
title="No deployed apps yet"
|
||||||
|
hint="Once you deploy an app, its URL and any custom domains will appear here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
|
{live.map((app) => (
|
||||||
|
<DomainCard key={app.uuid} app={app} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: "8px 16px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#71717a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Use your custom domain
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DomainCard({ app }: { app: LiveApp }) {
|
||||||
|
const urls = urlsFor(app);
|
||||||
|
return (
|
||||||
|
<Card padding={0}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderBottom: urls.length ? `1px solid ${THEME.borderSoft}` : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: "0.95rem", fontWeight: 600, color: THEME.ink }}
|
||||||
|
>
|
||||||
|
{app.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: "0.78rem", color: THEME.muted }}>
|
||||||
|
{app.sourceLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{urls.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: THEME.mid,
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No domain assigned yet — still deploying.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
urls.map((url, i) => (
|
||||||
|
<DomainRow key={url} url={url} last={i === urls.length - 1} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainRow({ url, last }: { url: string; last: boolean }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = () => {
|
||||||
|
navigator.clipboard?.writeText(url).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "12px 20px",
|
||||||
|
borderBottom: last ? "none" : `1px solid ${THEME.borderSoft}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe size={14} style={{ color: THEME.muted, flexShrink: 0 }} />
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: THEME.ink,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url.replace(/^https?:\/\//, "")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Open"
|
||||||
|
style={{ color: THEME.muted, display: "inline-flex", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
title="Copy URL"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: copied ? "#16a34a" : THEME.muted,
|
||||||
|
display: "inline-flex",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -427,15 +427,6 @@ function formatRelative(iso: string | undefined) {
|
|||||||
// Sub-components
|
// Sub-components
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function SectionHeader({ title, count }: { title: string; count: number }) {
|
|
||||||
return (
|
|
||||||
<div style={sectionHeader}>
|
|
||||||
<span style={sectionTitle}>{title}</span>
|
|
||||||
<span style={countPill}>{count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptySection({
|
function EmptySection({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@@ -492,13 +483,13 @@ function EmptySection({
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const INK = {
|
const INK = {
|
||||||
ink: "#1a1a1a",
|
ink: "#111827",
|
||||||
mid: "#5f5e5a",
|
mid: "#4b5563",
|
||||||
muted: "#a09a90",
|
muted: "#9ca3af",
|
||||||
border: "#e8e4dc",
|
border: "#e5e7eb",
|
||||||
borderSoft: "#efebe1",
|
borderSoft: "#f3f4f6",
|
||||||
cardBg: "#fff",
|
cardBg: "#fff",
|
||||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
|
||||||
} as const;
|
} as const;
|
||||||
const GREEN = "#10b981";
|
const GREEN = "#10b981";
|
||||||
const AMBER = "#f59e0b";
|
const AMBER = "#f59e0b";
|
||||||
|
|||||||
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,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
background: "#faf8f5",
|
background: "#f9fafb",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,97 +1,217 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Search } from "lucide-react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Activity, Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
||||||
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
SecondaryButton,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
|
type LiveApp = Anatomy["hosting"]["live"][number];
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 8000 });
|
||||||
|
const live = anatomy?.hosting.live ?? [];
|
||||||
|
|
||||||
|
const [activeUuid, setActiveUuid] = useState<string | null>(null);
|
||||||
|
const [logs, setLogs] = useState<string | null>(null);
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Auto-select first app if none selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (live.length > 0 && !activeUuid) {
|
||||||
|
setActiveUuid(live[0].uuid);
|
||||||
|
}
|
||||||
|
}, [live, activeUuid]);
|
||||||
|
|
||||||
|
const fetchLogs = async (uuid: string) => {
|
||||||
|
setLogsLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/mcp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "apps.logs",
|
||||||
|
params: { uuid, lines: 100 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
setLogs(
|
||||||
|
typeof d.result === "string"
|
||||||
|
? d.result
|
||||||
|
: JSON.stringify(d.result ?? d.error, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setLogs("Failed to load logs. Is the container running?");
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch when active app changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeUuid) fetchLogs(activeUuid);
|
||||||
|
}, [activeUuid]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "32px 48px",
|
minHeight: "100vh",
|
||||||
fontFamily: '"Outfit", "Inter", sans-serif',
|
background: THEME.canvasGradient,
|
||||||
color: "#18181b",
|
fontFamily: THEME.font,
|
||||||
maxWidth: 900,
|
padding: "36px 48px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<h1
|
|
||||||
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
|
|
||||||
>
|
|
||||||
Logs
|
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
|
|
||||||
View application and server logs.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#18181b",
|
maxWidth: 1000,
|
||||||
borderRadius: 12,
|
margin: "0 auto",
|
||||||
overflow: "hidden",
|
width: "100%",
|
||||||
color: "#e4e4e7",
|
flex: 1,
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<PageHeader
|
||||||
style={{
|
title="Runtime Logs"
|
||||||
padding: "12px 16px",
|
subtitle="Container stdout/stderr for your deployed apps."
|
||||||
borderBottom: "1px solid #3f3f46",
|
/>
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
{loading && !anatomy ? (
|
||||||
alignItems: "center",
|
<Card>
|
||||||
}}
|
<div
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
background: "#27272a",
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: "4px 10px",
|
|
||||||
width: 300,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Search size={14} color="#a1a1aa" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Filter logs..."
|
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
display: "flex",
|
||||||
outline: "none",
|
alignItems: "center",
|
||||||
background: "transparent",
|
gap: 8,
|
||||||
fontSize: "0.8rem",
|
color: THEME.mid,
|
||||||
width: "100%",
|
fontSize: "0.875rem",
|
||||||
color: "#fff",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Loader2 size={15} className="animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : live.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Activity size={22} />}
|
||||||
|
title="No apps running"
|
||||||
|
hint="Once you deploy an app, its runtime logs will appear here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", gap: 16, flex: 1, minHeight: 0 }}>
|
||||||
|
{/* App Picker Column */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 220,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{live.map((app) => (
|
||||||
|
<button
|
||||||
|
key={app.uuid}
|
||||||
|
onClick={() => setActiveUuid(app.uuid)}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "10px 14px",
|
||||||
|
background:
|
||||||
|
activeUuid === app.uuid ? THEME.subtleBg : THEME.cardBg,
|
||||||
|
border: `1px solid ${activeUuid === app.uuid ? THEME.border : "transparent"}`,
|
||||||
|
borderRadius: THEME.radiusSm,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: activeUuid === app.uuid ? THEME.ink : THEME.mid,
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Viewer Column */}
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
padding={0}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: `1px solid ${THEME.borderSoft}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: THEME.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{live.find((a) => a.uuid === activeUuid)?.name ?? "Logs"}
|
||||||
|
</span>
|
||||||
|
<SecondaryButton
|
||||||
|
icon={
|
||||||
|
logsLoading ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => activeUuid && fetchLogs(activeUuid)}
|
||||||
|
disabled={logsLoading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
padding: 16,
|
||||||
|
background: "#0a0a0a",
|
||||||
|
borderBottomLeftRadius: THEME.radius,
|
||||||
|
borderBottomRightRadius: THEME.radius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#e5e5e5",
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logsLoading && !logs
|
||||||
|
? "Loading..."
|
||||||
|
: logs || "No logs available."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
height: 400,
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", gap: 16 }}>
|
|
||||||
<span style={{ color: "#71717a" }}>14:32:01</span>
|
|
||||||
<span style={{ color: "#10b981" }}>[info]</span>
|
|
||||||
<span>Server started on port 3000</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 16 }}>
|
|
||||||
<span style={{ color: "#71717a" }}>14:32:05</span>
|
|
||||||
<span style={{ color: "#10b981" }}>[info]</span>
|
|
||||||
<span>Database connected successfully</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 16 }}>
|
|
||||||
<span style={{ color: "#71717a" }}>14:45:12</span>
|
|
||||||
<span style={{ color: "#3b82f6" }}>[http]</span>
|
|
||||||
<span>GET /api/users 200 OK - 45ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
Terminal,
|
||||||
Server,
|
Server,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
Card,
|
||||||
|
SectionHeader,
|
||||||
|
EmptyState,
|
||||||
|
Badge,
|
||||||
|
SecondaryButton,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hosting tab — user-facing: "Is my thing live? How do I reach it?"
|
* Hosting tab — user-facing: "Is my thing live? How do I reach it?"
|
||||||
@@ -51,67 +59,96 @@ export default function OverviewTab() {
|
|||||||
const showLoading = loading && !anatomy;
|
const showLoading = loading && !anatomy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={pageWrap}>
|
<div
|
||||||
{showLoading && (
|
style={{
|
||||||
<div style={centeredMsg}>
|
minHeight: "100vh",
|
||||||
<Loader2
|
background: THEME.canvasGradient,
|
||||||
size={16}
|
fontFamily: THEME.font,
|
||||||
className="animate-spin"
|
padding: "36px 48px",
|
||||||
style={{ color: INK.muted }}
|
}}
|
||||||
/>
|
>
|
||||||
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
|
<div style={{ maxWidth: 860, margin: "0 auto" }}>
|
||||||
Loading…
|
<PageHeader
|
||||||
</span>
|
title="Overview"
|
||||||
</div>
|
subtitle="Your live deployments and development previews."
|
||||||
)}
|
/>
|
||||||
{error && !showLoading && (
|
|
||||||
<div style={centeredMsg}>
|
|
||||||
<AlertCircle size={15} style={{ color: DANGER }} />
|
|
||||||
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{anatomy && (
|
{showLoading && (
|
||||||
<>
|
<Card>
|
||||||
{/* ── Live endpoints ── */}
|
<div
|
||||||
<section>
|
style={{
|
||||||
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
|
display: "flex",
|
||||||
{anatomy.hosting.live.length === 0 ? (
|
alignItems: "center",
|
||||||
<EmptySection
|
gap: 8,
|
||||||
icon={<Server size={20} style={{ color: INK.muted }} />}
|
color: THEME.mid,
|
||||||
title="Nothing deployed yet"
|
fontSize: "0.875rem",
|
||||||
hint="Ask the AI to deploy your app and it will appear here."
|
}}
|
||||||
promptSuggestion="Deploy my app to production"
|
>
|
||||||
/>
|
<Loader2 size={15} className="animate-spin" /> Loading…
|
||||||
) : (
|
</div>
|
||||||
<div
|
</Card>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
)}
|
||||||
>
|
{error && !showLoading && (
|
||||||
{anatomy.hosting.live.map((item) => (
|
<Card>
|
||||||
<LiveCard key={item.uuid} item={item} projectId={projectId} />
|
<div
|
||||||
))}
|
style={{
|
||||||
</div>
|
display: "flex",
|
||||||
)}
|
alignItems: "center",
|
||||||
</section>
|
gap: 8,
|
||||||
|
color: THEME.danger,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={15} /> {error}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Previews ── */}
|
{anatomy && (
|
||||||
{anatomy.hosting.previews.length > 0 && (
|
<>
|
||||||
<section style={{ marginTop: 40 }}>
|
{/* ── Live endpoints ── */}
|
||||||
<SectionHeader
|
<section>
|
||||||
title="Dev Previews"
|
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
|
||||||
count={anatomy.hosting.previews.length}
|
{anatomy.hosting.live.length === 0 ? (
|
||||||
/>
|
<EmptyState
|
||||||
<div
|
icon={<Server size={22} />}
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 10 }}
|
title="Nothing deployed yet"
|
||||||
>
|
hint="Ask the AI to deploy your app and it will appear here."
|
||||||
{anatomy.hosting.previews.map((p) => (
|
/>
|
||||||
<PreviewRow key={p.id} preview={p} />
|
) : (
|
||||||
))}
|
<div
|
||||||
</div>
|
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||||
|
>
|
||||||
|
{anatomy.hosting.live.map((item) => (
|
||||||
|
<LiveCard
|
||||||
|
key={item.uuid}
|
||||||
|
item={item}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
</>
|
{/* ── Previews ── */}
|
||||||
)}
|
{anatomy.hosting.previews.length > 0 && (
|
||||||
|
<section style={{ marginTop: 40 }}>
|
||||||
|
<SectionHeader
|
||||||
|
title="Dev Previews"
|
||||||
|
count={anatomy.hosting.previews.length}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 10 }}
|
||||||
|
>
|
||||||
|
{anatomy.hosting.previews.map((p) => (
|
||||||
|
<PreviewRow key={p.id} preview={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,6 +167,14 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
|
|||||||
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
|
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
|
||||||
const phase = classifyPhase(item.status);
|
const phase = classifyPhase(item.status);
|
||||||
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
|
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
|
||||||
|
const statusTheme =
|
||||||
|
phase === "healthy"
|
||||||
|
? "success"
|
||||||
|
: phase === "building"
|
||||||
|
? "warning"
|
||||||
|
: phase === "failed"
|
||||||
|
? "danger"
|
||||||
|
: "neutral";
|
||||||
|
|
||||||
const redeploy = async () => {
|
const redeploy = async () => {
|
||||||
if (deploying) return;
|
if (deploying) return;
|
||||||
@@ -185,77 +230,91 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={card}>
|
<Card padding={16}>
|
||||||
{/* ── Card header ── */}
|
<div
|
||||||
<div style={cardHeader}>
|
style={{
|
||||||
<div
|
display: "flex",
|
||||||
style={{
|
alignItems: "center",
|
||||||
display: "flex",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
gap: 16,
|
||||||
gap: 10,
|
marginBottom: 12,
|
||||||
minWidth: 0,
|
}}
|
||||||
flex: 1,
|
>
|
||||||
}}
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
>
|
<StatusDot status={statusTheme} />
|
||||||
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
|
<span style={{ fontSize: "1rem", fontWeight: 600, color: THEME.ink }}>
|
||||||
<span style={cardTitle}>{item.name}</span>
|
{item.name}
|
||||||
<span style={sourcePill(item.source)}>
|
|
||||||
{item.source === "repo" ? "built" : "image"}
|
|
||||||
</span>
|
</span>
|
||||||
|
<Badge color="default">
|
||||||
|
{item.source === "repo" ? "built" : "image"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<SecondaryButton
|
||||||
<button
|
onClick={redeploy}
|
||||||
onClick={redeploy}
|
disabled={deploying}
|
||||||
disabled={deploying}
|
icon={
|
||||||
style={actionBtn}
|
deploying ? (
|
||||||
title="Redeploy now"
|
<Loader2 size={14} className="animate-spin" />
|
||||||
>
|
|
||||||
{deploying ? (
|
|
||||||
<Loader2 size={13} className="animate-spin" />
|
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw size={13} />
|
<RefreshCw size={14} />
|
||||||
)}
|
)
|
||||||
{deploying ? "Deploying…" : "Redeploy"}
|
}
|
||||||
</button>
|
>
|
||||||
</div>
|
{deploying ? "Deploying…" : "Redeploy"}
|
||||||
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Status line ── */}
|
<div style={{ fontSize: "0.85rem", marginBottom: 16 }}>
|
||||||
<div style={statusLine}>
|
|
||||||
<span style={{ color: statusColor, fontWeight: 600 }}>
|
<span style={{ color: statusColor, fontWeight: 600 }}>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
{item.lastBuild && (
|
{item.lastBuild && (
|
||||||
<span style={{ color: INK.muted }}>
|
<span style={{ color: THEME.muted, marginLeft: 6 }}>
|
||||||
· Last build {item.lastBuild.status}{" "}
|
· Last build {item.lastBuild.status}{" "}
|
||||||
{formatRelative(item.lastBuild.finishedAt)}
|
{formatRelative(item.lastBuild.finishedAt)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Live URL ── */}
|
|
||||||
{primaryUrl ? (
|
{primaryUrl ? (
|
||||||
<div style={urlRow}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} />
|
<Globe size={14} style={{ color: "#059669", flexShrink: 0 }} />
|
||||||
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}>
|
<a
|
||||||
|
href={primaryUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: THEME.ink,
|
||||||
|
textDecoration: "underline",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{primaryUrl}
|
{primaryUrl}
|
||||||
</a>
|
</a>
|
||||||
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
|
<ExternalLink size={12} style={{ color: THEME.muted }} />
|
||||||
<button onClick={copyUrl} style={iconBtn} title="Copy URL">
|
<button
|
||||||
{copied ? (
|
onClick={copyUrl}
|
||||||
<Check size={12} style={{ color: "#2e7d32" }} />
|
style={{
|
||||||
) : (
|
background: "transparent",
|
||||||
<Copy size={12} />
|
border: "none",
|
||||||
)}
|
cursor: "pointer",
|
||||||
|
color: copied ? "#059669" : THEME.muted,
|
||||||
|
display: "flex",
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
title="Copy URL"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={urlRow}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
|
<Globe size={14} style={{ color: THEME.muted, flexShrink: 0 }} />
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: INK.muted,
|
color: THEME.muted,
|
||||||
fontSize: "0.82rem",
|
fontSize: "0.85rem",
|
||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -264,15 +323,14 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Extra domains ── */}
|
|
||||||
{item.domains.length > 1 && (
|
{item.domains.length > 1 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: 23,
|
paddingLeft: 22,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
marginTop: 4,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.domains.slice(1).map((d) => (
|
{item.domains.slice(1).map((d) => (
|
||||||
@@ -281,45 +339,78 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
|
|||||||
href={`https://${d}`}
|
href={`https://${d}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: THEME.mid,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{d}{" "}
|
{d} <ExternalLink size={10} style={{ display: "inline" }} />
|
||||||
<ExternalLink
|
|
||||||
size={10}
|
|
||||||
style={{ display: "inline", verticalAlign: "middle" }}
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Logs toggle ── */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 14,
|
marginTop: 16,
|
||||||
borderTop: `1px solid ${INK.borderSoft}`,
|
borderTop: `1px solid ${THEME.borderSoft}`,
|
||||||
paddingTop: 10,
|
paddingTop: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button onClick={openLogs} style={logsToggleBtn}>
|
<button
|
||||||
|
onClick={openLogs}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: THEME.mid,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Terminal size={12} />
|
<Terminal size={12} />
|
||||||
{logsOpen ? "Hide logs" : "Show recent logs"}
|
{logsOpen ? "Hide logs" : "Show recent logs"}
|
||||||
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{logsOpen && (
|
{logsOpen && (
|
||||||
<div style={logsBox}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
background: "#0a0a0a",
|
||||||
|
borderRadius: THEME.radiusSm,
|
||||||
|
padding: 12,
|
||||||
|
maxHeight: 300,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{logsLoading ? (
|
{logsLoading ? (
|
||||||
<span style={{ color: INK.muted, fontSize: "0.8rem" }}>
|
<span style={{ color: THEME.muted, fontSize: "0.8rem" }}>
|
||||||
Loading…
|
Loading…
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<pre style={logsPre}>{logs || "(no logs)"}</pre>
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "#e5e5e5",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs || "(no logs)"}
|
||||||
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,43 +421,46 @@ function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
|
|||||||
function PreviewRow({ preview }: { preview: Preview }) {
|
function PreviewRow({ preview }: { preview: Preview }) {
|
||||||
const running = preview.state === "running";
|
const running = preview.state === "running";
|
||||||
return (
|
return (
|
||||||
<div style={{ ...card, padding: "12px 16px" }}>
|
<Card
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
padding={16}
|
||||||
<CircleDot
|
style={{ display: "flex", alignItems: "center", gap: 12 }}
|
||||||
size={10}
|
>
|
||||||
style={{ color: running ? "#10b981" : INK.muted, flexShrink: 0 }}
|
<StatusDot status={running ? "success" : "neutral"} />
|
||||||
/>
|
<span style={{ fontSize: "0.95rem", fontWeight: 600, color: THEME.ink }}>
|
||||||
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>
|
{preview.name}
|
||||||
{preview.name}
|
</span>
|
||||||
</span>
|
<span style={{ fontSize: "0.8rem", color: THEME.muted }}>
|
||||||
<span style={{ fontSize: "0.75rem", color: INK.mid }}>
|
port {preview.port}
|
||||||
port {preview.port}
|
</span>
|
||||||
</span>
|
{preview.url && running && (
|
||||||
{preview.url && running && (
|
<div
|
||||||
<div
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={preview.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: "auto",
|
fontSize: "0.85rem",
|
||||||
display: "flex",
|
color: THEME.ink,
|
||||||
gap: 8,
|
textDecoration: "underline",
|
||||||
alignItems: "center",
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a
|
{preview.url.replace(/^https?:\/\//, "")}{" "}
|
||||||
href={preview.url}
|
<ExternalLink
|
||||||
target="_blank"
|
size={12}
|
||||||
rel="noreferrer"
|
style={{ display: "inline", verticalAlign: "middle" }}
|
||||||
style={{ ...urlLink, marginLeft: 0 }}
|
/>
|
||||||
>
|
</a>
|
||||||
{preview.url.replace(/^https?:\/\//, "")}{" "}
|
</div>
|
||||||
<ExternalLink
|
)}
|
||||||
size={10}
|
</Card>
|
||||||
style={{ display: "inline", verticalAlign: "middle" }}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,15 +521,6 @@ function formatRelative(iso: string | undefined) {
|
|||||||
// Sub-components
|
// Sub-components
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function SectionHeader({ title, count }: { title: string; count: number }) {
|
|
||||||
return (
|
|
||||||
<div style={sectionHeader}>
|
|
||||||
<span style={sectionTitle}>{title}</span>
|
|
||||||
<span style={countPill}>{count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptySection({
|
function EmptySection({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@@ -492,13 +577,13 @@ function EmptySection({
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const INK = {
|
const INK = {
|
||||||
ink: "#1a1a1a",
|
ink: "#111827",
|
||||||
mid: "#5f5e5a",
|
mid: "#4b5563",
|
||||||
muted: "#a09a90",
|
muted: "#9ca3af",
|
||||||
border: "#e8e4dc",
|
border: "#e5e7eb",
|
||||||
borderSoft: "#efebe1",
|
borderSoft: "#f3f4f6",
|
||||||
cardBg: "#fff",
|
cardBg: "#fff",
|
||||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
|
||||||
} as const;
|
} as const;
|
||||||
const GREEN = "#10b981";
|
const GREEN = "#10b981";
|
||||||
const AMBER = "#f59e0b";
|
const AMBER = "#f59e0b";
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ import {
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
Card,
|
||||||
|
SectionHeader,
|
||||||
|
PrimaryButton,
|
||||||
|
SecondaryButton,
|
||||||
|
Badge,
|
||||||
|
StatusDot,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Types & Fetcher
|
// Types & Fetcher
|
||||||
@@ -88,101 +98,125 @@ export default function PlanTab() {
|
|||||||
const showLoading = !plan && !error;
|
const showLoading = !plan && !error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={pageWrap}>
|
<div
|
||||||
{showLoading && (
|
style={{
|
||||||
<div style={centeredMsg}>
|
minHeight: "100vh",
|
||||||
<Loader2
|
background: THEME.canvasGradient,
|
||||||
size={16}
|
fontFamily: THEME.font,
|
||||||
className="animate-spin"
|
padding: "36px 48px",
|
||||||
style={{ color: INK.muted }}
|
}}
|
||||||
/>
|
>
|
||||||
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
|
<div style={{ maxWidth: 1400, margin: "0 auto" }}>
|
||||||
Loading plan…
|
<PageHeader
|
||||||
</span>
|
title="Plan & Specs"
|
||||||
</div>
|
subtitle="Your product brief, execution plan, and AI instructions."
|
||||||
)}
|
/>
|
||||||
{error && !showLoading && (
|
|
||||||
<div style={centeredMsg}>
|
|
||||||
<AlertCircle size={15} style={{ color: "#d93025" }} />
|
|
||||||
<span style={{ fontSize: "0.85rem", color: "#d93025" }}>
|
|
||||||
{error.message || "Failed to load plan"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{plan && (
|
{showLoading && (
|
||||||
<div style={grid}>
|
<Card>
|
||||||
{/* ── Left Rail (Master Index) ── */}
|
<div
|
||||||
<section style={leftCol}>
|
style={{
|
||||||
<div style={railGroup}>
|
display: "flex",
|
||||||
<div style={railGroupHeader}>
|
alignItems: "center",
|
||||||
<h3 style={railGroupTitle}>Scope</h3>
|
gap: 8,
|
||||||
</div>
|
color: THEME.mid,
|
||||||
<div style={railItems}>
|
fontSize: "0.875rem",
|
||||||
<RailItem
|
}}
|
||||||
id="objective"
|
>
|
||||||
label="Product Brief"
|
<Loader2 size={15} className="animate-spin" /> Loading plan…
|
||||||
icon={<Target />}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onClick={setSelectedId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{error && !showLoading && (
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
color: THEME.danger,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={15} /> {error.message || "Failed to load plan"}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={railGroup}>
|
{plan && (
|
||||||
<div style={railGroupHeader}>
|
<div style={grid}>
|
||||||
<h3 style={railGroupTitle}>Blueprint</h3>
|
{/* ── Left Rail (Master Index) ── */}
|
||||||
</div>
|
<section style={leftCol}>
|
||||||
<div style={railItems}>
|
<div style={railGroup}>
|
||||||
{BLUEPRINT_DOCS.map((doc) => (
|
<div style={railGroupHeader}>
|
||||||
|
<h3 style={railGroupTitle}>Scope</h3>
|
||||||
|
</div>
|
||||||
|
<div style={railItems}>
|
||||||
<RailItem
|
<RailItem
|
||||||
key={doc.id}
|
id="objective"
|
||||||
id={doc.id}
|
label="Product Brief"
|
||||||
label={doc.label}
|
icon={<Target />}
|
||||||
icon={doc.icon}
|
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onClick={setSelectedId}
|
onClick={setSelectedId}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={railGroup}>
|
<div style={railGroup}>
|
||||||
<div style={railGroupHeader}>
|
<div style={railGroupHeader}>
|
||||||
<h3 style={railGroupTitle}>Delegate to AI</h3>
|
<h3 style={railGroupTitle}>Blueprint</h3>
|
||||||
|
</div>
|
||||||
|
<div style={railItems}>
|
||||||
|
{BLUEPRINT_DOCS.map((doc) => (
|
||||||
|
<RailItem
|
||||||
|
key={doc.id}
|
||||||
|
id={doc.id}
|
||||||
|
label={doc.label}
|
||||||
|
icon={doc.icon}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onClick={setSelectedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={railItems}>
|
|
||||||
<RailItem
|
<div style={railGroup}>
|
||||||
id="kanban"
|
<div style={railGroupHeader}>
|
||||||
label="Execution Plan"
|
<h3 style={railGroupTitle}>Delegate to AI</h3>
|
||||||
icon={<Play />}
|
</div>
|
||||||
selectedId={selectedId}
|
<div style={railItems}>
|
||||||
onClick={setSelectedId}
|
<RailItem
|
||||||
|
id="kanban"
|
||||||
|
label="Execution Plan"
|
||||||
|
icon={<Play />}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onClick={setSelectedId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Right Rail (Detail Viewer) ── */}
|
||||||
|
<section style={rightCol}>
|
||||||
|
{selectedId === "objective" && (
|
||||||
|
<ObjectivePanel
|
||||||
|
plan={plan}
|
||||||
|
projectId={projectId}
|
||||||
|
onChange={setPlan}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Right Rail (Detail Viewer) ── */}
|
{BLUEPRINT_DOCS.some((d) => d.id === selectedId) && (
|
||||||
<section style={rightCol}>
|
<DocumentPanel plan={plan} docId={selectedId} />
|
||||||
{selectedId === "objective" && (
|
)}
|
||||||
<ObjectivePanel
|
|
||||||
plan={plan}
|
|
||||||
projectId={projectId}
|
|
||||||
onChange={setPlan}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{BLUEPRINT_DOCS.some((d) => d.id === selectedId) && (
|
{selectedId === "kanban" && (
|
||||||
<DocumentPanel plan={plan} docId={selectedId} />
|
<DelegatePanel plan={plan} projectId={projectId} />
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
{selectedId === "kanban" && (
|
</div>
|
||||||
<DelegatePanel plan={plan} projectId={projectId} />
|
)}
|
||||||
)}
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,15 +244,15 @@ function RailItem({
|
|||||||
onClick={() => onClick(id)}
|
onClick={() => onClick(id)}
|
||||||
style={{
|
style={{
|
||||||
...flatTile,
|
...flatTile,
|
||||||
background: isActive ? INK.cardBg : "transparent",
|
background: isActive ? THEME.cardBg : "transparent",
|
||||||
borderColor: isActive ? INK.border : "transparent",
|
borderColor: isActive ? THEME.border : "transparent",
|
||||||
boxShadow: isActive ? "0 1px 3px rgba(0,0,0,0.02)" : "none",
|
boxShadow: isActive ? THEME.shadow : "none",
|
||||||
color: isActive ? INK.ink : INK.muted,
|
color: isActive ? THEME.ink : THEME.muted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{React.cloneElement(icon, {
|
{React.cloneElement(icon, {
|
||||||
size: 15,
|
size: 15,
|
||||||
color: isActive ? INK.ink : INK.muted,
|
color: isActive ? THEME.ink : THEME.muted,
|
||||||
} as React.SVGProps<SVGSVGElement> & { size?: number | string })}
|
} as React.SVGProps<SVGSVGElement> & { size?: number | string })}
|
||||||
<span style={{ fontSize: "0.85rem", fontWeight: isActive ? 600 : 500 }}>
|
<span style={{ fontSize: "0.85rem", fontWeight: isActive ? 600 : 500 }}>
|
||||||
{label}
|
{label}
|
||||||
@@ -279,125 +313,156 @@ function ObjectivePanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={panel}>
|
<Card
|
||||||
<div style={panelHeader}>
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
padding={0}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 32px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={panelTitle}>Product Brief</h2>
|
<h2
|
||||||
<p style={panelDesc}>
|
style={{
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: THEME.ink,
|
||||||
|
margin: "0 0 6px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Product Brief
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: 0, fontSize: "0.875rem", color: THEME.muted }}>
|
||||||
The high-level business case and elevator pitch.
|
The high-level business case and elevator pitch.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
{saving && (
|
|
||||||
<span style={{ fontSize: "0.75rem", color: INK.muted }}>
|
|
||||||
Saving...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<button onClick={() => setEditing(true)} style={actionBtn}>
|
<SecondaryButton
|
||||||
<Pencil size={14} /> Edit Objective
|
onClick={() => setEditing(true)}
|
||||||
</button>
|
icon={<Pencil size={14} />}
|
||||||
|
>
|
||||||
|
Edit Objective
|
||||||
|
</SecondaryButton>
|
||||||
)}
|
)}
|
||||||
{editing && (
|
{editing && (
|
||||||
<>
|
<>
|
||||||
<button
|
<PrimaryButton
|
||||||
onClick={() => save(draft)}
|
onClick={() => save(draft)}
|
||||||
disabled={!dirty || saving}
|
disabled={!dirty || saving}
|
||||||
className="btn-primary"
|
|
||||||
>
|
>
|
||||||
Save Changes
|
{saving ? "Saving…" : "Save Changes"}
|
||||||
</button>
|
</PrimaryButton>
|
||||||
<button onClick={cancel} className="btn-ghost">
|
<SecondaryButton onClick={cancel}>Cancel</SecondaryButton>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={editorContainer}>
|
<div
|
||||||
{editing ? (
|
style={{
|
||||||
<>
|
flex: 1,
|
||||||
<div style={editorTabs}>
|
display: "flex",
|
||||||
<button
|
flexDirection: "column",
|
||||||
type="button"
|
minHeight: 0,
|
||||||
onClick={() => setEditorView("write")}
|
padding: "0 32px 32px",
|
||||||
style={
|
}}
|
||||||
editorView === "write" ? editorTabActive : editorTabInactive
|
>
|
||||||
}
|
<div style={editorContainer}>
|
||||||
>
|
{editing ? (
|
||||||
<FileText size={14} /> Write
|
<>
|
||||||
</button>
|
<div style={editorTabs}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditorView("preview")}
|
onClick={() => setEditorView("write")}
|
||||||
style={
|
style={
|
||||||
editorView === "preview" ? editorTabActive : editorTabInactive
|
editorView === "write" ? editorTabActive : editorTabInactive
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<BookOpen size={14} /> Preview
|
|
||||||
</button>
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
{dirty && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "#f97316",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
● Unsaved
|
<FileText size={14} /> Write
|
||||||
</span>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => setEditorView("preview")}
|
||||||
{editorView === "write" ? (
|
style={
|
||||||
<textarea
|
editorView === "preview"
|
||||||
value={draft}
|
? editorTabActive
|
||||||
onChange={(e) => {
|
: editorTabInactive
|
||||||
setDraft(e.target.value);
|
}
|
||||||
setDirty(true);
|
>
|
||||||
}}
|
<BookOpen size={14} /> Preview
|
||||||
style={textAreaStyle}
|
</button>
|
||||||
placeholder="Describe the business objective..."
|
<div style={{ flex: 1 }} />
|
||||||
spellCheck
|
{dirty && (
|
||||||
/>
|
<span
|
||||||
) : (
|
style={{
|
||||||
<div style={previewAreaStyle}>
|
fontSize: "0.75rem",
|
||||||
{draft.trim() ? (
|
color: "#f97316",
|
||||||
<div className="markdown-prose">
|
display: "flex",
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
alignItems: "center",
|
||||||
{draft}
|
fontWeight: 500,
|
||||||
</ReactMarkdown>
|
}}
|
||||||
</div>
|
>
|
||||||
) : (
|
● Unsaved
|
||||||
<div style={{ color: INK.muted, fontStyle: "italic" }}>
|
</span>
|
||||||
Nothing to preview yet.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
{editorView === "write" ? (
|
||||||
) : (
|
<textarea
|
||||||
<div style={previewAreaStyle}>
|
value={draft}
|
||||||
{draft.trim() ? (
|
onChange={(e) => {
|
||||||
<div className="markdown-prose">
|
setDraft(e.target.value);
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
setDirty(true);
|
||||||
{draft}
|
}}
|
||||||
</ReactMarkdown>
|
style={textAreaStyle}
|
||||||
</div>
|
placeholder="Describe the business objective..."
|
||||||
) : (
|
spellCheck
|
||||||
<div style={{ color: INK.muted, fontStyle: "italic" }}>
|
/>
|
||||||
No objective set. Click Edit to add one.
|
) : (
|
||||||
</div>
|
<div style={previewAreaStyle}>
|
||||||
)}
|
{draft.trim() ? (
|
||||||
</div>
|
<div className="markdown-prose">
|
||||||
)}
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{draft}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: THEME.muted, fontStyle: "italic" }}>
|
||||||
|
Nothing to preview yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={previewAreaStyle}>
|
||||||
|
{draft.trim() ? (
|
||||||
|
<div className="markdown-prose">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{draft}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: THEME.muted, fontStyle: "italic" }}>
|
||||||
|
No objective set. Click Edit to add one.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +479,15 @@ function DocumentPanel({ plan, docId }: { plan: Plan; docId: string }) {
|
|||||||
const content = plan.blueprint?.[docId as keyof typeof plan.blueprint];
|
const content = plan.blueprint?.[docId as keyof typeof plan.blueprint];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={panel}>
|
<Card
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
padding={32}
|
||||||
|
>
|
||||||
{content ? (
|
{content ? (
|
||||||
<div
|
<div
|
||||||
className="markdown-prose"
|
className="markdown-prose"
|
||||||
@@ -423,40 +496,44 @@ function DocumentPanel({ plan, docId }: { plan: Plan; docId: string }) {
|
|||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div
|
||||||
<div style={panelHeader}>
|
style={{
|
||||||
<div>
|
flex: 1,
|
||||||
<h2 style={panelTitle}>{docConfig.label}</h2>
|
display: "flex",
|
||||||
<p style={panelDesc}>This document is currently empty.</p>
|
flexDirection: "column",
|
||||||
</div>
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: THEME.muted,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: THEME.muted,
|
||||||
|
display: "flex",
|
||||||
|
transform: "scale(1.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{docConfig.icon}
|
||||||
</div>
|
</div>
|
||||||
<div style={emptyBox}>
|
<div style={{ textAlign: "center", marginTop: 8 }}>
|
||||||
{React.cloneElement(
|
<h3
|
||||||
docConfig.icon as React.ReactElement,
|
|
||||||
{
|
|
||||||
size: 32,
|
|
||||||
color: INK.muted,
|
|
||||||
style: { marginBottom: 16, opacity: 0.5 },
|
|
||||||
} as React.SVGProps<SVGSVGElement> & { size?: number | string },
|
|
||||||
)}
|
|
||||||
<p style={{ fontWeight: 500, margin: 0, color: INK.ink }}>
|
|
||||||
Not generated yet.
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.85rem",
|
fontSize: "1.1rem",
|
||||||
maxWidth: 300,
|
fontWeight: 600,
|
||||||
margin: "8px 0 0",
|
color: THEME.ink,
|
||||||
lineHeight: 1.5,
|
margin: "0 0 4px 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
This document is generated and maintained by the AI agent. Chat
|
{docConfig.label}
|
||||||
with your agent to update the scope and blueprint.
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.875rem", margin: 0 }}>
|
||||||
|
This document is currently empty. Ask the AI to draft it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,43 +584,38 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TaskCard = ({ t }: { t: Plan["tasks"][number] }) => (
|
const TaskCard = ({ t }: { t: Plan["tasks"][number] }) => (
|
||||||
<div style={taskCard}>
|
<Card
|
||||||
|
padding={16}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 8 }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ marginTop: 4 }}>
|
||||||
style={{
|
<StatusDot
|
||||||
...taskStatusDot,
|
status={
|
||||||
borderColor:
|
|
||||||
t.status === "done"
|
t.status === "done"
|
||||||
? "#10b981"
|
? "success"
|
||||||
: t.status === "open"
|
: t.status === "open"
|
||||||
? INK.muted
|
? "neutral"
|
||||||
: "#f59e0b",
|
: "warning"
|
||||||
background: t.status === "done" ? "#10b981" : "transparent",
|
}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
{t.status === "done" && (
|
|
||||||
<Check size={10} color="#fff" strokeWidth={3} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.95rem",
|
fontSize: "0.95rem",
|
||||||
color: INK.ink,
|
color: THEME.ink,
|
||||||
textDecoration: t.status === "done" ? "line-through" : "none",
|
textDecoration: t.status === "done" ? "line-through" : "none",
|
||||||
opacity: t.status === "done" ? 0.6 : 1,
|
opacity: t.status === "done" ? 0.6 : 1,
|
||||||
wordWrap: "break-word",
|
wordWrap: "break-word",
|
||||||
overflowWrap: "break-word",
|
|
||||||
hyphens: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.title}
|
{t.title}
|
||||||
@@ -552,12 +624,10 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.8rem",
|
||||||
color: INK.muted,
|
color: THEME.muted,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
wordWrap: "break-word",
|
wordWrap: "break-word",
|
||||||
overflowWrap: "break-word",
|
|
||||||
hyphens: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.description}
|
{t.description}
|
||||||
@@ -565,43 +635,66 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
<div
|
||||||
<span style={taskBadge}>
|
style={{ display: "flex", justifyContent: "flex-end", marginTop: 12 }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
t.status === "done"
|
||||||
|
? "success"
|
||||||
|
: t.status === "open"
|
||||||
|
? "default"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
{t.status === "open"
|
{t.status === "open"
|
||||||
? "Queued"
|
? "Queued"
|
||||||
: t.status === "done"
|
: t.status === "done"
|
||||||
? "Completed"
|
? "Completed"
|
||||||
: "In Progress"}
|
: "In Progress"}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...panel,
|
display: "flex",
|
||||||
background: "transparent",
|
flexDirection: "column",
|
||||||
border: "none",
|
flex: 1,
|
||||||
boxShadow: "none",
|
minHeight: 0,
|
||||||
padding: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={panelHeader}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={panelTitle}>Execution Plan</h2>
|
<h2
|
||||||
<p style={panelDesc}>
|
style={{
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: THEME.ink,
|
||||||
|
margin: "0 0 6px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Execution Plan
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: 0, fontSize: "0.875rem", color: THEME.muted }}>
|
||||||
The prioritized roadmap for the AI background runner to execute.
|
The prioritized roadmap for the AI background runner to execute.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<PrimaryButton
|
||||||
className="btn-primary"
|
|
||||||
onClick={handleDelegate}
|
onClick={handleDelegate}
|
||||||
disabled={delegating || openTasks.length === 0}
|
disabled={delegating || openTasks.length === 0}
|
||||||
style={{ background: INK.ink, color: "#fff" }}
|
|
||||||
>
|
>
|
||||||
{delegating ? "Starting Jarvis..." : "Delegate Build"}
|
{delegating ? "Starting Jarvis..." : "Delegate Build"}
|
||||||
</button>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -673,23 +766,6 @@ function DelegatePanel({ plan, projectId }: { plan: Plan; projectId: string }) {
|
|||||||
// Styles (Mapped to infrastructure/product design language)
|
// Styles (Mapped to infrastructure/product design language)
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const INK = {
|
|
||||||
ink: "#1a1a1a",
|
|
||||||
mid: "#5f5e5a",
|
|
||||||
muted: "#a09a90",
|
|
||||||
border: "#e8e4dc",
|
|
||||||
borderSoft: "#efebe1",
|
|
||||||
cardBg: "#fff",
|
|
||||||
bgHover: "#fafaf6",
|
|
||||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const pageWrap: React.CSSProperties = {
|
|
||||||
padding: "28px 48px 48px",
|
|
||||||
fontFamily: INK.fontSans,
|
|
||||||
color: INK.ink,
|
|
||||||
};
|
|
||||||
|
|
||||||
const grid: React.CSSProperties = {
|
const grid: React.CSSProperties = {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
|
gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
|
||||||
@@ -712,14 +788,6 @@ const rightCol: React.CSSProperties = {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
};
|
};
|
||||||
|
|
||||||
const centeredMsg: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 10,
|
|
||||||
padding: "24px 0",
|
|
||||||
justifyContent: "center",
|
|
||||||
};
|
|
||||||
|
|
||||||
const railGroup: React.CSSProperties = {
|
const railGroup: React.CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -735,7 +803,7 @@ const railGroupTitle: React.CSSProperties = {
|
|||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: "0.12em",
|
letterSpacing: "0.12em",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
color: INK.muted,
|
color: THEME.muted,
|
||||||
};
|
};
|
||||||
const railItems: React.CSSProperties = {
|
const railItems: React.CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -757,58 +825,11 @@ const flatTile: React.CSSProperties = {
|
|||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
};
|
};
|
||||||
|
|
||||||
const panel: React.CSSProperties = {
|
|
||||||
background: INK.cardBg,
|
|
||||||
border: `1px solid ${INK.border}`,
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 32,
|
|
||||||
flex: 1,
|
|
||||||
minHeight: "calc(100vh - 150px)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const panelHeader: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 24,
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
const panelTitle: React.CSSProperties = {
|
|
||||||
fontSize: "1.2rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
margin: 0,
|
|
||||||
color: INK.ink,
|
|
||||||
};
|
|
||||||
const panelDesc: React.CSSProperties = {
|
|
||||||
color: INK.muted,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
margin: "4px 0 0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionBtn: React.CSSProperties = {
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
padding: "8px 16px",
|
|
||||||
border: `1px solid ${INK.border}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
font: "inherit",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: INK.ink,
|
|
||||||
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const editorContainer: React.CSSProperties = {
|
const editorContainer: React.CSSProperties = {
|
||||||
border: `1px solid ${INK.border}`,
|
border: `1px solid ${THEME.border}`,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
background: INK.cardBg,
|
background: THEME.cardBg,
|
||||||
boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
|
boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -817,8 +838,8 @@ const editorContainer: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
const editorTabs: React.CSSProperties = {
|
const editorTabs: React.CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
background: INK.bgHover,
|
background: THEME.subtleBg,
|
||||||
borderBottom: `1px solid ${INK.borderSoft}`,
|
borderBottom: `1px solid ${THEME.borderSoft}`,
|
||||||
padding: "8px 16px",
|
padding: "8px 16px",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -829,10 +850,10 @@ const editorTabActive: React.CSSProperties = {
|
|||||||
gap: 6,
|
gap: 6,
|
||||||
padding: "6px 12px",
|
padding: "6px 12px",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
background: INK.cardBg,
|
background: THEME.cardBg,
|
||||||
border: `1px solid ${INK.borderSoft}`,
|
border: `1px solid ${THEME.borderSoft}`,
|
||||||
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
||||||
color: INK.ink,
|
color: THEME.ink,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.85rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -842,7 +863,7 @@ const editorTabInactive: React.CSSProperties = {
|
|||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "1px solid transparent",
|
border: "1px solid transparent",
|
||||||
boxShadow: "none",
|
boxShadow: "none",
|
||||||
color: INK.muted,
|
color: THEME.muted,
|
||||||
};
|
};
|
||||||
|
|
||||||
const textAreaStyle: React.CSSProperties = {
|
const textAreaStyle: React.CSSProperties = {
|
||||||
@@ -856,7 +877,7 @@ const textAreaStyle: React.CSSProperties = {
|
|||||||
outline: "none",
|
outline: "none",
|
||||||
resize: "none",
|
resize: "none",
|
||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
color: INK.ink,
|
color: THEME.ink,
|
||||||
display: "block",
|
display: "block",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
margin: 0,
|
margin: 0,
|
||||||
@@ -869,26 +890,6 @@ const previewAreaStyle: React.CSSProperties = {
|
|||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyBox: React.CSSProperties = {
|
|
||||||
border: `1px dashed ${INK.borderSoft}`,
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "48px 32px",
|
|
||||||
textAlign: "center",
|
|
||||||
color: INK.muted,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
margin: "auto 0",
|
|
||||||
};
|
|
||||||
const emptyBoxSmall: React.CSSProperties = {
|
|
||||||
padding: 32,
|
|
||||||
textAlign: "center",
|
|
||||||
border: `1px dashed ${INK.border}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
color: INK.muted,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
const kanbanCol: React.CSSProperties = {
|
const kanbanCol: React.CSSProperties = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 300,
|
minWidth: 300,
|
||||||
@@ -905,15 +906,15 @@ const kanbanColHeader: React.CSSProperties = {
|
|||||||
const kanbanColTitle: React.CSSProperties = {
|
const kanbanColTitle: React.CSSProperties = {
|
||||||
fontSize: "0.95rem",
|
fontSize: "0.95rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: INK.ink,
|
color: THEME.ink,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
};
|
};
|
||||||
const kanbanCount: React.CSSProperties = {
|
const kanbanCount: React.CSSProperties = {
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
background: INK.borderSoft,
|
background: THEME.borderSoft,
|
||||||
padding: "2px 8px",
|
padding: "2px 8px",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
color: INK.muted,
|
color: THEME.muted,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
};
|
};
|
||||||
const kanbanList: React.CSSProperties = {
|
const kanbanList: React.CSSProperties = {
|
||||||
@@ -925,50 +926,18 @@ const kanbanList: React.CSSProperties = {
|
|||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskCard: React.CSSProperties = {
|
const emptyBoxSmall: React.CSSProperties = {
|
||||||
border: `1px solid ${INK.border}`,
|
padding: 32,
|
||||||
|
textAlign: "center",
|
||||||
|
border: `1px dashed ${THEME.border}`,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 16,
|
color: THEME.muted,
|
||||||
background: INK.cardBg,
|
fontSize: "0.85rem",
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
|
|
||||||
};
|
|
||||||
const taskStatusDot: React.CSSProperties = {
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: "50%",
|
|
||||||
marginTop: 2,
|
|
||||||
flexShrink: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
};
|
|
||||||
const taskBadge: React.CSSProperties = {
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: INK.muted,
|
|
||||||
background: INK.bgHover,
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: 4,
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global styles
|
// Global styles
|
||||||
const styleTag = `
|
const styleTag = `
|
||||||
.btn-primary, .btn-secondary, .btn-ghost {
|
.markdown-prose { font-size: 0.85rem; color: #111827; }
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
||||||
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
|
|
||||||
cursor: pointer; transition: all 0.15s ease; border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
.btn-primary { background: #1a1a1a; color: white; }
|
|
||||||
.btn-primary:hover { background: #333; }
|
|
||||||
.btn-secondary { background: #fff; border-color: #e8e4dc; color: #1a1a1a; }
|
|
||||||
.btn-secondary:hover { background: #fafaf6; }
|
|
||||||
.btn-ghost { background: transparent; color: #a09a90; }
|
|
||||||
.btn-ghost:hover { background: #fafaf6; color: #1a1a1a; }
|
|
||||||
|
|
||||||
.markdown-prose { font-size: 0.85rem; color: #1a1a1a; }
|
|
||||||
.markdown-prose h1 { font-size: 1.25rem; font-weight: 700; margin-top: 0; }
|
.markdown-prose h1 { font-size: 1.25rem; font-weight: 700; margin-top: 0; }
|
||||||
.markdown-prose h2 { font-size: 1.15rem; font-weight: 600; margin-top: 1.5rem; }
|
.markdown-prose h2 { font-size: 1.15rem; font-weight: 600; margin-top: 1.5rem; }
|
||||||
.markdown-prose h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; }
|
.markdown-prose h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; }
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Loader2, AlertCircle, ChevronDown, ChevronRight,
|
Loader2,
|
||||||
Box, Container, CircleDot,
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
CircleDot,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
|
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
|
||||||
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
|
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
|
||||||
@@ -34,15 +39,17 @@ export default function ProductTab() {
|
|||||||
const { anatomy, loading, error } = useAnatomy(projectId);
|
const { anatomy, loading, error } = useAnatomy(projectId);
|
||||||
|
|
||||||
const codebases = anatomy?.product.codebases ?? null;
|
const codebases = anatomy?.product.codebases ?? null;
|
||||||
const images = anatomy?.product.images ?? null;
|
const images = anatomy?.product.images ?? null;
|
||||||
const reason = anatomy?.codebasesReason;
|
const reason = anatomy?.codebasesReason;
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (codebases && codebases[0]) {
|
if (codebases && codebases[0]) {
|
||||||
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev));
|
setExpanded((prev) =>
|
||||||
|
prev.size === 0 ? new Set([codebases[0].id]) : prev,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [codebases]);
|
}, [codebases]);
|
||||||
|
|
||||||
@@ -52,7 +59,7 @@ export default function ProductTab() {
|
|||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const toggleCodebase = (id: string) => {
|
const toggleCodebase = (id: string) => {
|
||||||
setExpanded(prev => {
|
setExpanded((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
else next.add(id);
|
else next.add(id);
|
||||||
@@ -68,10 +75,14 @@ export default function ProductTab() {
|
|||||||
{/* ── Left rail ── */}
|
{/* ── Left rail ── */}
|
||||||
<section style={leftCol}>
|
<section style={leftCol}>
|
||||||
{showLoading && (
|
{showLoading && (
|
||||||
<Inline><Loader2 size={13} className="animate-spin" /> Loading…</Inline>
|
<Inline>
|
||||||
|
<Loader2 size={13} className="animate-spin" /> Loading…
|
||||||
|
</Inline>
|
||||||
)}
|
)}
|
||||||
{error && !showLoading && (
|
{error && !showLoading && (
|
||||||
<Inline><AlertCircle size={13} /> {error}</Inline>
|
<Inline>
|
||||||
|
<AlertCircle size={13} /> {error}
|
||||||
|
</Inline>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{anatomy && (
|
{anatomy && (
|
||||||
@@ -80,12 +91,24 @@ export default function ProductTab() {
|
|||||||
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
|
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
|
||||||
{codebases && codebases.length === 0 && (
|
{codebases && codebases.length === 0 && (
|
||||||
<RailEmpty>
|
<RailEmpty>
|
||||||
{reason === "no_repo"
|
{reason === "no_repo" ? (
|
||||||
? <>No codebase yet. <span style={nudge}>Try: "Start building my app"</span></>
|
<>
|
||||||
: <>Repo is empty — push a first commit. <span style={nudge}>Try: "Scaffold a Next.js app"</span></>}
|
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>
|
</RailEmpty>
|
||||||
)}
|
)}
|
||||||
{codebases?.map(cb => {
|
{codebases?.map((cb) => {
|
||||||
const isOpen = expanded.has(cb.id);
|
const isOpen = expanded.has(cb.id);
|
||||||
return (
|
return (
|
||||||
<article key={cb.id} style={codebaseTile}>
|
<article key={cb.id} style={codebaseTile}>
|
||||||
@@ -96,11 +119,19 @@ export default function ProductTab() {
|
|||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<span style={chevronCell}>
|
<span style={chevronCell}>
|
||||||
{isOpen
|
{isOpen ? (
|
||||||
? <ChevronDown size={13} style={{ color: INK.mid }} />
|
<ChevronDown size={13} style={{ color: INK.mid }} />
|
||||||
: <ChevronRight size={13} style={{ color: INK.mid }} />}
|
) : (
|
||||||
|
<ChevronRight
|
||||||
|
size={13}
|
||||||
|
style={{ color: INK.mid }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
<Box
|
||||||
|
size={13}
|
||||||
|
style={{ color: INK.mid, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
<div style={{ minWidth: 0, textAlign: "left" }}>
|
<div style={{ minWidth: 0, textAlign: "left" }}>
|
||||||
<div style={tileLabel}>{cb.label}</div>
|
<div style={tileLabel}>{cb.label}</div>
|
||||||
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
|
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
|
||||||
@@ -112,12 +143,17 @@ export default function ProductTab() {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
rootPath={cb.path}
|
rootPath={cb.path}
|
||||||
selectedPath={
|
selectedPath={
|
||||||
selection?.type === "file" && selection.codebaseId === cb.id
|
selection?.type === "file" &&
|
||||||
|
selection.codebaseId === cb.id
|
||||||
? selection.path
|
? selection.path
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onSelectFile={(p) =>
|
onSelectFile={(p) =>
|
||||||
setSelection({ type: "file", codebaseId: cb.id, path: p })
|
setSelection({
|
||||||
|
type: "file",
|
||||||
|
codebaseId: cb.id,
|
||||||
|
path: p,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,31 +167,62 @@ export default function ProductTab() {
|
|||||||
<RailGroup title="Images" count={images?.length ?? 0}>
|
<RailGroup title="Images" count={images?.length ?? 0}>
|
||||||
{images && images.length === 0 && (
|
{images && images.length === 0 && (
|
||||||
<RailEmpty>
|
<RailEmpty>
|
||||||
Self-hosted tools (Twenty CRM, n8n, Plausible…) you run appear here.
|
Self-hosted tools (Twenty CRM, n8n, Plausible…) you run
|
||||||
<span style={nudge}>Try: "Install Twenty CRM for my project"</span>
|
appear here.
|
||||||
|
<span style={nudge}>
|
||||||
|
Try: "Install Twenty CRM for my project"
|
||||||
|
</span>
|
||||||
</RailEmpty>
|
</RailEmpty>
|
||||||
)}
|
)}
|
||||||
{images?.map(img => (
|
{images?.map((img) => (
|
||||||
<button
|
<button
|
||||||
key={img.uuid}
|
key={img.uuid}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelection({ type: "image", uuid: img.uuid })}
|
onClick={() =>
|
||||||
|
setSelection({ type: "image", uuid: img.uuid })
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
...flatTile,
|
...flatTile,
|
||||||
borderColor: selection?.type === "image" && selection.uuid === img.uuid ? INK.ink : INK.borderSoft,
|
borderColor:
|
||||||
boxShadow: selection?.type === "image" && selection.uuid === img.uuid ? `0 0 0 1px ${INK.ink}` : "none",
|
selection?.type === "image" &&
|
||||||
background: selection?.type === "image" && selection.uuid === img.uuid ? "#fffdf8" : INK.cardBg,
|
selection.uuid === img.uuid
|
||||||
|
? INK.ink
|
||||||
|
: INK.borderSoft,
|
||||||
|
boxShadow:
|
||||||
|
selection?.type === "image" &&
|
||||||
|
selection.uuid === img.uuid
|
||||||
|
? `0 0 0 1px ${INK.ink}`
|
||||||
|
: "none",
|
||||||
|
background:
|
||||||
|
selection?.type === "image" &&
|
||||||
|
selection.uuid === img.uuid
|
||||||
|
? "#fffdf8"
|
||||||
|
: INK.cardBg,
|
||||||
}}
|
}}
|
||||||
aria-pressed={selection?.type === "image" && selection.uuid === img.uuid}
|
aria-pressed={
|
||||||
|
selection?.type === "image" && selection.uuid === img.uuid
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Container size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
<Container
|
||||||
|
size={13}
|
||||||
|
style={{ color: INK.mid, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||||
<div style={tileLabel}>{img.name}</div>
|
<div style={tileLabel}>{img.name}</div>
|
||||||
<div style={tileHint}>
|
<div style={tileHint}>
|
||||||
{img.image}{img.version ? `:${img.version}` : ""}
|
{img.image}
|
||||||
|
{img.version ? `:${img.version}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{img.status && <CircleDot size={9} style={{ color: statusColor(img.status), flexShrink: 0 }} />}
|
{img.status && (
|
||||||
|
<CircleDot
|
||||||
|
size={9}
|
||||||
|
style={{
|
||||||
|
color: statusColor(img.status),
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</RailGroup>
|
</RailGroup>
|
||||||
@@ -188,22 +255,26 @@ export default function ProductTab() {
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
|
function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
|
||||||
const img = anatomy.product.images.find(i => i.uuid === uuid);
|
const img = anatomy.product.images.find((i) => i.uuid === uuid);
|
||||||
if (!img) return <Empty>This image is no longer in the project.</Empty>;
|
if (!img) return <Empty>This image is no longer in the project.</Empty>;
|
||||||
const live = anatomy.hosting.live.find(l => l.uuid === uuid);
|
const live = anatomy.hosting.live.find((l) => l.uuid === uuid);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
<DetailRow label="Image" value={img.image} />
|
<DetailRow label="Image" value={img.image} />
|
||||||
<DetailRow label="Version" value={img.version || "latest"} />
|
<DetailRow label="Version" value={img.version || "latest"} />
|
||||||
<DetailRow label="Type" value={img.serviceType ?? "—"} />
|
<DetailRow label="Type" value={img.serviceType ?? "—"} />
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Status"
|
label="Status"
|
||||||
value={img.status ?? "unknown"}
|
value={img.status ?? "unknown"}
|
||||||
dot={statusColor(img.status ?? "")}
|
dot={statusColor(img.status ?? "")}
|
||||||
/>
|
/>
|
||||||
{live?.fqdn && (
|
{live?.fqdn && (
|
||||||
<DetailRow label="URL" value={live.fqdn} href={`https://${live.fqdn}`} />
|
<DetailRow
|
||||||
|
label="URL"
|
||||||
|
value={live.fqdn}
|
||||||
|
href={`https://${live.fqdn}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -214,8 +285,14 @@ function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function RailGroup({
|
function RailGroup({
|
||||||
title, count, children,
|
title,
|
||||||
}: { title: string; count: number; children: React.ReactNode }) {
|
count,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={railGroup}>
|
<div style={railGroup}>
|
||||||
<header style={railGroupHeader}>
|
<header style={railGroupHeader}>
|
||||||
@@ -232,16 +309,28 @@ function RailEmpty({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DetailRow({
|
function DetailRow({
|
||||||
label, value, dot, href,
|
label,
|
||||||
}: { label: string; value: string; dot?: string; href?: string }) {
|
value,
|
||||||
|
dot,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
dot?: string;
|
||||||
|
href?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={detailRow}>
|
<div style={detailRow}>
|
||||||
<span style={detailLabel}>{label}</span>
|
<span style={detailLabel}>{label}</span>
|
||||||
<span style={detailValue}>
|
<span style={detailValue}>
|
||||||
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
|
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
|
||||||
{href ? (
|
{href ? (
|
||||||
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a>
|
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>
|
||||||
) : value}
|
{value}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -249,11 +338,19 @@ function DetailRow({
|
|||||||
|
|
||||||
function Inline({ children }: { children: React.ReactNode }) {
|
function Inline({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
display: "flex", alignItems: "center", gap: 8,
|
style={{
|
||||||
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
|
display: "flex",
|
||||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
|
alignItems: "center",
|
||||||
}}>
|
gap: 8,
|
||||||
|
padding: "12px 14px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
color: INK.mid,
|
||||||
|
background: INK.cardBg,
|
||||||
|
border: `1px solid ${INK.borderSoft}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -261,10 +358,18 @@ function Inline({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function Empty({ children }: { children: React.ReactNode }) {
|
function Empty({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
style={{
|
||||||
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
|
flex: 1,
|
||||||
}}>
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: INK.mid,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
padding: "32px 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -286,7 +391,8 @@ function statusColor(status: string) {
|
|||||||
const s = status.toLowerCase();
|
const s = status.toLowerCase();
|
||||||
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
|
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
|
||||||
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
|
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
|
||||||
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
|
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy"))
|
||||||
|
return "#c5392b";
|
||||||
return "#a09a90";
|
return "#a09a90";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,13 +401,13 @@ function statusColor(status: string) {
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const INK = {
|
const INK = {
|
||||||
ink: "#1a1a1a",
|
ink: "#111827",
|
||||||
mid: "#5f5e5a",
|
mid: "#4b5563",
|
||||||
muted: "#a09a90",
|
muted: "#9ca3af",
|
||||||
border: "#e8e4dc",
|
border: "#e5e7eb",
|
||||||
borderSoft: "#efebe1",
|
borderSoft: "#f3f4f6",
|
||||||
cardBg: "#fff",
|
cardBg: "#fff",
|
||||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const pageWrap: React.CSSProperties = {
|
const pageWrap: React.CSSProperties = {
|
||||||
@@ -318,79 +424,157 @@ const grid: React.CSSProperties = {
|
|||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
};
|
};
|
||||||
const leftCol: React.CSSProperties = {
|
const leftCol: React.CSSProperties = {
|
||||||
minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 18,
|
||||||
};
|
};
|
||||||
const rightCol: React.CSSProperties = {
|
const rightCol: React.CSSProperties = {
|
||||||
minWidth: 0, display: "flex", flexDirection: "column",
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
};
|
};
|
||||||
const heading: React.CSSProperties = {
|
const heading: React.CSSProperties = {
|
||||||
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
|
fontSize: "0.72rem",
|
||||||
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: INK.muted,
|
||||||
|
margin: "0 0 14px",
|
||||||
|
};
|
||||||
|
const railGroup: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
};
|
};
|
||||||
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
|
|
||||||
const railGroupHeader: React.CSSProperties = {
|
const railGroupHeader: React.CSSProperties = {
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
padding: "0 4px 8px",
|
padding: "0 4px 8px",
|
||||||
};
|
};
|
||||||
const railGroupTitle: React.CSSProperties = {
|
const railGroupTitle: React.CSSProperties = {
|
||||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
|
fontSize: "0.68rem",
|
||||||
textTransform: "uppercase", color: INK.muted,
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: INK.muted,
|
||||||
};
|
};
|
||||||
const countPill: React.CSSProperties = {
|
const countPill: React.CSSProperties = {
|
||||||
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
|
fontSize: "0.7rem",
|
||||||
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
|
fontWeight: 600,
|
||||||
|
color: INK.mid,
|
||||||
|
padding: "1px 7px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "#f3eee4",
|
||||||
|
};
|
||||||
|
const railItems: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
};
|
};
|
||||||
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
|
|
||||||
const railEmpty: React.CSSProperties = {
|
const railEmpty: React.CSSProperties = {
|
||||||
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
|
padding: "10px 12px",
|
||||||
border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
|
fontSize: "0.74rem",
|
||||||
|
color: INK.muted,
|
||||||
|
border: `1px dashed ${INK.borderSoft}`,
|
||||||
|
borderRadius: 8,
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
};
|
};
|
||||||
const nudge: React.CSSProperties = {
|
const nudge: React.CSSProperties = {
|
||||||
display: "block", marginTop: 6, fontStyle: "normal",
|
display: "block",
|
||||||
background: "#f3eee4", borderRadius: 4, padding: "3px 8px",
|
marginTop: 6,
|
||||||
fontSize: "0.72rem", color: "#7a6a50",
|
fontStyle: "normal",
|
||||||
|
background: "#f3eee4",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 8px",
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
color: "#7a6a50",
|
||||||
};
|
};
|
||||||
const flatTile: React.CSSProperties = {
|
const flatTile: React.CSSProperties = {
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
display: "flex",
|
||||||
width: "100%", padding: "12px 14px",
|
alignItems: "center",
|
||||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10,
|
gap: 10,
|
||||||
cursor: "pointer", font: "inherit", color: "inherit",
|
width: "100%",
|
||||||
|
padding: "12px 14px",
|
||||||
|
background: INK.cardBg,
|
||||||
|
border: `1px solid ${INK.borderSoft}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: "pointer",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit",
|
||||||
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
|
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
|
||||||
};
|
};
|
||||||
const codebaseTile: React.CSSProperties = {
|
const codebaseTile: React.CSSProperties = {
|
||||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden",
|
background: INK.cardBg,
|
||||||
|
border: `1px solid ${INK.borderSoft}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: "hidden",
|
||||||
};
|
};
|
||||||
const tileHeader: React.CSSProperties = {
|
const tileHeader: React.CSSProperties = {
|
||||||
display: "flex", alignItems: "center", gap: 8, width: "100%",
|
display: "flex",
|
||||||
padding: "12px 14px", background: "transparent", border: "none",
|
alignItems: "center",
|
||||||
cursor: "pointer", font: "inherit", color: "inherit",
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 14px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit",
|
||||||
};
|
};
|
||||||
const tileLabel: React.CSSProperties = {
|
const tileLabel: React.CSSProperties = {
|
||||||
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: INK.ink,
|
||||||
|
marginBottom: 2,
|
||||||
|
};
|
||||||
|
const tileHint: React.CSSProperties = {
|
||||||
|
fontSize: "0.74rem",
|
||||||
|
color: INK.mid,
|
||||||
|
lineHeight: 1.4,
|
||||||
};
|
};
|
||||||
const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 };
|
|
||||||
const tileBody: React.CSSProperties = {
|
const tileBody: React.CSSProperties = {
|
||||||
padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`,
|
padding: "8px 10px 12px",
|
||||||
|
borderTop: `1px solid ${INK.borderSoft}`,
|
||||||
};
|
};
|
||||||
const chevronCell: React.CSSProperties = {
|
const chevronCell: React.CSSProperties = {
|
||||||
width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
width: 14,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
const panel: React.CSSProperties = {
|
const panel: React.CSSProperties = {
|
||||||
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
|
background: INK.cardBg,
|
||||||
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
|
border: `1px solid ${INK.border}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
};
|
};
|
||||||
const detailRow: React.CSSProperties = {
|
const detailRow: React.CSSProperties = {
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
display: "flex",
|
||||||
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "12px 4px",
|
||||||
|
borderBottom: `1px solid ${INK.borderSoft}`,
|
||||||
};
|
};
|
||||||
const detailLabel: React.CSSProperties = {
|
const detailLabel: React.CSSProperties = {
|
||||||
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
|
fontSize: "0.72rem",
|
||||||
textTransform: "uppercase", color: INK.muted,
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: INK.muted,
|
||||||
};
|
};
|
||||||
const detailValue: React.CSSProperties = {
|
const detailValue: React.CSSProperties = {
|
||||||
fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
|
fontSize: "0.85rem",
|
||||||
|
color: INK.ink,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
};
|
};
|
||||||
const detailLink: React.CSSProperties = {
|
const detailLink: React.CSSProperties = {
|
||||||
color: INK.ink, textDecoration: "underline",
|
color: INK.ink,
|
||||||
|
textDecoration: "underline",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
// Sub-components
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function SectionHeader({ title, count }: { title: string; count: number }) {
|
|
||||||
return (
|
|
||||||
<div style={sectionHeader}>
|
|
||||||
<span style={sectionTitle}>{title}</span>
|
|
||||||
<span style={countPill}>{count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptySection({
|
function EmptySection({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@@ -505,13 +496,13 @@ function EmptySection({
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const INK = {
|
const INK = {
|
||||||
ink: "#1a1a1a",
|
ink: "#111827",
|
||||||
mid: "#5f5e5a",
|
mid: "#4b5563",
|
||||||
muted: "#a09a90",
|
muted: "#9ca3af",
|
||||||
border: "#e8e4dc",
|
border: "#e5e7eb",
|
||||||
borderSoft: "#efebe1",
|
borderSoft: "#f3f4f6",
|
||||||
cardBg: "#fff",
|
cardBg: "#fff",
|
||||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
fontSans: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
|
||||||
} as const;
|
} as const;
|
||||||
const GREEN = "#10b981";
|
const GREEN = "#10b981";
|
||||||
const AMBER = "#f59e0b";
|
const AMBER = "#f59e0b";
|
||||||
|
|||||||
@@ -1,99 +1,150 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Save, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
Card,
|
||||||
|
SettingCard,
|
||||||
|
SectionHeader,
|
||||||
|
PrimaryButton,
|
||||||
|
TextField,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
export default function AppSettingsPage() {
|
export default function AppSettingsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
// ── Load real project metadata ──
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
|
||||||
|
if (cancelled) return;
|
||||||
|
setName(d.project?.name ?? "");
|
||||||
|
setDescription(d.project?.description ?? "");
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// ── Persist via PATCH ──
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ name, description }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
|
||||||
|
toast.success("App settings saved");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "32px 48px",
|
minHeight: "100vh",
|
||||||
fontFamily: '"Outfit", "Inter", sans-serif',
|
background: THEME.canvasGradient,
|
||||||
color: "#18181b",
|
fontFamily: THEME.font,
|
||||||
maxWidth: 900,
|
padding: "36px 48px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ maxWidth: 860, margin: "0 auto" }}>
|
||||||
<h1
|
<PageHeader
|
||||||
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
|
title="App Settings"
|
||||||
>
|
subtitle="General configuration for your application."
|
||||||
App Settings
|
/>
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
|
|
||||||
General configuration for your application.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
<SectionHeader title="General" />
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<Card>
|
||||||
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}>
|
{loading ? (
|
||||||
App Name
|
<div
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
defaultValue="CampCore"
|
|
||||||
style={{
|
|
||||||
padding: "10px 14px",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
||||||
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
defaultValue="A comprehensive operating system for camp and youth activity providers."
|
|
||||||
style={{
|
|
||||||
padding: "10px 14px",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
resize: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ borderTop: "1px solid #e4e4e7", margin: "12px 0" }}></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: "1rem",
|
display: "flex",
|
||||||
fontWeight: 600,
|
alignItems: "center",
|
||||||
margin: "0 0 4px 0",
|
gap: 8,
|
||||||
color: "#ef4444",
|
color: THEME.mid,
|
||||||
|
fontSize: "0.875rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete Application
|
<Loader2 size={15} className="animate-spin" /> Loading…
|
||||||
</h3>
|
</div>
|
||||||
<p style={{ fontSize: "0.85rem", color: "#71717a", margin: 0 }}>
|
) : error ? (
|
||||||
Permanently delete this app and all of its data.
|
<div style={{ color: THEME.danger, fontSize: "0.875rem" }}>
|
||||||
</p>
|
{error}
|
||||||
</div>
|
</div>
|
||||||
<button
|
) : (
|
||||||
style={{
|
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||||
background: "#fee2e2",
|
<TextField
|
||||||
color: "#ef4444",
|
label="App Name"
|
||||||
border: "1px solid #fca5a5",
|
value={name}
|
||||||
borderRadius: 8,
|
onChange={setName}
|
||||||
padding: "8px 16px",
|
placeholder="My app"
|
||||||
fontSize: "0.85rem",
|
/>
|
||||||
fontWeight: 600,
|
<TextField
|
||||||
cursor: "pointer",
|
label="Description"
|
||||||
}}
|
multiline
|
||||||
>
|
rows={3}
|
||||||
Delete App
|
value={description}
|
||||||
</button>
|
onChange={setDescription}
|
||||||
|
placeholder="What does this app do?"
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
icon={
|
||||||
|
saving ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={15} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save changes"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Danger zone — there is no project-delete endpoint, so we don't fake a
|
||||||
|
button that does nothing. Deletion is handled through the AI chat. */}
|
||||||
|
<div style={{ marginTop: 32 }}>
|
||||||
|
<SectionHeader title="Danger zone" />
|
||||||
|
<SettingCard
|
||||||
|
danger
|
||||||
|
title="Delete Application"
|
||||||
|
description="Permanently deletes this app and all of its data. To delete it, ask the AI in chat — this is intentionally not a one-click action."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,246 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Search, ChevronDown, ListFilter } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
|
import {
|
||||||
|
THEME,
|
||||||
|
PageHeader,
|
||||||
|
EmptyState,
|
||||||
|
} from "@/components/project/dashboard-ui";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "32px 48px",
|
minHeight: "100vh",
|
||||||
fontFamily: '"Outfit", "Inter", sans-serif',
|
background: THEME.canvasGradient,
|
||||||
color: "#18181b",
|
fontFamily: THEME.font,
|
||||||
maxWidth: 900,
|
padding: "36px 48px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ maxWidth: 860, margin: "0 auto" }}>
|
||||||
style={{
|
<PageHeader
|
||||||
display: "flex",
|
title="Users"
|
||||||
justifyContent: "space-between",
|
subtitle="Manage the end-users of your application."
|
||||||
alignItems: "flex-start",
|
/>
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
|
|
||||||
>
|
|
||||||
Users
|
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
|
|
||||||
Manage the app's users and their roles
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 12 }}>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 8,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListFilter size={16} color="#18181b" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: "#18181b",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "0 16px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
height: 36,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Invite User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
|
<EmptyState
|
||||||
<button
|
icon={<Users size={22} />}
|
||||||
style={{
|
title="User management coming soon"
|
||||||
background: "#fff",
|
hint="A built-in user directory is in development. In the meantime, you can view your users directly in your database via the Data tab, or in your connected Auth provider's dashboard (e.g. Clerk, NextAuth)."
|
||||||
border: "1px solid #e4e4e7",
|
/>
|
||||||
borderRadius: 8,
|
|
||||||
padding: "8px 48px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Users
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: "#f4f4f5",
|
|
||||||
border: "none",
|
|
||||||
color: "#71717a",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "8px 48px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pending requests
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px",
|
|
||||||
borderBottom: "1px solid #e4e4e7",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
|
|
||||||
Users
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: "flex", gap: 12 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: "6px 12px",
|
|
||||||
width: 240,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Search size={14} color="#a1a1aa" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by Email or Name"
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
background: "transparent",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #e4e4e7",
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
all roles <ChevronDown size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
borderCollapse: "collapse",
|
|
||||||
textAlign: "left",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
style={{
|
|
||||||
background: "#fafafa",
|
|
||||||
borderBottom: "1px solid #e4e4e7",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "#71717a",
|
|
||||||
width: "40%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "#71717a",
|
|
||||||
width: "20%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Role
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "#71717a",
|
|
||||||
width: "40%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
style={{ padding: "16px", borderBottom: "1px solid #e4e4e7" }}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 500, color: "#18181b" }}>
|
|
||||||
Mark Henderson
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{ color: "#71717a", fontSize: "0.8rem", marginTop: 2 }}
|
|
||||||
>
|
|
||||||
Owner
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "16px",
|
|
||||||
borderBottom: "1px solid #e4e4e7",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
admin
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "16px",
|
|
||||||
borderBottom: "1px solid #e4e4e7",
|
|
||||||
color: "#18181b",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
markhenderson1977@gmail.com
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Database,
|
Database,
|
||||||
BarChart2,
|
|
||||||
Globe,
|
Globe,
|
||||||
Plug,
|
|
||||||
ShieldCheck,
|
|
||||||
Code2,
|
Code2,
|
||||||
Terminal,
|
Terminal,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -24,6 +21,15 @@ import {
|
|||||||
|
|
||||||
import { useAnatomy } from "@/components/project/use-anatomy";
|
import { useAnatomy } from "@/components/project/use-anatomy";
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
segment: string;
|
||||||
|
label: string;
|
||||||
|
Icon: React.ElementType;
|
||||||
|
badge?: string;
|
||||||
|
hasChildren?: boolean;
|
||||||
|
children?: { segment: string; label: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
export function DashboardSidebar({
|
export function DashboardSidebar({
|
||||||
workspace,
|
workspace,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -61,7 +67,7 @@ export function DashboardSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems: MenuItem[] = [
|
||||||
{ segment: "overview", label: "Overview", Icon: LayoutGrid },
|
{ segment: "overview", label: "Overview", Icon: LayoutGrid },
|
||||||
{ segment: "plan", label: "Plan & Specs", Icon: ClipboardList },
|
{ segment: "plan", label: "Plan & Specs", Icon: ClipboardList },
|
||||||
{ segment: "code", label: "Code", Icon: Code2 },
|
{ segment: "code", label: "Code", Icon: Code2 },
|
||||||
@@ -78,27 +84,8 @@ export function DashboardSidebar({
|
|||||||
{ segment: "storage", label: "Storage", Icon: HardDrive },
|
{ segment: "storage", label: "Storage", Icon: HardDrive },
|
||||||
{ segment: "services", label: "Services", Icon: Blocks },
|
{ segment: "services", label: "Services", Icon: Blocks },
|
||||||
{ segment: "users", label: "Auth / Users", Icon: Users },
|
{ segment: "users", label: "Auth / Users", Icon: Users },
|
||||||
{ segment: "integrations", label: "Integrations", Icon: Plug },
|
|
||||||
{ segment: "security", label: "Security", Icon: ShieldCheck },
|
|
||||||
{ segment: "logs", label: "Logs", Icon: Terminal },
|
{ segment: "logs", label: "Logs", Icon: Terminal },
|
||||||
{ segment: "domains", label: "Domains", Icon: Globe },
|
{ segment: "domains", label: "Domains", Icon: Globe },
|
||||||
{
|
|
||||||
segment: "analytics",
|
|
||||||
label: "Analytics",
|
|
||||||
Icon: BarChart2,
|
|
||||||
badge: "Soon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
segment: "marketing",
|
|
||||||
label: "Marketing",
|
|
||||||
Icon: BarChart2,
|
|
||||||
badge: "New",
|
|
||||||
hasChildren: true,
|
|
||||||
children: [
|
|
||||||
{ segment: "marketing/seo", label: "SEO & GEO" },
|
|
||||||
{ segment: "marketing/social", label: "Social content" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
segment: "settings",
|
segment: "settings",
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
@@ -125,7 +112,7 @@ export function DashboardSidebar({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 250,
|
width: 250,
|
||||||
borderRight: "1px solid #e4e4e7",
|
borderRight: "1px solid #e5e7eb",
|
||||||
background: "#ffffff",
|
background: "#ffffff",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -194,10 +181,10 @@ export function DashboardSidebar({
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background:
|
background:
|
||||||
isMainActive && !item.hasChildren
|
isMainActive && !item.hasChildren
|
||||||
? "#eff6ff"
|
? "#f3f4f6"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
color:
|
color:
|
||||||
isMainActive && !item.hasChildren ? "#1d4ed8" : "#52525b",
|
isMainActive && !item.hasChildren ? "#111827" : "#4b5563",
|
||||||
transition: "all 0.1s ease",
|
transition: "all 0.1s ease",
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -248,8 +235,8 @@ export function DashboardSidebar({
|
|||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
background: "#eef2ff",
|
background: "#f3f4f6",
|
||||||
color: "#4f46e5",
|
color: "#4b5563",
|
||||||
fontSize: "0.65rem",
|
fontSize: "0.65rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
padding: "2px 6px",
|
padding: "2px 6px",
|
||||||
@@ -331,7 +318,8 @@ export function DashboardSidebar({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
background: "#fff",
|
background:
|
||||||
|
"radial-gradient(120% 80% at 50% 0%, #ffffff 0%, #f9fafb 52%, #f3f4f6 100%)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user