@flaredev/cli 0.2.0 → 0.3.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.
Files changed (64) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +751 -280
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/page.tsx +6 -4
  6. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  7. package/templates/app/app/dashboard/import-actions.ts +91 -0
  8. package/templates/app/app/dashboard/layout.tsx +42 -39
  9. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  10. package/templates/app/app/dashboard/page.tsx +106 -25
  11. package/templates/app/app/verify-email/page.tsx +33 -0
  12. package/templates/app/components/account/security-settings.tsx +6 -9
  13. package/templates/app/components/auth/otp-input.tsx +95 -0
  14. package/templates/app/components/auth/password-field.tsx +116 -0
  15. package/templates/app/components/auth/password-forms.tsx +76 -79
  16. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  17. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  18. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  19. package/templates/app/components/auth/use-resend.ts +44 -0
  20. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  21. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  22. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
  23. package/templates/app/components/dashboard/export-button.tsx +41 -0
  24. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  25. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  26. package/templates/app/components/dashboard/page-header.tsx +57 -0
  27. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  28. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  29. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  30. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  31. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
  32. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  33. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  34. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  35. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  36. package/templates/app/db/flare-schema.ts +33 -1
  37. package/templates/app/lib/analytics.ts +121 -0
  38. package/templates/app/lib/audit.ts +80 -0
  39. package/templates/app/lib/auth-config.ts +38 -33
  40. package/templates/app/lib/auth.ts +34 -0
  41. package/templates/app/lib/cache.ts +83 -83
  42. package/templates/app/lib/csv.ts +24 -0
  43. package/templates/app/lib/dashboard-nav.ts +15 -0
  44. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  45. package/templates/app/lib/password-rules.ts +159 -0
  46. package/templates/app/lib/site.ts +56 -47
  47. package/templates/app/migrations/0000_init.sql +15 -3
  48. package/templates/app/migrations/meta/0000_snapshot.json +97 -1
  49. package/templates/app/migrations/meta/_journal.json +1 -1
  50. package/templates/app/worker-configuration.d.ts +15764 -0
  51. package/templates/locks/package-lock.json +9189 -0
  52. package/templates/locks/pnpm-lock.yaml +6347 -0
  53. package/templates/app/app/admin/layout.tsx +0 -32
  54. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  55. package/templates/app/lib/admin-nav.ts +0 -15
  56. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  57. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  58. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  59. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  60. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  61. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  62. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  63. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  64. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,55 +1,61 @@
