705 lines
17 KiB
TypeScript
705 lines
17 KiB
TypeScript
"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",
|
|
justifyContent: "space-between",
|
|
marginBottom: 14,
|
|
paddingBottom: 8,
|
|
borderBottom: `1px solid ${THEME.borderSoft}`,
|
|
}}
|
|
>
|
|
<h2
|
|
style={{
|
|
margin: 0,
|
|
fontSize: "0.9rem",
|
|
fontWeight: 600,
|
|
color: THEME.ink,
|
|
}}
|
|
>
|
|
{title}
|
|
</h2>
|
|
{typeof count === "number" && (
|
|
<span
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
fontWeight: 600,
|
|
color: THEME.mid,
|
|
padding: "2px 8px",
|
|
borderRadius: 999,
|
|
background: THEME.borderSoft,
|
|
}}
|
|
>
|
|
{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;
|
|
style?: React.CSSProperties;
|
|
};
|
|
|
|
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,
|
|
style,
|
|
}: 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,
|
|
...style,
|
|
}}
|
|
>
|
|
{icon}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function SecondaryButton({
|
|
children,
|
|
icon,
|
|
onClick,
|
|
type = "button",
|
|
disabled,
|
|
danger,
|
|
style,
|
|
}: 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,
|
|
...style,
|
|
}}
|
|
>
|
|
{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>
|
|
);
|
|
}
|