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:
@@ -1,9 +1,12 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Greeting } from './Greeting';
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
@@ -24,6 +27,7 @@ interface DashboardViewProps {
|
||||
}
|
||||
|
||||
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
@@ -102,6 +106,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Greeting />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{dashboards.length > 1 && (
|
||||
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
|
||||
@@ -114,15 +119,19 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
{dashboards.length === 1 && <h1 className="text-xl font-semibold">{dashboard.label}</h1>}
|
||||
{dashboards.length === 1 && <h2 className="text-lg font-semibold">{dashboard.label}</h2>}
|
||||
|
||||
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{liveStatus === 'error' && liveError && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{liveError}
|
||||
<div className="flex items-center gap-3 rounded-xl border border-highlight/40 bg-highlight-soft px-4 py-3 text-sm text-foreground">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-highlight" />
|
||||
<span>
|
||||
{/404/.test(liveError)
|
||||
? t('dashboard.liveUnavailable', "Live numbers aren't available on this server yet. The rest of the dashboard still works.")
|
||||
: liveError}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' {
|
||||
if (hour < 12) return 'morning';
|
||||
if (hour < 18) return 'afternoon';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
/** The dashboard's hello: whose server this is, and a nod to the time of day. */
|
||||
export function Greeting() {
|
||||
const { t } = useTranslation();
|
||||
const accounts = useAuthStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const full = (activeAccountId && accounts[activeAccountId]?.name) || '';
|
||||
const name = full.split('@')[0];
|
||||
const part = partOfDay(new Date().getHours());
|
||||
const hello =
|
||||
part === 'morning'
|
||||
? t('greeting.morning', 'Good morning')
|
||||
: part === 'afternoon'
|
||||
? t('greeting.afternoon', 'Good afternoon')
|
||||
: t('greeting.evening', 'Good evening');
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-2xl border bg-gradient-to-br from-accent/70 via-card to-card px-5 py-4 shadow-soft">
|
||||
<img src={inbuxaMark} alt="" className="h-12 w-auto drop-shadow-sm" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold">
|
||||
{hello}
|
||||
{name && `, ${name}`}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('greeting.subtitle', "Here's how your mail server is doing.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { IconTile } from '@/components/common/IconTile';
|
||||
import { useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { LineChart, Line } from 'recharts';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -17,24 +18,6 @@ import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers'
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { getChartColor } from '@/components/ui/chart';
|
||||
|
||||
const warnedIcons = new Set<string>();
|
||||
|
||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||
const formatted = name
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||
if (!IconComp) {
|
||||
if (import.meta.env.DEV && !warnedIcons.has(name)) {
|
||||
warnedIcons.add(name);
|
||||
console.warn(`Unknown icon name: "${name}"`);
|
||||
}
|
||||
return <LucideIcons.HelpCircle className={className} />;
|
||||
}
|
||||
return <IconComp className={className} />;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
card: CardSchema;
|
||||
historySamples: Metric[];
|
||||
@@ -70,10 +53,10 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
|
||||
}, [card, historySamples, from, to]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<LucideIcon name={card.icon} className="h-4 w-4 text-muted-foreground" />
|
||||
<IconTile name={card.icon} size="sm" />
|
||||
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
|
||||
{card.description && (
|
||||
<TooltipProvider>
|
||||
@@ -89,7 +72,7 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-2xl font-bold">{formattedValue}</div>
|
||||
<div className="mt-3 font-display text-3xl font-semibold tracking-tight">{formattedValue}</div>
|
||||
|
||||
{(delta || sparkline) && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user