Files
vibn-frontend/app/[workspace]/project/[projectId]/infrastructure/page.tsx
Mark Henderson bada63452f feat(ui): apply Justine ink & parchment design system
- Map Justine tokens to shadcn CSS variables (--vibn-* aliases)
- Switch fonts to Inter + Lora via next/font (IBM Plex Mono for code)
- Base typography: body Inter, h1–h3 Lora; marketing hero + wordmark serif
- Project shell and global chrome use semantic colors
- Replace Outfit/Newsreader references across TSX inline styles

Made-with: Cursor
2026-04-01 21:03:40 -07:00

354 lines
15 KiB
TypeScript

"use client";
import { Suspense, useState, useEffect } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
// ── Types ─────────────────────────────────────────────────────────────────────
interface InfraApp {
name: string;
domain?: string | null;
coolifyServiceUuid?: string | null;
}
interface ProjectData {
giteaRepo?: string;
giteaRepoUrl?: string;
apps?: InfraApp[];
}
// ── Tab definitions ───────────────────────────────────────────────────────────
const TABS = [
{ id: "builds", label: "Builds", icon: "⬡" },
{ id: "databases", label: "Databases", icon: "◫" },
{ id: "services", label: "Services", icon: "◎" },
{ id: "environment", label: "Environment", icon: "≡" },
{ id: "domains", label: "Domains", icon: "◬" },
{ id: "logs", label: "Logs", icon: "≈" },
] as const;
type TabId = typeof TABS[number]["id"];
// ── Shared empty state ────────────────────────────────────────────────────────
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
return (
<div style={{
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
padding: 60, textAlign: "center", gap: 16,
}}>
<div style={{
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.5rem", color: "#b5b0a6",
}}>
{icon}
</div>
<div>
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
</div>
<div style={{
marginTop: 8, padding: "8px 18px",
background: "#1a1a1a", color: "#fff",
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
opacity: 0.4, cursor: "default",
}}>
Coming soon
</div>
</div>
);
}
// ── Builds tab ────────────────────────────────────────────────────────────────
function BuildsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="⬡"
title="No deployments yet"
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Deployed Apps
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}></span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
{app.domain && (
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
)}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
</div>
</div>
))}
</div>
</div>
);
}
// ── Databases tab ─────────────────────────────────────────────────────────────
function DatabasesTab() {
return (
<ComingSoonPanel
icon="◫"
title="Databases"
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
/>
);
}
// ── Services tab ──────────────────────────────────────────────────────────────
function ServicesTab() {
return (
<ComingSoonPanel
icon="◎"
title="Services"
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
/>
);
}
// ── Environment tab ───────────────────────────────────────────────────────────
function EnvironmentTab({ project }: { project: ProjectData | null }) {
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Environment Variables & Secrets
</div>
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
overflow: "hidden", marginBottom: 20,
}}>
{/* Header row */}
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "10px 18px", background: "#faf8f5",
borderBottom: "1px solid #e8e4dc",
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.06em", textTransform: "uppercase",
}}>
<span>Key</span><span>Value</span><span />
</div>
{/* Placeholder rows */}
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
<div key={k} style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
alignItems: "center",
}}>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}></span>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
</div>
))}
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
<button style={{
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
cursor: "pointer", width: "100%",
}}>
+ Add variable
</button>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
</div>
</div>
);
}
// ── Domains tab ───────────────────────────────────────────────────────────────
function DomainsTab({ project }: { project: ProjectData | null }) {
const apps = (project?.apps ?? []).filter(a => a.domain);
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Domains & SSL
</div>
{apps.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div>
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
{app.domain}
</div>
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
</div>
</div>
))}
</div>
) : (
<div style={{
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
padding: "32px 24px", textAlign: "center", marginBottom: 20,
}}>
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
</div>
)}
<button style={{
background: "#1a1a1a", color: "#fff", border: "none",
borderRadius: 8, padding: "9px 20px",
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
opacity: 0.5,
}}>
+ Add domain
</button>
</div>
);
}
// ── Logs tab ──────────────────────────────────────────────────────────────────
function LogsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="≈"
title="No logs yet"
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 900 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Runtime Logs
</div>
<div style={{
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
lineHeight: 1.6, minHeight: 200,
}}>
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
</div>
</div>
);
}
// ── Inner page ────────────────────────────────────────────────────────────────
function InfrastructurePageInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
const [project, setProject] = useState<ProjectData | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/apps`)
.then(r => r.json())
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
.catch(() => {});
}, [projectId]);
const setTab = (id: TabId) => {
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
{/* ── Left sub-nav ── */}
<div style={{
width: 190, flexShrink: 0,
borderRight: "1px solid #e8e4dc",
background: "#faf8f5",
display: "flex", flexDirection: "column",
padding: "16px 8px",
gap: 2,
overflow: "auto",
}}>
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.1em", textTransform: "uppercase",
padding: "0 8px 10px",
}}>
Infrastructure
</div>
{TABS.map(tab => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setTab(tab.id)}
style={{
display: "flex", alignItems: "center", gap: 9,
padding: "7px 10px", borderRadius: 6,
background: active ? "#f0ece4" : "transparent",
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
color: active ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
transition: "background 0.1s",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
{tab.label}
</button>
);
})}
</div>
{/* ── Content ── */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
{activeTab === "builds" && <BuildsTab project={project} />}
{activeTab === "databases" && <DatabasesTab />}
{activeTab === "services" && <ServicesTab />}
{activeTab === "environment" && <EnvironmentTab project={project} />}
{activeTab === "domains" && <DomainsTab project={project} />}
{activeTab === "logs" && <LogsTab project={project} />}
</div>
</div>
);
}
// ── Export ────────────────────────────────────────────────────────────────────
export default function InfrastructurePage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<InfrastructurePageInner />
</Suspense>
);
}