People as cards, friendly empty states, and a first-domain hello #92
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user