People as cards, friendly empty states, and a first-domain hello #92

Merged
jcoffey-dev merged 1 commits from feature/people-cards into main 2026-10-06 18:42:10 +00:00
10 changed files with 794 additions and 120 deletions

No files matched your search

+3
View File
@@ -65,6 +65,7 @@ import { coerceLabel } from '@/lib/objectOptions';
import { SECRET_MASK } from '@/lib/jmapUtils';
import { toast } from '@/hooks/use-toast';
import { deleteWithUndo } from '@/components/common/undoableDelete';
import { celebrateIfFirstDomain } from '@/features/celebrate/firstDomain';
import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
@@ -529,6 +530,8 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic
if (setResult.created && setResult.created['new-0']) {
const createdData = setResult.created['new-0'];
const newId = createdData.id as string;
// inbuxa: the server's first domain gets a moment of its own (roadmap item 12)
if (obj.objectName === 'x:Domain') void celebrateIfFirstDomain(newId, String(formData.name ?? ''));
const noisyKeys = new Set(['id', 'blobId']);
const extraKeys = Object.keys(createdData).filter((k) => !noisyKeys.has(k));
+226 -120
View File
@@ -8,7 +8,7 @@
*/
import { pageAbout } from '@/help/texts';
import { EmptyState } from '@/components/common/EmptyState';
import { ListEmptyState } from '@/components/lists/ListEmptyState';
import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel';
import { ObjectHoverCard } from '@/features/hovercards/ObjectHoverCard';
@@ -29,6 +29,8 @@ import {
Lock,
Search,
RotateCcw,
LayoutGrid,
Rows3,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -61,6 +63,14 @@ import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { toast } from '@/hooks/use-toast';
import { deleteWithUndo } from '@/components/common/undoableDelete';
import { useShortcut } from '@/lib/shortcuts';
import { PersonCard } from '@/features/people/PersonCard';
import {
PERSON_CARD_PROPERTIES,
personOf,
readPeopleLayout,
writePeopleLayout,
type PeopleLayout,
} from '@/features/people/person';
import { isPendingDelete, usePendingDeletes } from '@/lib/pendingDeletes';
import { friendlySetError } from '@/lib/jmapErrors';
import { coerceLabel } from '@/lib/objectOptions';
@@ -374,6 +384,9 @@ interface DynamicListProps {
viewName: string;
}
/** inbuxa: the People list, which can show cards (roadmap item 10). */
const PEOPLE_VIEW = 'x:Account/User';
export function DynamicList({ viewName }: DynamicListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -397,6 +410,10 @@ export function DynamicList({ viewName }: DynamicListProps) {
}, [schema, viewName]);
// inbuxa: lock and unlock people from the account list (AL-1)
const directoryLocks = useDirectoryLocks(resolved?.obj.objectName === 'x:Account');
// inbuxa: People can be cards or the plain table (roadmap item 10)
const peopleView = viewName === PEOPLE_VIEW;
const [peopleLayout, setPeopleLayout] = useState<PeopleLayout>(readPeopleLayout);
const showCards = peopleView && peopleLayout === 'cards';
const [items, setItems] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState<number | null>(null);
@@ -464,7 +481,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
try {
const accountId = getAccountId(obj.objectName);
const properties = ['id', ...list.columns.map((c) => c.name)];
const properties = [
'id',
...list.columns.map((c) => c.name),
...(viewName === PEOPLE_VIEW ? PERSON_CARD_PROPERTIES : []),
];
const filter = buildFilter();
const sortArr = buildSort();
@@ -517,7 +538,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setLoading(false);
}
},
[resolved, schema, buildFilter, buildSort, t],
[resolved, schema, viewName, buildFilter, buildSort, t],
);
// inbuxa: reload when a delete of this kind is held, undone or sent (item 9)
@@ -904,6 +925,25 @@ export function DynamicList({ viewName }: DynamicListProps) {
const explainLogs = explainAvailable && obj.objectName === 'x:Log';
const hasRowCell = hasItemActions || explainLogs || directoryLocks.enabled;
// inbuxa: an empty list says why, and what to do (roadmap item 12)
const emptyState = (
<ListEmptyState
viewName={viewName}
plural={list.pluralName ?? list.title.toLowerCase()}
singular={list.singularName ?? list.title.toLowerCase()}
filtered={Object.values(appliedFilters).some((v) => v !== '' && v != null)}
onClearFilters={resetFilters}
onCreate={
canCreate && obj.objectType.type === 'object'
? () => {
const section = viewToSection[viewName];
if (section) navigate(`/${section}/${viewName}/new`);
}
: undefined
}
/>
);
const pageStart = anchorStack.length * PAGE_SIZE;
const rangeStart = pageStart + 1;
const rangeEnd = pageStart + items.length;
@@ -1190,6 +1230,34 @@ export function DynamicList({ viewName }: DynamicListProps) {
/>
<div className="flex items-center gap-2">
<HelpPanel viewName={viewName} title={list.title} />
{peopleView && (
<div
className="flex rounded-md border p-0.5"
role="group"
aria-label={t('people.layout', 'Show people as')}
>
{(['cards', 'table'] as const).map((mode) => (
<Button
key={mode}
type="button"
variant={peopleLayout === mode ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
aria-pressed={peopleLayout === mode}
title={mode === 'cards' ? t('people.cards', 'Cards') : t('people.table', 'Table')}
onClick={() => {
setPeopleLayout(mode);
writePeopleLayout(mode);
}}
>
{mode === 'cards' ? <LayoutGrid className="h-4 w-4" /> : <Rows3 className="h-4 w-4" />}
<span className="sr-only">
{mode === 'cards' ? t('people.cards', 'Cards') : t('people.table', 'Table')}
</span>
</Button>
))}
</div>
)}
{hasMassActions && selectedIds.size > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1317,127 +1385,165 @@ export function DynamicList({ viewName }: DynamicListProps) {
</div>
)}
<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/60 text-xs uppercase tracking-wide text-muted-foreground">
{hasMassActions && (
<th className="w-10 px-3 py-3">
<Checkbox
checked={items.length > 0 && selectedIds.size === items.length}
onCheckedChange={toggleSelectAll}
aria-label={t('list.selectAll', 'Select all')}
/>
</th>
)}
{list.columns.map((col) => (
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground">
<div className="flex items-center">
{col.label}
{renderSortIndicator(col.name)}
</div>
</th>
))}
{hasRowCell && (
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground">
{t('list.actions', 'Actions')}
</th>
)}
</tr>
</thead>
<tbody>
{loading && items.length === 0 ? (
<tr>
<td
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasRowCell ? 1 : 0)}
className="px-3 py-12 text-center"
>
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</td>
{showCards ? (
loading && items.length === 0 ? (
<div className="py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</div>
) : items.length === 0 ? (
<div className="rounded-xl border bg-card shadow-soft">{emptyState}</div>
) : (
<div className="space-y-3">
{hasMassActions && (
<label className="flex w-fit items-center gap-2 text-sm text-muted-foreground">
<Checkbox
checked={items.length > 0 && selectedIds.size === items.length}
onCheckedChange={toggleSelectAll}
/>
{t('list.selectAll', 'Select all')}
</label>
)}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const itemId = item.id as string;
return (
<PersonCard
key={itemId}
person={personOf(item)}
selected={selectedIds.has(itemId)}
onToggleSelect={hasMassActions ? () => toggleSelectItem(itemId) : undefined}
onOpen={() => handleRowClick(item)}
actions={renderItemActions(item)}
badge={directoryLocks.badge(item)}
/>
);
})}
</div>
</div>
)
) : (
<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/60 text-xs uppercase tracking-wide text-muted-foreground">
{hasMassActions && (
<th className="w-10 px-3 py-3">
<Checkbox
checked={items.length > 0 && selectedIds.size === items.length}
onCheckedChange={toggleSelectAll}
aria-label={t('list.selectAll', 'Select all')}
/>
</th>
)}
{list.columns.map((col) => (
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground">
<div className="flex items-center">
{col.label}
{renderSortIndicator(col.name)}
</div>
</th>
))}
{hasRowCell && (
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground">
{t('list.actions', 'Actions')}
</th>
)}
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasRowCell ? 1 : 0)} className="px-3">
<EmptyState
title={t('list.emptyTitle', 'Nothing here yet')}
hint={t('list.noResults', 'No results found')}
/>
</td>
</tr>
) : (
items.map((item) => {
const itemId = item.id as string;
return (
<tr
key={itemId}
className="border-b cursor-pointer transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => handleRowClick(item)}
// inbuxa: a row opens from the keyboard too (roadmap item 13)
tabIndex={0}
onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleRowClick(item);
}
}}
</thead>
<tbody>
{loading && items.length === 0 ? (
<tr>
<td
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasRowCell ? 1 : 0)}
className="px-3 py-12 text-center"
>
{hasMassActions && (
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(itemId)}
onCheckedChange={() => toggleSelectItem(itemId)}
aria-label={t('list.selectItem', 'Select item')}
/>
</td>
)}
{list.columns.map((col, colIndex) => {
const cell = renderCellValue(
item[col.name],
fields[col.name],
col.name,
schema!,
resolved.obj.objectName,
getDisplayName,
);
return (
<td key={col.name} className="px-3 py-2">
{colIndex === 0 && item[col.name] != null ? (
<>
<ObjectHoverCard objectName={resolved.obj.objectName} id={itemId}>
{cell}
</ObjectHoverCard>
{directoryLocks.badge(item)}
</>
) : (
cell
)}
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasRowCell ? 1 : 0)}
className="px-3"
>
{emptyState}
</td>
</tr>
) : (
items.map((item) => {
const itemId = item.id as string;
return (
<tr
key={itemId}
className="border-b cursor-pointer transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => handleRowClick(item)}
// inbuxa: a row opens from the keyboard too (roadmap item 13)
tabIndex={0}
onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleRowClick(item);
}
}}
>
{hasMassActions && (
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(itemId)}
onCheckedChange={() => toggleSelectItem(itemId)}
aria-label={t('list.selectItem', 'Select item')}
/>
</td>
);
})}
{hasRowCell && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{explainLogs && item.id != null && (
<ExplainButton
icon
className="h-8 w-8"
title={String(item.event ?? item.id)}
subject={{ '@type': 'LogEntry', logId: String(item.id) }}
/>
)}
{renderItemActions(item)}
</div>
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
)}
{list.columns.map((col, colIndex) => {
const cell = renderCellValue(
item[col.name],
fields[col.name],
col.name,
schema!,
resolved.obj.objectName,
getDisplayName,
);
return (
<td key={col.name} className="px-3 py-2">
{colIndex === 0 && item[col.name] != null ? (
<>
<ObjectHoverCard objectName={resolved.obj.objectName} id={itemId}>
{cell}
</ObjectHoverCard>
{directoryLocks.badge(item)}
</>
) : (
cell
)}
</td>
);
})}
{hasRowCell && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{explainLogs && item.id != null && (
<ExplainButton
icon
className="h-8 w-8"
title={String(item.event ?? item.id)}
subject={{ '@type': 'LogEntry', logId: String(item.id) }}
/>
)}
{renderItemActions(item)}
</div>
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{items.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
+113
View File
@@ -0,0 +1,113 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: what an empty list says (admin UX roadmap, item 12). Empty because
* of the filters: say so, and offer to clear them. Empty for real: say what
* goes here, in plain words for the lists people meet first, and offer to
* create the first one.
*/
import { useTranslation } from 'react-i18next';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/common/EmptyState';
export function ListEmptyState({
viewName,
plural,
singular,
filtered,
onClearFilters,
onCreate,
}: {
viewName: string;
/** "domains", "users"; the list's own words. */
plural: string;
singular: string;
/** Filters are on, so the list may not be empty at all. */
filtered: boolean;
onClearFilters: () => void;
/** Present when the reader may create one. */
onCreate?: () => void;
}) {
const { t } = useTranslation();
if (filtered) {
return (
<EmptyState
title={t('empty.filteredTitle', 'Nothing matches')}
hint={t('empty.filteredHint', 'No {{plural}} match these filters.', { plural })}
action={
<Button type="button" variant="outline" size="sm" onClick={onClearFilters}>
<X className="mr-1.5 h-4 w-4" />
{t('empty.clearFilters', 'Clear filters')}
</Button>
}
/>
);
}
let title: string;
let hint: string;
switch (viewName) {
case 'x:Domain':
title = t('empty.domainsTitle', 'No domains yet');
hint = t(
'empty.domainsHint',
'A domain is the part after the @. Add yours and you’ll get the DNS records to publish for it.',
);
break;
case 'x:Account/User':
title = t('empty.peopleTitle', 'No people yet');
hint = t('empty.peopleHint', 'Each person gets a mailbox, a calendar and contacts. Add the first one.');
break;
case 'x:Account/Group':
title = t('empty.groupsTitle', 'No groups yet');
hint = t('empty.groupsHint', 'A group gives several people one address, and can share mailboxes with them.');
break;
case 'x:MailingList':
title = t('empty.listsTitle', 'No mailing lists yet');
hint = t('empty.listsHint', 'A mailing list sends one message on to everyone on it.');
break;
case 'x:QueuedMessage':
title = t('empty.queueTitle', 'The queue is empty');
hint = t('empty.queueHint', 'Mail waiting to be delivered shows up here until it goes out.');
break;
case 'x:DmarcExternalReport':
case 'x:TlsExternalReport':
case 'x:ArfExternalReport':
title = t('empty.reportsTitle', 'No reports yet');
hint = t('empty.reportsHint', 'Large providers send one the day after they get mail from your domains.');
break;
case 'x:ApiKey':
title = t('empty.apiKeysTitle', 'No API keys yet');
hint = t('empty.apiKeysHint', 'An API key lets a script or another app manage this server.');
break;
case 'x:WebHook':
title = t('empty.webhooksTitle', 'No webhooks yet');
hint = t('empty.webhooksHint', 'A webhook tells another app when something happens here.');
break;
default:
title = t('empty.title', 'No {{plural}} yet', { plural });
hint = t('empty.hint', 'When one is added, it shows up here.');
}
return (
<EmptyState
title={title}
hint={hint}
action={
onCreate && (
<Button type="button" size="sm" onClick={onCreate}>
<Plus className="mr-1.5 h-4 w-4" />
{t('list.create', 'Create {{name}}', { name: singular })}
</Button>
)
}
/>
);
}
@@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: "Your first domain" (admin UX roadmap, item 12): a short hello from
* the cat and the two things to do next. The domain's own page carries the
* DNS setup, with its "Guided or manual?" choice, so this only leads there.
*/
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useSchemaStore } from '@/stores/schemaStore';
import inbuxaMark from '@/assets/inbuxa-mark.png';
import { useFirstDomain } from './firstDomain';
export function FirstDomainCelebration() {
const { t } = useTranslation();
const navigate = useNavigate();
const viewToSection = useSchemaStore((s) => s.viewToSection);
const domain = useFirstDomain((s) => s.domain);
const close = useFirstDomain((s) => s.close);
const go = (path: string) => {
close();
navigate(path);
};
return (
<Dialog open={domain !== null} onOpenChange={(open) => !open && close()}>
<DialogContent className="max-w-md text-center">
<img src={inbuxaMark} alt="" className="mx-auto h-20 w-auto animate-in zoom-in-50 fade-in duration-500" />
<DialogHeader className="items-center sm:text-center">
<DialogTitle className="font-display text-xl">
{t('firstDomain.title', 'Your first domain is in')}
</DialogTitle>
<DialogDescription>
{t(
'firstDomain.body',
'{{name}} is on the server. Once its DNS records are published, mail for it starts arriving here.',
{ name: domain?.name ?? '' },
)}
</DialogDescription>
</DialogHeader>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:justify-center">
<Button
type="button"
onClick={() => domain && go(`/${viewToSection['x:Domain'] ?? 'Management'}/x:Domain/${domain.id}`)}
>
{t('firstDomain.dns', 'Set up its DNS')}
<ArrowRight className="ml-1.5 h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
onClick={() => go(`/${viewToSection['x:Account/User'] ?? 'Management'}/x:Account/User/new`)}
>
<UserPlus className="mr-1.5 h-4 w-4" />
{t('firstDomain.person', 'Add the first person')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: the first domain on a server gets a moment of its own (admin UX
* roadmap, item 12). After a domain is created, ask how many there are; if
* this is the only one, the celebration opens.
*/
import { create } from 'zustand';
import { getAccountId, jmapRequest } from '@/services/jmap/client';
interface State {
domain: { id: string; name: string } | null;
close: () => void;
}
export const useFirstDomain = create<State>((set) => ({
domain: null,
close: () => set({ domain: null }),
}));
/** Opens the celebration when the domain just created is the server's only one. */
export async function celebrateIfFirstDomain(id: string, name: string): Promise<void> {
try {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest([['x:Domain/query', { accountId, limit: 2, calculateTotal: true }, 'q']]);
const result = responses[0]?.[1] as { total?: number; ids?: string[] } | undefined;
const count = result?.total ?? result?.ids?.length;
if (count === 1) useFirstDomain.setState({ domain: { id, name } });
} catch {
// A celebration is never worth an error message.
}
}
+154
View File
@@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: one person on the People list, as a card (admin UX roadmap, item
* 10): initials, name and address, a storage ring, role, groups and since
* when, and the row's own quick actions. It opens like a row does.
*/
import type { CSSProperties, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Checkbox } from '@/components/ui/checkbox';
import { formatSize } from '@/lib/durationFormat';
import { cn } from '@/lib/utils';
import type { Person } from './person';
function StorageRing({ fill }: { fill: number | null }) {
const r = 15;
const circumference = 2 * Math.PI * r;
const tone =
fill === null
? 'text-muted-foreground/40'
: fill >= 0.9
? 'text-destructive'
: fill >= 0.75
? 'text-highlight'
: 'text-primary';
return (
<svg viewBox="0 0 36 36" className="h-9 w-9 shrink-0 -rotate-90" aria-hidden="true">
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="4" className="stroke-muted" />
{fill !== null && fill > 0 && (
<circle
cx="18"
cy="18"
r={r}
fill="none"
strokeWidth="4"
strokeLinecap="round"
className={cn('stroke-current', tone)}
strokeDasharray={`${Math.max(fill, 0.02) * circumference} ${circumference}`}
/>
)}
</svg>
);
}
export function PersonCard({
person,
selected,
onToggleSelect,
onOpen,
actions,
badge,
}: {
person: Person;
/** Shown only when the list allows mass actions. */
selected?: boolean;
onToggleSelect?: () => void;
onOpen: () => void;
actions?: ReactNode;
badge?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
const storage =
person.quota === null
? t('people.usedNoLimit', '{{used}} used, no limit', { used: formatSize(person.used) })
: t('people.usedOf', '{{used}} of {{quota}}', { used: formatSize(person.used), quota: formatSize(person.quota) });
return (
<article
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onOpen();
}
}}
className={cn(
'flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 shadow-soft transition-colors hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
selected && 'border-primary/60 bg-accent/40',
)}
>
<div className="flex items-start gap-3">
{onToggleSelect && (
<div className="pt-2.5" onClick={stop}>
<Checkbox
checked={selected}
onCheckedChange={onToggleSelect}
aria-label={t('people.select', 'Select {{name}}', { name: person.name ?? person.address })}
/>
</div>
)}
<span
aria-hidden="true"
style={{ '--h': person.hue } as CSSProperties}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[hsl(var(--h)_60%_90%)] text-sm font-semibold text-[hsl(var(--h)_55%_24%)] dark:bg-[hsl(var(--h)_35%_24%)] dark:text-[hsl(var(--h)_70%_86%)]"
>
{person.initials}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{person.name ?? person.address}</p>
{person.name && <p className="truncate text-sm text-muted-foreground">{person.address}</p>}
{badge}
</div>
{actions && (
<div onClick={stop} onKeyDown={stop}>
{actions}
</div>
)}
</div>
<div className="flex items-center gap-3">
<StorageRing fill={person.fill} />
<div className="min-w-0 text-sm">
<p className="truncate">{storage}</p>
{person.fill !== null && (
<p className="text-xs text-muted-foreground">
{t('people.percentFull', '{{percent}}% full', { percent: Math.round(person.fill * 100) })}
</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{person.role !== 'user' && (
<span className="rounded-full border px-2 py-0.5 font-medium text-foreground">
{person.role === 'admin' ? t('hover.roleAdmin', 'Administrator') : t('hover.roleCustom', 'Custom role')}
</span>
)}
{person.groups > 0 && (
<span>
{t('people.groups', {
count: person.groups,
defaultValue_one: 'In {{count}} group',
defaultValue_other: 'In {{count}} groups',
})}
</span>
)}
{person.createdAt && (
<span>
{t('people.since', 'Since {{date}}', {
date: new Date(person.createdAt).toLocaleDateString(i18n.language, { dateStyle: 'medium' }),
})}
</span>
)}
</div>
</article>
);
}
+61
View File
@@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { hueOf, initialsOf, personOf } from './person';
describe('initialsOf', () => {
it('takes first and last name, or the start of one name or the address', () => {
expect(initialsOf('Ada King Lovelace', '[email protected]')).toBe('AL');
expect(initialsOf('Cher', '[email protected]')).toBe('CH');
expect(initialsOf(undefined, '[email protected]')).toBe('JD');
expect(initialsOf(' ', 'é[email protected]')).toBe('ÉM');
expect(initialsOf(undefined, '@')).toBe('?');
});
});
describe('hueOf', () => {
it('is the same for the same address, whatever its case, and in range', () => {
expect(hueOf('[email protected]')).toBe(hueOf('[email protected]'));
const h = hueOf('[email protected]');
expect(h).toBeGreaterThanOrEqual(0);
expect(h).toBeLessThan(360);
});
});
describe('personOf', () => {
it('reads a People row', () => {
const p = personOf({
id: 'a',
emailAddress: '[email protected]',
description: 'Ada Lovelace',
usedDiskQuota: 750,
quotas: { maxDiskQuota: 1000 },
roles: { '@type': 'Admin' },
memberGroupIds: { g1: true, g2: true },
createdAt: '2026-03-01T00:00:00Z',
});
expect(p).toMatchObject({
name: 'Ada Lovelace',
address: '[email protected]',
initials: 'AL',
used: 750,
quota: 1000,
fill: 0.75,
role: 'admin',
groups: 2,
});
});
it('has no fill without a limit, and is a plain user without a role', () => {
const p = personOf({ id: 'b', emailAddress: '[email protected]', usedDiskQuota: 5, quotas: { maxDiskQuota: 0 } });
expect(p).toMatchObject({ quota: null, fill: null, role: 'user', groups: 0, name: undefined, initials: 'BO' });
});
it('caps the fill at full', () => {
expect(personOf({ id: 'c', emailAddress: 'c@x', usedDiskQuota: 20, quotas: { maxDiskQuota: 10 } }).fill).toBe(1);
});
});
+88
View File
@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: what a person's card shows (admin UX roadmap, item 10), read from a
* People list row. Initials only: account pictures were decided against.
*/
/** Properties a card needs beyond the list's own columns. */
export const PERSON_CARD_PROPERTIES = ['usedDiskQuota', 'quotas', 'roles', 'memberGroupIds'];
export interface Person {
/** The full name, when one is set. */
name?: string;
address: string;
initials: string;
/** A hue (0–359) for the initials circle, the same for the same address every time. */
hue: number;
used: number;
/** The storage limit in bytes, or null for none. */
quota: number | null;
/** How full, 0–1, when there is a limit. */
fill: number | null;
role: 'admin' | 'custom' | 'user';
groups: number;
createdAt?: string;
}
/** Two letters: first and last name, or the start of the address. */
export function initialsOf(name: string | undefined, address: string): string {
const words = (name ?? '').trim().split(/\s+/).filter(Boolean);
if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase();
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
const local = address.split('@')[0].replace(/[^\p{L}\p{N}]/gu, '');
return (local.slice(0, 2) || '?').toUpperCase();
}
/** A stable hue for an address. */
export function hueOf(seed: string): number {
let h = 0;
for (const ch of seed.toLowerCase()) h = (h * 31 + ch.codePointAt(0)!) >>> 0;
return h % 360;
}
export function personOf(item: Record<string, unknown>): Person {
const address = String(item.emailAddress ?? item.name ?? item.id ?? '');
const name = typeof item.description === 'string' && item.description.trim() ? item.description.trim() : undefined;
const quotas = (item.quotas ?? {}) as Record<string, unknown>;
const quota = typeof quotas.maxDiskQuota === 'number' && quotas.maxDiskQuota > 0 ? quotas.maxDiskQuota : null;
const used = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
const roleType = (item.roles as { '@type'?: string } | undefined)?.['@type'];
return {
name,
address,
initials: initialsOf(name, address),
hue: hueOf(address),
used,
quota,
fill: quota ? Math.min(1, used / quota) : null,
role: roleType === 'Admin' ? 'admin' : roleType === 'Custom' ? 'custom' : 'user',
groups:
item.memberGroupIds && typeof item.memberGroupIds === 'object' ? Object.keys(item.memberGroupIds).length : 0,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
};
}
/** Cards or the plain table, remembered per browser. */
export type PeopleLayout = 'cards' | 'table';
const LAYOUT_KEY = 'inbuxa-people-layout';
export function readPeopleLayout(): PeopleLayout {
try {
return localStorage.getItem(LAYOUT_KEY) === 'table' ? 'table' : 'cards';
} catch {
return 'cards';
}
}
export function writePeopleLayout(layout: PeopleLayout) {
try {
localStorage.setItem(LAYOUT_KEY, layout);
} catch {
// Private windows can refuse storage; the choice just isn't remembered.
}
}
+41
View File
@@ -2176,5 +2176,46 @@
"motionFull": "Full",
"shortcuts": "Keyboard shortcuts",
"skip": "Skip to content"
},
"people": {
"layout": "Show people as",
"cards": "Cards",
"table": "Table",
"usedNoLimit": "{{used}} used, no limit",
"usedOf": "{{used}} of {{quota}}",
"select": "Select {{name}}",
"percentFull": "{{percent}}% full",
"groups_one": "In {{count}} group",
"groups_other": "In {{count}} groups",
"since": "Since {{date}}"
},
"empty": {
"filteredTitle": "Nothing matches",
"filteredHint": "No {{plural}} match these filters.",
"clearFilters": "Clear filters",
"domainsTitle": "No domains yet",
"domainsHint": "A domain is the part after the @. Add yours and you’ll get the DNS records to publish for it.",
"peopleTitle": "No people yet",
"peopleHint": "Each person gets a mailbox, a calendar and contacts. Add the first one.",
"groupsTitle": "No groups yet",
"groupsHint": "A group gives several people one address, and can share mailboxes with them.",
"listsTitle": "No mailing lists yet",
"listsHint": "A mailing list sends one message on to everyone on it.",
"queueTitle": "The queue is empty",
"queueHint": "Mail waiting to be delivered shows up here until it goes out.",
"reportsTitle": "No reports yet",
"reportsHint": "Large providers send one the day after they get mail from your domains.",
"apiKeysTitle": "No API keys yet",
"apiKeysHint": "An API key lets a script or another app manage this server.",
"webhooksTitle": "No webhooks yet",
"webhooksHint": "A webhook tells another app when something happens here.",
"title": "No {{plural}} yet",
"hint": "When one is added, it shows up here."
},
"firstDomain": {
"title": "Your first domain is in",
"body": "{{name}} is on the server. Once its DNS records are published, mail for it starts arriving here.",
"dns": "Set up its DNS",
"person": "Add the first person"
}
}
+2
View File
@@ -20,6 +20,7 @@ import { loadAccountTheme, setAccountSettingsTarget } from '@/lib/accountSetting
import { setLocale } from '@/i18n';
import { TopBar } from '@/components/layout/TopBar';
import { KeyboardShortcuts } from '@/components/common/KeyboardShortcuts';
import { FirstDomainCelebration } from '@/features/celebrate/FirstDomainCelebration';
import { Sidebar } from '@/components/layout/Sidebar';
import { SectionNav } from '@/components/layout/SectionNav';
import { MainContent } from '@/components/layout/MainContent';
@@ -330,6 +331,7 @@ export default function AdminPanel() {
{t('a11y.skip', 'Skip to content')}
</a>
<KeyboardShortcuts />
<FirstDomainCelebration />
<TopBar />
{useSectionNav && currentLayout && <SectionNav layout={currentLayout} />}
<div className="flex flex-1">