diff --git a/package-lock.json b/package-lock.json index fe1731e..b3e2aa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "inbuxa-admin", - "version": "1.0.7", + "version": "1.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inbuxa-admin", - "version": "1.0.7", + "version": "1.0.11", "dependencies": { "@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-checkbox": "^1.3.11", "@radix-ui/react-collapsible": "^1.1.20", @@ -541,6 +543,24 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "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": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/package.json b/package.json index 8701466..14c243d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ }, "dependencies": { "@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-checkbox": "^1.3.11", "@radix-ui/react-collapsible": "^1.1.20", @@ -68,4 +70,4 @@ "vite": "^8.2.0", "vitest": "^4.1.10" } -} \ No newline at end of file +} diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..98172c7 --- /dev/null +++ b/src/components/common/EmptyState.tsx @@ -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 ( +
+ +

{title}

+ {hint &&

{hint}

} + {action &&
{action}
} +
+ ); +} diff --git a/src/components/common/IconTile.tsx b/src/components/common/IconTile.tsx new file mode 100644 index 0000000..5c6e77b --- /dev/null +++ b/src/components/common/IconTile.tsx @@ -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 = { + 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)[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 ( + + {createElement(iconComponent(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })} + + ); +} diff --git a/src/components/common/LoadingFallback.tsx b/src/components/common/LoadingFallback.tsx index 3feebbf..4839edb 100644 --- a/src/components/common/LoadingFallback.tsx +++ b/src/components/common/LoadingFallback.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 (
- -

{t('common.loading')}

+ +

+ + {t('common.loading')} +

); } diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..75c8869 --- /dev/null +++ b/src/components/common/PageHeader.tsx @@ -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 ( +
+
+ {leading} + {icon && } +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index d3e0d48..9bc433a 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -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 (
-
- -
-

{formTitle}

- {formSubtitle &&

{formSubtitle}

} -
-
+ navigate(-1)}> + + + } + icon={iconForView(schema, viewName)} + title={formTitle} + subtitle={formSubtitle} + /> {generalError && (
@@ -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 }]; } diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index e9b362e..3931146 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -1,9 +1,11 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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({ handleFieldChange(name, v)} readOnly={readOnly} @@ -1500,7 +1502,7 @@ function EmbeddedObjectField({ handleFieldChange(name, v)} readOnly={readOnly} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9f359eb..21c445a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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({ @@ -268,13 +279,19 @@ function SidebarTopItem({ return ( - - + {items.map((sub) => ( ; + 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 ( + + + + + {name} + + ); + } + const { name, icon, items } = item.container; + if (!subtreeHasVisibleLink(items, edition)) return null; + const links = visibleLinks(items, edition); + const containsActive = subtreeContainsActive(items, currentPath, sectionName); + return ( + + + + + + + + {name} + + + + + {name} + + {links.map((l) => ( + navigate(resolveViewPath(sectionName, l.viewName))} + > + {l.name} + + ))} + + + ); +} + 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 ( + + + + ); + } + return ( <>
{layouts.length > 1 && ( - -
+
+
{layouts.map((target) => { - const Icon = (LucideIcons as Record)[ - target.icon - .split('-') - .map((s) => s[0].toUpperCase() + s.slice(1)) - .join('') - ] as LucideIcons.LucideIcon | undefined; const isActive = target.name === activeSection; return ( - - - - - {target.name} - + ); })}
- +
)} {/* INBUXA: the AGPL's offer, always on screen: the exact source of this version. */} -
- +
+

+ +

+
setUpsellOpen(false)} /> diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index e048048..20c4e93 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -1,9 +1,13 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 ( -
-
-
-

{list.title}

- {list.subtitle &&

{list.subtitle}

} -
+
+
+
{hasMassActions && selectedIds.size > 0 && ( @@ -1233,11 +1234,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
)} -
-
+
+
- + {hasMassActions && ( ) : ( diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e8e492e..f69d064 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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', }, }, diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index e023bec..7379032 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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>(({ className, ...props }, ref) => ( -
+
)); Card.displayName = 'Card'; @@ -22,7 +23,7 @@ CardHeader.displayName = 'CardHeader'; const CardTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
), ); CardTitle.displayName = 'CardTitle'; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index 4c54b35..9ad604d 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 1e79db8..d040448 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ @@ -14,7 +15,7 @@ const Input = React.forwardRef + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +import { useTranslation } from 'react-i18next'; +import { Greeting } from './Greeting'; import { useEffect, useMemo, useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { AlertCircle } from 'lucide-react'; @@ -24,6 +27,7 @@ interface DashboardViewProps { } export function DashboardView({ dashboardId, section }: DashboardViewProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const schema = useSchemaStore((s) => s.schema); const period = useDashboardStore((s) => s.period); @@ -102,6 +106,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) { return (
+
{dashboards.length > 1 && ( navigate(`/${section}/Dashboard/${id}`)}> @@ -114,15 +119,19 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) { )} - {dashboards.length === 1 &&

{dashboard.label}

} + {dashboards.length === 1 &&

{dashboard.label}

}
{liveStatus === 'error' && liveError && ( -
- - {liveError} +
+ + + {/404/.test(liveError) + ? t('dashboard.liveUnavailable', "Live numbers aren't available on this server yet. The rest of the dashboard still works.") + : liveError} +
)} diff --git a/src/features/dashboard/components/Greeting.tsx b/src/features/dashboard/components/Greeting.tsx new file mode 100644 index 0000000..91a7930 --- /dev/null +++ b/src/features/dashboard/components/Greeting.tsx @@ -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 ( +
+ +
+

+ {hello} + {name && `, ${name}`} +

+

+ {t('greeting.subtitle', "Here's how your mail server is doing.")} +

+
+
+ ); +} diff --git a/src/features/dashboard/components/StatCard.tsx b/src/features/dashboard/components/StatCard.tsx index 529d41d..3bb8d55 100644 --- a/src/features/dashboard/components/StatCard.tsx +++ b/src/features/dashboard/components/StatCard.tsx @@ -1,11 +1,12 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +import { IconTile } from '@/components/common/IconTile'; import { useMemo } from 'react'; -import * as LucideIcons from 'lucide-react'; import { Info } from 'lucide-react'; import { LineChart, Line } from 'recharts'; import { Card, CardContent } from '@/components/ui/card'; @@ -17,24 +18,6 @@ import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers' import { useLiveMetricsStore } from '../stores/liveMetricsStore'; import { getChartColor } from '@/components/ui/chart'; -const warnedIcons = new Set(); - -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)[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 ; - } - return ; -} - interface StatCardProps { card: CardSchema; historySamples: Metric[]; @@ -70,10 +53,10 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps) }, [card, historySamples, from, to]); return ( - - + +
- + {card.title} {card.description && ( @@ -89,7 +72,7 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps) )}
-
{formattedValue}
+
{formattedValue}
{(delta || sparkline) && (
diff --git a/src/i18n/en.json b/src/i18n/en.json index b5dd2fd..bbdf247 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -61,7 +61,8 @@ "preset7d": "Last 7 days", "preset90d": "Last 90 days", "title": "Dashboard", - "to": "To" + "to": "To", + "liveUnavailable": "Live numbers aren't available on this server yet. The rest of the dashboard still works." }, "deliveryTrace": { "attemptCount_one": "{{count}} attempt", @@ -282,7 +283,8 @@ "showing": "Showing {{from}}-{{to}} of {{total}} {{name}}", "showingItems": "Showing {{count}} items", "sort": "Sort", - "unknownError": "Unknown error" + "unknownError": "Unknown error", + "emptyTitle": "Nothing here yet" }, "login": { "continue": "Continue", @@ -382,5 +384,11 @@ "source": { "download": "Source code of this version ({{id}}), 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." } } diff --git a/src/index.css b/src/index.css index 846e2c9..a7945b4 100644 --- a/src/index.css +++ b/src/index.css @@ -8,77 +8,89 @@ @import 'tailwindcss'; :root { - --background: #ffffff; - --foreground: #111827; + /* INBUXA, light: warm paper, the mark's navy for text, its teal to act. */ + --background: #fbfaf7; + --foreground: #16262f; --card: #ffffff; - --card-foreground: #111827; + --card-foreground: #16262f; --popover: #ffffff; - --popover-foreground: #111827; + --popover-foreground: #16262f; - --content-background: #f6f8fa; + --content-background: #f4f1ea; - --primary: #0f766e; + --primary: #0d8a82; --primary-foreground: #ffffff; - --secondary: #eef1f4; - --secondary-foreground: #111827; + --secondary: #efece5; + --secondary-foreground: #16262f; - --muted: #eef1f4; - --muted-foreground: #5b6472; + --muted: #efece5; + --muted-foreground: #5f6b73; - --accent: #d9f1ee; - --accent-foreground: #0b5750; + --accent: #e1f4f1; + --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; - --border: #e3e7ec; - --input: #e3e7ec; - --ring: #0f766e; + --border: #e7e2d8; + --input: #ddd7cb; + --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-2: #f9a34c; + --chart-1: #0d8a82; + --chart-2: #f59e3f; --chart-3: #1c4053; - --chart-4: #0e7490; + --chart-4: #46cac3; --chart-5: #b45309; } .dark { - --background: #111a2b; - --foreground: #e5e9f0; + /* INBUXA, dark: the lockup's own background, lifted surfaces, bright teal. */ + --background: #0e1c26; + --foreground: #e7eef2; - --card: #111a2b; - --card-foreground: #e5e9f0; + --card: #132633; + --card-foreground: #e7eef2; - --popover: #111a2b; - --popover-foreground: #e5e9f0; + --popover: #152a38; + --popover-foreground: #e7eef2; - --content-background: #0b1220; + --content-background: #0b1720; - --primary: #2dd4bf; - --primary-foreground: #052e2b; + --primary: #46cac3; + --primary-foreground: #062a28; - --secondary: #1f2a3d; - --secondary-foreground: #e5e9f0; + --secondary: #1a3140; + --secondary-foreground: #e7eef2; - --muted: #1f2a3d; - --muted-foreground: #9aa5b8; + --muted: #1a3140; + --muted-foreground: #93a8b6; - --accent: #12343a; - --accent-foreground: #99f6e4; + --accent: #133d40; + --accent-foreground: #9ff0e9; + + --highlight: #f9a34c; + --highlight-soft: #3a2a18; --destructive: #f87171; - --destructive-foreground: #0b1220; + --destructive-foreground: #0b1720; - --border: #1f2a3d; - --input: #1f2a3d; - --ring: #2dd4bf; + --border: #1f3847; + --input: #274455; + --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-3: #9fc2d6; --chart-4: #67e8f9; @@ -108,6 +120,9 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); + --color-highlight: var(--highlight); + --color-highlight-soft: var(--highlight-soft); + --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); @@ -115,16 +130,20 @@ --color-input: var(--input); --color-ring: var(--ring); - --color-chart-1: hsl(var(--chart-1)); - --color-chart-2: hsl(var(--chart-2)); - --color-chart-3: hsl(var(--chart-3)); - --color-chart-4: hsl(var(--chart-4)); - --color-chart-5: hsl(var(--chart-5)); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --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 { @@ -133,7 +152,24 @@ } 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; } } diff --git a/src/lib/humanize.test.ts b/src/lib/humanize.test.ts new file mode 100644 index 0000000..ededbcd --- /dev/null +++ b/src/lib/humanize.test.ts @@ -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'); + }); +}); diff --git a/src/lib/humanize.ts b/src/lib/humanize.ts new file mode 100644 index 0000000..05bf25d --- /dev/null +++ b/src/lib/humanize.ts @@ -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 = { + 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(' '); +} diff --git a/src/lib/iconTones.ts b/src/lib/iconTones.ts new file mode 100644 index 0000000..b7d1654 --- /dev/null +++ b/src/lib/iconTones.ts @@ -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 = { + '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]; +} diff --git a/src/lib/viewIcon.ts b/src/lib/viewIcon.ts new file mode 100644 index 0000000..8e90de2 --- /dev/null +++ b/src/lib/viewIcon.ts @@ -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; +} diff --git a/src/main.tsx b/src/main.tsx index f7fc0d6..2aa61c1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import './i18n'; +import '@fontsource-variable/inter'; +import '@fontsource-variable/space-grotesk'; import './index.css'; import App from './App'; import LoginPage from './pages/LoginPage'; diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index f914a3f..7324799 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * 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 setActiveSection = useUIStore((s) => s.setActiveSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); + const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed); const { canViewObject } = usePermissions(); const [initError, setInitError] = useState(null); @@ -270,7 +272,7 @@ export default function AdminPanel() {
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index f6344f9..1e4fd1f 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -13,12 +13,15 @@ type Theme = 'light' | 'dark'; interface UIState { theme: Theme; sidebarOpen: boolean; + /** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */ + sidebarCollapsed: boolean; activeSection: string; toggleTheme: () => void; setTheme: (theme: Theme) => void; toggleSidebar: () => void; setSidebarOpen: (open: boolean) => void; + toggleSidebarCollapsed: () => void; setActiveSection: (section: string) => void; } @@ -36,6 +39,7 @@ export const useUIStore = create()( theme: typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true, + sidebarCollapsed: false, activeSection: '', toggleTheme: () => { @@ -57,6 +61,10 @@ export const useUIStore = create()( set({ sidebarOpen: open }); }, + toggleSidebarCollapsed: () => { + set({ sidebarCollapsed: !get().sidebarCollapsed }); + }, + setActiveSection: (section) => { set({ activeSection: section }); }, @@ -65,6 +73,7 @@ export const useUIStore = create()( name: 'inbuxa-ui', partialize: (state) => ({ theme: state.theme, + sidebarCollapsed: state.sidebarCollapsed, }), onRehydrateStorage: () => { return (state) => {
- {t('list.noResults', 'No results found')} +