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:
Generated
+22
-2
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "inbuxa-admin",
|
"name": "inbuxa-admin",
|
||||||
"version": "1.0.7",
|
"version": "1.0.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "inbuxa-admin",
|
"name": "inbuxa-admin",
|
||||||
"version": "1.0.7",
|
"version": "1.0.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@daypicker/react": "^10.0.1",
|
"@daypicker/react": "^10.0.1",
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.20",
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
@@ -541,6 +543,24 @@
|
|||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/space-grotesk": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
|
|||||||
+3
-1
@@ -17,6 +17,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@daypicker/react": "^10.0.1",
|
"@daypicker/react": "^10.0.1",
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.20",
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
@@ -68,4 +70,4 @@
|
|||||||
"vite": "^8.2.0",
|
"vite": "^8.2.0",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
@@ -7,13 +8,17 @@
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||||
|
|
||||||
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
|
<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" />
|
<img src={inbuxaMark} alt="" className="h-12 w-auto animate-bounce [animation-duration:1.4s]" />
|
||||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { humanize } from '@/lib/humanize';
|
||||||
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
|
import { iconForView } from '@/lib/viewIcon';
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useNavigate, useBlocker } from 'react-router-dom';
|
import { useNavigate, useBlocker } from 'react-router-dom';
|
||||||
@@ -687,7 +690,8 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
if (!resolved || !schema) return '';
|
if (!resolved || !schema) return '';
|
||||||
const { obj } = resolved;
|
const { obj } = resolved;
|
||||||
|
|
||||||
if (isSingleton) return titleForm?.title ?? obj.objectType.description;
|
// With no form of its own, the object's description is a sentence, not a title: spell out its name instead.
|
||||||
|
if (isSingleton) return titleForm?.title ?? humanize(viewName);
|
||||||
|
|
||||||
const list = resolveList(schema, viewName, obj.objectName);
|
const list = resolveList(schema, viewName, obj.objectName);
|
||||||
const name = list?.singularName ?? obj.objectType.description;
|
const name = list?.singularName ?? obj.objectType.description;
|
||||||
@@ -705,8 +709,9 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
}, [resolved, schema, isCreate, isSingleton, formData, viewName, titleForm, t]);
|
}, [resolved, schema, isCreate, isSingleton, formData, viewName, titleForm, t]);
|
||||||
|
|
||||||
const formSubtitle = useMemo(() => {
|
const formSubtitle = useMemo(() => {
|
||||||
return titleForm?.subtitle;
|
if (titleForm?.subtitle) return titleForm.subtitle;
|
||||||
}, [titleForm]);
|
return isSingleton && !titleForm ? resolved?.obj.objectType.description : undefined;
|
||||||
|
}, [titleForm, isSingleton, resolved]);
|
||||||
|
|
||||||
if (!schema || !resolved) {
|
if (!schema || !resolved) {
|
||||||
return (
|
return (
|
||||||
@@ -746,15 +751,16 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<PageHeader
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
leading={
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<Button type="button" variant="ghost" size="icon" className="rounded-xl" onClick={() => navigate(-1)}>
|
||||||
</Button>
|
<ArrowLeft className="h-5 w-5" />
|
||||||
<div className="flex-1">
|
</Button>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{formTitle}</h1>
|
}
|
||||||
{formSubtitle && <p className="text-sm text-muted-foreground mt-1">{formSubtitle}</p>}
|
icon={iconForView(schema, viewName)}
|
||||||
</div>
|
title={formTitle}
|
||||||
</div>
|
subtitle={formSubtitle}
|
||||||
|
/>
|
||||||
|
|
||||||
{generalError && (
|
{generalError && (
|
||||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||||
@@ -1005,7 +1011,7 @@ function buildSections(
|
|||||||
|
|
||||||
if (!form) {
|
if (!form) {
|
||||||
const allFields = Object.entries(fields!.properties)
|
const allFields = Object.entries(fields!.properties)
|
||||||
.map(([name, field]) => buildRenderableField({ name, label: name }, field, isCreate, edition))
|
.map(([name, field]) => buildRenderableField({ name, label: humanize(name) }, field, isCreate, edition))
|
||||||
.filter((f): f is RenderableField => f !== null);
|
.filter((f): f is RenderableField => f !== null);
|
||||||
return [{ fields: allFields }];
|
return [{ fields: allFields }];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { humanize } from '@/lib/humanize';
|
||||||
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||||
@@ -1435,7 +1437,7 @@ function EmbeddedObjectField({
|
|||||||
<FieldWidget
|
<FieldWidget
|
||||||
key={name}
|
key={name}
|
||||||
field={fieldDef}
|
field={fieldDef}
|
||||||
formField={{ name, label: name }}
|
formField={{ name, label: humanize(name) }}
|
||||||
value={objValue[name]}
|
value={objValue[name]}
|
||||||
onChange={(v) => handleFieldChange(name, v)}
|
onChange={(v) => handleFieldChange(name, v)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
@@ -1500,7 +1502,7 @@ function EmbeddedObjectField({
|
|||||||
<FieldWidget
|
<FieldWidget
|
||||||
key={name}
|
key={name}
|
||||||
field={fieldDef}
|
field={fieldDef}
|
||||||
formField={{ name, label: name }}
|
formField={{ name, label: humanize(name) }}
|
||||||
value={objValue[name]}
|
value={objValue[name]}
|
||||||
onChange={(v) => handleFieldChange(name, v)}
|
onChange={(v) => handleFieldChange(name, v)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
|||||||
@@ -8,13 +8,22 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { ChevronDown, Lock } = LucideIcons;
|
const { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen, FileCode } = LucideIcons;
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
import { SourceLink } from '@/components/common/SourceLink';
|
import { SourceLink } from '@/components/common/SourceLink';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { IconTile } from '@/components/common/IconTile';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { sourceDownloadUrl } from '@/lib/sourceDownload';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
@@ -150,11 +159,10 @@ function SidebarSubItem({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
ref={isActive ? activeItemRef : undefined}
|
ref={isActive ? activeItemRef : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start gap-2 font-normal',
|
'relative h-8 w-full justify-start gap-2 rounded-lg px-3 text-[13px] font-normal text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
isActive && 'bg-accent text-accent-foreground',
|
isActive && 'bg-accent font-medium text-accent-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
depth > 0 && 'text-sm',
|
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
onUpsell();
|
onUpsell();
|
||||||
@@ -178,8 +186,8 @@ function SidebarSubItem({
|
|||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start gap-2 font-normal text-sm"
|
className="h-8 w-full justify-start gap-1.5 rounded-lg px-3 text-[13px] font-normal text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
@@ -243,7 +251,10 @@ function SidebarTopItem({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
ref={isActive ? activeItemRef : undefined}
|
ref={isActive ? activeItemRef : undefined}
|
||||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
className={cn(
|
||||||
|
'h-10 w-full justify-start gap-3 rounded-xl px-2 font-medium text-foreground/85 hover:bg-muted hover:text-foreground',
|
||||||
|
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
onUpsell();
|
onUpsell();
|
||||||
@@ -252,7 +263,7 @@ function SidebarTopItem({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
<IconTile name={icon} />
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -268,13 +279,19 @@ function SidebarTopItem({
|
|||||||
return (
|
return (
|
||||||
<AutoOpenCollapsible containsActive={containsActive}>
|
<AutoOpenCollapsible containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
<Button
|
||||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-full justify-start gap-3 rounded-xl px-2 font-medium text-foreground/85 hover:bg-muted hover:text-foreground',
|
||||||
|
containsActive && 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconTile name={icon} />
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
<ChevronDown className="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent className="ml-[1.35rem] mt-0.5 mb-1 space-y-0.5 border-l border-border pl-2.5">
|
||||||
{items.map((sub) => (
|
{items.map((sub) => (
|
||||||
<SidebarSubItem
|
<SidebarSubItem
|
||||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||||
@@ -296,6 +313,93 @@ function SidebarTopItem({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** INBUXA: every visible link under a container, flattened, for the rail's pop-out menu. */
|
||||||
|
function visibleLinks(items: LayoutSubItem[], edition: string, prefix = ''): { name: string; viewName: string }[] {
|
||||||
|
const out: { name: string; viewName: string }[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type === 'link') {
|
||||||
|
if (!checkLinkVisible(it.viewName)) continue;
|
||||||
|
const enterprise = checkIsEnterprise(it.viewName);
|
||||||
|
if (enterprise && edition === 'oss') continue;
|
||||||
|
out.push({ name: `${prefix}${it.name || 'Overview'}`, viewName: it.viewName });
|
||||||
|
} else if (subtreeHasVisibleLink(it.items, edition)) {
|
||||||
|
out.push(...visibleLinks(it.items, edition, `${prefix}${it.name} › `));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** INBUXA: one entry of the collapsed sidebar: its tile, a label on hover, a menu for a group. */
|
||||||
|
function RailItem({
|
||||||
|
item,
|
||||||
|
sectionName,
|
||||||
|
currentPath,
|
||||||
|
navigate,
|
||||||
|
edition,
|
||||||
|
}: {
|
||||||
|
item: LayoutItem;
|
||||||
|
sectionName: string;
|
||||||
|
currentPath: string;
|
||||||
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
|
edition: string;
|
||||||
|
}) {
|
||||||
|
const base = 'mx-auto flex h-11 w-11 items-center justify-center rounded-xl transition-colors hover:bg-muted';
|
||||||
|
if ('link' in item) {
|
||||||
|
const { name, icon, viewName } = item.link;
|
||||||
|
if (!checkLinkVisible(viewName)) return null;
|
||||||
|
const isActive = pathMatchesView(currentPath, sectionName, viewName);
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={name}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={cn(base, isActive && 'bg-accent')}
|
||||||
|
onClick={() => navigate(resolveViewPath(sectionName, viewName))}
|
||||||
|
>
|
||||||
|
<IconTile name={icon} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { name, icon, items } = item.container;
|
||||||
|
if (!subtreeHasVisibleLink(items, edition)) return null;
|
||||||
|
const links = visibleLinks(items, edition);
|
||||||
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" aria-label={name} className={cn(base, containsActive && 'bg-accent')}>
|
||||||
|
<IconTile name={icon} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent side="right" align="start" className="w-56">
|
||||||
|
<DropdownMenuLabel className="flex items-center gap-2">
|
||||||
|
<IconTile name={icon} size="sm" />
|
||||||
|
{name}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{links.map((l) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={l.viewName}
|
||||||
|
className={cn(pathMatchesView(currentPath, sectionName, l.viewName) && 'bg-accent text-accent-foreground')}
|
||||||
|
onClick={() => navigate(resolveViewPath(sectionName, l.viewName))}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -303,6 +407,8 @@ export function Sidebar() {
|
|||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
||||||
|
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||||
|
const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
const permissions = useAccountStore((s) => s.permissions);
|
const permissions = useAccountStore((s) => s.permissions);
|
||||||
@@ -342,6 +448,83 @@ export function Sidebar() {
|
|||||||
if (first) navigate(`/${target.name}/${first}`);
|
if (first) navigate(`/${target.name}/${first}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Folding to a rail is for wide screens; a phone keeps the slide-over.
|
||||||
|
const collapsed = sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches;
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={150}>
|
||||||
|
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-[4.5rem] flex-col border-r bg-background">
|
||||||
|
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto py-3 [scrollbar-width:none]">
|
||||||
|
{layout.items.map((item) => (
|
||||||
|
<RailItem
|
||||||
|
key={'link' in item ? item.link.viewName : item.container.name}
|
||||||
|
item={item}
|
||||||
|
sectionName={layout.name}
|
||||||
|
currentPath={location.pathname}
|
||||||
|
navigate={navigate}
|
||||||
|
edition={edition}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="flex flex-col items-center gap-1 border-t py-2">
|
||||||
|
{layouts.length > 1 &&
|
||||||
|
layouts.map((target) => {
|
||||||
|
const isActive = target.name === activeSection;
|
||||||
|
return (
|
||||||
|
<Tooltip key={target.name}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={target.name}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
onClick={() => handleSectionClick(target)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
|
isActive && 'bg-card text-primary shadow-soft',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LucideIcon name={target.icon} className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{target.name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<a
|
||||||
|
href={sourceDownloadUrl()}
|
||||||
|
download
|
||||||
|
aria-label="Source code of this version (AGPL-3.0)"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FileCode className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<SourceLink />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Expand sidebar</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -368,42 +551,46 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{layouts.length > 1 && (
|
{layouts.length > 1 && (
|
||||||
<TooltipProvider>
|
<div className="border-t px-3 pt-3 pb-2">
|
||||||
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
|
<div className="flex gap-1 rounded-xl bg-muted p-1" role="tablist">
|
||||||
{layouts.map((target) => {
|
{layouts.map((target) => {
|
||||||
const Icon = (LucideIcons as Record<string, unknown>)[
|
|
||||||
target.icon
|
|
||||||
.split('-')
|
|
||||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
|
||||||
.join('')
|
|
||||||
] as LucideIcons.LucideIcon | undefined;
|
|
||||||
const isActive = target.name === activeSection;
|
const isActive = target.name === activeSection;
|
||||||
return (
|
return (
|
||||||
<Tooltip key={target.name}>
|
<button
|
||||||
<TooltipTrigger asChild>
|
key={target.name}
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
role="tab"
|
||||||
variant="ghost"
|
aria-selected={isActive}
|
||||||
size="icon"
|
aria-current={isActive ? 'page' : undefined}
|
||||||
aria-label={target.name}
|
onClick={() => handleSectionClick(target)}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
className={cn(
|
||||||
onClick={() => handleSectionClick(target)}
|
'flex flex-1 flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground',
|
||||||
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
|
isActive && 'bg-card text-foreground shadow-soft',
|
||||||
>
|
)}
|
||||||
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
>
|
||||||
</Button>
|
<LucideIcon name={target.icon} className={cn('h-4 w-4', isActive && 'text-primary')} />
|
||||||
</TooltipTrigger>
|
<span className="truncate">{target.name}</span>
|
||||||
<TooltipContent side="top">{target.name}</TooltipContent>
|
</button>
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* INBUXA: the AGPL's offer, always on screen: the exact source of this version. */}
|
{/* INBUXA: the AGPL's offer, always on screen: the exact source of this version. */}
|
||||||
<div className="border-t px-3 py-2 text-center text-[11px] leading-tight text-muted-foreground">
|
<div className="flex items-center gap-2 border-t px-3 py-2">
|
||||||
<SourceLink className="underline-offset-2 hover:text-foreground hover:underline" />
|
<p className="min-w-0 flex-1 text-[11px] leading-tight text-muted-foreground">
|
||||||
|
<SourceLink className="underline-offset-2 hover:text-foreground hover:underline" />
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
title="Collapse sidebar"
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
className="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground md:flex"
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/common/EmptyState';
|
||||||
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
|
import { iconForView } from '@/lib/viewIcon';
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -1105,12 +1109,9 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-4">
|
<div className="relative space-y-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<PageHeader icon={iconForView(schema, viewName)} title={list.title} subtitle={list.subtitle} />
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{list.title}</h1>
|
|
||||||
{list.subtitle && <p className="text-sm text-muted-foreground mt-1">{list.subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasMassActions && selectedIds.size > 0 && (
|
{hasMassActions && selectedIds.size > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -1233,11 +1234,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-lg border bg-background shadow-sm">
|
<div className="rounded-xl border bg-card shadow-soft">
|
||||||
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]">
|
<div className="overflow-x-auto rounded-[calc(var(--radius-xl)-1px)]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted">
|
<tr className="border-b bg-muted/60 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
{hasMassActions && (
|
{hasMassActions && (
|
||||||
<th className="w-10 px-3 py-3">
|
<th className="w-10 px-3 py-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1276,9 +1277,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||||
className="px-3 py-12 text-center text-muted-foreground"
|
className="px-3"
|
||||||
>
|
>
|
||||||
{t('list.noResults', 'No results found')}
|
<EmptyState
|
||||||
|
title={t('list.emptyTitle', 'Nothing here yet')}
|
||||||
|
hint={t('list.noResults', 'No results found')}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
@@ -11,21 +12,21 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow',
|
||||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
outline: 'border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2',
|
default: 'h-9 px-4 py-2',
|
||||||
sm: 'h-8 rounded-md px-3 text-xs',
|
sm: 'h-8 rounded-lg px-3 text-xs',
|
||||||
lg: 'h-10 rounded-md px-8',
|
lg: 'h-10 rounded-xl px-8',
|
||||||
icon: 'h-9 w-9',
|
icon: 'h-9 w-9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
@@ -9,7 +10,7 @@ import * as React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
<div ref={ref} className={cn('rounded-2xl border bg-card text-card-foreground shadow-soft', className)} {...props} />
|
||||||
));
|
));
|
||||||
Card.displayName = 'Card';
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ CardHeader.displayName = 'CardHeader';
|
|||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
<div ref={ref} className={cn('font-display font-semibold leading-none', className)} {...props} />
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
CardTitle.displayName = 'CardTitle';
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
@@ -10,11 +11,11 @@ import type { TooltipPayload } from 'recharts/types/state/tooltipSlice';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export const CHART_COLORS = [
|
export const CHART_COLORS = [
|
||||||
'hsl(var(--chart-1))',
|
'var(--chart-1)',
|
||||||
'hsl(var(--chart-2))',
|
'var(--chart-2)',
|
||||||
'hsl(var(--chart-3))',
|
'var(--chart-3)',
|
||||||
'hsl(var(--chart-4))',
|
'var(--chart-4)',
|
||||||
'hsl(var(--chart-5))',
|
'var(--chart-5)',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function getChartColor(index: number): string {
|
export function getChartColor(index: number): string {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
@@ -14,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'flex h-9 w-full rounded-lg border border-input bg-background/60 px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* 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 { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
@@ -24,6 +27,7 @@ interface DashboardViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const period = useDashboardStore((s) => s.period);
|
const period = useDashboardStore((s) => s.period);
|
||||||
@@ -102,6 +106,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Greeting />
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{dashboards.length > 1 && (
|
{dashboards.length > 1 && (
|
||||||
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
|
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
|
||||||
@@ -114,15 +119,19 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</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} />
|
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{liveStatus === 'error' && liveError && (
|
{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">
|
<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" />
|
<AlertCircle className="h-4 w-4 shrink-0 text-highlight" />
|
||||||
{liveError}
|
<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>
|
</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: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { IconTile } from '@/components/common/IconTile';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { LineChart, Line } from 'recharts';
|
import { LineChart, Line } from 'recharts';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -17,24 +18,6 @@ import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers'
|
|||||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||||
import { getChartColor } from '@/components/ui/chart';
|
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 {
|
interface StatCardProps {
|
||||||
card: CardSchema;
|
card: CardSchema;
|
||||||
historySamples: Metric[];
|
historySamples: Metric[];
|
||||||
@@ -70,10 +53,10 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
|
|||||||
}, [card, historySamples, from, to]);
|
}, [card, historySamples, from, to]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="transition-shadow hover:shadow-md">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
|
||||||
{card.description && (
|
{card.description && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -89,7 +72,7 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) && (
|
{(delta || sparkline) && (
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
|||||||
+10
-2
@@ -61,7 +61,8 @@
|
|||||||
"preset7d": "Last 7 days",
|
"preset7d": "Last 7 days",
|
||||||
"preset90d": "Last 90 days",
|
"preset90d": "Last 90 days",
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"to": "To"
|
"to": "To",
|
||||||
|
"liveUnavailable": "Live numbers aren't available on this server yet. The rest of the dashboard still works."
|
||||||
},
|
},
|
||||||
"deliveryTrace": {
|
"deliveryTrace": {
|
||||||
"attemptCount_one": "{{count}} attempt",
|
"attemptCount_one": "{{count}} attempt",
|
||||||
@@ -282,7 +283,8 @@
|
|||||||
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
||||||
"showingItems": "Showing {{count}} items",
|
"showingItems": "Showing {{count}} items",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
"unknownError": "Unknown error"
|
"unknownError": "Unknown error",
|
||||||
|
"emptyTitle": "Nothing here yet"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
@@ -382,5 +384,11 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"download": "Source code of this version ({{id}}), AGPL-3.0",
|
"download": "Source code of this version ({{id}}), AGPL-3.0",
|
||||||
"menu": "Source code (AGPL-3.0)"
|
"menu": "Source code (AGPL-3.0)"
|
||||||
|
},
|
||||||
|
"greeting": {
|
||||||
|
"morning": "Good morning",
|
||||||
|
"afternoon": "Good afternoon",
|
||||||
|
"evening": "Good evening",
|
||||||
|
"subtitle": "Here's how your mail server is doing."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-46
@@ -8,77 +8,89 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
/* INBUXA, light: warm paper, the mark's navy for text, its teal to act. */
|
||||||
--foreground: #111827;
|
--background: #fbfaf7;
|
||||||
|
--foreground: #16262f;
|
||||||
|
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--card-foreground: #111827;
|
--card-foreground: #16262f;
|
||||||
|
|
||||||
--popover: #ffffff;
|
--popover: #ffffff;
|
||||||
--popover-foreground: #111827;
|
--popover-foreground: #16262f;
|
||||||
|
|
||||||
--content-background: #f6f8fa;
|
--content-background: #f4f1ea;
|
||||||
|
|
||||||
--primary: #0f766e;
|
--primary: #0d8a82;
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
--secondary: #eef1f4;
|
--secondary: #efece5;
|
||||||
--secondary-foreground: #111827;
|
--secondary-foreground: #16262f;
|
||||||
|
|
||||||
--muted: #eef1f4;
|
--muted: #efece5;
|
||||||
--muted-foreground: #5b6472;
|
--muted-foreground: #5f6b73;
|
||||||
|
|
||||||
--accent: #d9f1ee;
|
--accent: #e1f4f1;
|
||||||
--accent-foreground: #0b5750;
|
--accent-foreground: #0a5f59;
|
||||||
|
|
||||||
--destructive: #dc2626;
|
/* The cat's orange, for the few things that should feel warm. */
|
||||||
|
--highlight: #f59e3f;
|
||||||
|
--highlight-soft: #fdebd5;
|
||||||
|
|
||||||
|
--destructive: #d9383a;
|
||||||
--destructive-foreground: #ffffff;
|
--destructive-foreground: #ffffff;
|
||||||
|
|
||||||
--border: #e3e7ec;
|
--border: #e7e2d8;
|
||||||
--input: #e3e7ec;
|
--input: #ddd7cb;
|
||||||
--ring: #0f766e;
|
--ring: #0d8a82;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.75rem;
|
||||||
|
--shadow-soft: 0 1px 2px rgb(22 38 47 / 0.04), 0 4px 16px rgb(22 38 47 / 0.05);
|
||||||
|
|
||||||
--chart-1: #0f766e;
|
--chart-1: #0d8a82;
|
||||||
--chart-2: #f9a34c;
|
--chart-2: #f59e3f;
|
||||||
--chart-3: #1c4053;
|
--chart-3: #1c4053;
|
||||||
--chart-4: #0e7490;
|
--chart-4: #46cac3;
|
||||||
--chart-5: #b45309;
|
--chart-5: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: #111a2b;
|
/* INBUXA, dark: the lockup's own background, lifted surfaces, bright teal. */
|
||||||
--foreground: #e5e9f0;
|
--background: #0e1c26;
|
||||||
|
--foreground: #e7eef2;
|
||||||
|
|
||||||
--card: #111a2b;
|
--card: #132633;
|
||||||
--card-foreground: #e5e9f0;
|
--card-foreground: #e7eef2;
|
||||||
|
|
||||||
--popover: #111a2b;
|
--popover: #152a38;
|
||||||
--popover-foreground: #e5e9f0;
|
--popover-foreground: #e7eef2;
|
||||||
|
|
||||||
--content-background: #0b1220;
|
--content-background: #0b1720;
|
||||||
|
|
||||||
--primary: #2dd4bf;
|
--primary: #46cac3;
|
||||||
--primary-foreground: #052e2b;
|
--primary-foreground: #062a28;
|
||||||
|
|
||||||
--secondary: #1f2a3d;
|
--secondary: #1a3140;
|
||||||
--secondary-foreground: #e5e9f0;
|
--secondary-foreground: #e7eef2;
|
||||||
|
|
||||||
--muted: #1f2a3d;
|
--muted: #1a3140;
|
||||||
--muted-foreground: #9aa5b8;
|
--muted-foreground: #93a8b6;
|
||||||
|
|
||||||
--accent: #12343a;
|
--accent: #133d40;
|
||||||
--accent-foreground: #99f6e4;
|
--accent-foreground: #9ff0e9;
|
||||||
|
|
||||||
|
--highlight: #f9a34c;
|
||||||
|
--highlight-soft: #3a2a18;
|
||||||
|
|
||||||
--destructive: #f87171;
|
--destructive: #f87171;
|
||||||
--destructive-foreground: #0b1220;
|
--destructive-foreground: #0b1720;
|
||||||
|
|
||||||
--border: #1f2a3d;
|
--border: #1f3847;
|
||||||
--input: #1f2a3d;
|
--input: #274455;
|
||||||
--ring: #2dd4bf;
|
--ring: #46cac3;
|
||||||
|
|
||||||
--chart-1: #2dd4bf;
|
--shadow-soft: 0 1px 2px rgb(0 0 0 / 0.25), 0 6px 20px rgb(0 0 0 / 0.2);
|
||||||
|
|
||||||
|
--chart-1: #46cac3;
|
||||||
--chart-2: #f9a34c;
|
--chart-2: #f9a34c;
|
||||||
--chart-3: #9fc2d6;
|
--chart-3: #9fc2d6;
|
||||||
--chart-4: #67e8f9;
|
--chart-4: #67e8f9;
|
||||||
@@ -108,6 +120,9 @@
|
|||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
|
--color-highlight: var(--highlight);
|
||||||
|
--color-highlight-soft: var(--highlight-soft);
|
||||||
|
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
@@ -115,16 +130,20 @@
|
|||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
--color-chart-1: hsl(var(--chart-1));
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: hsl(var(--chart-2));
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: hsl(var(--chart-3));
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: hsl(var(--chart-4));
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: hsl(var(--chart-5));
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: 'Space Grotesk Variable', 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--shadow-soft: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -133,7 +152,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground antialiased;
|
||||||
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in oklab, var(--primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { humanize } from './humanize';
|
||||||
|
|
||||||
|
describe('humanize', () => {
|
||||||
|
it('spells out property names', () => {
|
||||||
|
expect(humanize('defaultCertificateId')).toBe('Default certificate ID');
|
||||||
|
expect(humanize('mailExchangers')).toBe('Mail exchangers');
|
||||||
|
expect(humanize('proxyTrustedNetworks')).toBe('Proxy trusted networks');
|
||||||
|
expect(humanize('maxConnections')).toBe('Max connections');
|
||||||
|
});
|
||||||
|
it('keeps acronyms', () => {
|
||||||
|
expect(humanize('useHttpsForSmtp')).toBe('Use HTTPS for SMTP');
|
||||||
|
expect(humanize('oauthClientId')).toBe('OAuth client ID');
|
||||||
|
expect(humanize('DNSServer')).toBe('DNS server');
|
||||||
|
});
|
||||||
|
it('names views', () => {
|
||||||
|
expect(humanize('x:SystemSettings')).toBe('System settings');
|
||||||
|
expect(humanize('x:Account/User')).toBe('User');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Words that stay in capitals, or in their own casing, when a name is spelled out. */
|
||||||
|
const SPECIAL: Record<string, string> = {
|
||||||
|
id: 'ID',
|
||||||
|
ids: 'IDs',
|
||||||
|
url: 'URL',
|
||||||
|
urls: 'URLs',
|
||||||
|
uri: 'URI',
|
||||||
|
uris: 'URIs',
|
||||||
|
tls: 'TLS',
|
||||||
|
dns: 'DNS',
|
||||||
|
mx: 'MX',
|
||||||
|
ip: 'IP',
|
||||||
|
ips: 'IPs',
|
||||||
|
api: 'API',
|
||||||
|
http: 'HTTP',
|
||||||
|
https: 'HTTPS',
|
||||||
|
smtp: 'SMTP',
|
||||||
|
imap: 'IMAP',
|
||||||
|
pop3: 'POP3',
|
||||||
|
jmap: 'JMAP',
|
||||||
|
dkim: 'DKIM',
|
||||||
|
spf: 'SPF',
|
||||||
|
dmarc: 'DMARC',
|
||||||
|
arc: 'ARC',
|
||||||
|
acme: 'ACME',
|
||||||
|
ldap: 'LDAP',
|
||||||
|
sql: 'SQL',
|
||||||
|
ttl: 'TTL',
|
||||||
|
oauth: 'OAuth',
|
||||||
|
oidc: 'OIDC',
|
||||||
|
sni: 'SNI',
|
||||||
|
mta: 'MTA',
|
||||||
|
dav: 'DAV',
|
||||||
|
cal: 'Cal',
|
||||||
|
ai: 'AI',
|
||||||
|
llm: 'LLM',
|
||||||
|
otp: 'OTP',
|
||||||
|
totp: 'TOTP',
|
||||||
|
s3: 'S3',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `defaultCertificateId` → "Default certificate ID", `x:SystemSettings` →
|
||||||
|
* "System settings". For names the server gives no label of its own: a
|
||||||
|
* person reads words, not identifiers.
|
||||||
|
*/
|
||||||
|
export function humanize(name: string): string {
|
||||||
|
const bare = name.replace(/^x:/, '').split('/').pop() ?? name;
|
||||||
|
const words = bare
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
return words
|
||||||
|
.map((w, i) => {
|
||||||
|
const special = SPECIAL[w.toLowerCase()];
|
||||||
|
if (special) return special;
|
||||||
|
const lower = w.toLowerCase();
|
||||||
|
return i === 0 ? lower[0].toUpperCase() + lower.slice(1) : lower;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Tone = 'teal' | 'orange' | 'sky' | 'violet' | 'rose' | 'amber' | 'emerald' | 'indigo' | 'slate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colors by meaning, for the icons the server's layout names:
|
||||||
|
* - teal: mail itself;
|
||||||
|
* - rose: security;
|
||||||
|
* - amber: storage and data;
|
||||||
|
* - sky: network and connectivity;
|
||||||
|
* - violet: people and identity;
|
||||||
|
* - indigo: monitoring and reports;
|
||||||
|
* - emerald: automation and tasks;
|
||||||
|
* - orange: look and feel;
|
||||||
|
* - slate: the rest.
|
||||||
|
*/
|
||||||
|
const TONES: Record<string, Tone> = {
|
||||||
|
'layout-dashboard': 'teal',
|
||||||
|
mail: 'teal',
|
||||||
|
inbox: 'teal',
|
||||||
|
send: 'teal',
|
||||||
|
'mail-minus': 'teal',
|
||||||
|
plane: 'teal',
|
||||||
|
route: 'sky',
|
||||||
|
globe: 'sky',
|
||||||
|
cable: 'sky',
|
||||||
|
zap: 'sky',
|
||||||
|
monitor: 'sky',
|
||||||
|
'shield-check': 'rose',
|
||||||
|
'shield-alert': 'rose',
|
||||||
|
lock: 'rose',
|
||||||
|
fingerprint: 'rose',
|
||||||
|
'key-round': 'rose',
|
||||||
|
'key-square': 'rose',
|
||||||
|
filter: 'rose',
|
||||||
|
database: 'amber',
|
||||||
|
archive: 'amber',
|
||||||
|
boxes: 'amber',
|
||||||
|
folder: 'amber',
|
||||||
|
search: 'amber',
|
||||||
|
'search-code': 'amber',
|
||||||
|
users: 'violet',
|
||||||
|
'circle-user': 'violet',
|
||||||
|
contact: 'violet',
|
||||||
|
calendar: 'violet',
|
||||||
|
activity: 'indigo',
|
||||||
|
'chart-line': 'indigo',
|
||||||
|
'file-text': 'indigo',
|
||||||
|
'list-checks': 'emerald',
|
||||||
|
clock: 'emerald',
|
||||||
|
brain: 'emerald',
|
||||||
|
'file-code': 'emerald',
|
||||||
|
palette: 'orange',
|
||||||
|
'app-window': 'orange',
|
||||||
|
settings: 'slate',
|
||||||
|
'sliders-horizontal': 'slate',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK: Tone[] = ['teal', 'sky', 'violet', 'amber', 'emerald', 'indigo', 'orange', 'rose'];
|
||||||
|
|
||||||
|
/** The tone for an icon name: by meaning where known, otherwise a stable pick from its name. */
|
||||||
|
export function toneFor(name: string): Tone {
|
||||||
|
const known = TONES[name];
|
||||||
|
if (known) return known;
|
||||||
|
let h = 0;
|
||||||
|
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||||
|
return FALLBACK[h % FALLBACK.length];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LayoutSubItem, Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
function subtreeHas(items: LayoutSubItem[], viewName: string): boolean {
|
||||||
|
return items.some((it) => (it.type === 'link' ? it.viewName === viewName : subtreeHas(it.items, viewName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The icon of the sidebar entry a view sits under, so its page wears the same tile. */
|
||||||
|
export function iconForView(schema: Schema | null, viewName: string): string | null {
|
||||||
|
if (!schema) return null;
|
||||||
|
for (const layout of schema.layouts ?? []) {
|
||||||
|
for (const item of layout.items) {
|
||||||
|
if ('link' in item && item.link.viewName === viewName) return item.link.icon;
|
||||||
|
if ('container' in item && subtreeHas(item.container.items, viewName)) return item.container.icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
import '@fontsource-variable/space-grotesk';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
@@ -81,6 +82,7 @@ export default function AdminPanel() {
|
|||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
|
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||||
const { canViewObject } = usePermissions();
|
const { canViewObject } = usePermissions();
|
||||||
|
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
@@ -270,7 +272,7 @@ export default function AdminPanel() {
|
|||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main
|
<main
|
||||||
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
|
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64') : ''}`}
|
||||||
>
|
>
|
||||||
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
||||||
<MainContent viewName={viewName} id={id} section={section} />
|
<MainContent viewName={viewName} id={id} section={section} />
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ type Theme = 'light' | 'dark';
|
|||||||
interface UIState {
|
interface UIState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
/** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
activeSection: string;
|
activeSection: string;
|
||||||
|
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
toggleSidebarCollapsed: () => void;
|
||||||
setActiveSection: (section: string) => void;
|
setActiveSection: (section: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ export const useUIStore = create<UIState>()(
|
|||||||
theme:
|
theme:
|
||||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||||
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
||||||
|
sidebarCollapsed: false,
|
||||||
activeSection: '',
|
activeSection: '',
|
||||||
|
|
||||||
toggleTheme: () => {
|
toggleTheme: () => {
|
||||||
@@ -57,6 +61,10 @@ export const useUIStore = create<UIState>()(
|
|||||||
set({ sidebarOpen: open });
|
set({ sidebarOpen: open });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleSidebarCollapsed: () => {
|
||||||
|
set({ sidebarCollapsed: !get().sidebarCollapsed });
|
||||||
|
},
|
||||||
|
|
||||||
setActiveSection: (section) => {
|
setActiveSection: (section) => {
|
||||||
set({ activeSection: section });
|
set({ activeSection: section });
|
||||||
},
|
},
|
||||||
@@ -65,6 +73,7 @@ export const useUIStore = create<UIState>()(
|
|||||||
name: 'inbuxa-ui',
|
name: 'inbuxa-ui',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
}),
|
}),
|
||||||
onRehydrateStorage: () => {
|
onRehydrateStorage: () => {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user