@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +751 -280
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/page.tsx +6 -4
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -39
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +106 -25
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +6 -9
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/migrations/0000_init.sql +15 -3
- package/templates/app/migrations/meta/0000_snapshot.json +97 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
|
@@ -1,281 +1,331 @@
|
|
|
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 {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
content =
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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'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
|
+
}
|