@fougere/admin 0.3.0-alpha.0 → 0.5.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,611 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Box,
5
+ Button,
6
+ ButtonBase,
7
+ Card,
8
+ CardContent,
9
+ Chip,
10
+ Skeleton,
11
+ SvgIcon,
12
+ Typography,
13
+ type SvgIconProps,
14
+ } from '@mui/material';
15
+ import {
16
+ createContext,
17
+ useContext,
18
+ useEffect,
19
+ useMemo,
20
+ useState,
21
+ type ComponentType,
22
+ type ReactElement,
23
+ } from 'react';
24
+ import {
25
+ useDataProvider,
26
+ useLocaleState,
27
+ useRedirect,
28
+ useResourceDefinitions,
29
+ useTranslate,
30
+ Title,
31
+ type ResourceOptions,
32
+ } from 'react-admin';
33
+ import type { AdminFacets, EditorialFacet, UsersFacet } from './facets.js';
34
+
35
+ export interface FougereResourceOptions extends ResourceOptions {
36
+ primary: string;
37
+ facets: AdminFacets;
38
+ /** The frond that owns this door — the card groups by it, so the panel can too. */
39
+ frond?: string;
40
+ /** What the door answers, with each op's kind. `query` reads, `command` writes. */
41
+ operations?: readonly { name: string; kind: 'query' | 'command' }[];
42
+ /** How many columns the shape yields — a rough measure of an entity's width. */
43
+ fieldCount?: number;
44
+ }
45
+
46
+ export interface FougereDashboardResource {
47
+ name: string;
48
+ label: string;
49
+ primary: string;
50
+ facets: AdminFacets;
51
+ hasCreate: boolean;
52
+ hasEdit: boolean;
53
+ hasShow: boolean;
54
+ total: number;
55
+ rows: Record<string, unknown>[];
56
+ states: Record<string, number>;
57
+ }
58
+
59
+ export interface FougereDashboardMetrics {
60
+ content: number;
61
+ drafts: number;
62
+ published: number;
63
+ users: number;
64
+ }
65
+
66
+ export interface FougereDashboardContextValue {
67
+ loading: boolean;
68
+ resources: FougereDashboardResource[];
69
+ editorial: FougereDashboardResource[];
70
+ users: FougereDashboardResource[];
71
+ metrics: FougereDashboardMetrics;
72
+ navigate(view: 'list' | 'show' | 'edit' | 'create', resource: string, id?: string | number): void;
73
+ }
74
+
75
+ const DashboardContext = createContext<FougereDashboardContextValue | undefined>(undefined);
76
+
77
+ /** Data shared by built-in and contributed widgets. */
78
+ export function useFougereDashboard(): FougereDashboardContextValue {
79
+ const context = useContext(DashboardContext);
80
+ if (!context) throw new Error('useFougereDashboard must be used inside FougereDashboard');
81
+ return context;
82
+ }
83
+
84
+ export type FougereDashboardZone = 'hero' | 'metrics' | 'main';
85
+
86
+ export interface FougereDashboardWidget {
87
+ id: string;
88
+ zone: FougereDashboardZone;
89
+ /** Twelve-column width for `main`, four-column width for `metrics`. */
90
+ span: number;
91
+ component: ComponentType;
92
+ hidden?: boolean;
93
+ }
94
+
95
+ /** A delta over stable widget ids; a component on a new id contributes a new widget. */
96
+ export interface FougereDashboardExtension {
97
+ widget: string;
98
+ component?: ComponentType;
99
+ zone?: FougereDashboardZone;
100
+ span?: number;
101
+ hidden?: boolean;
102
+ before?: string;
103
+ after?: string;
104
+ }
105
+
106
+ export function applyDashboardExtensions(
107
+ defaults: readonly FougereDashboardWidget[],
108
+ extensions: readonly FougereDashboardExtension[] = [],
109
+ ): FougereDashboardWidget[] {
110
+ const widgets = defaults.map((widget) => ({ ...widget }));
111
+ for (const extension of extensions) {
112
+ let index = widgets.findIndex((widget) => widget.id === extension.widget);
113
+ if (index === -1) {
114
+ if (!extension.component) {
115
+ throw new Error(`Dashboard widget '${extension.widget}' does not exist; a new widget needs a component`);
116
+ }
117
+ widgets.push({
118
+ id: extension.widget,
119
+ component: extension.component,
120
+ zone: extension.zone ?? 'main',
121
+ span: extension.span ?? 4,
122
+ hidden: extension.hidden,
123
+ });
124
+ index = widgets.length - 1;
125
+ } else {
126
+ const current = widgets[index]!;
127
+ widgets[index] = {
128
+ ...current,
129
+ ...(extension.component ? { component: extension.component } : {}),
130
+ ...(extension.zone ? { zone: extension.zone } : {}),
131
+ ...(extension.span !== undefined ? { span: extension.span } : {}),
132
+ ...(extension.hidden !== undefined ? { hidden: extension.hidden } : {}),
133
+ };
134
+ }
135
+
136
+ const anchorId = extension.before ?? extension.after;
137
+ if (!anchorId) continue;
138
+ const moving = widgets.splice(index, 1)[0]!;
139
+ const anchor = widgets.findIndex((widget) => widget.id === anchorId);
140
+ if (anchor === -1) widgets.push(moving);
141
+ else widgets.splice(anchor + (extension.after ? 1 : 0), 0, moving);
142
+ }
143
+ return widgets.filter((widget) => !widget.hidden);
144
+ }
145
+
146
+ type IconProps = SvgIconProps;
147
+
148
+ export const FougereContentIcon = (props: IconProps) => (
149
+ <SvgIcon {...props}><path d="M6 2h9l5 5v15H6a2 2 0 0 1-2-2V4c0-1.1.9-2 2-2Zm8 2H6v16h12V8h-4V4Zm-6 7h8v2H8v-2Zm0 4h8v2H8v-2Z" /></SvgIcon>
150
+ );
151
+ const DraftIcon = (props: IconProps) => (
152
+ <SvgIcon {...props}><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm1 11H7v-2h4V5h2v8Z" /></SvgIcon>
153
+ );
154
+ const PublishedIcon = (props: IconProps) => (
155
+ <SvgIcon {...props}><path d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2Z" /></SvgIcon>
156
+ );
157
+ export const FougereUsersIcon = (props: IconProps) => (
158
+ <SvgIcon {...props}><path d="M16 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3Zm-8 0c1.66 0 3-1.34 3-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3Zm0 2c-2.33 0-7 1.17-7 3.5V19h10v-2.5c0-.87.34-1.62.91-2.26C10.55 13.41 8.9 13 8 13Zm8 0c-.9 0-2.55.41-3.91 1.24.57.64.91 1.39.91 2.26V19h10v-2.5c0-2.33-4.67-3.5-7-3.5Z" /></SvgIcon>
159
+ );
160
+ const ArrowIcon = (props: IconProps) => (
161
+ <SvgIcon {...props}><path d="m9.3 5.3 6.7 6.7-6.7 6.7-1.4-1.4 5.3-5.3-5.3-5.3 1.4-1.4Z" /></SvgIcon>
162
+ );
163
+ const AddIcon = (props: IconProps) => (
164
+ <SvgIcon {...props}><path d="M11 5h2v14h-2zM5 11h14v2H5z" /></SvgIcon>
165
+ );
166
+
167
+ const unique = (values: readonly (string | undefined)[]): string[] =>
168
+ [...new Set(values.filter((value): value is string => !!value))];
169
+
170
+ /**
171
+ * Every visible sentence, under a key, with the English wording as its fallback.
172
+ *
173
+ * The keys live under `fougere.admin.*` — react-admin's own namespace is
174
+ * `resources.*` and `ra.*`, and writing into it would collide with what it ships.
175
+ * This is the same convention `FormField.labelKey` states for a field: the schema
176
+ * carries no display text, and neither does a widget.
177
+ */
178
+ function useLabels() {
179
+ const translate = useTranslate();
180
+ return (key: string, fallback: string, options?: Record<string, unknown>) =>
181
+ translate(`fougere.admin.${key}`, { _: fallback, ...options });
182
+ }
183
+
184
+ /**
185
+ * What names a row, and nothing is guessed.
186
+ *
187
+ * A declared facet says which field is the title; the primary key is the fallback,
188
+ * because a row always has one and it identifies without pretending to describe.
189
+ * The list here used to continue `'title', 'name', 'email'` — recognition by an
190
+ * English word, which is what this package stopped doing on 2026-08-21: an entity
191
+ * spelling `titre` got its id, and nothing said the facet was missing.
192
+ */
193
+ function titleOf(row: Record<string, unknown>, resource: FougereDashboardResource): string {
194
+ const editorial = resource.facets.editorial as EditorialFacet | undefined;
195
+ const users = resource.facets.users as UsersFacet | undefined;
196
+ for (const key of unique([editorial?.title, users?.name, resource.primary])) {
197
+ const value = row[key];
198
+ if (typeof value === 'string' || typeof value === 'number') return String(value);
199
+ }
200
+ return '';
201
+ }
202
+
203
+ /** When a row last moved — from the declared facet only, for the reason `titleOf` gives. */
204
+ function timestampOf(row: Record<string, unknown>, resource: FougereDashboardResource): number {
205
+ const editorial = resource.facets.editorial as EditorialFacet | undefined;
206
+ for (const key of unique([editorial?.updatedAt, editorial?.createdAt])) {
207
+ const value = row[key];
208
+ if (typeof value === 'string' || value instanceof Date) {
209
+ const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
210
+ if (!Number.isNaN(timestamp)) return timestamp;
211
+ }
212
+ }
213
+ return 0;
214
+ }
215
+
216
+ /**
217
+ * The viewer's locale, never a fixed one: react-admin already holds which language is
218
+ * on, and `Intl` is the only thing here that formats without a translation key.
219
+ */
220
+ const dateLabel = (timestamp: number, locale: string, fallback: string): string => timestamp
221
+ ? new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(timestamp)
222
+ : fallback;
223
+
224
+ function MetricCard({
225
+ label,
226
+ value,
227
+ hint,
228
+ icon,
229
+ }: {
230
+ label: string;
231
+ value: number;
232
+ hint: string;
233
+ icon: ReactElement;
234
+ }): ReactElement {
235
+ const { loading } = useFougereDashboard();
236
+ return (
237
+ <Card sx={{ height: '100%' }}>
238
+ <CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
239
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
240
+ <Box sx={{ display: 'grid', placeItems: 'center', fontSize: 18, opacity: .7 }}>{icon}</Box>
241
+ <Typography variant="body2" sx={{ fontWeight: 600 }}>{label}</Typography>
242
+ </Box>
243
+ {/* The number is the message, so it is the biggest thing in the card and the
244
+ first one read. The icon identifies the row it belongs to; it does not
245
+ compete for the same weight. */}
246
+ {loading
247
+ ? <Skeleton width={72} height={44} sx={{ mt: .75 }} />
248
+ : (
249
+ <Typography sx={{ mt: .5, fontSize: '2rem', lineHeight: 1.05, fontWeight: 680, letterSpacing: '-.045em', fontVariantNumeric: 'tabular-nums' }}>
250
+ {value}
251
+ </Typography>
252
+ )}
253
+ <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: .75 }}>{hint}</Typography>
254
+ </CardContent>
255
+ </Card>
256
+ );
257
+ }
258
+
259
+ function OverviewWidget(): ReactElement {
260
+ const { editorial, users, navigate } = useFougereDashboard();
261
+ const t = useLabels();
262
+ const [locale] = useLocaleState();
263
+ const content = editorial.find((resource) => resource.hasCreate);
264
+ const user = users.find((resource) => resource.hasCreate);
265
+ const today = new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long' }).format(new Date());
266
+
267
+ return (
268
+ <Box sx={{
269
+ display: 'flex', alignItems: { xs: 'flex-start', sm: 'flex-end' },
270
+ justifyContent: 'space-between', flexDirection: { xs: 'column', sm: 'row' },
271
+ gap: 2, mb: 1,
272
+ }}>
273
+ <Box>
274
+ <Typography variant="overline" sx={{ color: 'text.secondary', letterSpacing: '.08em' }}>
275
+ {today}
276
+ </Typography>
277
+ <Typography component="h1" sx={{ fontSize: { xs: '1.5rem', md: '1.75rem' }, fontWeight: 660, letterSpacing: '-.03em', mt: .25 }}>
278
+ {t('overview.title', 'Overview')}
279
+ </Typography>
280
+ </Box>
281
+ {/* One primary action per screen. A second would make neither of them the answer. */}
282
+ <Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
283
+ {user && (
284
+ <Button variant="text" startIcon={<FougereUsersIcon />} onClick={() => navigate('create', user.name)}>
285
+ {t('action.inviteUser', 'Invite a user')}
286
+ </Button>
287
+ )}
288
+ {content && (
289
+ <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('create', content.name)}>
290
+ {t('action.createContent', 'Create content')}
291
+ </Button>
292
+ )}
293
+ </Box>
294
+ </Box>
295
+ );
296
+ }
297
+
298
+ const ContentMetricWidget = () => {
299
+ const { metrics, editorial } = useFougereDashboard();
300
+ const t = useLabels();
301
+ return <MetricCard label={t('metric.content', 'Content')} value={metrics.content} hint={t('metric.contentHint', `${editorial.length} collections`, { smart_count: editorial.length })} icon={<FougereContentIcon />} />;
302
+ };
303
+ const DraftMetricWidget = () => {
304
+ const { metrics } = useFougereDashboard();
305
+ const t = useLabels();
306
+ return <MetricCard label={t('metric.drafts', 'Drafts')} value={metrics.drafts} hint={t('metric.draftsHint', 'To finish or review')} icon={<DraftIcon />} />;
307
+ };
308
+ const PublishedMetricWidget = () => {
309
+ const { metrics } = useFougereDashboard();
310
+ const t = useLabels();
311
+ return <MetricCard label={t('metric.published', 'Published')} value={metrics.published} hint={t('metric.publishedHint', 'Currently visible')} icon={<PublishedIcon />} />;
312
+ };
313
+ const UsersMetricWidget = () => {
314
+ const { metrics, users } = useFougereDashboard();
315
+ const t = useLabels();
316
+ return <MetricCard label={t('metric.users', 'Users')} value={metrics.users} hint={users.length ? t('metric.usersHint', 'Managed accounts') : t('metric.noUsersFacet', 'No users facet declared')} icon={<FougereUsersIcon />} />;
317
+ };
318
+
319
+ function RecentContentWidget(): ReactElement {
320
+ const { loading, editorial, navigate } = useFougereDashboard();
321
+ const t = useLabels();
322
+ const [locale] = useLocaleState();
323
+ const recent = useMemo(() => editorial.flatMap((resource) => resource.rows.map((row) => ({
324
+ resource, row, date: timestampOf(row, resource),
325
+ }))).sort((a, b) => b.date - a.date).slice(0, 6), [editorial]);
326
+ return (
327
+ <Card sx={{ height: '100%' }}>
328
+ <CardContent sx={{ p: 0, '&:last-child': { pb: 0 } }}>
329
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2.5, pt: 2.25, pb: 1.75 }}>
330
+ <Box><Typography variant="h5">{t('recent.title', 'Recent activity')}</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: .4 }}>{t('recent.subtitle', 'The most recently edited content')}</Typography></Box>
331
+ {editorial[0] && <Button size="small" endIcon={<ArrowIcon />} onClick={() => navigate('list', editorial[0]!.name)}>{t('action.seeAll', 'See all')}</Button>}
332
+ </Box>
333
+ {loading ? <Box sx={{ px: 2.5, pb: 2.5 }}>{[1, 2, 3, 4].map((key) => <Skeleton key={key} height={58} />)}</Box>
334
+ : recent.length === 0 ? <Typography color="text.secondary" sx={{ px: 2.5, pb: 2.5 }}>{t('recent.empty', 'No content yet.')}</Typography>
335
+ : recent.map(({ resource, row, date }, index) => {
336
+ const facet = resource.facets.editorial as EditorialFacet;
337
+ const state = facet.state && typeof row[facet.state.field] === 'string' ? String(row[facet.state.field]) : undefined;
338
+ const id = row.id as string | number | undefined;
339
+ return (
340
+ <ButtonBase key={`${resource.name}-${String(id ?? index)}`}
341
+ onClick={() => resource.hasShow && id !== undefined ? navigate('show', resource.name, id) : navigate('list', resource.name)}
342
+ sx={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) auto auto', alignItems: 'center', gap: 2, width: '100%', minHeight: 60, px: 2.5, py: 1.2, borderTop: 1, borderColor: 'divider', textAlign: 'left', '&:hover': { bgcolor: 'action.hover' }, '&.Mui-focusVisible': { outline: 2, outlineOffset: -2, outlineColor: 'primary.main' } }}>
343
+ <Box sx={{ minWidth: 0 }}><Typography noWrap sx={{ fontWeight: 650 }}>{titleOf(row, resource)}</Typography><Typography variant="caption" color="text.secondary">{resource.label}</Typography></Box>
344
+ {state && <Chip label={state} size="small" />}
345
+ <Typography variant="caption" color="text.secondary" sx={{ minWidth: 52, textAlign: 'right' }}>{dateLabel(date, locale, t('recent.undated', 'Recently'))}</Typography>
346
+ </ButtonBase>
347
+ );
348
+ })}
349
+ </CardContent>
350
+ </Card>
351
+ );
352
+ }
353
+
354
+ function UsersWidget(): ReactElement {
355
+ const { loading, users, navigate } = useFougereDashboard();
356
+ const t = useLabels();
357
+ const resource = users[0];
358
+ const facet = resource?.facets.users as UsersFacet | undefined;
359
+ return (
360
+ <Card sx={{ height: '100%' }}>
361
+ <CardContent sx={{ p: 0, '&:last-child': { pb: 0 } }}>
362
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2.5, pt: 2.25, pb: 1.75 }}>
363
+ <Box><Typography variant="h5">{t('users.title', 'Users')}</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: .4 }}>{t('users.subtitle', 'Access and roles')}</Typography></Box>
364
+ {resource?.hasCreate && <Button size="small" startIcon={<AddIcon />} onClick={() => navigate('create', resource.name)}>{t('action.invite', 'Invite')}</Button>}
365
+ </Box>
366
+ {loading ? <Box sx={{ px: 2.5, pb: 2.5 }}>{[1, 2, 3].map((key) => <Skeleton key={key} height={58} />)}</Box>
367
+ : !resource || !facet ? <Typography color="text.secondary" sx={{ px: 2.5, pb: 2.5 }}>{t('users.empty', 'Declare a `users` facet to fill this panel.')}</Typography>
368
+ : resource.rows.slice(0, 5).map((row, index) => {
369
+ const id = row.id as string | number | undefined;
370
+ const role = facet.role && typeof row[facet.role] === 'string' ? String(row[facet.role]) : undefined;
371
+ const state = facet.state && typeof row[facet.state.field] === 'string' ? String(row[facet.state.field]) : undefined;
372
+ return (
373
+ <ButtonBase key={String(id ?? index)}
374
+ onClick={() => id !== undefined && resource.hasEdit ? navigate('edit', resource.name, id) : navigate('list', resource.name)}
375
+ sx={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) auto', alignItems: 'center', gap: 1.5, width: '100%', minHeight: 60, px: 2.5, py: 1.1, borderTop: 1, borderColor: 'divider', textAlign: 'left', '&:hover': { bgcolor: 'action.hover' }, '&.Mui-focusVisible': { outline: 2, outlineOffset: -2, outlineColor: 'primary.main' } }}>
376
+ <Box sx={{ minWidth: 0 }}><Typography noWrap sx={{ fontWeight: 650 }}>{titleOf(row, resource)}</Typography><Typography variant="caption" color="text.secondary" noWrap>{role ?? (facet.email ? String(row[facet.email] ?? '') : '')}</Typography></Box>
377
+ {state && <Chip label={state} size="small" />}
378
+ </ButtonBase>
379
+ );
380
+ })}
381
+ {resource && <Box sx={{ borderTop: 1, borderColor: 'divider', p: 1.25 }}><Button fullWidth endIcon={<ArrowIcon />} onClick={() => navigate('list', resource.name)}>{t('action.manageUsers', 'Manage users')}</Button></Box>}
382
+ </CardContent>
383
+ </Card>
384
+ );
385
+ }
386
+
387
+ /**
388
+ * What the application IS, as opposed to what it holds.
389
+ *
390
+ * Every other widget counts rows; this one counts the shape that produced them —
391
+ * fronds, doors, operations split by kind, and how wide each entity is. All of it comes
392
+ * from the same card the menu was built from, so it costs no query at all.
393
+ *
394
+ * **Remotes are not in it, and the card is why.** `identityCardOf` maps `app.fronds`,
395
+ * which holds what this process scanned; a frond named in `remotes:` lives in a separate
396
+ * index (`boot/remote.ts`), discovered lazily at the first miss. So a consumer's card
397
+ * announces its own fronds and never the ones it routes to. Saying "1 frond" while three
398
+ * answer would be a lie the panel invented, so the count is labelled for what it is.
399
+ */
400
+ function StructureWidget(): ReactElement {
401
+ const t = useLabels();
402
+ const definitions = useResourceDefinitions();
403
+
404
+ const structure = useMemo(() => {
405
+ const byFrond = new Map<string, { doors: number; queries: number; commands: number; fields: number }>();
406
+ let queries = 0;
407
+ let commands = 0;
408
+ let fields = 0;
409
+ for (const definition of Object.values(definitions)) {
410
+ const options = definition.options as FougereResourceOptions | undefined;
411
+ const frond = options?.frond ?? '—';
412
+ const ops = options?.operations ?? [];
413
+ const q = ops.filter((op) => op.kind === 'query').length;
414
+ const c = ops.length - q;
415
+ const width = options?.fieldCount ?? 0;
416
+ queries += q; commands += c; fields += width;
417
+ const held = byFrond.get(frond) ?? { doors: 0, queries: 0, commands: 0, fields: 0 };
418
+ byFrond.set(frond, {
419
+ doors: held.doors + 1,
420
+ queries: held.queries + q,
421
+ commands: held.commands + c,
422
+ fields: held.fields + width,
423
+ });
424
+ }
425
+ return {
426
+ fronds: [...byFrond.entries()].map(([name, counts]) => ({ name, ...counts })),
427
+ doors: Object.keys(definitions).length,
428
+ queries, commands, fields,
429
+ };
430
+ }, [definitions]);
431
+
432
+ const totals: [string, string, number][] = [
433
+ ['structure.fronds', 'Fronds', structure.fronds.length],
434
+ ['structure.doors', 'Doors', structure.doors],
435
+ ['structure.queries', 'Queries', structure.queries],
436
+ ['structure.commands', 'Commands', structure.commands],
437
+ ];
438
+
439
+ return (
440
+ <Card sx={{ height: '100%' }}>
441
+ <CardContent sx={{ p: 0, '&:last-child': { pb: 0 } }}>
442
+ <Box sx={{ px: 2.5, pt: 2.25, pb: 1.75 }}>
443
+ <Typography variant="h5">{t('structure.title', 'Structure')}</Typography>
444
+ <Typography variant="body2" color="text.secondary" sx={{ mt: .4 }}>
445
+ {t('structure.subtitle', 'What the identity card announces about this app')}
446
+ </Typography>
447
+ </Box>
448
+
449
+ <Box sx={{
450
+ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0,1fr))',
451
+ borderTop: 1, borderColor: 'divider',
452
+ }}>
453
+ {totals.map(([key, fallback, value], index) => (
454
+ <Box key={key} sx={{ px: 2.5, py: 1.75, borderLeft: index ? 1 : 0, borderColor: 'divider' }}>
455
+ <Typography sx={{ fontSize: '1.5rem', fontWeight: 680, letterSpacing: '-.04em', fontVariantNumeric: 'tabular-nums' }}>
456
+ {value}
457
+ </Typography>
458
+ <Typography variant="caption" color="text.secondary">{t(key, fallback)}</Typography>
459
+ </Box>
460
+ ))}
461
+ </Box>
462
+
463
+ {structure.fronds.map((frond) => (
464
+ <Box key={frond.name} sx={{
465
+ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) auto', alignItems: 'center',
466
+ gap: 2, px: 2.5, py: 1.4, borderTop: 1, borderColor: 'divider',
467
+ }}>
468
+ <Box sx={{ minWidth: 0 }}>
469
+ <Typography noWrap sx={{ fontWeight: 640 }}>{frond.name}</Typography>
470
+ <Typography variant="caption" color="text.secondary">
471
+ {t('structure.frondDoors', `${frond.doors} doors`, { smart_count: frond.doors })}
472
+ {' · '}
473
+ {t('structure.frondFields', `${frond.fields} fields`, { smart_count: frond.fields })}
474
+ </Typography>
475
+ </Box>
476
+ <Box sx={{ display: 'flex', gap: .75 }}>
477
+ <Chip size="small" label={`${frond.queries} ${t('structure.read', 'read')}`} />
478
+ <Chip size="small" label={`${frond.commands} ${t('structure.write', 'write')}`} />
479
+ </Box>
480
+ </Box>
481
+ ))}
482
+
483
+ <Box sx={{ px: 2.5, py: 1.5, borderTop: 1, borderColor: 'divider' }}>
484
+ <Typography variant="caption" color="text.secondary">
485
+ {t('structure.localOnly', 'Local fronds only — a card does not announce what it routes to. Topology says where a call goes.')}
486
+ </Typography>
487
+ </Box>
488
+ </CardContent>
489
+ </Card>
490
+ );
491
+ }
492
+
493
+ function CollectionsWidget(): ReactElement {
494
+ const t = useLabels();
495
+ const { loading, resources, navigate } = useFougereDashboard();
496
+ return (
497
+ <Card><CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
498
+ <Typography variant="h5">{t('collections.title', 'Collections')}</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: .4, mb: 2 }}>{t('collections.subtitle', 'Every door the card announced')}</Typography>
499
+ <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2,minmax(0,1fr))', lg: 'repeat(3,minmax(0,1fr))' }, gap: 1 }}>
500
+ {loading ? [1, 2, 3].map((key) => <Skeleton key={key} height={58} />) : resources.map((resource) => (
501
+ <Box key={resource.name} component="button" onClick={() => navigate('list', resource.name)} sx={{ appearance: 'none', width: '100%', display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25, color: 'text.primary', bgcolor: 'transparent', border: 0, borderRadius: 2, textAlign: 'left', cursor: 'pointer', font: 'inherit', '&:hover': { bgcolor: 'action.hover' } }}>
502
+ <Box sx={{ display: 'grid', placeItems: 'center', width: 34, height: 34, borderRadius: 2, color: 'primary.main', bgcolor: 'action.hover' }}>{resource.facets.users ? <FougereUsersIcon fontSize="small" /> : <FougereContentIcon fontSize="small" />}</Box>
503
+ <Box sx={{ minWidth: 0, flex: 1 }}><Typography noWrap sx={{ fontSize: '.875rem', fontWeight: 650 }}>{resource.label}</Typography><Typography variant="caption" color="text.secondary">{t('resource.rows', `${resource.total} rows`, { smart_count: resource.total })}</Typography></Box>
504
+ <ArrowIcon fontSize="small" sx={{ color: 'text.secondary' }} />
505
+ </Box>
506
+ ))}
507
+ </Box>
508
+ </CardContent></Card>
509
+ );
510
+ }
511
+
512
+ export const FOUGERE_DASHBOARD_WIDGETS: readonly FougereDashboardWidget[] = [
513
+ { id: 'fougere.overview', zone: 'hero', span: 12, component: OverviewWidget },
514
+ { id: 'fougere.content-total', zone: 'metrics', span: 1, component: ContentMetricWidget },
515
+ { id: 'fougere.drafts', zone: 'metrics', span: 1, component: DraftMetricWidget },
516
+ { id: 'fougere.published', zone: 'metrics', span: 1, component: PublishedMetricWidget },
517
+ { id: 'fougere.users-total', zone: 'metrics', span: 1, component: UsersMetricWidget },
518
+ { id: 'fougere.recent-content', zone: 'main', span: 8, component: RecentContentWidget },
519
+ { id: 'fougere.users', zone: 'main', span: 4, component: UsersWidget },
520
+ { id: 'fougere.structure', zone: 'main', span: 12, component: StructureWidget },
521
+ { id: 'fougere.collections', zone: 'main', span: 12, component: CollectionsWidget },
522
+ ];
523
+
524
+ const EMPTY_EXTENSIONS: readonly FougereDashboardExtension[] = [];
525
+
526
+ export function FougereDashboard({ extensions = EMPTY_EXTENSIONS }: { extensions?: readonly FougereDashboardExtension[] }): ReactElement {
527
+ const definitions = useResourceDefinitions<FougereResourceOptions>();
528
+ const dataProvider = useDataProvider();
529
+ const redirect = useRedirect();
530
+ const [resources, setResources] = useState<FougereDashboardResource[]>([]);
531
+ const [loading, setLoading] = useState(true);
532
+ const resourceKey = Object.values(definitions).filter((definition) => definition.hasList).map((definition) => definition.name).sort().join('\u0000');
533
+
534
+ useEffect(() => {
535
+ let active = true;
536
+ const listable = Object.values(definitions).filter((definition) => definition.hasList);
537
+ if (!listable.length) {
538
+ setResources([]);
539
+ setLoading(false);
540
+ return () => { active = false; };
541
+ }
542
+ setLoading(true);
543
+ void Promise.all(listable.map(async (definition): Promise<FougereDashboardResource> => {
544
+ const options = definition.options as FougereResourceOptions | undefined;
545
+ const facets = options?.facets ?? {};
546
+ const editorial = facets.editorial as EditorialFacet | undefined;
547
+ const stateValues = unique([...(editorial?.state?.draft ?? []), ...(editorial?.state?.published ?? [])]);
548
+ const primary = options?.primary ?? 'id';
549
+ try {
550
+ const page = await dataProvider.getList(definition.name, { pagination: { page: 1, perPage: 8 }, sort: { field: primary, order: 'DESC' }, filter: {} });
551
+ const states = editorial?.state ? Object.fromEntries(await Promise.all(stateValues.map(async (state) => {
552
+ try {
553
+ const filtered = await dataProvider.getList(definition.name, { pagination: { page: 1, perPage: 1 }, sort: { field: primary, order: 'DESC' }, filter: { [editorial.state!.field]: state } });
554
+ return [state, filtered.total ?? filtered.data.length] as const;
555
+ } catch { return [state, 0] as const; }
556
+ }))) : {};
557
+ return { name: definition.name, label: options?.label ?? definition.name, primary, facets, hasCreate: !!definition.hasCreate, hasEdit: !!definition.hasEdit, hasShow: !!definition.hasShow, total: page.total ?? page.data.length, rows: page.data as Record<string, unknown>[], states };
558
+ } catch {
559
+ return { name: definition.name, label: options?.label ?? definition.name, primary, facets, hasCreate: !!definition.hasCreate, hasEdit: !!definition.hasEdit, hasShow: !!definition.hasShow, total: 0, rows: [], states: {} };
560
+ }
561
+ })).then((loaded) => { if (active) setResources(loaded); }).finally(() => { if (active) setLoading(false); });
562
+ return () => { active = false; };
563
+ }, [dataProvider, resourceKey]);
564
+
565
+ const editorial = resources.filter((resource) => !!resource.facets.editorial);
566
+ const users = resources.filter((resource) => !!resource.facets.users);
567
+ const metrics = useMemo<FougereDashboardMetrics>(() => ({
568
+ content: editorial.reduce((sum, resource) => sum + resource.total, 0),
569
+ drafts: editorial.reduce((sum, resource) => {
570
+ const facet = resource.facets.editorial as EditorialFacet;
571
+ return sum + (facet.state?.draft ?? []).reduce((subtotal, state) => subtotal + (resource.states[state] ?? 0), 0);
572
+ }, 0),
573
+ published: editorial.reduce((sum, resource) => {
574
+ const facet = resource.facets.editorial as EditorialFacet;
575
+ return sum + (facet.state?.published ?? []).reduce((subtotal, state) => subtotal + (resource.states[state] ?? 0), 0);
576
+ }, 0),
577
+ users: users.reduce((sum, resource) => sum + resource.total, 0),
578
+ }), [editorial, users]);
579
+
580
+ const t = useLabels();
581
+ const context = useMemo<FougereDashboardContextValue>(() => ({
582
+ loading, resources, editorial, users, metrics,
583
+ navigate: (view, resource, id) => redirect(view, resource, id),
584
+ }), [loading, resources, editorial, users, metrics, redirect]);
585
+ const widgets = useMemo(() => applyDashboardExtensions(FOUGERE_DASHBOARD_WIDGETS, extensions), [extensions]);
586
+
587
+ const renderZone = (zone: FougereDashboardZone) => widgets.filter((widget) => widget.zone === zone).map((widget) => {
588
+ const Widget = widget.component;
589
+ const gridColumn = zone === 'main'
590
+ ? {
591
+ xs: 'span 12',
592
+ md: `span ${Math.min(12, Math.max(6, widget.span))}`,
593
+ lg: `span ${Math.min(12, Math.max(1, widget.span))}`,
594
+ }
595
+ : zone === 'metrics'
596
+ ? { xs: 'span 1', lg: `span ${Math.min(4, Math.max(1, widget.span))}` }
597
+ : undefined;
598
+ return <Box key={widget.id} sx={gridColumn ? { gridColumn } : undefined}><Widget /></Box>;
599
+ });
600
+
601
+ return (
602
+ <DashboardContext.Provider value={context}>
603
+ <Title title={t('overview.title', 'Overview')} />
604
+ <Box sx={{ width: '100%', maxWidth: 1440, mx: 'auto', pb: 4 }}>
605
+ <Box sx={{ display: 'grid', gap: 2, mb: 3.5 }}>{renderZone('hero')}</Box>
606
+ <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2,minmax(0,1fr))', lg: 'repeat(4,minmax(0,1fr))' }, gap: 2, mb: 3 }}>{renderZone('metrics')}</Box>
607
+ <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(12,minmax(0,1fr))', gap: 2 }}>{renderZone('main')}</Box>
608
+ </Box>
609
+ </DashboardContext.Provider>
610
+ );
611
+ }