@flaredev/cli 0.3.0 → 0.4.1

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 (49) hide show
  1. package/dist/index.js +498 -220
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +73 -5
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/app/globals.css +0 -6
  13. package/templates/app/app/layout.tsx +37 -28
  14. package/templates/app/components/account/account-nav.tsx +39 -0
  15. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  16. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  19. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  20. package/templates/app/components/dashboard/editable.ts +12 -0
  21. package/templates/app/components/dashboard/fields/file-field.tsx +310 -302
  22. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  23. package/templates/app/components/dashboard/import-dialog.tsx +62 -9
  24. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  25. package/templates/app/components/dashboard/local-time.tsx +77 -0
  26. package/templates/app/components/dashboard/query.ts +2 -0
  27. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  28. package/templates/app/components/dashboard/record-detail.tsx +203 -0
  29. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  30. package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
  31. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  32. package/templates/app/components/dashboard/resource-form.tsx +165 -165
  33. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  34. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  35. package/templates/app/components/dashboard/resource-table.tsx +408 -331
  36. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  37. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  38. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  39. package/templates/app/components/ui/sonner.tsx +33 -13
  40. package/templates/app/db/flare-schema.ts +22 -0
  41. package/templates/app/lib/api.ts +12 -0
  42. package/templates/app/lib/dashboard.ts +165 -138
  43. package/templates/app/lib/views.ts +29 -0
  44. package/templates/app/migrations/0000_init.sql +11 -0
  45. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  46. package/templates/app/vite.config.ts +41 -31
  47. package/templates/locks/package-lock.json +9 -9
  48. package/templates/locks/pnpm-lock.yaml +12 -12
  49. package/templates/locks/yarn.lock +3548 -0
