design(dashboard): remove unused routes, rename existing routes to match Base44 menu structure
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
Loader2, AlertCircle, ChevronDown, ChevronRight,
|
||||
Box, Container, CircleDot,
|
||||
} from "lucide-react";
|
||||
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
|
||||
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
|
||||
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
|
||||
|
||||
/**
|
||||
* Product tab — everything that makes up the thing being shipped.
|
||||
*
|
||||
* Left rail (top → bottom):
|
||||
* 1. Codebases — Gitea repos, each tile expands inline into a file
|
||||
* tree; clicking a file previews it on the right.
|
||||
* 2. Images — Coolify services backed by an upstream Docker image
|
||||
* (Twenty CRM, n8n…). Clicking shows image meta on the right.
|
||||
*
|
||||
* Dev containers do not appear here — they are the AI's workshop, not
|
||||
* part of the product surface.
|
||||
*/
|
||||
|
||||
type Selection =
|
||||
| { type: "file"; codebaseId: string; path: string }
|
||||
| { type: "image"; uuid: string }
|
||||
| null;
|
||||
|
||||
export default function CodeTab() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const { anatomy, loading, error } = useAnatomy(projectId);
|
||||
|
||||
const codebases = anatomy?.product.codebases ?? null;
|
||||
const images = anatomy?.product.images ?? null;
|
||||
const reason = anatomy?.codebasesReason;
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [selection, setSelection] = useState<Selection>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (codebases && codebases[0]) {
|
||||
setExpanded(prev => (prev.size === 0 ? new Set([codebases[0].id]) : prev));
|
||||
}
|
||||
}, [codebases]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection(null);
|
||||
setExpanded(new Set());
|
||||
}, [projectId]);
|
||||
|
||||
const toggleCodebase = (id: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
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 && (
|
||||
<>
|
||||
{/* Codebases */}
|
||||
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
|
||||
{codebases && codebases.length === 0 && (
|
||||
<RailEmpty>
|
||||
{reason === "no_repo"
|
||||
? <>No codebase yet. <span style={nudge}>Try: "Start building my app"</span></>
|
||||
: <>Repo is empty — push a first commit. <span style={nudge}>Try: "Scaffold a Next.js app"</span></>}
|
||||
</RailEmpty>
|
||||
)}
|
||||
{codebases?.map(cb => {
|
||||
const isOpen = expanded.has(cb.id);
|
||||
return (
|
||||
<article key={cb.id} style={codebaseTile}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCodebase(cb.id)}
|
||||
style={tileHeader}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span style={chevronCell}>
|
||||
{isOpen
|
||||
? <ChevronDown size={13} style={{ color: INK.mid }} />
|
||||
: <ChevronRight size={13} style={{ color: INK.mid }} />}
|
||||
</span>
|
||||
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left" }}>
|
||||
<div style={tileLabel}>{cb.label}</div>
|
||||
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div style={tileBody}>
|
||||
<GiteaFileTree
|
||||
projectId={projectId}
|
||||
rootPath={cb.path}
|
||||
selectedPath={
|
||||
selection?.type === "file" && selection.codebaseId === cb.id
|
||||
? selection.path
|
||||
: undefined
|
||||
}
|
||||
onSelectFile={(p) =>
|
||||
setSelection({ type: "file", codebaseId: cb.id, path: p })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</RailGroup>
|
||||
|
||||
{/* Images */}
|
||||
<RailGroup title="Images" count={images?.length ?? 0}>
|
||||
{images && images.length === 0 && (
|
||||
<RailEmpty>
|
||||
Self-hosted tools (Twenty CRM, n8n, Plausible…) you run appear here.
|
||||
<span style={nudge}>Try: "Install Twenty CRM for my project"</span>
|
||||
</RailEmpty>
|
||||
)}
|
||||
{images?.map(img => (
|
||||
<button
|
||||
key={img.uuid}
|
||||
type="button"
|
||||
onClick={() => setSelection({ type: "image", uuid: img.uuid })}
|
||||
style={{
|
||||
...flatTile,
|
||||
borderColor: selection?.type === "image" && selection.uuid === img.uuid ? INK.ink : INK.borderSoft,
|
||||
boxShadow: selection?.type === "image" && selection.uuid === img.uuid ? `0 0 0 1px ${INK.ink}` : "none",
|
||||
background: selection?.type === "image" && selection.uuid === img.uuid ? "#fffdf8" : INK.cardBg,
|
||||
}}
|
||||
aria-pressed={selection?.type === "image" && selection.uuid === img.uuid}
|
||||
>
|
||||
<Container size={13} style={{ color: INK.mid, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0, textAlign: "left", flex: 1 }}>
|
||||
<div style={tileLabel}>{img.name}</div>
|
||||
<div style={tileHint}>
|
||||
{img.image}{img.version ? `:${img.version}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{img.status && <CircleDot size={9} style={{ color: statusColor(img.status), flexShrink: 0 }} />}
|
||||
</button>
|
||||
))}
|
||||
</RailGroup>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Right pane ── */}
|
||||
<aside style={rightCol}>
|
||||
<h3 style={heading}>{paneHeading(selection)}</h3>
|
||||
<div style={panel}>
|
||||
{selection?.type === "file" && (
|
||||
<GiteaFileViewer projectId={projectId} path={selection.path} />
|
||||
)}
|
||||
{selection?.type === "image" && anatomy && (
|
||||
<ImageDetail uuid={selection.uuid} anatomy={anatomy} />
|
||||
)}
|
||||
{!selection && (
|
||||
<Empty>Pick a codebase file or an image on the left.</Empty>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Image details (right pane)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function ImageDetail({ uuid, anatomy }: { uuid: string; anatomy: Anatomy }) {
|
||||
const img = anatomy.product.images.find(i => i.uuid === uuid);
|
||||
if (!img) return <Empty>This image is no longer in the project.</Empty>;
|
||||
const live = anatomy.hosting.live.find(l => l.uuid === uuid);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<DetailRow label="Image" value={img.image} />
|
||||
<DetailRow label="Version" value={img.version || "latest"} />
|
||||
<DetailRow label="Type" value={img.serviceType ?? "—"} />
|
||||
<DetailRow
|
||||
label="Status"
|
||||
value={img.status ?? "unknown"}
|
||||
dot={statusColor(img.status ?? "")}
|
||||
/>
|
||||
{live?.fqdn && (
|
||||
<DetailRow label="URL" value={live.fqdn} href={`https://${live.fqdn}`} />
|
||||
)}
|
||||
</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 DetailRow({
|
||||
label, value, dot, href,
|
||||
}: { label: string; value: string; dot?: string; href?: string }) {
|
||||
return (
|
||||
<div style={detailRow}>
|
||||
<span style={detailLabel}>{label}</span>
|
||||
<span style={detailValue}>
|
||||
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
|
||||
{href ? (
|
||||
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a>
|
||||
) : value}
|
||||
</span>
|
||||
</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.type === "file") return `Preview · ${shortPath(s.path)}`;
|
||||
return "Image";
|
||||
}
|
||||
function shortPath(p: string) {
|
||||
const parts = p.split("/");
|
||||
if (parts.length <= 2) return p;
|
||||
return ".../" + parts.slice(-2).join("/");
|
||||
}
|
||||
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 flatTile: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
width: "100%", padding: "12px 14px",
|
||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10,
|
||||
cursor: "pointer", font: "inherit", color: "inherit",
|
||||
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
|
||||
};
|
||||
const codebaseTile: React.CSSProperties = {
|
||||
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden",
|
||||
};
|
||||
const tileHeader: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 8, width: "100%",
|
||||
padding: "12px 14px", background: "transparent", border: "none",
|
||||
cursor: "pointer", font: "inherit", color: "inherit",
|
||||
};
|
||||
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 };
|
||||
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",
|
||||
};
|
||||
const detailRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
|
||||
};
|
||||
const detailLabel: React.CSSProperties = {
|
||||
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
};
|
||||
const detailValue: React.CSSProperties = {
|
||||
fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
|
||||
};
|
||||
const detailLink: React.CSSProperties = {
|
||||
color: INK.ink, textDecoration: "underline",
|
||||
};
|
||||
Reference in New Issue
Block a user