From 1cc3192cd54418d44a2a98405994f70f1fc7eb92 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 6 Oct 2026 11:39:40 -0700 Subject: [PATCH] People as cards, friendly empty states, and a first-domain hello People opens as cards: initials in a color of their own, name and address, a storage ring against the quota, role, groups and since when, with the row's quick actions and lock badge. A switch beside the help button goes back to the table, remembered per browser. Selection, paging, filters and Undo work the same in both. An empty list now says why: filters that match nothing offer to clear them; an empty list says what goes there, in plain words for domains, people, groups, mailing lists, the queue, reports, API keys and webhooks, and offers to create the first one. The server's first domain opens a short hello with the cat and the two next steps: its page, where DNS is set up, and adding the first person. Admin UX roadmap, items 10 and 12. No last sign-in on the cards: the server doesn't record one per account. --- src/components/forms/DynamicForm.tsx | 3 + src/components/lists/DynamicList.tsx | 346 ++++++++++++------ src/components/lists/ListEmptyState.tsx | 113 ++++++ .../celebrate/FirstDomainCelebration.tsx | 69 ++++ src/features/celebrate/firstDomain.ts | 37 ++ src/features/people/PersonCard.tsx | 154 ++++++++ src/features/people/person.test.ts | 61 +++ src/features/people/person.ts | 88 +++++ src/i18n/en.json | 41 +++ src/pages/AdminPanel.tsx | 2 + 10 files changed, 794 insertions(+), 120 deletions(-) create mode 100644 src/components/lists/ListEmptyState.tsx create mode 100644 src/features/celebrate/FirstDomainCelebration.tsx create mode 100644 src/features/celebrate/firstDomain.ts create mode 100644 src/features/people/PersonCard.tsx create mode 100644 src/features/people/person.test.ts create mode 100644 src/features/people/person.ts diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 0c0a83d..8869c6e 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -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)); diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 38158c9..22425e9 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -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(readPeopleLayout); + const showCards = peopleView && peopleLayout === 'cards'; const [items, setItems] = useState[]>([]); const [total, setTotal] = useState(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 = ( + 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) { />
+ {peopleView && ( +
+ {(['cards', 'table'] as const).map((mode) => ( + + ))} +
+ )} {hasMassActions && selectedIds.size > 0 && ( @@ -1317,127 +1385,165 @@ export function DynamicList({ viewName }: DynamicListProps) {
)} -
-
- - - - {hasMassActions && ( - - )} - {list.columns.map((col) => ( - - ))} - {hasRowCell && ( - - )} - - - - {loading && items.length === 0 ? ( - - + {showCards ? ( + loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( +
{emptyState}
+ ) : ( +
+ {hasMassActions && ( + + )} +
+ {items.map((item) => { + const itemId = item.id as string; + return ( + toggleSelectItem(itemId) : undefined} + onOpen={() => handleRowClick(item)} + actions={renderItemActions(item)} + badge={directoryLocks.badge(item)} + /> + ); + })} +
+
+ ) + ) : ( +
+
+
- 0 && selectedIds.size === items.length} - onCheckedChange={toggleSelectAll} - aria-label={t('list.selectAll', 'Select all')} - /> - -
- {col.label} - {renderSortIndicator(col.name)} -
-
- {t('list.actions', 'Actions')} -
- -
+ + + {hasMassActions && ( + + )} + {list.columns.map((col) => ( + + ))} + {hasRowCell && ( + + )} - ) : items.length === 0 ? ( - - - - ) : ( - items.map((item) => { - const itemId = item.id as string; - return ( - 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); - } - }} + + + {loading && items.length === 0 ? ( + + - )} - {list.columns.map((col, colIndex) => { - const cell = renderCellValue( - item[col.name], - fields[col.name], - col.name, - schema!, - resolved.obj.objectName, - getDisplayName, - ); - return ( - + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((item) => { + const itemId = item.id as string; + return ( + 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 && ( + - ); - })} - {hasRowCell && ( - - )} - - ); - }) - )} - -
+ 0 && selectedIds.size === items.length} + onCheckedChange={toggleSelectAll} + aria-label={t('list.selectAll', 'Select all')} + /> + +
+ {col.label} + {renderSortIndicator(col.name)} +
+
+ {t('list.actions', 'Actions')} +
- -
- {hasMassActions && ( - e.stopPropagation()}> - toggleSelectItem(itemId)} - aria-label={t('list.selectItem', 'Select item')} - /> - - {colIndex === 0 && item[col.name] != null ? ( - <> - - {cell} - - {directoryLocks.badge(item)} - - ) : ( - cell - )} + +
+ {emptyState} +
e.stopPropagation()}> + toggleSelectItem(itemId)} + aria-label={t('list.selectItem', 'Select item')} + /> -
- {explainLogs && item.id != null && ( - - )} - {renderItemActions(item)} -
-
+ )} + {list.columns.map((col, colIndex) => { + const cell = renderCellValue( + item[col.name], + fields[col.name], + col.name, + schema!, + resolved.obj.objectName, + getDisplayName, + ); + return ( + + {colIndex === 0 && item[col.name] != null ? ( + <> + + {cell} + + {directoryLocks.badge(item)} + + ) : ( + cell + )} + + ); + })} + {hasRowCell && ( + +
+ {explainLogs && item.id != null && ( + + )} + {renderItemActions(item)} +
+ + )} + + ); + }) + )} + + +
- + )} {items.length > 0 && (
diff --git a/src/components/lists/ListEmptyState.tsx b/src/components/lists/ListEmptyState.tsx new file mode 100644 index 0000000..57c6b45 --- /dev/null +++ b/src/components/lists/ListEmptyState.tsx @@ -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 ( + + + {t('empty.clearFilters', 'Clear filters')} + + } + /> + ); + } + + 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 ( + + + {t('list.create', 'Create {{name}}', { name: singular })} + + ) + } + /> + ); +} diff --git a/src/features/celebrate/FirstDomainCelebration.tsx b/src/features/celebrate/FirstDomainCelebration.tsx new file mode 100644 index 0000000..2cbbdaf --- /dev/null +++ b/src/features/celebrate/FirstDomainCelebration.tsx @@ -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 ( + !open && close()}> + + + + + {t('firstDomain.title', 'Your first domain is in')} + + + {t( + 'firstDomain.body', + '{{name}} is on the server. Once its DNS records are published, mail for it starts arriving here.', + { name: domain?.name ?? '' }, + )} + + +
+ + +
+
+
+ ); +} diff --git a/src/features/celebrate/firstDomain.ts b/src/features/celebrate/firstDomain.ts new file mode 100644 index 0000000..5f7f0ab --- /dev/null +++ b/src/features/celebrate/firstDomain.ts @@ -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((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 { + 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. + } +} diff --git a/src/features/people/PersonCard.tsx b/src/features/people/PersonCard.tsx new file mode 100644 index 0000000..1d4ca2f --- /dev/null +++ b/src/features/people/PersonCard.tsx @@ -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 ( + + ); +} + +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 ( +
{ + 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', + )} + > +
+ {onToggleSelect && ( +
+ +
+ )} + +
+

{person.name ?? person.address}

+ {person.name &&

{person.address}

} + {badge} +
+ {actions && ( +
+ {actions} +
+ )} +
+ +
+ +
+

{storage}

+ {person.fill !== null && ( +

+ {t('people.percentFull', '{{percent}}% full', { percent: Math.round(person.fill * 100) })} +

+ )} +
+
+ +
+ {person.role !== 'user' && ( + + {person.role === 'admin' ? t('hover.roleAdmin', 'Administrator') : t('hover.roleCustom', 'Custom role')} + + )} + {person.groups > 0 && ( + + {t('people.groups', { + count: person.groups, + defaultValue_one: 'In {{count}} group', + defaultValue_other: 'In {{count}} groups', + })} + + )} + {person.createdAt && ( + + {t('people.since', 'Since {{date}}', { + date: new Date(person.createdAt).toLocaleDateString(i18n.language, { dateStyle: 'medium' }), + })} + + )} +
+
+ ); +} diff --git a/src/features/people/person.test.ts b/src/features/people/person.test.ts new file mode 100644 index 0000000..17700f8 --- /dev/null +++ b/src/features/people/person.test.ts @@ -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', 'ada@example.org')).toBe('AL'); + expect(initialsOf('Cher', 'cher@example.org')).toBe('CH'); + expect(initialsOf(undefined, 'j.doe@example.org')).toBe('JD'); + expect(initialsOf(' ', 'émile@example.org')).toBe('ÉM'); + expect(initialsOf(undefined, '@')).toBe('?'); + }); +}); + +describe('hueOf', () => { + it('is the same for the same address, whatever its case, and in range', () => { + expect(hueOf('Ada@Example.org')).toBe(hueOf('ada@example.org')); + const h = hueOf('someone@example.org'); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThan(360); + }); +}); + +describe('personOf', () => { + it('reads a People row', () => { + const p = personOf({ + id: 'a', + emailAddress: 'ada@example.org', + 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: 'ada@example.org', + 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: 'bo@example.org', 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); + }); +}); diff --git a/src/features/people/person.ts b/src/features/people/person.ts new file mode 100644 index 0000000..0b1315c --- /dev/null +++ b/src/features/people/person.ts @@ -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): 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; + 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. + } +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 9d4976e..9debc5f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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" } } diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 0c545c2..ec3c84e 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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')} + {useSectionNav && currentLayout && }
-- 2.54.0