@@ -1,331 +1,408 @@
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
- }
1
+ import Link from "next/link";
2
+ import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
3
+ import { clientResource, 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 { ColumnMenu } from "./column-menu";
22
+ import { ExportButton } from "./export-button";
23
+ import { isInlineEditable } from "./editable";
24
+ import { InlineCell } from "./inline-cell";
25
+ import type { RelationMeta } from "./fields/field-widget";
26
+ import { ImportDialog } from "./import-dialog";
27
+ import { LocalTime } from "./local-time";
28
+ import { hrefWith, toSearchParams, type SearchParams } from "./query";
29
+ import { NewRecordButton } from "./resource-form-dialog";
30
+ import { ResourceTableToolbar } from "./resource-table-toolbar";
31
+ import { SaveViewButton } from "./save-view-button";
32
+ import { RowActions } from "./row-actions";
33
+ import { RowCheckbox, SelectAllCheckbox, SelectionBar, TableSelection } from "./table-selection";
34
+
35
+ type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
36
+
37
+ const NUMERIC = new Set(["int", "float"]);
38
+
39
+ /**
40
+ * Paginated, sortable, filterable list of a resource's records. Everything comes from the
41
+ * descriptor: columns (fields with `list`), sortable headers, search, and filters. State
42
+ * lives in the URL (?page, ?sort, ?q, ?filter[field]), so views are linkable and work
43
+ * without client-side data fetching.
44
+ */
45
+ export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
46
+ await requireAccess(resource, "read");
47
+ // Hooks and computed values are functions, and a function can't be sent to the browser.
48
+ // This is the same resource without them, for the client components below.
49
+ const forClient = clientResource(resource);
50
+ const basePath = resourcePath(resource);
51
+ const permissions = await adminPermissions(resource.name);
52
+ const params = toSearchParams(searchParams);
53
+ const store = dashboardStore(resource.name);
54
+
55
+ let result = await store.list(params);
56
+ let ignoredQuery = false;
57
+ if (!result.ok) {
58
+ ignoredQuery = true;
59
+ result = await store.list(new URLSearchParams());
60
+ }
61
+ if (!result.ok) throw new Error(result.error);
62
+ const { data: rows, meta } = result.data;
63
+
64
+ const fields = storedFields(resource);
65
+ const allColumns: Column[] = [
66
+ ...fields.filter(([, def]) => def.list !== false).map(([key, def]) => ({ key, label: def.label, def })),
67
+ { key: "createdAt", label: "Created", def: { kind: "timestamp" as const } },
68
+ ];
69
+ // ?columns=name,email narrows the table; anything unknown in it is ignored.
70
+ const chosen = (params.get("columns") ?? "").split(",").filter(Boolean);
71
+ const columns = chosen.length > 0 ? allColumns.filter((column) => chosen.includes(column.key)) : allColumns;
72
+
73
+ // Resolve belongsTo ids to their related record titles, one query per relation.
74
+ const byName = new Map(allResources().map((r) => [r.name, r]));
75
+ const titles: Record<string, Record<string, string>> = {};
76
+ await Promise.all(
77
+ columns.map(async ({ key, def }) => {
78
+ if (def.kind !== "belongsTo") return;
79
+ const ids = rows.map((row) => row[key]).filter((id): id is string => typeof id === "string");
80
+ titles[key] = byName.has(def.target) ? await dashboardStore(def.target).titles(ids) : {};
81
+ }),
82
+ );
83
+
84
+ // The dialog form needs the same relation metadata the form page builds.
85
+ const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
86
+ for (const [key, def] of fields) {
87
+ if (def.kind !== "belongsTo") continue;
88
+ const target = byName.get(def.target);
89
+ if (!target) continue;
90
+ relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
91
+ }
92
+
93
+ const modalForms = site.dashboard.forms === "modal";
94
+ const rowIds = rows.map((row) => String(row.id));
95
+ const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
96
+
97
+ const sort = params.get("sort") ?? `${resource.defaultSort.direction === "desc" ? "-" : ""}${resource.defaultSort.field}`;
98
+ const sortField = sort.replace(/^-/, "");
99
+ const sortDescending = sort.startsWith("-");
100
+ const activeFilters = [...params.keys()].filter((key) => key.startsWith("filter[")).length + (params.get("q") ? 1 : 0);
101
+ const first = meta.total === 0 ? 0 : (meta.page - 1) * meta.perPage + 1;
102
+ const last = Math.min(meta.page * meta.perPage, meta.total);
103
+
104
+ return (
105
+ <TableSelection ids={rowIds}>
106
+ <div className="flex flex-col gap-4">
107
+ <div className="flex flex-wrap items-center justify-between gap-2">
108
+ <ResourceTableToolbar resource={forClient} />
109
+ <div className="flex flex-wrap items-center gap-2">
110
+ <SaveViewButton resourceName={resource.name} label={resource.pluralLabel} />
111
+ <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
112
+ <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
113
+ {permissions.create && <ImportDialog resource={forClient} />}
114
+ {permissions.create &&
115
+ (modalForms ? (
116
+ <NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
117
+ ) : (
118
+ <Button asChild>
119
+ <Link href={resourcePath(resource, "new")}>
120
+ <PlusIcon data-icon="inline-start" />
121
+ New {resource.label.toLowerCase()}
122
+ </Link>
123
+ </Button>
124
+ ))}
125
+ </div>
126
+ </div>
127
+
128
+ {ignoredQuery && (
129
+ <Alert>
130
+ <AlertDescription>Some filters or sorting in the address weren&apos;t valid for {resource.pluralLabel.toLowerCase()} and were ignored.</AlertDescription>
131
+ </Alert>
132
+ )}
133
+
134
+ {rows.length === 0 ? (
135
+ <Empty className="rounded-lg border border-dashed">
136
+ <EmptyHeader>
137
+ <EmptyTitle>{activeFilters ? `No ${resource.pluralLabel.toLowerCase()} match` : `No ${resource.pluralLabel.toLowerCase()} yet`}</EmptyTitle>
138
+ <EmptyDescription>
139
+ {activeFilters ? "Try a different search or clear the filters." : `Create the first ${resource.label.toLowerCase()} to see it here.`}
140
+ </EmptyDescription>
141
+ </EmptyHeader>
142
+ <EmptyContent>
143
+ {activeFilters || !permissions.create ? (
144
+ <Button variant="outline" asChild>
145
+ <Link href={basePath}>Clear filters</Link>
146
+ </Button>
147
+ ) : (
148
+ <Button asChild>
149
+ <Link href={resourcePath(resource, "new")}>
150
+ <PlusIcon data-icon="inline-start" />
151
+ New {resource.label.toLowerCase()}
152
+ </Link>
153
+ </Button>
154
+ )}
155
+ </EmptyContent>
156
+ </Empty>
157
+ ) : (
158
+ <div className="overflow-hidden rounded-lg border bg-card">
159
+ <Table>
160
+ <TableHeader className="bg-muted">
161
+ <TableRow className="hover:bg-muted">
162
+ <TableHead className="w-10">
163
+ <SelectAllCheckbox label={resource.pluralLabel.toLowerCase()} />
164
+ </TableHead>
165
+ {columns.map((column) => {
166
+ const sortable = column.key === "createdAt" || (column.def.kind !== "timestamp" && isSortable(column.def));
167
+ const active = sortField === column.key;
168
+ const nextSort = active && !sortDescending ? `-${column.key}` : column.key;
169
+ const SortIcon = !active ? ChevronsUpDownIcon : sortDescending ? ArrowDownIcon : ArrowUpIcon;
170
+ return (
171
+ <TableHead key={column.key} className={cn(NUMERIC.has(column.def.kind) && "text-right")}>
172
+ {sortable ? (
173
+ <Link
174
+ href={hrefWith(basePath, params, { sort: nextSort })}
175
+ className="group inline-flex items-center gap-1 hover:text-foreground"
176
+ aria-label={`Sort by ${column.label}`}
177
+ >
178
+ {column.label}
179
+ <SortIcon className={cn("size-3.5", active ? "opacity-100" : "opacity-0 group-hover:opacity-60")} />
180
+ </Link>
181
+ ) : (
182
+ column.label
183
+ )}
184
+ </TableHead>
185
+ );
186
+ })}
187
+ <TableHead className="w-12">
188
+ <span className="sr-only">Actions</span>
189
+ </TableHead>
190
+ </TableRow>
191
+ </TableHeader>
192
+ <TableBody>
193
+ {rows.map((row) => {
194
+ const id = String(row.id);
195
+ return (
196
+ <TableRow key={id}>
197
+ <TableCell className="w-10">
198
+ <RowCheckbox id={id} label={rowLabel(resource, row)} />
199
+ </TableCell>
200
+ {columns.map((column, index) => {
201
+ const value = row[column.key];
202
+ let content: React.ReactNode = formatValue(column.def, value);
203
+ // Timestamps are formatted in the browser: the Worker's clock is UTC,
204
+ // so a date rendered here would be in nobody's timezone.
205
+ if ((column.def.kind === "timestamp" || column.def.kind === "datetime") && value != null) {
206
+ content = <LocalTime value={value as string | number | Date} />;
207
+ } else if (column.def.kind === "enum" && value != null) {
208
+ const tone = statusTone(value);
209
+ content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
210
+ } else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
211
+ const def = column.def;
212
+ content = (
213
+ <span className="flex flex-wrap gap-1">
214
+ {value.map((item) => (
215
+ <Badge key={String(item)} variant="secondary">
216
+ {optionLabel(def, String(item))}
217
+ </Badge>
218
+ ))}
219
+ </span>
220
+ );
221
+ } else if (index !== 0 && column.def.kind === "string" && typeof value === "string" && value) {
222
+ // Contact-style values are links; a colour shows its swatch.
223
+ const format = column.def.format;
224
+ const href =
225
+ format === "email" ? `mailto:${value}` : format === "tel" ? `tel:${value}` : format === "url" ? value : format === "domain" ? `https://${value}` : undefined;
226
+ if (href) {
227
+ content = (
228
+ <a href={href} className="hover:underline" {...(format === "url" || format === "domain" ? { target: "_blank", rel: "noreferrer" } : {})}>
229
+ {content}
230
+ </a>
231
+ );
232
+ } else if (format === "color") {
233
+ content = (
234
+ <span className="inline-flex items-center gap-2 font-mono text-xs">
235
+ <span className="size-3.5 rounded-sm border" style={{ backgroundColor: value }} aria-hidden="true" />
236
+ {value}
237
+ </span>
238
+ );
239
+ }
240
+ } else if (column.def.kind === "belongsTo" && typeof value === "string") {
241
+ const target = byName.get(column.def.target);
242
+ content = target ? (
243
+ <Link href={resourcePath(target, value)} className="hover:underline">
244
+ {titles[column.key]?.[value] ?? value}
245
+ </Link>
246
+ ) : (
247
+ value
248
+ );
249
+ } else if (index === 0) {
250
+ content = (
251
+ <Link href={resourcePath(resource, id)} className="font-medium hover:underline">
252
+ {content}
253
+ </Link>
254
+ );
255
+ }
256
+ // The first column is the link to the record; the rest of the simple
257
+ // ones can be changed where they are.
258
+ if (index !== 0 && permissions.update && column.def.kind !== "timestamp" && isInlineEditable(column.def)) {
259
+ content = (
260
+ <InlineCell
261
+ resourceName={resource.name}
262
+ id={id}
263
+ fieldKey={column.key}
264
+ def={column.def as StoredField & { label: string }}
265
+ value={value}
266
+ >
267
+ {content}
268
+ </InlineCell>
269
+ );
270
+ }
271
+ return (
272
+ <TableCell
273
+ key={column.key}
274
+ className={cn(
275
+ "max-w-80 truncate",
276
+ NUMERIC.has(column.def.kind) && "text-right tabular-nums",
277
+ (column.def.kind === "timestamp" || column.def.kind === "datetime" || column.def.kind === "date") &&
278
+ "text-muted-foreground tabular-nums",
279
+ )}
280
+ >
281
+ {content}
282
+ </TableCell>
283
+ );
284
+ })}
285
+ <TableCell className="text-right">
286
+ <RowActions
287
+ resource={forClient}
288
+ id={id}
289
+ record={modalForms ? row : undefined}
290
+ relations={relations}
291
+ listHref={basePath}
292
+ editHref={resourcePath(resource, id, "edit")}
293
+ detailHref={resourcePath(resource, id)}
294
+ canUpdate={permissions.update}
295
+ canDelete={permissions.delete}
296
+ />
297
+ </TableCell>
298
+ </TableRow>
299
+ );
300
+ })}
301
+ </TableBody>
302
+ </Table>
303
+ </div>
304
+ )}
305
+
306
+ {(meta.total > 0 || rows.length > 0) && (
307
+ <div className="flex flex-col items-center justify-between gap-3 text-sm text-muted-foreground sm:flex-row">
308
+ <p className="tabular-nums">
309
+ {meta.exactTotal ? (
310
+ <>
311
+ {first}–{last} of {meta.total.toLocaleString()} {meta.total === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()}
312
+ </>
313
+ ) : (
314
+ <>
315
+ Showing {rows.length} of more than {meta.total.toLocaleString()} {resource.pluralLabel.toLowerCase()}
316
+ </>
317
+ )}
318
+ {activeFilters > 0 && ` · ${activeFilters} filter${activeFilters === 1 ? "" : "s"} applied`}
319
+ </p>
320
+
321
+ {meta.exactTotal && meta.totalPages > 1 && (
322
+ <Pagination className="mx-0 w-auto">
323
+ <PaginationContent>
324
+ <PaginationItem>
325
+ <PaginationPrevious
326
+ href={hrefWith(basePath, params, { page: String(Math.max(1, meta.page - 1)) })}
327
+ aria-disabled={meta.page === 1}
328
+ className={cn(meta.page === 1 && "pointer-events-none opacity-50")}
329
+ />
330
+ </PaginationItem>
331
+ {pageWindow(meta.page, meta.totalPages).map((page) => (
332
+ <PaginationItem key={page}>
333
+ <PaginationLink href={hrefWith(basePath, params, { page: String(page) })} isActive={page === meta.page}>
334
+ {page}
335
+ </PaginationLink>
336
+ </PaginationItem>
337
+ ))}
338
+ <PaginationItem>
339
+ <PaginationNext
340
+ href={hrefWith(basePath, params, { page: String(Math.min(meta.totalPages, meta.page + 1)) })}
341
+ aria-disabled={meta.page === meta.totalPages}
342
+ className={cn(meta.page === meta.totalPages && "pointer-events-none opacity-50")}
343
+ />
344
+ </PaginationItem>
345
+ </PaginationContent>
346
+ </Pagination>
347
+ )}
348
+
349
+ {/* Past the count limit, pages are walked with cursors: numbering them would mean
350
+ counting every row, and jumping to page 20,000 would mean reading the 500,000
351
+ rows before it. */}
352
+ {!meta.exactTotal && (
353
+ <div className="flex items-center gap-2">
354
+ <Button variant="outline" size="sm" asChild={Boolean(meta.prevCursor)} disabled={!meta.prevCursor}>
355
+ {meta.prevCursor ? (
356
+ <Link href={hrefWith(basePath, params, { cursor: meta.prevCursor, page: null })}>
357
+ <ChevronLeftIcon data-icon="inline-start" />
358
+ Previous
359
+ </Link>
360
+ ) : (
361
+ <span>
362
+ <ChevronLeftIcon data-icon="inline-start" />
363
+ Previous
364
+ </span>
365
+ )}
366
+ </Button>
367
+ <Button variant="outline" size="sm" asChild={Boolean(meta.nextCursor)} disabled={!meta.nextCursor}>
368
+ {meta.nextCursor ? (
369
+ <Link href={hrefWith(basePath, params, { cursor: meta.nextCursor, page: null })}>
370
+ Next
371
+ <ChevronRightIcon data-icon="inline-end" />
372
+ </Link>
373
+ ) : (
374
+ <span>
375
+ Next
376
+ <ChevronRightIcon data-icon="inline-end" />
377
+ </span>
378
+ )}
379
+ </Button>
380
+ </div>
381
+ )}
382
+ </div>
383
+ )}
384
+
385
+ <SelectionBar
386
+ resourceName={resource.name}
387
+ label={resource.label}
388
+ pluralLabel={resource.pluralLabel}
389
+ canDelete={permissions.delete}
390
+ rows={rows}
391
+ columns={exportColumns}
392
+ />
393
+ </div>
394
+ </TableSelection>
395
+ );
396
+ }
397
+
398
+ /** What a row is called, for the checkbox's label. */
399
+ function rowLabel(resource: Resource, row: Record<string, unknown>): string {
400
+ const value = row[resource.titleField];
401
+ return typeof value === "string" && value ? value : String(row.id);
402
+ }
403
+
404
+ /** Up to five page numbers centred on the current page. */
405
+ function pageWindow(page: number, totalPages: number): number[] {
406
+ const start = Math.max(1, Math.min(page - 2, totalPages - 4));
407
+ return Array.from({ length: Math.min(5, totalPages) }, (_, i) => start + i);
408
+ }