This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/components/project/section-scaffold.tsx

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",
};