1
- import { notFound } from "next/navigation";
2
- import { formatValue, storedFields, type Resource } from "@flaredev/core";
3
- import { adminPath, adminResources, adminStore, requireAccess } from "@/lib/admin";
4
- import type { RelationMeta } from "./fields/field-widget";
5
- import { ResourceForm } from "./resource-form";
6
-
7
- /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
8
- export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
9
- await requireAccess(resource, id ? "update" : "create");
10
- let record: Record<string, unknown> | null = null;
11
- if (id) {
12
- const result = await adminStore(resource.name).get(id);
13
- if (!result.ok) notFound();
14
- record = result.data;
15
- }
16
-
17
- const byName = new Map(adminResources().map((r) => [r.name, r]));
18
- const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
19
- for (const [key, def] of storedFields(resource)) {
20
- if (def.kind !== "belongsTo") continue;
21
- const target = byName.get(def.target);
22
- if (!target) continue;
23
- const value = record?.[key];
24
- const titles = typeof value === "string" ? await adminStore(target.name).titles([value]) : {};
25
- relations[key] = {
26
- name: target.name,
27
- label: target.label,
28
- pluralLabel: target.pluralLabel,
29
- slug: target.slug,
30
- titleField: target.titleField,
31
- initialTitle: typeof value === "string" ? titles[value] : undefined,
32
- };
33
- }
34
-
35
- const titleDef = resource.fields[resource.titleField];
36
- const title =
37
- record && titleDef && titleDef.kind !== "hasMany" ? formatValue(titleDef, record[resource.titleField]) : `New ${resource.label.toLowerCase()}`;
38
-
39
- return (
40
- <div className="flex flex-col gap-6">
41
- <div className="flex flex-col gap-1">
42
- <h1 className="text-2xl font-semibold">{title}</h1>
43
- {record && <p className="text-xs text-muted-foreground">Edit {resource.label.toLowerCase()}</p>}
44
- </div>
45
- <ResourceForm
46
- resource={resource}
47
- mode={record ? "edit" : "create"}
48
- id={id}
49
- record={record}
50
- relations={relations}
51
- listHref={adminPath(resource)}
52
- />
53
- </div>
54
- );
55
- }
1
+ import { notFound } from "next/navigation";
2
+ import { formatValue, storedFields, type Resource } from "@flaredev/core";
3
+ import { resourcePath, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
4
+ import type { RelationMeta } from "./fields/field-widget";
5
+ import { PageHeader } from "./page-header";
6
+ import { ResourceForm } from "./resource-form";
7
+
8
+ /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
9
+ export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
10
+ await requireAccess(resource, id ? "update" : "create");
11
+ let record: Record<string, unknown> | null = null;
12
+ if (id) {
13
+ const result = await dashboardStore(resource.name).get(id);
14
+ if (!result.ok) notFound();
15
+ record = result.data;
16
+ }
17
+
18
+ const byName = new Map(allResources().map((r) => [r.name, r]));
19
+ const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
20
+ for (const [key, def] of storedFields(resource)) {
21
+ if (def.kind !== "belongsTo") continue;
22
+ const target = byName.get(def.target);
23
+ if (!target) continue;
24
+ const value = record?.[key];
25
+ const titles = typeof value === "string" ? await dashboardStore(target.name).titles([value]) : {};
26
+ relations[key] = {
27
+ name: target.name,
28
+ label: target.label,
29
+ pluralLabel: target.pluralLabel,
30
+ slug: target.slug,
31
+ titleField: target.titleField,
32
+ initialTitle: typeof value === "string" ? titles[value] : undefined,
33
+ };
34
+ }
35
+
36
+ const titleDef = resource.fields[resource.titleField];
37
+ const title =
38
+ record && titleDef && titleDef.kind !== "hasMany" ? formatValue(titleDef, record[resource.titleField]) : `New ${resource.label.toLowerCase()}`;
39
+
40
+ return (
41
+ <div className="flex flex-col gap-6">
42
+ <PageHeader
43
+ title={title}
44
+ description={record ? `Edit this ${resource.label.toLowerCase()}.` : `Add a ${resource.label.toLowerCase()} to ${resource.pluralLabel.toLowerCase()}.`}
45
+ crumbs={[
46
+ { label: "Dashboard", href: "/dashboard" },
47
+ { label: resource.pluralLabel, href: resourcePath(resource) },
48
+ { label: record ? "Edit" : "New" },
49
+ ]}
50
+ />
51
+ <ResourceForm
52
+ resource={resource}
53
+ mode={record ? "edit" : "create"}
54
+ id={id}
55
+ record={record}
56
+ relations={relations}
57
+ listHref={resourcePath(resource)}
58
+ />
59
+ </div>
60
+ );
61
+ }
@@ -1,139 +1,165 @@
1
- "use client";
2
-
3
- import Link from "next/link";
4
- import { useRouter } from "next/navigation";
5
- import { useMemo, useState, useTransition, type FormEvent } from "react";
6
- import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type Resource } from "@flaredev/core";
7
- import { toast } from "sonner";
8
- import { createRecordAction, updateRecordAction } from "@/app/admin/actions";
9
- import { Alert, AlertDescription } from "@/components/ui/alert";
10
- import { Button } from "@/components/ui/button";
11
- import { Card, CardContent, CardFooter } from "@/components/ui/card";
12
- import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
13
- import { Spinner } from "@/components/ui/spinner";
14
- import { FieldWidget, type RelationMeta } from "./fields/field-widget";
15
-
16
- export interface ResourceFormProps {
17
- resource: Resource;
18
- mode: "create" | "edit";
19
- /** Record id (edit mode). */
20
- id?: string;
21
- record?: Record<string, unknown> | null;
22
- /** belongsTo field key → related resource metadata and the current value's title. */
23
- relations?: Record<string, RelationMeta & { initialTitle?: string }>;
24
- /** Where Cancel and a successful save go. */
25
- listHref: string;
26
- }
27
-
28
- const WIDE = new Set(["text", "file"]);
29
-
30
- /**
31
- * Create/edit form rendered from field metadata. Values are validated in the browser
32
- * with the descriptor's zod schemas, then again on the server by the same store the
33
- * REST API uses; server errors (e.g. unique conflicts) land on their fields.
34
- */
35
- export function ResourceForm({ resource, mode, id, record, relations = {}, listHref }: ResourceFormProps) {
36
- const router = useRouter();
37
- const validators = useMemo(() => createValidators(resource), [resource]);
38
- const fields = useMemo(() => storedFields(resource), [resource]);
39
- const [values, setValues] = useState(() => initialFormValues(resource, record));
40
- const [errors, setErrors] = useState<Record<string, string>>({});
41
- const [pending, startTransition] = useTransition();
42
-
43
- const setValue = (key: string, value: string | boolean) => {
44
- setValues((current) => ({ ...current, [key]: value }));
45
- if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
46
- };
47
-
48
- function focusFirstError(fieldErrors: Record<string, string>) {
49
- const first = fields.find(([key]) => fieldErrors[key]);
50
- if (first) document.getElementById(`field-${first[0]}`)?.focus();
51
- }
52
-
53
- function onSubmit(event: FormEvent) {
54
- event.preventDefault();
55
- const input = formValuesToInput(resource, values, mode);
56
- const parsed = (mode === "create" ? validators.create : validators.update).safeParse(input);
57
- if (!parsed.success) {
58
- const fieldErrors = issuesByField(parsed.error.issues);
59
- setErrors(fieldErrors);
60
- focusFirstError(fieldErrors);
61
- return;
62
- }
63
-
64
- startTransition(async () => {
65
- const result = mode === "create" ? await createRecordAction(resource.name, input) : await updateRecordAction(resource.name, id!, input);
66
- if (result.ok) {
67
- toast.success(`${resource.label} ${mode === "create" ? "created" : "saved"}.`);
68
- router.push(listHref);
69
- router.refresh();
70
- return;
71
- }
72
- const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
73
- setErrors(fieldErrors);
74
- focusFirstError(fieldErrors);
75
- });
76
- }
77
-
78
- return (
79
- <form onSubmit={onSubmit} noValidate>
80
- <Card>
81
- <CardContent>
82
- {errors._form && (
83
- <Alert variant="destructive" className="mb-6">
84
- <AlertDescription>{errors._form}</AlertDescription>
85
- </Alert>
86
- )}
87
- <FieldGroup className="grid gap-6 md:grid-cols-2">
88
- {fields.map(([key, def]) => {
89
- const error = errors[key];
90
- const widgetId = `field-${key}`;
91
- const horizontal = def.kind === "boolean";
92
- return (
93
- <Field
94
- key={key}
95
- data-invalid={error ? true : undefined}
96
- className={WIDE.has(def.kind) ? "md:col-span-2" : undefined}
97
- >
98
- <FieldLabel htmlFor={widgetId}>
99
- {def.label}
100
- {def.required && def.kind !== "boolean" && (
101
- <span className="ml-0.5 text-muted-foreground" aria-hidden>
102
- *
103
- </span>
104
- )}
105
- </FieldLabel>
106
- <div className={horizontal ? "flex h-9 items-center" : undefined}>
107
- <FieldWidget
108
- id={widgetId}
109
- name={key}
110
- resourceName={resource.name}
111
- fieldKey={key}
112
- field={def}
113
- value={values[key] ?? ""}
114
- onChange={(value) => setValue(key, value)}
115
- invalid={Boolean(error)}
116
- disabled={pending}
117
- relation={relations[key]}
118
- />
119
- </div>
120
- {def.helpText && <FieldDescription>{def.helpText}</FieldDescription>}
121
- {error && <FieldError>{error}</FieldError>}
122
- </Field>
123
- );
124
- })}
125
- </FieldGroup>
126
- </CardContent>
127
- <CardFooter className="justify-end gap-2 border-t">
128
- <Button variant="outline" asChild>
129
- <Link href={listHref}>Cancel</Link>
130
- </Button>
131
- <Button type="submit" disabled={pending}>
132
- {pending && <Spinner data-icon="inline-start" />}
133
- {mode === "create" ? `Create ${resource.label.toLowerCase()}` : "Save changes"}
134
- </Button>
135
- </CardFooter>
136
- </Card>
137
- </form>
138
- );
139
- }
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useMemo, useState, useTransition, type FormEvent } from "react";
6
+ import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type Resource } from "@flaredev/core";
7
+ import { toast } from "sonner";
8
+ import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
9
+ import { Alert, AlertDescription } from "@/components/ui/alert";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Card, CardContent, CardFooter } from "@/components/ui/card";
12
+ import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
13
+ import { Spinner } from "@/components/ui/spinner";
14
+ import { FieldWidget, type RelationMeta } from "./fields/field-widget";
15
+
16
+ export interface ResourceFormProps {
17
+ resource: Resource;
18
+ mode: "create" | "edit";
19
+ /** Record id (edit mode). */
20
+ id?: string;
21
+ record?: Record<string, unknown> | null;
22
+ /** belongsTo field key → related resource metadata and the current value's title. */
23
+ relations?: Record<string, RelationMeta & { initialTitle?: string }>;
24
+ /** Where Cancel and a successful save go, when the form owns the page. */
25
+ listHref: string;
26
+ /** In a dialog: close it instead of navigating, and drop the card around the fields. */
27
+ onDone?: () => void;
28
+ }
29
+
30
+ const WIDE = new Set(["text", "file"]);
31
+
32
+ /**
33
+ * Create/edit form rendered from field metadata. Values are validated in the browser
34
+ * with the descriptor's zod schemas, then again on the server by the same store the
35
+ * REST API uses; server errors (e.g. unique conflicts) land on their fields.
36
+ */
37
+ export function ResourceForm({ resource, mode, id, record, relations = {}, listHref, onDone }: ResourceFormProps) {
38
+ const router = useRouter();
39
+ const validators = useMemo(() => createValidators(resource), [resource]);
40
+ const fields = useMemo(() => storedFields(resource), [resource]);
41
+ const [values, setValues] = useState(() => initialFormValues(resource, record));
42
+ const [errors, setErrors] = useState<Record<string, string>>({});
43
+ const [pending, startTransition] = useTransition();
44
+
45
+ const setValue = (key: string, value: string | boolean) => {
46
+ setValues((current) => ({ ...current, [key]: value }));
47
+ if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
48
+ };
49
+
50
+ function focusFirstError(fieldErrors: Record<string, string>) {
51
+ const first = fields.find(([key]) => fieldErrors[key]);
52
+ if (first) document.getElementById(`field-${first[0]}`)?.focus();
53
+ }
54
+
55
+ function onSubmit(event: FormEvent) {
56
+ event.preventDefault();
57
+ const input = formValuesToInput(resource, values, mode);
58
+ const parsed = (mode === "create" ? validators.create : validators.update).safeParse(input);
59
+ if (!parsed.success) {
60
+ const fieldErrors = issuesByField(parsed.error.issues);
61
+ setErrors(fieldErrors);
62
+ focusFirstError(fieldErrors);
63
+ return;
64
+ }
65
+
66
+ startTransition(async () => {
67
+ const result = mode === "create" ? await createRecordAction(resource.name, input) : await updateRecordAction(resource.name, id!, input);
68
+ if (result.ok) {
69
+ toast.success(`${resource.label} ${mode === "create" ? "created" : "saved"}.`);
70
+ if (onDone) onDone();
71
+ else router.push(listHref);
72
+ router.refresh();
73
+ return;
74
+ }
75
+ const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
76
+ setErrors(fieldErrors);
77
+ focusFirstError(fieldErrors);
78
+ });
79
+ }
80
+
81
+ const Frame = onDone ? DialogFrame : Card;
82
+ const Body = onDone ? DialogBody : CardContent;
83
+ const Footer = onDone ? DialogFooterRow : CardFooter;
84
+
85
+ return (
86
+ <form onSubmit={onSubmit} noValidate>
87
+ <Frame>
88
+ <Body>
89
+ {errors._form && (
90
+ <Alert variant="destructive" className="mb-6">
91
+ <AlertDescription>{errors._form}</AlertDescription>
92
+ </Alert>
93
+ )}
94
+ <FieldGroup className="grid gap-6 md:grid-cols-2">
95
+ {fields.map(([key, def]) => {
96
+ const error = errors[key];
97
+ const widgetId = `field-${key}`;
98
+ const horizontal = def.kind === "boolean";
99
+ return (
100
+ <Field
101
+ key={key}
102
+ data-invalid={error ? true : undefined}
103
+ className={WIDE.has(def.kind) ? "md:col-span-2" : undefined}
104
+ >
105
+ <FieldLabel htmlFor={widgetId}>
106
+ {def.label}
107
+ {def.required && def.kind !== "boolean" && (
108
+ <span className="ml-0.5 text-muted-foreground" aria-hidden>
109
+ *
110
+ </span>
111
+ )}
112
+ </FieldLabel>
113
+ <div className={horizontal ? "flex h-9 items-center" : undefined}>
114
+ <FieldWidget
115
+ id={widgetId}
116
+ name={key}
117
+ resourceName={resource.name}
118
+ fieldKey={key}
119
+ field={def}
120
+ value={values[key] ?? ""}
121
+ onChange={(value) => setValue(key, value)}
122
+ invalid={Boolean(error)}
123
+ disabled={pending}
124
+ relation={relations[key]}
125
+ />
126
+ </div>
127
+ {def.helpText && <FieldDescription>{def.helpText}</FieldDescription>}
128
+ {error && <FieldError>{error}</FieldError>}
129
+ </Field>
130
+ );
131
+ })}
132
+ </FieldGroup>
133
+ </Body>
134
+ <Footer className="flex justify-end gap-2 border-t pt-4">
135
+ {onDone ? (
136
+ <Button type="button" variant="outline" onClick={onDone} disabled={pending}>
137
+ Cancel
138
+ </Button>
139
+ ) : (
140
+ <Button variant="outline" asChild>
141
+ <Link href={listHref}>Cancel</Link>
142
+ </Button>
143
+ )}
144
+ <Button type="submit" disabled={pending}>
145
+ {pending && <Spinner data-icon="inline-start" />}
146
+ {mode === "create" ? `Create ${resource.label.toLowerCase()}` : "Save changes"}
147
+ </Button>
148
+ </Footer>
149
+ </Frame>
150
+ </form>
151
+ );
152
+ }
153
+
154
+ /** Plain wrappers so the same form works in a page (a card) and in a dialog (no card). */
155
+ function DialogFrame({ children }: { children: React.ReactNode }) {
156
+ return <div className="flex flex-col gap-4">{children}</div>;
157
+ }
158
+
159
+ function DialogBody({ children }: { children: React.ReactNode }) {
160
+ return <div className="max-h-[60vh] overflow-y-auto px-1">{children}</div>;
161
+ }
162
+
163
+ function DialogFooterRow({ children, className }: { children: React.ReactNode; className?: string }) {
164
+ return <div className={className}>{children}</div>;
165
+ }
@@ -0,0 +1,53 @@
1
+ import { storedFields, optionLabel, type Resource } from "@flaredev/core";
2
+ import { dashboardStore, recentCounts, recordCount, trend } from "@/lib/dashboard";
3
+ import { resourceIcon } from "./resource-icon";
4
+ import { StatCards, type Stat } from "./stat-card";
5
+
6
+ /**
7
+ * The numbers above a resource's table: how many there are, how many are new, and how
8
+ * they split across the resource's own status field.
9
+ *
10
+ * The split comes from the first enum field the descriptor marks as filterable, which
11
+ * in practice is the status of the thing — a deal's stage, an invoice's state.
12
+ */
13
+ export async function ResourceStats({ resource }: { resource: Resource }) {
14
+ const [total, recent] = await Promise.all([recordCount(resource.name), recentCounts(resource.name)]);
15
+
16
+ const stats: Stat[] = [
17
+ {
18
+ label: `Total ${resource.pluralLabel.toLowerCase()}`,
19
+ value: total,
20
+ icon: resourceIcon(resource.icon),
21
+ },
22
+ {
23
+ label: "New this week",
24
+ value: recent.current,
25
+ change: trend(recent.current, recent.previous),
26
+ hint: `${recent.previous.toLocaleString()} the week before`,
27
+ },
28
+ ];
29
+
30
+ const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
31
+ if (statusField && total > 0) {
32
+ const [key, def] = statusField;
33
+ if (def.kind === "enum") {
34
+ const store = dashboardStore(resource.name);
35
+ // Two options at most: a strip of eight cards is a wall, not a summary.
36
+ const counts = await Promise.all(
37
+ def.options.slice(0, 2).map(async (option) => {
38
+ const result = await store.list(new URLSearchParams({ perPage: "1", [`filter[${key}]`]: option }));
39
+ return { option, total: result.ok ? result.data.meta.total : 0 };
40
+ }),
41
+ );
42
+ for (const { option, total: count } of counts) {
43
+ stats.push({
44
+ label: optionLabel(def, option),
45
+ value: count,
46
+ hint: total > 0 ? `${Math.round((count / total) * 100)}% of all` : undefined,
47
+ });
48
+ }
49
+ }
50
+ }
51
+
52
+ return <StatCards stats={stats.slice(0, 4)} />;
53
+ }