A warmer, friendlier admin: first pass
- The INBUXA palette on warm surfaces, softer cards, buttons and inputs, and self-hosted Inter and Space Grotesk. - Each section's icon sits on a colored tile, colored by what the section is about. - The sidebar gets a guide line for sub-pages and a labeled Management / Settings / Account switch, and folds to a rail of tiles (remembered). - Every page has a header with its section's tile. - Fields and pages with no label of their own are spelled out in words (defaultCertificateId becomes Default certificate ID). - The dashboard greets you, and its stat cards wear colored tiles. - Unavailable live numbers are a calm note, not an error. - The cat appears in empty lists and while loading. - Chart colors work again: they were hex values wrapped in hsl().
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
/** Nothing to show yet: the cat, a line saying so, and what to do about it. */
|
||||
export function EmptyState({ title, hint, action }: { title: ReactNode; hint?: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 px-6 py-12 text-center">
|
||||
<img src={inbuxaMark} alt="" className="mb-1 h-14 w-auto opacity-90 grayscale-[15%]" />
|
||||
<p className="font-display text-base font-semibold text-foreground">{title}</p>
|
||||
{hint && <p className="max-w-sm text-sm text-muted-foreground">{hint}</p>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createElement } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toneFor, type Tone } from '@/lib/iconTones';
|
||||
|
||||
const TONE_CLASSES: Record<Tone, string> = {
|
||||
teal: 'bg-teal-500/15 text-teal-700 dark:bg-teal-400/15 dark:text-teal-300',
|
||||
orange: 'bg-orange-400/20 text-orange-700 dark:bg-orange-400/15 dark:text-orange-300',
|
||||
sky: 'bg-sky-500/15 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300',
|
||||
violet: 'bg-violet-500/15 text-violet-700 dark:bg-violet-400/15 dark:text-violet-300',
|
||||
rose: 'bg-rose-500/15 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300',
|
||||
amber: 'bg-amber-400/20 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300',
|
||||
emerald: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300',
|
||||
indigo: 'bg-indigo-500/15 text-indigo-700 dark:bg-indigo-400/15 dark:text-indigo-300',
|
||||
slate: 'bg-slate-500/15 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300',
|
||||
};
|
||||
|
||||
function iconComponent(name: string): LucideIcons.LucideIcon {
|
||||
const pascal = name
|
||||
.split('-')
|
||||
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
||||
.join('');
|
||||
return ((LucideIcons as Record<string, unknown>)[pascal] as LucideIcons.LucideIcon | undefined) ?? LucideIcons.Circle;
|
||||
}
|
||||
|
||||
/**
|
||||
* A section's icon on a small colored tile. The color comes from what the
|
||||
* section is about (see iconTones), so the same kind of thing looks the same
|
||||
* everywhere, and a glance at the color finds it.
|
||||
*/
|
||||
export function IconTile({
|
||||
name,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}) {
|
||||
const box = size === 'sm' ? 'h-6 w-6 rounded-md' : size === 'lg' ? 'h-10 w-10 rounded-xl' : 'h-7 w-7 rounded-lg';
|
||||
const glyph = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4';
|
||||
return (
|
||||
<span className={cn('inline-flex shrink-0 items-center justify-center', box, TONE_CLASSES[toneFor(name)], className)}>
|
||||
{createElement(iconComponent(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
@@ -7,13 +8,17 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
<img src={inbuxaMark} alt="" className="h-12 w-auto animate-bounce [animation-duration:1.4s]" />
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconTile } from '@/components/common/IconTile';
|
||||
|
||||
/**
|
||||
* The top of every page: the section's tile, a title that says where you are,
|
||||
* a line on what it's for, and the page's own actions on the right.
|
||||
*/
|
||||
export function PageHeader({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
leading,
|
||||
actions,
|
||||
}: {
|
||||
icon?: string | null;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 pb-1">
|
||||
<div className="flex min-w-0 items-center gap-3.5">
|
||||
{leading}
|
||||
{icon && <IconTile name={icon} size="lg" />}
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold leading-tight">{title}</h1>
|
||||
{subtitle && <p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user