@flaredev/cli 0.1.2 → 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 (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,251 +1,331 @@
1
- import Link from "next/link";
2
- import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
3
- import { formatValue, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
- import { isSortable } from "@flaredev/core/server";
5
- import { Alert, AlertDescription } from "@/components/ui/alert";
6
- import { Badge } from "@/components/ui/badge";
7
- import { Button } from "@/components/ui/button";
8
- import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
9
- import {
10
- Pagination,
11
- PaginationContent,
12
- PaginationItem,
13
- PaginationLink,
14
- PaginationNext,
15
- PaginationPrevious,
16
- } from "@/components/ui/pagination";
17
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
18
- import { adminPath, adminPermissions, adminResources, adminStore, requireAccess } from "@/lib/admin";
19
- import { cn } from "@/lib/utils";
20
- import { hrefWith, toSearchParams, type SearchParams } from "./query";
21
- import { ResourceTableToolbar } from "./resource-table-toolbar";
22
- import { RowActions } from "./row-actions";
23
-
24
- type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
25
-
26
- const NUMERIC = new Set(["int", "float"]);
27
-
28
- /**
29
- * Paginated, sortable, filterable list of a resource's records. Everything comes from the
30
- * descriptor: columns (fields with `list`), sortable headers, search, and filters. State
31
- * lives in the URL (?page, ?sort, ?q, ?filter[field]), so views are linkable and work
32
- * without client-side data fetching.
33
- */
34
- export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
35
- await requireAccess(resource, "read");
36
- const basePath = adminPath(resource);
37
- const permissions = await adminPermissions(resource.name);
38
- const params = toSearchParams(searchParams);
39
- const store = adminStore(resource.name);
40
-
41
- let result = await store.list(params);
42
- let ignoredQuery = false;
43
- if (!result.ok) {
44
- ignoredQuery = true;
45
- result = await store.list(new URLSearchParams());
46
- }
47
- if (!result.ok) throw new Error(result.error);
48
- const { data: rows, meta } = result.data;
49
-
50
- const fields = storedFields(resource);
51
- const columns: Column[] = [
52
- ...fields.filter(([, def]) => def.list !== false).map(([key, def]) => ({ key, label: def.label, def })),
53
- { key: "createdAt", label: "Created", def: { kind: "timestamp" } },
54
- ];
55
-
56
- // Resolve belongsTo ids to their related record titles, one query per relation.
57
- const byName = new Map(adminResources().map((r) => [r.name, r]));
58
- const titles: Record<string, Record<string, string>> = {};
59
- await Promise.all(
60
- columns.map(async ({ key, def }) => {
61
- if (def.kind !== "belongsTo") return;
62
- const ids = rows.map((row) => row[key]).filter((id): id is string => typeof id === "string");
63
- titles[key] = byName.has(def.target) ? await adminStore(def.target).titles(ids) : {};
64
- }),
65
- );
66
-
67
- const sort = params.get("sort") ?? `${resource.defaultSort.direction === "desc" ? "-" : ""}${resource.defaultSort.field}`;
68
- const sortField = sort.replace(/^-/, "");
69
- const sortDescending = sort.startsWith("-");
70
- const activeFilters = [...params.keys()].filter((key) => key.startsWith("filter[")).length + (params.get("q") ? 1 : 0);
71
- const first = meta.total === 0 ? 0 : (meta.page - 1) * meta.perPage + 1;
72
- const last = Math.min(meta.page * meta.perPage, meta.total);
73
-
74
- return (
75
- <div className="flex flex-col gap-4">
76
- <div className="flex flex-wrap items-center justify-between gap-2">
77
- <ResourceTableToolbar resource={resource} />
78
- {permissions.create && (
79
- <Button asChild>
80
- <Link href={adminPath(resource, "new")}>
81
- <PlusIcon data-icon="inline-start" />
82
- New {resource.label.toLowerCase()}
83
- </Link>
84
- </Button>
85
- )}
86
- </div>
87
-
88
- {ignoredQuery && (
89
- <Alert>
90
- <AlertDescription>Some filters or sorting in the address weren&apos;t valid for {resource.pluralLabel.toLowerCase()} and were ignored.</AlertDescription>
91
- </Alert>
92
- )}
93
-
94
- {rows.length === 0 ? (
95
- <Empty className="rounded-lg border border-dashed">
96
- <EmptyHeader>
97
- <EmptyTitle>{activeFilters ? `No ${resource.pluralLabel.toLowerCase()} match` : `No ${resource.pluralLabel.toLowerCase()} yet`}</EmptyTitle>
98
- <EmptyDescription>
99
- {activeFilters ? "Try a different search or clear the filters." : `Create the first ${resource.label.toLowerCase()} to see it here.`}
100
- </EmptyDescription>
101
- </EmptyHeader>
102
- <EmptyContent>
103
- {activeFilters || !permissions.create ? (
104
- <Button variant="outline" asChild>
105
- <Link href={basePath}>Clear filters</Link>
106
- </Button>
107
- ) : (
108
- <Button asChild>
109
- <Link href={adminPath(resource, "new")}>
110
- <PlusIcon data-icon="inline-start" />
111
- New {resource.label.toLowerCase()}
112
- </Link>
113
- </Button>
114
- )}
115
- </EmptyContent>
116
- </Empty>
117
- ) : (
118
- <div className="overflow-hidden rounded-lg border bg-card">
119
- <Table>
120
- <TableHeader className="bg-muted">
121
- <TableRow className="hover:bg-muted">
122
- {columns.map((column) => {
123
- const sortable = column.key === "createdAt" || (column.def.kind !== "timestamp" && isSortable(column.def));
124
- const active = sortField === column.key;
125
- const nextSort = active && !sortDescending ? `-${column.key}` : column.key;
126
- const SortIcon = !active ? ChevronsUpDownIcon : sortDescending ? ArrowDownIcon : ArrowUpIcon;
127
- return (
128
- <TableHead key={column.key} className={cn(NUMERIC.has(column.def.kind) && "text-right")}>
129
- {sortable ? (
130
- <Link
131
- href={hrefWith(basePath, params, { sort: nextSort })}
132
- className="group inline-flex items-center gap-1 hover:text-foreground"
133
- aria-label={`Sort by ${column.label}`}
134
- >
135
- {column.label}
136
- <SortIcon className={cn("size-3.5", active ? "opacity-100" : "opacity-0 group-hover:opacity-60")} />
137
- </Link>
138
- ) : (
139
- column.label
140
- )}
141
- </TableHead>
142
- );
143
- })}
144
- <TableHead className="w-12">
145
- <span className="sr-only">Actions</span>
146
- </TableHead>
147
- </TableRow>
148
- </TableHeader>
149
- <TableBody>
150
- {rows.map((row) => {
151
- const id = String(row.id);
152
- return (
153
- <TableRow key={id}>
154
- {columns.map((column, index) => {
155
- const value = row[column.key];
156
- let content: React.ReactNode = formatValue(column.def, value);
157
- if (column.def.kind === "enum" && value != null) {
158
- const tone = statusTone(value);
159
- content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
160
- } else if (column.def.kind === "belongsTo" && typeof value === "string") {
161
- const target = byName.get(column.def.target);
162
- content = target ? (
163
- <Link href={adminPath(target, value, "edit")} className="hover:underline">
164
- {titles[column.key]?.[value] ?? value}
165
- </Link>
166
- ) : (
167
- value
168
- );
169
- } else if (index === 0) {
170
- content = (
171
- <Link href={adminPath(resource, id, "edit")} className="font-medium hover:underline">
172
- {content}
173
- </Link>
174
- );
175
- }
176
- return (
177
- <TableCell
178
- key={column.key}
179
- className={cn(
180
- "max-w-80 truncate",
181
- NUMERIC.has(column.def.kind) && "text-right tabular-nums",
182
- (column.def.kind === "timestamp" || column.def.kind === "datetime" || column.def.kind === "date") &&
183
- "text-muted-foreground tabular-nums",
184
- )}
185
- >
186
- {content}
187
- </TableCell>
188
- );
189
- })}
190
- <TableCell className="text-right">
191
- <RowActions
192
- resourceName={resource.name}
193
- label={resource.label}
194
- id={id}
195
- editHref={adminPath(resource, id, "edit")}
196
- canUpdate={permissions.update}
197
- canDelete={permissions.delete}
198
- />
199
- </TableCell>
200
- </TableRow>
201
- );
202
- })}
203
- </TableBody>
204
- </Table>
205
- </div>
206
- )}
207
-
208
- {meta.total > 0 && (
209
- <div className="flex flex-col items-center justify-between gap-3 text-sm text-muted-foreground sm:flex-row">
210
- <p className="tabular-nums">
211
- {first}–{last} of {meta.total} {meta.total === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()}
212
- {activeFilters > 0 && ` · ${activeFilters} filter${activeFilters === 1 ? "" : "s"} applied`}
213
- </p>
214
- {meta.totalPages > 1 && (
215
- <Pagination className="mx-0 w-auto">
216
- <PaginationContent>
217
- <PaginationItem>
218
- <PaginationPrevious
219
- href={hrefWith(basePath, params, { page: String(Math.max(1, meta.page - 1)) })}
220
- aria-disabled={meta.page === 1}
221
- className={cn(meta.page === 1 && "pointer-events-none opacity-50")}
222
- />
223
- </PaginationItem>
224
- {pageWindow(meta.page, meta.totalPages).map((page) => (
225
- <PaginationItem key={page}>
226
- <PaginationLink href={hrefWith(basePath, params, { page: String(page) })} isActive={page === meta.page}>
227
- {page}
228
- </PaginationLink>
229
- </PaginationItem>
230
- ))}
231
- <PaginationItem>
232
- <PaginationNext
233
- href={hrefWith(basePath, params, { page: String(Math.min(meta.totalPages, meta.page + 1)) })}
234
- aria-disabled={meta.page === meta.totalPages}
235
- className={cn(meta.page === meta.totalPages && "pointer-events-none opacity-50")}
236
- />
237
- </PaginationItem>
238
- </PaginationContent>
239
- </Pagination>
240
- )}
241
- </div>
242
- )}
243
- </div>
244
- );
245
- }
246
-
247
- /** Up to five page numbers centred on the current page. */
248
- function pageWindow(page: number, totalPages: number): number[] {
249
- const start = Math.max(1, Math.min(page - 2, totalPages - 4));
250
- return Array.from({ length: Math.min(5, totalPages) }, (_, i) => start + i);
251
- }
1
+ import Link from "next/link";
2
+ import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
3
+ import { formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
+ import { isSortable } from "@flaredev/core/server";
5
+ import { Alert, AlertDescription } from "@/components/ui/alert";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
9
+ import {
10
+ Pagination,
11
+ PaginationContent,
12
+ PaginationItem,
13
+ PaginationLink,
14
+ PaginationNext,
15
+ PaginationPrevious,
16
+ } from "@/components/ui/pagination";
17
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
18
+ import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
19
+ import { site } from "@/lib/site";
20
+ import { cn } from "@/lib/utils";
21
+ import { ExportButton } from "./export-button";
22
+ import type { RelationMeta } from "./fields/field-widget";
23
+ import { ImportDialog } from "./import-dialog";
24
+ import { hrefWith, toSearchParams, type SearchParams } from "./query";
25
+ import { NewRecordButton } from "./resource-form-dialog";
26
+ import { ResourceTableToolbar } from "./resource-table-toolbar";
27
+ import { RowActions } from "./row-actions";
28
+ import { RowCheckbox, SelectAllCheckbox, SelectionBar, TableSelection } from "./table-selection";
29
+
30
+ type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
31
+
32
+ const NUMERIC = new Set(["int", "float"]);
33
+
34
+ /**
35
+ * Paginated, sortable, filterable list of a resource's records. Everything comes from the
36
+ * descriptor: columns (fields with `list`), sortable headers, search, and filters. State
37
+ * lives in the URL (?page, ?sort, ?q, ?filter[field]), so views are linkable and work
38
+ * without client-side data fetching.
39
+ */
40
+ export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
41
+ await requireAccess(resource, "read");
42
+ const basePath = resourcePath(resource);
43
+ const permissions = await adminPermissions(resource.name);
44
+ const params = toSearchParams(searchParams);
45
+ const store = dashboardStore(resource.name);
46
+
47
+ let result = await store.list(params);
48
+ let ignoredQuery = false;
49
+ if (!result.ok) {
50
+ ignoredQuery = true;
51
+ result = await store.list(new URLSearchParams());
52
+ }
53
+ if (!result.ok) throw new Error(result.error);
54
+ const { data: rows, meta } = result.data;
55
+
56
+ const fields = storedFields(resource);
57
+ const columns: Column[] = [
58
+ ...fields.filter(([, def]) => def.list !== false).map(([key, def]) => ({ key, label: def.label, def })),
59
+ { key: "createdAt", label: "Created", def: { kind: "timestamp" } },
60
+ ];
61
+
62
+ // Resolve belongsTo ids to their related record titles, one query per relation.
63
+ const byName = new Map(allResources().map((r) => [r.name, r]));
64
+ const titles: Record<string, Record<string, string>> = {};
65
+ await Promise.all(
66
+ columns.map(async ({ key, def }) => {
67
+ if (def.kind !== "belongsTo") return;
68
+ const ids = rows.map((row) => row[key]).filter((id): id is string => typeof id === "string");
69
+ titles[key] = byName.has(def.target) ? await dashboardStore(def.target).titles(ids) : {};
70
+ }),
71
+ );
72
+
73
+ // The dialog form needs the same relation metadata the form page builds.
74
+ const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
75
+ for (const [key, def] of fields) {
76
+ if (def.kind !== "belongsTo") continue;
77
+ const target = byName.get(def.target);
78
+ if (!target) continue;
79
+ relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
80
+ }
81
+
82
+ const modalForms = site.dashboard.forms === "modal";
83
+ const rowIds = rows.map((row) => String(row.id));
84
+ const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
85
+
86
+ const sort = params.get("sort") ?? `${resource.defaultSort.direction === "desc" ? "-" : ""}${resource.defaultSort.field}`;
87
+ const sortField = sort.replace(/^-/, "");
88
+ const sortDescending = sort.startsWith("-");
89
+ const activeFilters = [...params.keys()].filter((key) => key.startsWith("filter[")).length + (params.get("q") ? 1 : 0);
90
+ const first = meta.total === 0 ? 0 : (meta.page - 1) * meta.perPage + 1;
91
+ const last = Math.min(meta.page * meta.perPage, meta.total);
92
+
93
+ return (
94
+ <TableSelection ids={rowIds}>
95
+ <div className="flex flex-col gap-4">
96
+ <div className="flex flex-wrap items-center justify-between gap-2">
97
+ <ResourceTableToolbar resource={resource} />
98
+ <div className="flex flex-wrap items-center gap-2">
99
+ <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
100
+ {permissions.create && <ImportDialog resource={resource} />}
101
+ {permissions.create &&
102
+ (modalForms ? (
103
+ <NewRecordButton resource={resource} relations={relations} listHref={basePath} />
104
+ ) : (
105
+ <Button asChild>
106
+ <Link href={resourcePath(resource, "new")}>
107
+ <PlusIcon data-icon="inline-start" />
108
+ New {resource.label.toLowerCase()}
109
+ </Link>
110
+ </Button>
111
+ ))}
112
+ </div>
113
+ </div>
114
+
115
+ {ignoredQuery && (
116
+ <Alert>
117
+ <AlertDescription>Some filters or sorting in the address weren&apos;t valid for {resource.pluralLabel.toLowerCase()} and were ignored.</AlertDescription>
118
+ </Alert>
119
+ )}
120
+
121
+ {rows.length === 0 ? (
122
+ <Empty className="rounded-lg border border-dashed">
123
+ <EmptyHeader>
124
+ <EmptyTitle>{activeFilters ? `No ${resource.pluralLabel.toLowerCase()} match` : `No ${resource.pluralLabel.toLowerCase()} yet`}</EmptyTitle>
125
+ <EmptyDescription>
126
+ {activeFilters ? "Try a different search or clear the filters." : `Create the first ${resource.label.toLowerCase()} to see it here.`}
127
+ </EmptyDescription>
128
+ </EmptyHeader>
129
+ <EmptyContent>
130
+ {activeFilters || !permissions.create ? (
131
+ <Button variant="outline" asChild>
132
+ <Link href={basePath}>Clear filters</Link>
133
+ </Button>
134
+ ) : (
135
+ <Button asChild>
136
+ <Link href={resourcePath(resource, "new")}>
137
+ <PlusIcon data-icon="inline-start" />
138
+ New {resource.label.toLowerCase()}
139
+ </Link>
140
+ </Button>
141
+ )}
142
+ </EmptyContent>
143
+ </Empty>
144
+ ) : (
145
+ <div className="overflow-hidden rounded-lg border bg-card">
146
+ <Table>
147
+ <TableHeader className="bg-muted">
148
+ <TableRow className="hover:bg-muted">
149
+ <TableHead className="w-10">
150
+ <SelectAllCheckbox label={resource.pluralLabel.toLowerCase()} />
151
+ </TableHead>
152
+ {columns.map((column) => {
153
+ const sortable = column.key === "createdAt" || (column.def.kind !== "timestamp" && isSortable(column.def));
154
+ const active = sortField === column.key;
155
+ const nextSort = active && !sortDescending ? `-${column.key}` : column.key;
156
+ const SortIcon = !active ? ChevronsUpDownIcon : sortDescending ? ArrowDownIcon : ArrowUpIcon;
157
+ return (
158
+ <TableHead key={column.key} className={cn(NUMERIC.has(column.def.kind) && "text-right")}>
159
+ {sortable ? (
160
+ <Link
161
+ href={hrefWith(basePath, params, { sort: nextSort })}
162
+ className="group inline-flex items-center gap-1 hover:text-foreground"
163
+ aria-label={`Sort by ${column.label}`}
164
+ >
165
+ {column.label}
166
+ <SortIcon className={cn("size-3.5", active ? "opacity-100" : "opacity-0 group-hover:opacity-60")} />
167
+ </Link>
168
+ ) : (
169
+ column.label
170
+ )}
171
+ </TableHead>
172
+ );
173
+ })}
174
+ <TableHead className="w-12">
175
+ <span className="sr-only">Actions</span>
176
+ </TableHead>
177
+ </TableRow>
178
+ </TableHeader>
179
+ <TableBody>
180
+ {rows.map((row) => {
181
+ const id = String(row.id);
182
+ return (
183
+ <TableRow key={id}>
184
+ <TableCell className="w-10">
185
+ <RowCheckbox id={id} label={rowLabel(resource, row)} />
186
+ </TableCell>
187
+ {columns.map((column, index) => {
188
+ const value = row[column.key];
189
+ let content: React.ReactNode = formatValue(column.def, value);
190
+ if (column.def.kind === "enum" && value != null) {
191
+ const tone = statusTone(value);
192
+ content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
193
+ } else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
194
+ const def = column.def;
195
+ content = (
196
+ <span className="flex flex-wrap gap-1">
197
+ {value.map((item) => (
198
+ <Badge key={String(item)} variant="secondary">
199
+ {optionLabel(def, String(item))}
200
+ </Badge>
201
+ ))}
202
+ </span>
203
+ );
204
+ } else if (index !== 0 && column.def.kind === "string" && typeof value === "string" && value) {
205
+ // Contact-style values are links; a colour shows its swatch.
206
+ const format = column.def.format;
207
+ const href =
208
+ format === "email" ? `mailto:${value}` : format === "tel" ? `tel:${value}` : format === "url" ? value : format === "domain" ? `https://${value}` : undefined;
209
+ if (href) {
210
+ content = (
211
+ <a href={href} className="hover:underline" {...(format === "url" || format === "domain" ? { target: "_blank", rel: "noreferrer" } : {})}>
212
+ {content}
213
+ </a>
214
+ );
215
+ } else if (format === "color") {
216
+ content = (
217
+ <span className="inline-flex items-center gap-2 font-mono text-xs">
218
+ <span className="size-3.5 rounded-sm border" style={{ backgroundColor: value }} aria-hidden="true" />
219
+ {value}
220
+ </span>
221
+ );
222
+ }
223
+ } else if (column.def.kind === "belongsTo" && typeof value === "string") {
224
+ const target = byName.get(column.def.target);
225
+ content = target ? (
226
+ <Link href={resourcePath(target, value, "edit")} className="hover:underline">
227
+ {titles[column.key]?.[value] ?? value}
228
+ </Link>
229
+ ) : (
230
+ value
231
+ );
232
+ } else if (index === 0) {
233
+ content = (
234
+ <Link href={resourcePath(resource, id, "edit")} className="font-medium hover:underline">
235
+ {content}
236
+ </Link>
237
+ );
238
+ }
239
+ return (
240
+ <TableCell
241
+ key={column.key}
242
+ className={cn(
243
+ "max-w-80 truncate",
244
+ NUMERIC.has(column.def.kind) && "text-right tabular-nums",
245
+ (column.def.kind === "timestamp" || column.def.kind === "datetime" || column.def.kind === "date") &&
246
+ "text-muted-foreground tabular-nums",
247
+ )}
248
+ >
249
+ {content}
250
+ </TableCell>
251
+ );
252
+ })}
253
+ <TableCell className="text-right">
254
+ <RowActions
255
+ resource={resource}
256
+ id={id}
257
+ record={modalForms ? row : undefined}
258
+ relations={relations}
259
+ listHref={basePath}
260
+ editHref={resourcePath(resource, id, "edit")}
261
+ canUpdate={permissions.update}
262
+ canDelete={permissions.delete}
263
+ />
264
+ </TableCell>
265
+ </TableRow>
266
+ );
267
+ })}
268
+ </TableBody>
269
+ </Table>
270
+ </div>
271
+ )}
272
+
273
+ {meta.total > 0 && (
274
+ <div className="flex flex-col items-center justify-between gap-3 text-sm text-muted-foreground sm:flex-row">
275
+ <p className="tabular-nums">
276
+ {first}–{last} of {meta.total} {meta.total === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()}
277
+ {activeFilters > 0 && ` · ${activeFilters} filter${activeFilters === 1 ? "" : "s"} applied`}
278
+ </p>
279
+ {meta.totalPages > 1 && (
280
+ <Pagination className="mx-0 w-auto">
281
+ <PaginationContent>
282
+ <PaginationItem>
283
+ <PaginationPrevious
284
+ href={hrefWith(basePath, params, { page: String(Math.max(1, meta.page - 1)) })}
285
+ aria-disabled={meta.page === 1}
286
+ className={cn(meta.page === 1 && "pointer-events-none opacity-50")}
287
+ />
288
+ </PaginationItem>
289
+ {pageWindow(meta.page, meta.totalPages).map((page) => (
290
+ <PaginationItem key={page}>
291
+ <PaginationLink href={hrefWith(basePath, params, { page: String(page) })} isActive={page === meta.page}>
292
+ {page}
293
+ </PaginationLink>
294
+ </PaginationItem>
295
+ ))}
296
+ <PaginationItem>
297
+ <PaginationNext
298
+ href={hrefWith(basePath, params, { page: String(Math.min(meta.totalPages, meta.page + 1)) })}
299
+ aria-disabled={meta.page === meta.totalPages}
300
+ className={cn(meta.page === meta.totalPages && "pointer-events-none opacity-50")}
301
+ />
302
+ </PaginationItem>
303
+ </PaginationContent>
304
+ </Pagination>
305
+ )}
306
+ </div>
307
+ )}
308
+ <SelectionBar
309
+ resourceName={resource.name}
310
+ label={resource.label}
311
+ pluralLabel={resource.pluralLabel}
312
+ canDelete={permissions.delete}
313
+ rows={rows}
314
+ columns={exportColumns}
315
+ />
316
+ </div>
317
+ </TableSelection>
318
+ );
319
+ }
320
+
321
+ /** What a row is called, for the checkbox's label. */
322
+ function rowLabel(resource: Resource, row: Record<string, unknown>): string {
323
+ const value = row[resource.titleField];
324
+ return typeof value === "string" && value ? value : String(row.id);
325
+ }
326
+
327
+ /** Up to five page numbers centred on the current page. */
328
+ function pageWindow(page: number, totalPages: number): number[] {
329
+ const start = Math.max(1, Math.min(page - 2, totalPages - 4));
330
+ return Array.from({ length: Math.min(5, totalPages) }, (_, i) => start + i);
331
+ }