Replaces the old two-tile project landing with a tabbed shell anchored on three sections: Product (codebases), Infrastructure (swappable services), Hosting (runtime + reachability). Bare project URL redirects to /product so the founder always lands on the most actionable surface. Product tab is the only one wired with real data so far: each codebase tile is selectable and renders a lazy-loading Gitea file tree for apps/<codebase>/ in the right column. Both columns share height + a heading slot so panels stay visually aligned even when the right side is sparse. Infrastructure and Hosting are stubs ready for Phase 2 wiring (no behavioural change vs today). The old (workspace)/infrastructure route is removed in favour of the new tab; the other 15 sidebar routes are untouched and still reachable for the migration window. Made-with: Cursor
278 lines
6.6 KiB
TypeScript
278 lines
6.6 KiB
TypeScript
/**
|
|
* Shared layout for the Product / Infrastructure / Hosting tabs.
|
|
*
|
|
* The tab bar in the page header already names the section, so the
|
|
* page itself is just two columns:
|
|
* - left: a "what lives here" grid of sub-areas
|
|
* - right: live status panels (counts, empty states, CTAs)
|
|
*/
|
|
|
|
import { ReactNode } from "react";
|
|
|
|
export interface SubArea {
|
|
label: string;
|
|
hint: string;
|
|
/** When provided, the tile renders as a button; pair with `active`. */
|
|
onClick?: () => void;
|
|
/** Visually mark this tile as the current selection. */
|
|
active?: boolean;
|
|
}
|
|
|
|
interface SectionScaffoldProps {
|
|
subAreas: SubArea[];
|
|
rightPanel: ReactNode;
|
|
/** Defaults to "What lives here". Pass e.g. "Codebases" for the Product tab. */
|
|
subAreasHeading?: string;
|
|
/** Optional heading above the right panel — keeps both columns
|
|
* vertically aligned. If omitted, an invisible spacer is rendered
|
|
* with the same height so panels still line up with tiles. */
|
|
rightHeading?: string;
|
|
}
|
|
|
|
export function SectionScaffold({
|
|
subAreas,
|
|
rightPanel,
|
|
subAreasHeading = "What lives here",
|
|
rightHeading,
|
|
}: SectionScaffoldProps) {
|
|
return (
|
|
<div style={pageWrap}>
|
|
<div style={grid}>
|
|
<section style={leftCol}>
|
|
<h3 style={subHeading}>{subAreasHeading}</h3>
|
|
<ul style={subList}>
|
|
{subAreas.map(area => {
|
|
const interactive = typeof area.onClick === "function";
|
|
const style: React.CSSProperties = {
|
|
...subItem,
|
|
cursor: interactive ? "pointer" : "default",
|
|
borderColor: area.active ? INK.ink : INK.borderSoft,
|
|
boxShadow: area.active ? "0 0 0 1px " + INK.ink : "none",
|
|
transition: "border-color 0.12s, box-shadow 0.12s, background 0.12s",
|
|
background: area.active ? "#fffdf8" : INK.cardBg,
|
|
};
|
|
const content = (
|
|
<>
|
|
<span
|
|
style={{
|
|
...subItemDot,
|
|
background: area.active ? INK.ink : INK.stone,
|
|
}}
|
|
/>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={subItemLabel}>{area.label}</div>
|
|
<div style={subItemHint}>{area.hint}</div>
|
|
</div>
|
|
</>
|
|
);
|
|
return interactive ? (
|
|
<li key={area.label} style={{ listStyle: "none" }}>
|
|
<button
|
|
type="button"
|
|
onClick={area.onClick}
|
|
style={{
|
|
...style,
|
|
width: "100%",
|
|
textAlign: "left",
|
|
font: "inherit",
|
|
color: "inherit",
|
|
}}
|
|
aria-pressed={area.active}
|
|
>
|
|
{content}
|
|
</button>
|
|
</li>
|
|
) : (
|
|
<li key={area.label} style={style}>
|
|
{content}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
|
|
<aside style={rightCol}>
|
|
<h3 style={{ ...subHeading, visibility: rightHeading ? "visible" : "hidden" }}>
|
|
{rightHeading ?? "\u00A0"}
|
|
</h3>
|
|
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
{rightPanel}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StatusPanel({
|
|
title,
|
|
children,
|
|
cta,
|
|
}: {
|
|
title?: string;
|
|
children: ReactNode;
|
|
cta?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div style={panel}>
|
|
{(title || cta) && (
|
|
<div style={panelHeader}>
|
|
{title && <span style={panelTitle}>{title}</span>}
|
|
{cta}
|
|
</div>
|
|
)}
|
|
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
message,
|
|
hint,
|
|
}: {
|
|
message: string;
|
|
hint?: string;
|
|
}) {
|
|
return (
|
|
<div style={emptyWrap}>
|
|
<div style={emptyMsg}>{message}</div>
|
|
{hint && <div style={emptyHint}>{hint}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const INK = {
|
|
ink: "#1a1a1a",
|
|
mid: "#5f5e5a",
|
|
muted: "#a09a90",
|
|
stone: "#b5b0a6",
|
|
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(220px, 280px) minmax(0, 1fr)",
|
|
gap: 28,
|
|
maxWidth: 1280,
|
|
margin: "0 auto",
|
|
alignItems: "stretch",
|
|
};
|
|
|
|
const leftCol: React.CSSProperties = {
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
};
|
|
|
|
const rightCol: React.CSSProperties = {
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
};
|
|
|
|
const subHeading: React.CSSProperties = {
|
|
fontSize: "0.72rem",
|
|
fontWeight: 600,
|
|
letterSpacing: "0.12em",
|
|
textTransform: "uppercase",
|
|
color: INK.muted,
|
|
margin: "0 0 14px",
|
|
};
|
|
|
|
const subList: React.CSSProperties = {
|
|
listStyle: "none",
|
|
padding: 0,
|
|
margin: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
};
|
|
|
|
const subItem: React.CSSProperties = {
|
|
display: "flex",
|
|
gap: 10,
|
|
alignItems: "flex-start",
|
|
padding: "12px 14px",
|
|
background: INK.cardBg,
|
|
border: `1px solid ${INK.borderSoft}`,
|
|
borderRadius: 8,
|
|
};
|
|
|
|
const subItemDot: React.CSSProperties = {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: "50%",
|
|
background: INK.stone,
|
|
marginTop: 7,
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const subItemLabel: React.CSSProperties = {
|
|
fontSize: "0.85rem",
|
|
fontWeight: 600,
|
|
color: INK.ink,
|
|
marginBottom: 2,
|
|
};
|
|
|
|
const subItemHint: React.CSSProperties = {
|
|
fontSize: "0.75rem",
|
|
color: INK.mid,
|
|
lineHeight: 1.4,
|
|
};
|
|
|
|
const panel: React.CSSProperties = {
|
|
background: INK.cardBg,
|
|
border: `1px solid ${INK.border}`,
|
|
borderRadius: 10,
|
|
padding: 18,
|
|
marginBottom: 16,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
flex: 1,
|
|
minHeight: 0,
|
|
};
|
|
|
|
const panelHeader: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginBottom: 14,
|
|
gap: 12,
|
|
};
|
|
|
|
const panelTitle: React.CSSProperties = {
|
|
fontSize: "0.78rem",
|
|
fontWeight: 600,
|
|
letterSpacing: "0.06em",
|
|
textTransform: "uppercase",
|
|
color: INK.ink,
|
|
};
|
|
|
|
const emptyWrap: React.CSSProperties = {
|
|
padding: "20px 0 4px",
|
|
textAlign: "center",
|
|
};
|
|
|
|
const emptyMsg: React.CSSProperties = {
|
|
fontSize: "0.85rem",
|
|
color: INK.mid,
|
|
marginBottom: 4,
|
|
};
|
|
|
|
const emptyHint: React.CSSProperties = {
|
|
fontSize: "0.74rem",
|
|
color: INK.muted,
|
|
fontStyle: "italic",
|
|
};
|