design(preview): match toggle button sizes to device toggles

This commit is contained in:
2026-06-12 15:00:40 -07:00
parent 6687b79bfd
commit 960232e525
8 changed files with 771 additions and 2127 deletions

View File

@@ -3,8 +3,13 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2, AlertCircle, ChevronDown, ChevronRight,
Box, Container, CircleDot,
Loader2,
AlertCircle,
ChevronDown,
ChevronRight,
Box,
Container,
CircleDot,
} from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
@@ -23,10 +28,7 @@ import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
* part of the product surface.
*/
type Selection =
| { type: "file"; codebaseId: string; path: string }
| { type: "image"; uuid: string }
| null;
type Selection = { type: "file"; codebaseId: string; path: string } | null;
export default function CodeTab() {
const params = useParams();
@@ -34,32 +36,14 @@ export default function CodeTab() {
const { anatomy, loading, error } = useAnatomy(projectId);
const codebases = anatomy?.product.codebases ?? null;
const images = anatomy?.product.images ?? null;
const reason = anatomy?.codebasesReason;
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 (
@@ -68,97 +52,78 @@ export default function CodeTab() {
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Inline><Loader2 size={13} className="animate-spin" /> Loading</Inline>
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)}
{error && !showLoading && (
<Inline><AlertCircle size={13} /> {error}</Inline>
<Inline>
<AlertCircle size={13} /> {error}
</Inline>
)}
{anatomy && (
<>
{/* Codebases */}
<RailGroup title="Codebases" count={codebases?.length ?? 0}>
{/* Code Files */}
<RailGroup title="Code files" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && (
<RailEmpty>
{reason === "no_repo"
? <>No codebase yet. <span style={nudge}>Try: &quot;Start building my app&quot;</span></>
: <>Repo is empty push a first commit. <span style={nudge}>Try: &quot;Scaffold a Next.js app&quot;</span></>}
{reason === "no_repo" ? (
<>
No codebase yet.{" "}
<span style={nudge}>
Try: &quot;Start building my app&quot;
</span>
</>
) : (
<>
Repo is empty push a first commit.{" "}
<span style={nudge}>
Try: &quot;Scaffold a Next.js app&quot;
</span>
</>
)}
</RailEmpty>
)}
{codebases?.map(cb => {
const isOpen = expanded.has(cb.id);
{codebases?.map((cb) => {
return (
<article key={cb.id} style={codebaseTile}>
<button
type="button"
onClick={() => toggleCodebase(cb.id)}
style={tileHeader}
aria-expanded={isOpen}
>
<div style={tileHeader}>
<span style={chevronCell}>
{isOpen
? <ChevronDown size={13} style={{ color: INK.mid }} />
: <ChevronRight size={13} style={{ color: INK.mid }} />}
<ChevronDown size={13} style={{ color: INK.mid }} />
</span>
<Box size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<Box
size={13}
style={{ color: INK.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
</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>
)}
</div>
<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: &quot;Install Twenty CRM for my project&quot;</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>
@@ -170,12 +135,7 @@ export default function CodeTab() {
{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>
)}
{!selection && <Empty>Pick a codebase file on the left.</Empty>}
</div>
</aside>
</div>
@@ -183,39 +143,19 @@ export default function CodeTab() {
);
}
// ──────────────────────────────────────────────────
// 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 }) {
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
@@ -232,16 +172,28 @@ function RailEmpty({ children }: { children: React.ReactNode }) {
}
function DetailRow({
label, value, dot, href,
}: { label: string; value: string; dot?: string; href?: string }) {
label,
value,
dot,
href,
}: {
label: string;
value: string;
dot?: string;
href?: string;
}) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
<span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>{value}</a>
) : value}
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>
{value}
</a>
) : (
value
)}
</span>
</div>
);
@@ -249,11 +201,19 @@ function DetailRow({
function Inline({ children }: { children: React.ReactNode }) {
return (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: INK.mid,
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
}}
>
{children}
</div>
);
@@ -261,10 +221,18 @@ function Inline({ children }: { children: React.ReactNode }) {
function Empty({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
}}>
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: INK.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
</div>
);
@@ -275,7 +243,7 @@ function Empty({ children }: { children: React.ReactNode }) {
function paneHeading(s: Selection): string {
if (!s) return "Preview";
if (s.type === "file") return `Preview · ${shortPath(s.path)}`;
return "Image";
return "Preview";
}
function shortPath(p: string) {
const parts = p.split("/");
@@ -286,7 +254,8 @@ function statusColor(status: string) {
const s = status.toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy"))
return "#c5392b";
return "#a09a90";
}
@@ -318,79 +287,156 @@ const grid: React.CSSProperties = {
alignItems: "stretch",
};
const leftCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 18,
};
const rightCol: React.CSSProperties = {
minWidth: 0, display: "flex", flexDirection: "column",
minWidth: 0,
display: "flex",
flexDirection: "column",
};
const heading: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
fontSize: "0.72rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
margin: "0 0 14px",
};
const railGroup: React.CSSProperties = {
display: "flex",
flexDirection: "column",
};
const railGroup: React.CSSProperties = { display: "flex", flexDirection: "column" };
const railGroupHeader: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 4px 8px",
};
const railGroupTitle: React.CSSProperties = {
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
textTransform: "uppercase", color: INK.muted,
fontSize: "0.68rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
fontSize: "0.7rem",
fontWeight: 600,
color: INK.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const railItems: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 10,
};
const railItems: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
const railEmpty: React.CSSProperties = {
padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
padding: "10px 12px",
fontSize: "0.74rem",
color: INK.muted,
border: `1px dashed ${INK.borderSoft}`,
borderRadius: 8,
lineHeight: 1.6,
};
const nudge: React.CSSProperties = {
display: "block", marginTop: 6, fontStyle: "normal",
background: "#f3eee4", borderRadius: 4, padding: "3px 8px",
fontSize: "0.72rem", color: "#7a6a50",
display: "block",
marginTop: 6,
fontStyle: "normal",
background: "#f3eee4",
borderRadius: 4,
padding: "3px 8px",
fontSize: "0.72rem",
color: "#7a6a50",
};
const flatTile: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 10,
width: "100%", padding: "12px 14px",
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10,
cursor: "pointer", font: "inherit", color: "inherit",
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "12px 14px",
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 10,
cursor: "pointer",
font: "inherit",
color: "inherit",
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
};
const codebaseTile: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden",
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 10,
overflow: "hidden",
};
const tileHeader: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8, width: "100%",
padding: "12px 14px", background: "transparent", border: "none",
cursor: "pointer", font: "inherit", color: "inherit",
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "12px 14px",
background: "transparent",
border: "none",
font: "inherit",
color: "inherit",
};
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
fontSize: "0.85rem",
fontWeight: 600,
color: INK.ink,
marginBottom: 2,
};
const tileHint: React.CSSProperties = {
fontSize: "0.74rem",
color: INK.mid,
lineHeight: 1.4,
};
const tileHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4 };
const tileBody: React.CSSProperties = {
padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`,
padding: "8px 10px 12px",
borderTop: `1px solid ${INK.borderSoft}`,
};
const chevronCell: React.CSSProperties = {
width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
width: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
};
const panel: React.CSSProperties = {
background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 16,
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
};
const detailRow: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 4px",
borderBottom: `1px solid ${INK.borderSoft}`,
};
const detailLabel: React.CSSProperties = {
fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
textTransform: "uppercase", color: INK.muted,
fontSize: "0.72rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: INK.muted,
};
const detailValue: React.CSSProperties = {
fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
fontSize: "0.85rem",
color: INK.ink,
display: "inline-flex",
alignItems: "center",
};
const detailLink: React.CSSProperties = {
color: INK.ink, textDecoration: "underline",
color: INK.ink,
textDecoration: "underline",
};