Save frontend state (layout, sidebar, chat panel, preview refresh fix) before rollback

This commit is contained in:
2026-06-12 16:35:45 -07:00
parent c3fdc170d1
commit 76c0241bd1
22 changed files with 4552 additions and 209 deletions

View File

@@ -0,0 +1,383 @@
"use client";
import { Bot, Plus, FileText, Wrench, Plug } from "lucide-react";
export default function AgentsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 40 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Agents
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage agents and users
</p>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: 64,
}}
>
<div
style={{
width: 48,
height: 48,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
}}
>
<Bot size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 8px 0" }}
>
Create your first agent
</h2>
<p
style={{
fontSize: "0.9rem",
color: "#71717a",
textAlign: "center",
maxWidth: 480,
margin: "0 0 40px 0",
lineHeight: 1.5,
}}
>
Agents talk to your users, work with your data, and run on schedules
all guided by clear instructions you define in your own words.
</p>
<div
style={{
width: "100%",
background: "#f4f4f5",
borderRadius: 16,
padding: "24px 32px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<span
style={{ fontSize: "0.85rem", fontWeight: 500, color: "#71717a" }}
>
Suggested for your app
</span>
<button
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "#a1a1aa",
}}
>
<RefreshIcon />
</button>
</div>
<div
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}
>
{/* Blurred out cards */}
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
filter: "blur(2px)",
opacity: 0.5,
}}
>
<div
style={{
height: 12,
background: "#f4f4f5",
borderRadius: 6,
width: "60%",
marginBottom: 12,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "100%",
marginBottom: 8,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "80%",
}}
></div>
</div>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
filter: "blur(2px)",
opacity: 0.5,
}}
>
<div
style={{
height: 12,
background: "#f4f4f5",
borderRadius: 6,
width: "50%",
marginBottom: 12,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "90%",
marginBottom: 8,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "70%",
}}
></div>
</div>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
filter: "blur(2px)",
opacity: 0.5,
}}
>
<div
style={{
height: 12,
background: "#f4f4f5",
borderRadius: 6,
width: "70%",
marginBottom: 12,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "100%",
marginBottom: 8,
}}
></div>
<div
style={{
height: 8,
background: "#f4f4f5",
borderRadius: 4,
width: "60%",
}}
></div>
</div>
{/* Create from scratch */}
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 20,
height: 110,
border: "1px solid #e4e4e7",
cursor: "pointer",
transition: "border-color 0.2s",
}}
className="hover:border-blue-500"
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 8,
}}
>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
Create from scratch
</span>
<div
style={{ background: "#f4f4f5", borderRadius: 6, padding: 4 }}
>
<Plus size={14} color="#71717a" />
</div>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
margin: 0,
lineHeight: 1.4,
}}
>
Create your own agent, defining how it works, responds, and
integrates with your data.
</p>
</div>
</div>
</div>
<div style={{ width: "100%", marginTop: 48 }}>
<h3
style={{
fontSize: "0.9rem",
fontWeight: 500,
color: "#71717a",
marginBottom: 16,
}}
>
How you can use agents
</h3>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 16,
}}
>
<div style={{ padding: "16px 0" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<FileText size={16} color="#18181b" />
<span style={{ fontWeight: 600, fontSize: "0.9rem" }}>
Guidelines
</span>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
lineHeight: 1.5,
margin: 0,
}}
>
Define the agent's behavior, knowledge, and AI model.
</p>
</div>
<div style={{ padding: "16px 0" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<Wrench size={16} color="#18181b" />
<span style={{ fontWeight: 600, fontSize: "0.9rem" }}>
Tools
</span>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
lineHeight: 1.5,
margin: 0,
}}
>
Configure what tools and data the agent can access.
</p>
</div>
<div style={{ padding: "16px 0" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<Plug size={16} color="#18181b" />
<span style={{ fontWeight: 600, fontSize: "0.9rem" }}>
Connectors
</span>
</div>
<p
style={{
fontSize: "0.8rem",
color: "#71717a",
lineHeight: 1.5,
margin: 0,
}}
>
Connect the agent to Gmail, Calendar & more.
</p>
</div>
</div>
</div>
</div>
</div>
);
}
function RefreshIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path>
<path d="M16 21v-5h5"></path>
</svg>
);
}

View File

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

View File

