fix(dashboard): add missing dashboard-ui component kit

This commit is contained in:
2026-06-13 11:31:44 -07:00
parent 8f5853e684
commit f19155ed44

View File

@@ -0,0 +1,690 @@
"use client";
/**
* Shared dashboard design system.
*
* Single source of truth for the project dashboard look-and-feel. Replaces the
* `INK` palette object that was copy-pasted into every page. The aesthetic is
* the house "ink & parchment" brand (warm neutrals, NO blue/purple chrome) but
* the LAYOUT patterns — settings-as-cards, label-left/action-right rows, a
* single primary action per area, generous spacing — are borrowed from the
* Base44 dashboard reference.
*
* Everything is inline-styled (matching the existing pages) so it drops into any
* page with zero CSS wiring. Swap the brand accent in ONE place: THEME.accent.
*/
import React, { useState } from "react";
export const THEME = {
// Flowbite / Tailwind cool-grey ramp
ink: "#111827", // gray-900 (headings / primary text)
mid: "#4b5563", // gray-600 (body / secondary)
muted: "#9ca3af", // gray-400 (tertiary / meta / icons)
// Surfaces
canvas: "#f9fafb", // gray-50 page background (flat fallback)
// Subtle, color-free depth: a soft light at top-center easing down to gray-100.
// Barely-there — reads as "premium", not a colored gradient.
canvasGradient:
"radial-gradient(120% 80% at 50% 0%, #ffffff 0%, #f9fafb 52%, #f3f4f6 100%)",
cardBg: "#ffffff",
subtleBg: "#f3f4f6", // gray-100 hover / active nav pill / row stripe
// Lines
border: "#e5e7eb", // gray-200
borderSoft: "#f3f4f6", // gray-100
// Primary action — neutral graphite (gray-900) to match the grayscale chrome
// in the reference. For Flowbite's signature blue CTA instead, set
// accent/accentHover to "#1c64f2" / "#1a56db" — single-variable swap.
accent: "#111827",
accentHover: "#1f2937", // gray-800
accentText: "#ffffff",
// Destructive — Flowbite red
danger: "#e02424", // red-600
dangerBg: "#fdf2f2", // red-50
dangerBorder: "#f8b4b4", // red-300
// Shape, depth & type — Flowbite rounded-lg + soft shadow-sm, Inter type.
radius: 10,
radiusSm: 8,
shadow: "0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)",
font: '"Inter", ui-sans-serif, system-ui, -apple-system, sans-serif',
} as const;
// ── Page header ───────────────────────────────────────────────────────────
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string;
subtitle?: string;
actions?: React.ReactNode;
}) {
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 16,
marginBottom: 28,
}}
>
<div style={{ minWidth: 0 }}>
<h1
style={{
margin: 0,
fontSize: "1.6rem",
fontWeight: 700,
letterSpacing: "-0.012em",
color: THEME.ink,
}}
>
{title}
</h1>
{subtitle && (
<p
style={{
margin: "6px 0 0",
fontSize: "0.9rem",
color: THEME.mid,
lineHeight: 1.5,
maxWidth: 640,
}}
>
{subtitle}
</p>
)}
</div>
{actions && (
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>{actions}</div>
)}
</div>
);
}
// ── Card ────────────────────────────────────────────────────────────────────
export function Card({
children,
style,
padding = 24,
}: {
children: React.ReactNode;
style?: React.CSSProperties;
padding?: number;
}) {
return (
<div
style={{
background: THEME.cardBg,
border: `1px solid ${THEME.border}`,
borderRadius: THEME.radius,
boxShadow: THEME.shadow,
padding,
...style,
}}
>
{children}
</div>
);
}
// ── SettingCard — title + description left, action right (Base44 row pattern) ─
export function SettingCard({
title,
description,
action,
danger,
children,
}: {
title: string;
description?: string;
action?: React.ReactNode;
danger?: boolean;
children?: React.ReactNode;
}) {
return (
<Card
style={
danger
? { borderColor: THEME.dangerBorder, background: THEME.dangerBg }
: undefined
}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 16,
}}
>
<div style={{ minWidth: 0 }}>
<h3
style={{
margin: 0,
fontSize: "1.05rem",
fontWeight: 600,
color: danger ? THEME.danger : THEME.ink,
}}
>
{title}
</h3>
{description && (
<p
style={{
margin: "4px 0 0",
fontSize: "0.875rem",
color: THEME.mid,
lineHeight: 1.5,
}}
>
{description}
</p>
)}
</div>
{action && <div style={{ flexShrink: 0 }}>{action}</div>}
</div>
{children && <div style={{ marginTop: 18 }}>{children}</div>}
</Card>
);
}
// ── Section header (for grouping inside a page) ──────────────────────────────
export function SectionHeader({
title,
count,
}: {
title: string;
count?: number;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 14,
}}
>
<h2
style={{
margin: 0,
fontSize: "0.78rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: THEME.muted,
}}
>
{title}
</h2>
{typeof count === "number" && (
<span style={{ fontSize: "0.78rem", color: THEME.muted }}>
({count})
</span>
)}
</div>
);
}
// ── Empty state (dashed card, centered) ────────────────────────────
export function EmptyState({
icon,
title,
hint,
action,
}: {
icon?: React.ReactNode;
title: string;
hint?: string;
action?: React.ReactNode;
}) {
return (
<div
style={{
border: `1px dashed ${THEME.border}`,
borderRadius: THEME.radius,
background: "transparent",
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: 8,
padding: "40px 20px",
}}
>
{icon && <div style={{ color: THEME.muted }}>{icon}</div>}
<div style={{ fontSize: "0.95rem", fontWeight: 600, color: THEME.ink }}>
{title}
</div>
{hint && (
<div
style={{
fontSize: "0.85rem",
color: THEME.mid,
maxWidth: 420,
lineHeight: 1.5,
}}
>
{hint}
</div>
)}
{action && <div style={{ marginTop: 6 }}>{action}</div>}
</div>
);
}
// ── Buttons (icon + label; hover handled inline) ───────────────────────
type BtnProps = {
children: React.ReactNode;
icon?: React.ReactNode;
onClick?: () => void;
type?: "button" | "submit";
disabled?: boolean;
danger?: boolean;
};
const btnBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "9px 16px",
borderRadius: THEME.radiusSm,
fontSize: "0.875rem",
fontWeight: 600,
fontFamily: THEME.font,
cursor: "pointer",
transition: "background 0.15s ease, border-color 0.15s ease",
whiteSpace: "nowrap",
};
export function PrimaryButton({
children,
icon,
onClick,
type = "button",
disabled,
}: BtnProps) {
const [hover, setHover] = useState(false);
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
...btnBase,
background: hover ? THEME.accentHover : THEME.accent,
color: THEME.accentText,
border: `1px solid ${hover ? THEME.accentHover : THEME.accent}`,
opacity: disabled ? 0.5 : 1,
}}
>
{icon}
{children}
</button>
);
}
export function SecondaryButton({
children,
icon,
onClick,
type = "button",
disabled,
danger,
}: BtnProps) {
const [hover, setHover] = useState(false);
const color = danger ? THEME.danger : THEME.ink;
const borderColor = danger ? THEME.dangerBorder : THEME.border;
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
...btnBase,
background: hover
? danger
? THEME.dangerBg
: THEME.subtleBg
: THEME.cardBg,
color,
border: `1px solid ${borderColor}`,
opacity: disabled ? 0.5 : 1,
}}
>
{icon}
{children}
</button>
);
}
// ── List & Key-Value Rows ──────────────────────────────────────────────────
export function ListCard({
children,
style,
}: {
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div
style={{
background: THEME.cardBg,
border: `1px solid ${THEME.border}`,
borderRadius: THEME.radiusSm,
boxShadow: THEME.shadow,
overflow: "hidden",
display: "flex",
flexDirection: "column",
...style,
}}
>
{children}
</div>
);
}
export function ListRow({
label,
value,
action,
icon,
}: {
label: string | React.ReactNode;
value?: string | React.ReactNode;
action?: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: `1px solid ${THEME.borderSoft}`,
background: THEME.cardBg,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{icon && <div style={{ color: THEME.muted }}>{icon}</div>}
<span
style={{ fontSize: "0.875rem", fontWeight: 500, color: THEME.ink }}
>
{label}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
{value && (
<span style={{ fontSize: "0.875rem", color: THEME.mid }}>
{value}
</span>
)}
{action && <div>{action}</div>}
</div>
</div>
);
}
export function KvRow({
label,
value,
mono,
dot,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
dot?: string;
}) {
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "10px 4px",
borderBottom: `1px solid ${THEME.borderSoft}`,
}}
>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: THEME.muted,
}}
>
{label}
</span>
<span
style={{
display: "inline-flex",
alignItems: "center",
fontSize: "0.875rem",
color: THEME.ink,
fontFamily: mono
? "ui-monospace, SFMono-Regular, Menlo, monospace"
: "inherit",
}}
>
{dot && (
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: dot,
marginRight: 6,
flexShrink: 0,
}}
/>
)}
{value}
</span>
</div>
);
}
// ── Badges & Status ────────────────────────────────────────────────────────
export function Badge({
children,
color = "default",
}: {
children: React.ReactNode;
color?: "default" | "success" | "warning" | "danger" | "accent";
}) {
const bg =
color === "success"
? "#def7ec"
: color === "warning"
? "#fdf6b2"
: color === "danger"
? THEME.dangerBg
: color === "accent"
? THEME.ink
: THEME.subtleBg;
const text =
color === "success"
? "#03543f"
: color === "warning"
? "#723b13"
: color === "danger"
? THEME.danger
: color === "accent"
? THEME.cardBg
: THEME.mid;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "2px 8px",
borderRadius: 999,
fontSize: "0.7rem",
fontWeight: 600,
background: bg,
color: text,
}}
>
{children}
</span>
);
}
export function StatusDot({
status,
}: {
status: "success" | "warning" | "danger" | "neutral";
}) {
const color =
status === "success"
? "#31c48d"
: status === "warning"
? "#faca15"
: status === "danger"
? THEME.danger
: THEME.muted;
return (
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: color,
flexShrink: 0,
}}
/>
);
}
// ── Stat Card ──────────────────────────────────────────────────────────────
export function StatCard({
label,
value,
onClick,
}: {
label: string;
value: string | number;
onClick?: () => void;
}) {
return (
<button
type={onClick ? "button" : undefined}
onClick={onClick}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 6,
padding: "16px 20px",
background: THEME.cardBg,
border: `1px solid ${THEME.border}`,
borderRadius: THEME.radiusSm,
boxShadow: THEME.shadow,
textAlign: "left",
cursor: onClick ? "pointer" : "default",
width: "100%",
transition: "border-color 0.15s ease",
}}
>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: THEME.muted,
}}
>
{label}
</span>
<span
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: THEME.ink,
lineHeight: 1,
}}
>
{value}
</span>
</button>
);
}
// ── Text field (label + input/textarea) ──────────────────────────────────────
export function TextField({
label,
defaultValue,
value,
onChange,
placeholder,
multiline,
rows = 3,
}: {
label?: string;
defaultValue?: string;
value?: string;
onChange?: (v: string) => void;
placeholder?: string;
multiline?: boolean;
rows?: number;
}) {
const [focus, setFocus] = useState(false);
const fieldStyle: React.CSSProperties = {
padding: "10px 14px",
border: `1px solid ${focus ? THEME.muted : THEME.border}`,
borderRadius: THEME.radiusSm,
fontSize: "0.9rem",
fontFamily: THEME.font,
color: THEME.ink,
background: THEME.cardBg,
outline: "none",
width: "100%",
boxSizing: "border-box",
resize: multiline ? "vertical" : undefined,
transition: "border-color 0.15s ease",
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
{label && (
<label
style={{ fontSize: "0.85rem", fontWeight: 600, color: THEME.ink }}
>
{label}
</label>
)}
{multiline ? (
<textarea
rows={rows}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
onChange={(e) => onChange?.(e.target.value)}
style={fieldStyle}
/>
) : (
<input
type="text"
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
onChange={(e) => onChange?.(e.target.value)}
style={fieldStyle}
/>
)}
</div>
);
}