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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
* 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 { flushSync } from 'react-dom';
|
||||
import { useNavigate, useBlocker } from 'react-router-dom';
|
||||
@@ -687,7 +690,8 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
if (!resolved || !schema) return '';
|
||||
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 name = list?.singularName ?? obj.objectType.description;
|
||||
@@ -705,8 +709,9 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
}, [resolved, schema, isCreate, isSingleton, formData, viewName, titleForm, t]);
|
||||
|
||||
const formSubtitle = useMemo(() => {
|
||||
return titleForm?.subtitle;
|
||||
}, [titleForm]);
|
||||
if (titleForm?.subtitle) return titleForm.subtitle;
|
||||
return isSingleton && !titleForm ? resolved?.obj.objectType.description : undefined;
|
||||
}, [titleForm, isSingleton, resolved]);
|
||||
|
||||
if (!schema || !resolved) {
|
||||
return (
|
||||
@@ -746,15 +751,16 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{formTitle}</h1>
|
||||
{formSubtitle && <p className="text-sm text-muted-foreground mt-1">{formSubtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
leading={
|
||||
<Button type="button" variant="ghost" size="icon" className="rounded-xl" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
}
|
||||
icon={iconForView(schema, viewName)}
|
||||
title={formTitle}
|
||||
subtitle={formSubtitle}
|
||||
/>
|
||||
|
||||
{generalError && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||
@@ -1005,7 +1011,7 @@ function buildSections(
|
||||
|
||||
if (!form) {
|
||||
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);
|
||||
return [{ fields: allFields }];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { humanize } from '@/lib/humanize';
|
||||
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||
@@ -1435,7 +1437,7 @@ function EmbeddedObjectField({
|
||||
<FieldWidget
|
||||
key={name}
|
||||
field={fieldDef}
|
||||
formField={{ name, label: name }}
|
||||
formField={{ name, label: humanize(name) }}
|
||||
value={objValue[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
readOnly={readOnly}
|
||||
@@ -1500,7 +1502,7 @@ function EmbeddedObjectField({
|
||||
<FieldWidget
|
||||
key={name}
|
||||
field={fieldDef}
|
||||
formField={{ name, label: name }}
|
||||
formField={{ name, label: humanize(name) }}
|
||||
value={objValue[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
readOnly={readOnly}
|
||||
|
||||
@@ -8,13 +8,22 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { ChevronDown, Lock } = LucideIcons;
|
||||
const { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen, FileCode } = LucideIcons;
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
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 {
|
||||
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 { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
@@ -150,11 +159,10 @@ function SidebarSubItem({
|
||||
variant="ghost"
|
||||
ref={isActive ? activeItemRef : undefined}
|
||||
className={cn(
|
||||
'w-full justify-start gap-2 font-normal',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
depth > 0 && 'text-sm',
|
||||
'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 font-medium text-accent-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
@@ -178,8 +186,8 @@ function SidebarSubItem({
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 font-normal text-sm"
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||
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={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]" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
@@ -243,7 +251,10 @@ function SidebarTopItem({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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={() => {
|
||||
if (isLocked) {
|
||||
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>
|
||||
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||
</Button>
|
||||
@@ -268,13 +279,19 @@ function SidebarTopItem({
|
||||
return (
|
||||
<AutoOpenCollapsible containsActive={containsActive}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||
<Button
|
||||
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>
|
||||
<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>
|
||||
</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) => (
|
||||
<SidebarSubItem
|
||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||
@@ -296,6 +313,93 @@ function SidebarTopItem({
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -303,6 +407,8 @@ export function Sidebar() {
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
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 edition = useAccountStore((s) => s.edition);
|
||||
const permissions = useAccountStore((s) => s.permissions);
|
||||
@@ -342,6 +448,83 @@ export function Sidebar() {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
@@ -368,42 +551,46 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{layouts.length > 1 && (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
|
||||
<div className="border-t px-3 pt-3 pb-2">
|
||||
<div className="flex gap-1 rounded-xl bg-muted p-1" role="tablist">
|
||||
{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;
|
||||
return (
|
||||
<Tooltip key={target.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={target.name}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => handleSectionClick(target)}
|
||||
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{target.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
key={target.name}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => handleSectionClick(target)}
|
||||
className={cn(
|
||||
'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',
|
||||
isActive && 'bg-card text-foreground shadow-soft',
|
||||
)}
|
||||
>
|
||||
<LucideIcon name={target.icon} className={cn('h-4 w-4', isActive && 'text-primary')} />
|
||||
<span className="truncate">{target.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<SourceLink className="underline-offset-2 hover:text-foreground hover:underline" />
|
||||
<div className="flex items-center gap-2 border-t px-3 py-2">
|
||||
<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>
|
||||
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* 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 { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -1105,12 +1109,9 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<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="relative space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageHeader icon={iconForView(schema, viewName)} title={list.title} subtitle={list.subtitle} />
|
||||
<div className="flex items-center gap-2">
|
||||
{hasMassActions && selectedIds.size > 0 && (
|
||||
<DropdownMenu>
|
||||
@@ -1233,11 +1234,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-background shadow-sm">
|
||||
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]">
|
||||
<div className="rounded-xl border bg-card shadow-soft">
|
||||
<div className="overflow-x-auto rounded-[calc(var(--radius-xl)-1px)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted">
|
||||
<tr className="border-b bg-muted/60 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{hasMassActions && (
|
||||
<th className="w-10 px-3 py-3">
|
||||
<Checkbox
|
||||
@@ -1276,9 +1277,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
<tr>
|
||||
<td
|
||||
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>
|
||||
</tr>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -11,21 +12,21 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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: {
|
||||
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',
|
||||
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',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
sm: 'h-8 rounded-lg px-3 text-xs',
|
||||
lg: 'h-10 rounded-xl px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -9,7 +10,7 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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';
|
||||
|
||||
@@ -22,7 +23,7 @@ CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ 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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -10,11 +11,11 @@ import type { TooltipPayload } from 'recharts/types/state/tooltipSlice';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const CHART_COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
] as const;
|
||||
|
||||
export function getChartColor(index: number): string {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -14,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
<input
|
||||
type={type}
|
||||
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,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user