@@ -0,0 +1,158 @@
"use client";
import { Copy, Key } from "lucide-react";
export default function ApiPage() {
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" }}
>
API & Webhooks
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Connect external services to your application.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 32,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: "0 0 16px 0" }}>
REST API Endpoint
</h2>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
flex: 1,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "10px 16px",
fontSize: "0.9rem",
color: "#71717a",
display: "flex",
alignItems: "center",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
<span style={{ color: "#18181b" }}>
https://api.steadfast-camp-core-flow.vibn.app/v1
</span>
</div>
<button
style={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<Copy size={16} color="#71717a" />
</button>
</div>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
API Keys
</h2>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Generate Key
</button>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
padding: "16px 0",
borderBottom: "1px solid #e4e4e7",
}}
>
<div
style={{
width: 40,
height: 40,
background: "#fafafa",
border: "1px solid #e4e4e7",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Key size={16} color="#18181b" />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Production Key
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Created 2 days ago
</div>
</div>
<div
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.85rem",
color: "#71717a",
background: "#fafafa",
padding: "4px 8px",
borderRadius: 6,
border: "1px solid #e4e4e7",
}}
>
pk_live_*******************
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
export default function AutomationsPage() {
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" }}
>
Automations
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Build and manage automations in your app.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
overflow: "hidden",
}}
>
<div
style={{
padding: "16px 24px",
borderBottom: "1px solid #e4e4e7",
display: "flex",
gap: 16,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.95rem", fontWeight: 600 }}>
Automations
</span>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "#f97316",
border: "1px solid #ffedd5",
background: "#fff7ed",
padding: "2px 6px",
borderRadius: 4,
}}
>
Builder+
</span>
</div>
</div>
<div
style={{
padding: "80px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<h2
style={{
fontSize: "1.1rem",
fontWeight: 600,
margin: "0 0 12px 0",
}}
>
Unlock automations
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
To run automations in your app, you need backend functions enabled.
Upgrade to enable backend functions and start using automations.
</p>
<button
style={{
background: "#f97316",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
View plans
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,372 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import {
Loader2,
AlertCircle,
ChevronDown,
ChevronRight,
Database,
CircleDot,
} from "lucide-react";
import { DatabaseTableTree } from "@/components/project/database-table-tree";
import { TableViewer } from "@/components/project/table-viewer";
import { useAnatomy } from "@/components/project/use-anatomy";
type Selection = {
kind: "table";
dbUuid: string;
schema: string;
name: string;
} | null;
export default function DataTablesPage() {
const params = useParams();
const searchParams = useSearchParams();
const projectId = params.projectId as string;
const targetDbId = searchParams.get("db");
const { anatomy, loading, error } = useAnatomy(projectId);
const databases = anatomy?.infrastructure?.databases ?? [];
// If targetDbId is in the URL, only show that database.
// Otherwise, default to the first database in the list if available.
const activeDbId =
targetDbId || (databases.length > 0 ? databases[0].uuid : null);
const activeDatabases = databases.filter((db) => db.uuid === activeDbId);
const [selection, setSelection] = useState<Selection>(null);
useEffect(() => {
setSelection(null);
}, [projectId, targetDbId]);
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)}
{error && !showLoading && (
<Inline>
<AlertCircle size={13} /> {error}
</Inline>
)}
{anatomy && (
<RailGroup title="Databases" count={activeDatabases.length}>
{activeDatabases.length === 0 && (
<RailEmpty>
No databases yet.
<span style={nudge}>
Try: &quot;Add a Postgres database to my project&quot;
</span>
</RailEmpty>
)}
{activeDatabases.map((db) => {
return (
<article key={db.uuid} style={codebaseTile}>
<div style={tileHeader}>
<span style={chevronCell}>
<ChevronDown size={13} style={{ color: INK.mid }} />
</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 style={tileBody}>
<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>
);
})}
</RailGroup>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<h3 style={heading}>{paneHeading(selection)}</h3>
<div style={panel}>
{selection?.kind === "table" && (
<TableViewer
projectId={projectId}
dbUuid={selection.dbUuid}
schema={selection.schema}
table={selection.name}
/>
)}
{!selection && (
<Empty>Select a table on the left to preview data.</Empty>
)}
</div>
</aside>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
function RailEmpty({ children }: { children: React.ReactNode }) {
return <div style={railEmpty}>{children}</div>;
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: INK.mid,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
}}
>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: INK.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
</div>
);
}
function paneHeading(s: Selection): string {
if (!s) return "Preview";
if (s.kind === "table")
return `Preview · ${s.schema === "public" ? s.name : `${s.schema}.${s.name}`}`;
return "Preview";
}
function statusColor(status: string) {
const s = (status ?? "").toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy"))
return "#c5392b";
return "#a09a90";
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const pageWrap: React.CSSProperties = {
padding: "28px 48px 48px",
fontFamily: INK.fontSans,
color: INK.ink,
};
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
gap: 28,
maxWidth: 1400,
margin: "0 auto",
alignItems: "stretch",
};
const leftCol: React.CSSProperties = {
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 18,
};
const rightCol: React.CSSProperties = {
minWidth: 0,
display: "flex",
flexDirection: "column",
};
const heading: React.CSSProperties = {
fontSize: "0.72rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
margin: "0 0 14px",
};
const railGroup: React.CSSProperties = {
display: "flex",
flexDirection: "column",
};
const railGroupHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 4px 8px",
};
const railGroupTitle: React.CSSProperties = {
fontSize: "0.68rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const railItems: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 10,
};
const railEmpty: React.CSSProperties = {
padding: "10px 12px",
fontSize: "0.74rem",
color: INK.muted,
border: `1px dashed ${INK.borderSoft}`,
borderRadius: 8,
lineHeight: 1.6,
};
const nudge: React.CSSProperties = {
display: "block",
marginTop: 6,
fontStyle: "normal",
background: "#f3eee4",
borderRadius: 4,
padding: "3px 8px",
fontSize: "0.72rem",
color: "#7a6a50",
};
const codebaseTile: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 10,
overflow: "hidden",
};
const tileHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "12px 14px",
background: "transparent",
border: "none",
font: "inherit",
color: "inherit",
};
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem",
fontWeight: 600,
color: INK.ink,
marginBottom: 2,
};
const tileHint: React.CSSProperties = {
fontSize: "0.74rem",
color: INK.mid,
lineHeight: 1.4,
textTransform: "capitalize",
};
const tileBody: React.CSSProperties = {
padding: "8px 10px 12px",
borderTop: `1px solid ${INK.borderSoft}`,
};
const chevronCell: React.CSSProperties = {
width: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
};
const panel: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 16,
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
};

