fix(dashboard): add missing dashboard-ui component kit
This commit is contained in:
690
vibn-frontend/components/project/dashboard-ui.tsx
Normal file
690
vibn-frontend/components/project/dashboard-ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user