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 && }