View File

@@ -0,0 +1,227 @@
"use client";
import { Copy } from "lucide-react";
export default function DomainsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 32 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Domains
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Buy, connect and manage your domains.{" "}
<a href="#" style={{ color: "#18181b", textDecoration: "underline" }}>
Learn more
</a>
</p>
</div>
<div
style={{
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
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 16,
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
Email domain
</h2>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "#f97316",
border: "1px solid #ffedd5",
background: "#fff7ed",
padding: "2px 6px",
borderRadius: 4,
}}
>
Builder+
</span>
</div>
<div
style={{
fontSize: "0.95rem",
fontWeight: 500,
color: "#18181b",
marginBottom: 4,
}}
>
no-reply@notifications.vibn.app
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Sender Name: App
</div>
</div>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
color: "#71717a",
}}
>
Use your custom domain
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,98 @@
"use client";
import { Search } from "lucide-react";
export default function LogsPage() {
return (
<div
style={{
padding: "32px 48px",
fontFamily: '"Outfit", "Inter", sans-serif',
color: "#18181b",
maxWidth: 900,
}}
>
<div style={{ marginBottom: 24 }}>
<h1
style={{ fontSize: "1.5rem", fontWeight: 600, margin: "0 0 4px 0" }}
>
Logs
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
View application and server logs.
</p>
</div>
<div
style={{
background: "#18181b",
borderRadius: 12,
overflow: "hidden",
color: "#e4e4e7",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #3f3f46",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<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={{
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.8rem",
width: "100%",
color: "#fff",
}}
/>
</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>
);
}

View File

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

View File

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

View File

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

View File

@@ -181,9 +181,13 @@ export default function PreviewTab() {
const path = currentPath.startsWith("/")
? currentPath
: `/${currentPath}`;
setIframeSrc(`${base}${path}`);
// Add the refreshKey as a query param so the iframe completely remounts/refetches
// when the user hits the manual refresh button.
const urlObj = new URL(`${base}${path}`);
urlObj.searchParams.set("_refresh", refreshKey.toString());
setIframeSrc(urlObj.toString());
}
}, [primaryRunning?.url, currentPath]);
}, [primaryRunning?.url, currentPath, refreshKey]);
useEffect(() => {
if (!bridge || !iframeSrc || !iframeDomRef.current) return;

View File

@@ -0,0 +1,696 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2,
AlertCircle,
ExternalLink,
Globe,
RefreshCw,
CircleDot,
ChevronDown,
ChevronRight,
Copy,
Check,
Terminal,
Server,
} from "lucide-react";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Hosting tab — user-facing: "Is my thing live? How do I reach it?"
*
* One endpoint = one card. Each card shows:
* - Live URL (open in new tab)
* - Status dot + plain-language status
* - Redeploy button
* - Domain(s) list
* - Last build (time + status)
* - Expandable recent logs
*
* No master-detail split — with 1-3 services the overhead isn't worth it.
* Previews (dev server URLs) shown below in a secondary section.
*/
// ──────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────
type LiveItem = Anatomy["hosting"]["live"][number];
type Preview = Anatomy["hosting"]["previews"][number];
// ──────────────────────────────────────────────────
// Main component
// ──────────────────────────────────────────────────
export default function ServicesPage() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 });
const showLoading = loading && !anatomy;
return (
<div style={pageWrap}>
{showLoading && (
<div style={centeredMsg}>
<Loader2
size={16}
className="animate-spin"
style={{ color: INK.muted }}
/>
<span style={{ color: INK.muted, fontSize: "0.85rem" }}>
Loading
</span>
</div>
)}
{error && !showLoading && (
<div style={centeredMsg}>
<AlertCircle size={15} style={{ color: DANGER }} />
<span style={{ fontSize: "0.85rem", color: DANGER }}>{error}</span>
</div>
)}
{anatomy && (
<>
{/* ── Live endpoints ── */}
<section>
<SectionHeader title="Live" count={anatomy.hosting.live.length} />
{anatomy.hosting.live.length === 0 ? (
<EmptySection
icon={<Server size={20} style={{ color: INK.muted }} />}
title="Nothing deployed yet"
hint="Ask the AI to deploy your app and it will appear here."
promptSuggestion="Deploy my app to production"
/>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{anatomy.hosting.live.map((item) => (
<LiveCard key={item.uuid} item={item} projectId={projectId} />
))}
</div>
)}
</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>
);
}
// ──────────────────────────────────────────────────
// Live card
// ──────────────────────────────────────────────────
function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
const [deploying, setDeploying] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
const phase = classifyPhase(item.status);
const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
const redeploy = async () => {
if (deploying) return;
setDeploying(true);
try {
await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.deploy",
params: { uuid: item.uuid, projectId },
}),
});
} finally {
setTimeout(() => setDeploying(false), 3000);
}
};
const openLogs = async () => {
if (!logsOpen) {
setLogsOpen(true);
setLogsLoading(true);
try {
const r = await fetch(`/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apps.logs",
params: { uuid: item.uuid, lines: 60 },
}),
});
const d = await r.json();
setLogs(
typeof d.result === "string"
? d.result
: JSON.stringify(d.result ?? d.error, null, 2),
);
} catch {
setLogs("Failed to load logs.");
} finally {
setLogsLoading(false);
}
} else {
setLogsOpen(false);
}
};
const copyUrl = () => {
if (!primaryUrl) return;
navigator.clipboard.writeText(primaryUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={card}>
{/* ── Card header ── */}
<div style={cardHeader}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
flex: 1,
}}
>
<CircleDot size={11} style={{ color: statusColor, flexShrink: 0 }} />
<span style={cardTitle}>{item.name}</span>
<span style={sourcePill(item.source)}>
{item.source === "repo" ? "built" : "image"}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={redeploy}
disabled={deploying}
style={actionBtn}
title="Redeploy now"
>
{deploying ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}
{deploying ? "Deploying…" : "Redeploy"}
</button>
</div>
</div>
{/* ── Status line ── */}
<div style={statusLine}>
<span style={{ color: statusColor, fontWeight: 600 }}>
{statusLabel}
</span>
{item.lastBuild && (
<span style={{ color: INK.muted }}>
· Last build {item.lastBuild.status}{" "}
{formatRelative(item.lastBuild.finishedAt)}
</span>
)}
</div>
{/* ── Live URL ── */}
{primaryUrl ? (
<div style={urlRow}>
<Globe size={13} style={{ color: "#2e7d32", flexShrink: 0 }} />
<a href={primaryUrl} target="_blank" rel="noreferrer" style={urlLink}>
{primaryUrl}
</a>
<ExternalLink size={11} style={{ color: INK.muted, flexShrink: 0 }} />
<button onClick={copyUrl} style={iconBtn} title="Copy URL">
{copied ? (
<Check size={12} style={{ color: "#2e7d32" }} />
) : (
<Copy size={12} />
)}
</button>
</div>
) : (
<div style={urlRow}>
<Globe size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<span
style={{
color: INK.muted,
fontSize: "0.82rem",
fontStyle: "italic",
}}
>
No domain attached ask the AI to add one.
</span>
</div>
)}
{/* ── Extra domains ── */}
{item.domains.length > 1 && (
<div
style={{
paddingLeft: 23,
display: "flex",
flexDirection: "column",
gap: 4,
marginTop: 4,
}}
>
{item.domains.slice(1).map((d) => (
<a
key={d}
href={`https://${d}`}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, fontSize: "0.78rem", color: INK.mid }}
>
{d}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
))}
</div>
)}
{/* ── Logs toggle ── */}
<div
style={{
marginTop: 14,
borderTop: `1px solid ${INK.borderSoft}`,
paddingTop: 10,
}}
>
<button onClick={openLogs} style={logsToggleBtn}>
<Terminal size={12} />
{logsOpen ? "Hide logs" : "Show recent logs"}
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{logsOpen && (
<div style={logsBox}>
{logsLoading ? (
<span style={{ color: INK.muted, fontSize: "0.8rem" }}>
Loading
</span>
) : (
<pre style={logsPre}>{logs || "(no logs)"}</pre>
)}
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Preview row
// ──────────────────────────────────────────────────
function PreviewRow({ preview }: { preview: Preview }) {
const running = preview.state === "running";
return (
<div style={{ ...card, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<CircleDot
size={10}
style={{ color: running ? "#10b981" : INK.muted, flexShrink: 0 }}
/>
<span style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>
{preview.name}
</span>
<span style={{ fontSize: "0.75rem", color: INK.mid }}>
port {preview.port}
</span>
<span
style={{
fontSize: "0.75rem",
color: running ? "#10b981" : INK.muted,
fontWeight: 500,
background: running ? "#ecfdf5" : "#f4f4f5",
padding: "2px 8px",
borderRadius: 12,
marginLeft: 8,
}}
>
{preview.state}
</span>
{preview.url && running && (
<div
style={{
marginLeft: "auto",
display: "flex",
gap: 8,
alignItems: "center",
}}
>
<a
href={preview.url}
target="_blank"
rel="noreferrer"
style={{ ...urlLink, marginLeft: 0 }}
>
{preview.url.replace(/^https?:\/\//, "")}{" "}
<ExternalLink
size={10}
style={{ display: "inline", verticalAlign: "middle" }}
/>
</a>
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
type Phase = "up" | "deploying" | "down" | "unknown";
function classifyPhase(status: string | undefined): Phase {
const s = (status ?? "").toLowerCase();
if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up";
if (
/^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test(
s,
)
)
return "deploying";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
return "unknown";
}
function phaseDisplay(
phase: Phase,
item: LiveItem,
): { color: string; label: string } {
if (item.inFlightBuild)
return {
color: AMBER,
label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`,
};
switch (phase) {
case "up":
return { color: GREEN, label: "Live" };
case "deploying":
return { color: AMBER, label: "Starting…" };
case "down":
return { color: DANGER, label: "Down" };
default:
return { color: INK.muted, label: "Unknown" };
}
}
function formatRelative(iso: string | undefined) {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return "";
const min = Math.floor(ms / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// ──────────────────────────────────────────────────
// Sub-components
// ──────────────────────────────────────────────────
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div style={sectionHeader}>
<span style={sectionTitle}>{title}</span>
<span style={countPill}>{count}</span>
</div>
);
}
function EmptySection({
icon,
title,
hint,
promptSuggestion,
}: {
icon: React.ReactNode;
title: string;
hint: string;
promptSuggestion?: string;
}) {
return (
<div style={emptyBox}>
<div style={{ marginBottom: 10 }}>{icon}</div>
<div
style={{
fontWeight: 600,
fontSize: "0.9rem",
color: INK.ink,
marginBottom: 6,
}}
>
{title}
</div>
<div
style={{
fontSize: "0.82rem",
color: INK.mid,
marginBottom: promptSuggestion ? 14 : 0,
}}
>
{hint}
</div>
{promptSuggestion && (
<div style={promptChip}>
<span
style={{ fontSize: "0.7rem", color: INK.muted, marginRight: 6 }}
>
Try asking:
</span>
<span
style={{ fontStyle: "italic", fontSize: "0.8rem", color: INK.mid }}
>
&quot;{promptSuggestion}&quot;
</span>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const GREEN = "#10b981";
const AMBER = "#f59e0b";
const DANGER = "#ef4444";
// ──────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 64px",
fontFamily: INK.fontSans,
color: INK.ink,
maxWidth: 860,
};
const centeredMsg: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "24px 0",
};
const sectionHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 14,
};
const sectionTitle: React.CSSProperties = {
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const card: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: "18px 20px",
};
const cardHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 6,
};
const cardTitle: React.CSSProperties = {
fontSize: "0.95rem",
fontWeight: 700,
color: INK.ink,
};
const statusLine: React.CSSProperties = {
fontSize: "0.8rem",
color: INK.mid,
marginBottom: 12,
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
};
const urlRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
background: "#f8f5f0",
borderRadius: 6,
padding: "8px 12px",
marginBottom: 2,
};
const urlLink: React.CSSProperties = {
fontSize: "0.85rem",
color: INK.ink,
textDecoration: "none",
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 4,
};
const actionBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
border: `1px solid ${INK.border}`,
borderRadius: 6,
background: "#fff",
cursor: "pointer",
font: "inherit",
fontSize: "0.78rem",
fontWeight: 600,
color: INK.mid,
transition: "background 0.1s, border-color 0.1s",
};
const iconBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
border: "none",
background: "transparent",
cursor: "pointer",
color: INK.muted,
borderRadius: 4,
flexShrink: 0,
};
const logsToggleBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: "0.75rem",
fontWeight: 600,
color: INK.mid,
background: "none",
border: "none",
cursor: "pointer",
font: "inherit",
padding: 0,
};
const logsBox: React.CSSProperties = {
marginTop: 10,
background: "#1a1a1a",
borderRadius: 6,
padding: "12px 14px",
maxHeight: 320,
overflowY: "auto",
};
const logsPre: React.CSSProperties = {
margin: 0,
fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem",
color: "#d4d0c8",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const emptyBox: React.CSSProperties = {
border: `1px dashed ${INK.border}`,
borderRadius: 10,
padding: "36px 28px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
};
const promptChip: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
background: "#f3eee4",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
};
function sourcePill(source: "repo" | "image"): React.CSSProperties {
const isRepo = source === "repo";
return {
fontSize: "0.62rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: isRepo ? "#2e6d2e" : "#3b5a78",
background: isRepo ? "#eaf3e8" : "#e9eff5",
padding: "1px 6px",
borderRadius: 4,
flexShrink: 0,
};
}

View File

@@ -0,0 +1,101 @@
"use client";
export default function AppSettingsPage() {
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" }}
>
App Settings
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
General configuration for your application.
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: "0.9rem", fontWeight: 500 }}>
App Name
</label>
<input
type="text"
defaultValue="CampCore"
style={{
padding: "10px 14px",
border: "1px solid #e4e4e7",
borderRadius: 8,
fontSize: "0.9rem",
outline: "none",
}}
/>
</div>
<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={{
fontSize: "1rem",
fontWeight: 600,
margin: "0 0 4px 0",
color: "#ef4444",
}}
>
Delete Application
</h3>
<p style={{ fontSize: "0.85rem", color: "#71717a", margin: 0 }}>
Permanently delete this app and all of its data.
</p>
</div>
<button
style={{
background: "#fee2e2",
color: "#ef4444",
border: "1px solid #fca5a5",
borderRadius: 8,
padding: "8px 16px",
fontSize: "0.85rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Delete App
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
export default function AuthSettingsPage() {
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" }}
>
Authentication
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Configure how users sign in to your app.
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
padding: "24px",
marginBottom: 24,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Email & Password
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Allow users to sign up with an email and password.
</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={{ borderTop: "1px solid #e4e4e7", margin: "16px 0" }}></div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: 4 }}
>
Google OAuth
</div>
<div style={{ fontSize: "0.85rem", color: "#71717a" }}>
Allow users to sign in with their Google account.
</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>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { HardDrive } from "lucide-react";
export default function StoragePage() {
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" }}
>
Storage
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage your cloud storage buckets and assets.
</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,
}}
>
<HardDrive size={24} color="#18181b" />
</div>
<h2
style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 12px 0" }}
>
No buckets found
</h2>
<p
style={{
fontSize: "0.95rem",
color: "#71717a",
textAlign: "center",
maxWidth: 460,
margin: "0 0 24px 0",
lineHeight: 1.5,
}}
>
Create an S3-compatible storage bucket to start uploading user files,
avatars, and application assets.
</p>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "10px 24px",
fontSize: "0.9rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Create Bucket
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { Search, ChevronDown, ListFilter } from "lucide-react";
export default function UsersPage() {
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" }}
>
Users
</h1>
<p style={{ fontSize: "0.9rem", color: "#71717a", margin: 0 }}>
Manage the app's users and their roles
</p>
</div>
<div style={{ display: "flex", gap: 12 }}>
<button
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
cursor: "pointer",
}}
>
<ListFilter size={16} color="#18181b" />
</button>
<button
style={{
background: "#18181b",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "0 16px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
height: 36,
}}
>
Invite User
</button>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
<button
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 8,
padding: "8px 48px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
}}
>
Users
</button>
<button
style={{
background: "#f4f4f5",
border: "none",
color: "#71717a",
borderRadius: 8,
padding: "8px 48px",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Pending requests
</button>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 12,
overflow: "hidden",
}}
>
<div
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
Users
</h2>
<div style={{ display: "flex", gap: 12 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
width: 240,
}}
>
<Search size={14} color="#a1a1aa" />
<input
type="text"
placeholder="Search by Email or Name"
style={{
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.8rem",
width: "100%",
}}
/>
</div>
<button
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "#fff",
border: "1px solid #e4e4e7",
borderRadius: 6,
padding: "6px 12px",
fontSize: "0.8rem",
fontWeight: 500,
cursor: "pointer",
}}
>
all roles <ChevronDown size={14} />
</button>
</div>
</div>
<table
style={{
width: "100%",
borderCollapse: "collapse",
textAlign: "left",
fontSize: "0.85rem",
}}
>
<thead>
<tr
style={{
background: "#fafafa",
borderBottom: "1px solid #e4e4e7",
}}
>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "40%",
}}
>
Name
</th>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "20%",
}}
>
Role
</th>
<th
style={{
padding: "12px 16px",
fontWeight: 500,
color: "#71717a",
width: "40%",
}}
>
Email
</th>
</tr>
</thead>
<tbody>
<tr>
<td
style={{ padding: "16px", borderBottom: "1px solid #e4e4e7" }}
>
<div style={{ fontWeight: 500, color: "#18181b" }}>
Mark Henderson
</div>
<div
style={{ color: "#71717a", fontSize: "0.8rem", marginTop: 2 }}
>
Owner
</div>
</td>
<td
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
fontWeight: 500,
}}
>
admin
</td>
<td
style={{
padding: "16px",
borderBottom: "1px solid #e4e4e7",
color: "#18181b",
}}
>
markhenderson1977@gmail.com
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -793,81 +793,7 @@ async function loadPreviews(projectId: string): Promise<Preview[]> {
[projectId],
);
// Filter out zombies: if a server is marked 'running' but the URL returns a 50x
// Gateway error or times out, the process died. We mark it stopped so the
// UI can trigger an auto-restart.
const activePreviews: typeof rows = [];
await Promise.all(
rows.map(async (r) => {
if (r.state !== "running") {
activePreviews.push(r);
return;
}
try {
const controller = new AbortController();
// We use a short timeout because we don't want to block the anatomy
// response. A slow response doesn't mean it's dead (Next.js might
// just be compiling) — we ONLY want to catch instant 502/503s from Traefik.
const timeout = setTimeout(() => controller.abort(), 2000);
const ping = await fetch(r.preview_url, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeout);
// 502/503/504 means Traefik is up but the container isn't answering.
// 404 means Traefik doesn't even know about the route.
if (
ping.status === 502 ||
ping.status === 503 ||
ping.status === 504 ||
ping.status === 404
) {
// GRACE PERIOD: If the server was started less than 60 seconds ago,
// Traefik might return a 502/504 simply because the Node process hasn't
// finished booting and binding to the port yet. Do not kill it!
const ageMs = Date.now() - new Date(r.started_at).getTime();
if (ageMs < 60_000) {
activePreviews.push(r); // Give it the benefit of the doubt
return;
}
console.warn(
`[anatomy] Preview zombie detected for ${r.preview_url} (HTTP ${ping.status}, age ${Math.round(ageMs / 1000)}s). Marking stopped.`,
);
await query(
`UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
[r.id],
).catch(() => {});
} else {
activePreviews.push(r);
}
} catch (e: any) {
// If the fetch aborts due to our 2s timeout, the server is just slow
// (likely doing a cold Webpack compile). DO NOT mark it as a zombie!
// Only kill it if we get a hard DNS/network error that isn't a timeout.
if (
e.name === "AbortError" ||
e.type === "aborted" ||
e.message?.includes("timeout")
) {
activePreviews.push(r); // Benefit of the doubt — it's thinking
} else {
console.warn(
`[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`,
);
await query(
`UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
[r.id],
).catch(() => {});
}
}
}),
);
return sortDevPreviewsFrontendFirst(activePreviews).map((r) => ({
return sortDevPreviewsFrontendFirst(rows).map((r) => ({
id: r.id,
name: r.name,
command: r.command ?? undefined,

View File

@@ -166,63 +166,63 @@ function friendlyToolName(name: string): string {
"request.visual.qa": "Running visual QA",
// Core Platform Tools
"projects.list": "📂 Listing workspace projects",
"projects.get": "🗒️ Retrieving project spec sheets",
"workspace.describe": "💼 Fetching workspace details",
"gitea.credentials": "🔑 Resolving repository Git credentials",
"projects.list": "Listing workspace projects",
"projects.get": "Retrieving project spec sheets",
"workspace.describe": "Fetching workspace details",
"gitea.credentials": "Resolving repository Git credentials",
"shell.exec": "Running command",
ship: "Shipping",
"generate.media": "📸 Generating visual media assets",
"get.design.template": "📐 Retrieving design templates",
"apps.templates.scaffold": "🧱 Scaffolding bento-grid layouts",
"generate.media": "Generating visual media assets",
"get.design.template": "Retrieving design templates",
"apps.templates.scaffold": "Scaffolding bento-grid layouts",
// App & Database Deployment Tools
"apps.list": "🖥️ Listing deployed web services",
"apps.get": "🔍 Checking application build status",
"apps.list": "Listing deployed web services",
"apps.get": "Checking application build status",
"apps.create": "Creating app",
"apps.update": "⚙️ Updating application settings",
"apps.delete": "Removing deployed application",
"apps.update": "Updating application settings",
"apps.delete": "Removing deployed application",
"apps.deploy": "Deploying app",
"apps.deployments": "📜 Fetching recent deployment history",
"apps.envs.list": "🔒 Loading environment variables",
"apps.envs.upsert": "🔑 Injecting environment variables",
"apps.envs.delete": "🗑️ Removing environment variables",
"apps.domains.list": "🌐 Checking application domain routing",
"apps.domains.set": "🔗 Binding custom domains",
"apps.logs": "📋 Fetching application logs",
"apps.exec": "🐚 Running command inside container",
"databases.list": "🛢️ Listing database clusters",
"databases.create": "🛢️ Provisioning database service",
"databases.get": "🔌 Retrieving database connection credentials",
"databases.update": "⚙️ Updating database configuration",
"databases.delete": "🗑️ Removing database service",
"apps.deployments": "Fetching recent deployment history",
"apps.envs.list": "Loading environment variables",
"apps.envs.upsert": "Injecting environment variables",
"apps.envs.delete": "Removing environment variables",
"apps.domains.list": "Checking application domain routing",
"apps.domains.set": "Binding custom domains",
"apps.logs": "Fetching application logs",
"apps.exec": "Running command inside container",
"databases.list": "Listing database clusters",
"databases.create": "Provisioning database service",
"databases.get": "Retrieving database connection credentials",
"databases.update": "Updating database configuration",
"databases.delete": "Removing database service",
// Domain & Git Tools
"domains.search": "🔎 Searching open domain names",
"domains.list": "🌐 Listing registered domains",
"domains.get": "📄 Retrieving domain details",
"domains.register": "💳 Registering domain name",
"domains.attach": "🔌 Attaching domain reverse-proxy rules",
"gitea.repos.list": "📦 Listing Gitea repositories",
"gitea.repo.get": "🔍 Loading Gitea repository info",
"gitea.repo.create": "🏗️ Initializing Gitea repository",
"gitea.file.read": "📖 Reading file from Gitea",
"gitea.file.write": "💾 Saving file to Gitea",
"gitea.file.delete": "🗑️ Deleting file from Gitea",
"gitea.branches.list": "🌿 Checking repository branches",
"gitea.branch.create": "🌱 Creating Git branch",
"devcontainer.ensure": "🐋 Spinning up secure Alpine dev container",
"devcontainer.status": "💓 Probing dev container liveness",
"devcontainer.suspend": "💤 Suspending dev container",
"domains.search": "Searching open domain names",
"domains.list": "Listing registered domains",
"domains.get": "Retrieving domain details",
"domains.register": "Registering domain name",
"domains.attach": "Attaching domain reverse-proxy rules",
"gitea.repos.list": "Listing Gitea repositories",
"gitea.repo.get": "Loading Gitea repository info",
"gitea.repo.create": "Initializing Gitea repository",
"gitea.file.read": "Reading file from Gitea",
"gitea.file.write": "Saving file to Gitea",
"gitea.file.delete": "Deleting file from Gitea",
"gitea.branches.list": "Checking repository branches",
"gitea.branch.create": "Creating Git branch",
"devcontainer.ensure": "Spinning up secure Alpine dev container",
"devcontainer.status": "Probing dev container liveness",
"devcontainer.suspend": "Suspending dev container",
// Planning / Specs Tools
"plan.get": "📋 Loading specifications checklist",
"plan.vision.set": "🎯 Saving feature product specification",
"plan.idea.add": "💡 Adding planning ideation",
"plan.task.add": " Adding task to development roadmap",
"plan.task.edit": "✏️ Updating development roadmap task",
"plan.task.complete": "Toggling checklist milestone as completed",
"plan.document.update": "📝 Updating specs documentation",
"plan.get": "Loading specifications checklist",
"plan.vision.set": "Saving feature product specification",
"plan.idea.add": "Adding planning ideation",
"plan.task.add": "Adding task to development roadmap",
"plan.task.edit": "Updating development roadmap task",
"plan.task.complete": "Toggling checklist milestone as completed",
"plan.document.update": "Updating specs documentation",
};
return map[dotted] || dotted;
@@ -2359,25 +2359,23 @@ export function ChatPanel({
minWidth: 0,
}}
>
{workspace ? (
<Link
href={`/${workspace}/projects`}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
) : null}
<Link
href={workspace ? `/${workspace}/projects` : "/"}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
<button
type="button"
onClick={() => setShowThreads((v) => !v)}
@@ -2670,25 +2668,23 @@ export function ChatPanel({
minWidth: 0,
}}
>
{workspace ? (
<Link
href={`/${workspace}/projects`}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
) : null}
<Link
href={workspace ? `/${workspace}/projects` : "/"}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
<button
type="button"
onClick={() => setShowThreads((v) => !v)}

File diff suppressed because it is too large Load Diff

View File

@@ -53,7 +53,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"daisyui": "^5.5.1-beta.2",
"dotenv": "^17.2.3",
"dotenv": "^17.4.2",
"firebase": "^12.5.0",
"form-data": "^4.0.5",
"google-auth-library": "^10.5.0",
@@ -64,7 +64,7 @@
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"pdf-parse": "^1.1.1",
"pg": "^8.16.3",
"pg": "^8.21.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",