@flaredev/cli 0.2.0 → 0.4.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 (88) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +991 -344
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  6. package/templates/app/app/dashboard/account/page.tsx +11 -31
  7. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  8. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  9. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  10. package/templates/app/app/dashboard/actions.ts +246 -0
  11. package/templates/app/app/dashboard/import-actions.ts +91 -0
  12. package/templates/app/app/dashboard/layout.tsx +66 -39
  13. package/templates/app/app/dashboard/observability/page.tsx +175 -0
  14. package/templates/app/app/dashboard/page.tsx +108 -27
  15. package/templates/app/app/verify-email/page.tsx +33 -0
  16. package/templates/app/components/account/account-nav.tsx +39 -0
  17. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
  18. package/templates/app/components/auth/otp-input.tsx +95 -0
  19. package/templates/app/components/auth/password-field.tsx +116 -0
  20. package/templates/app/components/auth/password-forms.tsx +76 -79
  21. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  22. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  23. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  24. package/templates/app/components/auth/use-resend.ts +44 -0
  25. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  26. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  27. package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
  28. package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
  29. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  30. package/templates/app/components/dashboard/editable.ts +12 -0
  31. package/templates/app/components/dashboard/export-button.tsx +41 -0
  32. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
  33. package/templates/app/components/dashboard/import-dialog.tsx +540 -0
  34. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  35. package/templates/app/components/dashboard/local-time.tsx +77 -0
  36. package/templates/app/components/dashboard/page-header.tsx +57 -0
  37. package/templates/app/components/{admin → dashboard}/query.ts +2 -0
  38. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  39. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  40. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  41. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  42. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  43. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  44. package/templates/app/components/dashboard/resource-stats.tsx +48 -0
  45. package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
  46. package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
  47. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  48. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  49. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  50. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  51. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  52. package/templates/app/components/ui/sonner.tsx +33 -13
  53. package/templates/app/db/flare-schema.ts +55 -1
  54. package/templates/app/lib/analytics.ts +121 -0
  55. package/templates/app/lib/api.ts +12 -0
  56. package/templates/app/lib/audit.ts +80 -0
  57. package/templates/app/lib/auth-config.ts +38 -33
  58. package/templates/app/lib/auth.ts +34 -0
  59. package/templates/app/lib/cache.ts +83 -83
  60. package/templates/app/lib/csv.ts +24 -0
  61. package/templates/app/lib/dashboard-nav.ts +15 -0
  62. package/templates/app/lib/dashboard.ts +165 -0
  63. package/templates/app/lib/password-rules.ts +159 -0
  64. package/templates/app/lib/site.ts +56 -47
  65. package/templates/app/lib/views.ts +29 -0
  66. package/templates/app/migrations/0000_init.sql +25 -2
  67. package/templates/app/migrations/meta/0000_snapshot.json +159 -1
  68. package/templates/app/migrations/meta/_journal.json +1 -1
  69. package/templates/app/vite.config.ts +41 -31
  70. package/templates/app/worker-configuration.d.ts +15764 -0
  71. package/templates/locks/package-lock.json +9189 -0
  72. package/templates/locks/pnpm-lock.yaml +6347 -0
  73. package/templates/locks/yarn.lock +3548 -0
  74. package/templates/app/app/admin/actions.ts +0 -113
  75. package/templates/app/app/admin/layout.tsx +0 -32
  76. package/templates/app/components/admin/admin-header.tsx +0 -78
  77. package/templates/app/components/admin/resource-nav.tsx +0 -120
  78. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  79. package/templates/app/lib/admin-nav.ts +0 -15
  80. package/templates/app/lib/admin.ts +0 -103
  81. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  82. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  83. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  84. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  85. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -0,0 +1,540 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useMemo, useState, type ChangeEvent } from "react";
5
+ import { DownloadIcon, UploadIcon } from "lucide-react";
6
+ import {
7
+ createValidators,
8
+ issuesByField,
9
+ optionLabel,
10
+ storedFields,
11
+ type EnumField,
12
+ type MultiSelectField,
13
+ type Resource,
14
+ type StoredField,
15
+ } from "@flaredev/core";
16
+ import { importRecordsAction } from "@/app/dashboard/import-actions";
17
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
18
+ import { Button } from "@/components/ui/button";
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogFooter,
24
+ DialogHeader,
25
+ DialogTitle,
26
+ DialogTrigger,
27
+ } from "@/components/ui/dialog";
28
+ import { Field, FieldLabel } from "@/components/ui/field";
29
+ import { Progress } from "@/components/ui/progress";
30
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
31
+ import { Spinner } from "@/components/ui/spinner";
32
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
33
+ import { toCsv } from "@/lib/csv";
34
+ import { Dropzone } from "./dropzone";
35
+ import { downloadCsv } from "./table-selection";
36
+
37
+ /** What a column looks like in the downloadable template. */
38
+ function exampleFor(key: string, def: StoredField & { label: string }): string {
39
+ switch (def.kind) {
40
+ case "string":
41
+ if (def.format === "email") return "ada@example.com";
42
+ if (def.format === "tel") return "+256772123456";
43
+ if (def.format === "url") return "https://example.com";
44
+ if (def.format === "domain") return "example.com";
45
+ if (def.format === "country") return "UG";
46
+ if (def.format === "color") return "#f2541d";
47
+ if (def.format === "slug") return "my-first-" + key.toLowerCase();
48
+ return def.label;
49
+ case "text":
50
+ return "A longer note about this record.";
51
+ case "int":
52
+ return "42";
53
+ case "float":
54
+ return "9.99";
55
+ case "boolean":
56
+ return "true";
57
+ case "date":
58
+ return new Date().toISOString().slice(0, 10);
59
+ case "datetime":
60
+ return new Date().toISOString();
61
+ case "enum":
62
+ return String((def as EnumField).options[0] ?? "");
63
+ case "multiselect":
64
+ return ((def as MultiSelectField).options.slice(0, 2) as string[]).join("; ");
65
+ case "belongsTo":
66
+ return "id of an existing " + (def as { target: string }).target;
67
+ default:
68
+ return "";
69
+ }
70
+ }
71
+
72
+ /** Sentinel for "don't import this field": a SelectItem can't carry an empty value. */
73
+ const SKIP = "__skip__";
74
+
75
+ /** Rows per server action call. Small enough to stay well inside a Worker's time budget. */
76
+ const BATCH_SIZE = 500;
77
+
78
+ const PREVIEW_ROWS = 5;
79
+ const LISTED_FAILURES = 10;
80
+
81
+ type MappedField = [string, StoredField & { label: string }];
82
+
83
+ /** A row the import rejected, numbered by its line in the file so it can be found again. */
84
+ interface RowFailure {
85
+ line: number;
86
+ error: string;
87
+ }
88
+
89
+ interface Summary {
90
+ created: number;
91
+ total: number;
92
+ failures: RowFailure[];
93
+ }
94
+
95
+ /**
96
+ * Rows of a CSV. Written here rather than pulled from a package because the format an
97
+ * import needs is small: quoted fields, commas and newlines inside them, and `""` for a
98
+ * quote. Anything a spreadsheet exports parses; exotic dialects are not the point.
99
+ */
100
+ function parseCsv(text: string): string[][] {
101
+ // A BOM from Excel would otherwise become part of the first header.
102
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
103
+ const rows: string[][] = [];
104
+ let row: string[] = [];
105
+ let value = "";
106
+ let quoted = false;
107
+
108
+ for (let index = 0; index < source.length; index += 1) {
109
+ const char = source.charAt(index);
110
+ if (quoted) {
111
+ if (char !== '"') value += char;
112
+ else if (source.charAt(index + 1) === '"') {
113
+ value += '"';
114
+ index += 1;
115
+ } else quoted = false;
116
+ continue;
117
+ }
118
+ if (char === '"') quoted = true;
119
+ else if (char === ",") {
120
+ row.push(value);
121
+ value = "";
122
+ } else if (char === "\n" || char === "\r") {
123
+ if (char === "\r" && source.charAt(index + 1) === "\n") index += 1;
124
+ row.push(value);
125
+ rows.push(row);
126
+ row = [];
127
+ value = "";
128
+ } else value += char;
129
+ }
130
+ // Only a genuine last row survives here, so a trailing newline adds no empty record.
131
+ if (value !== "" || row.length > 0) {
132
+ row.push(value);
133
+ rows.push(row);
134
+ }
135
+ return rows;
136
+ }
137
+
138
+ const csvCell = (value: string) => (/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value);
139
+
140
+ const squash = (text: string) => text.toLowerCase().replace(/[\s_-]+/g, "");
141
+
142
+ /** A field must be mapped when nothing else can supply it: required, with no default. */
143
+ const needsColumn = (def: StoredField) => def.required && !("default" in def && def.default !== undefined);
144
+
145
+ /** An option from a cell holding either the stored value or the label shown in the admin. */
146
+ function matchOption(def: EnumField | MultiSelectField, text: string): string {
147
+ const match = def.options.find(
148
+ (option) => option === text || squash(option) === squash(text) || squash(optionLabel(def, option)) === squash(text),
149
+ );
150
+ return match ?? text;
151
+ }
152
+
153
+ /**
154
+ * A cell as the value its field expects. Text that doesn't convert is passed through
155
+ * unchanged so validation reports it against the field, rather than the import guessing.
156
+ */
157
+ function toValue(def: StoredField, raw: string): unknown {
158
+ const text = raw.trim();
159
+ if (text === "") return undefined;
160
+ switch (def.kind) {
161
+ case "int":
162
+ case "float": {
163
+ const number = Number(text);
164
+ return Number.isFinite(number) ? number : text;
165
+ }
166
+ case "boolean": {
167
+ const lower = text.toLowerCase();
168
+ if (["true", "yes", "1"].includes(lower)) return true;
169
+ if (["false", "no", "0"].includes(lower)) return false;
170
+ return text;
171
+ }
172
+ case "enum":
173
+ return matchOption(def, text);
174
+ case "multiselect":
175
+ return text
176
+ .split(/[;,]/)
177
+ .map((part) => part.trim())
178
+ .filter(Boolean)
179
+ .map((part) => matchOption(def, part));
180
+ default:
181
+ // Dates keep their text: the descriptor's schema decides which formats it takes.
182
+ return text;
183
+ }
184
+ }
185
+
186
+ /** A column per field, matched on header text; anything unrecognised starts as Skip. */
187
+ function guessMapping(fields: MappedField[], headers: string[]): Record<string, string> {
188
+ const taken = new Set<number>();
189
+ const mapping: Record<string, string> = {};
190
+ for (const [key, def] of fields) {
191
+ const names = [squash(key), squash(def.label)];
192
+ const index = headers.findIndex((header, position) => !taken.has(position) && names.includes(squash(header)));
193
+ mapping[key] = index === -1 ? SKIP : String(index);
194
+ if (index !== -1) taken.add(index);
195
+ }
196
+ return mapping;
197
+ }
198
+
199
+ /**
200
+ * Creates records from a CSV: pick a file, match its columns to the resource's fields,
201
+ * check the preview, then import. Rows are validated in the browser with the descriptor's
202
+ * schemas and again on the server, and sent in batches so a long file reports progress
203
+ * and never depends on one very long request.
204
+ */
205
+ export function ImportDialog({ resource }: { resource: Resource }) {
206
+ const router = useRouter();
207
+ const validators = useMemo(() => createValidators(resource), [resource]);
208
+ // Uploads need a signed URL per file, which a CSV cell can't stand in for.
209
+ const fields = useMemo(() => storedFields(resource).filter(([, def]) => def.kind !== "file"), [resource]);
210
+
211
+ const [open, setOpen] = useState(false);
212
+ const [fileName, setFileName] = useState("");
213
+ const [headers, setHeaders] = useState<string[]>([]);
214
+ const [rows, setRows] = useState<string[][]>([]);
215
+ const [mapping, setMapping] = useState<Record<string, string>>({});
216
+ const [fileError, setFileError] = useState("");
217
+ const [importError, setImportError] = useState("");
218
+ const [progress, setProgress] = useState(0);
219
+ const [running, setRunning] = useState(false);
220
+ const [summary, setSummary] = useState<Summary | null>(null);
221
+
222
+ const mapped = fields.filter(([key]) => mapping[key] !== undefined && mapping[key] !== SKIP);
223
+ const unmapped = fields.filter(([key, def]) => needsColumn(def) && (mapping[key] === undefined || mapping[key] === SKIP));
224
+ const ready = rows.length > 0 && mapped.length > 0 && unmapped.length === 0;
225
+
226
+ /** A starter file: the header this import wants, and one row of what each column holds. */
227
+ function downloadTemplate() {
228
+ const columns = fields.map(([key, def]) => ({ key, label: def.label }));
229
+ const example = Object.fromEntries(fields.map(([key, def]) => [key, exampleFor(key, def)]));
230
+ downloadCsv(`${resource.slug}-template.csv`, toCsv([example], columns));
231
+ }
232
+
233
+ function reset() {
234
+ setFileName("");
235
+ setHeaders([]);
236
+ setRows([]);
237
+ setMapping({});
238
+ setFileError("");
239
+ setImportError("");
240
+ setProgress(0);
241
+ setSummary(null);
242
+ }
243
+
244
+ async function onFile(files: File[]) {
245
+ const file = files[0];
246
+ if (!file) return;
247
+ reset();
248
+
249
+ const [head = [], ...body] = parseCsv(await file.text());
250
+ if (head.length === 0 || body.length === 0) {
251
+ setFileError("That file has no rows to import. A CSV needs a header row and at least one row under it.");
252
+ return;
253
+ }
254
+ setFileName(file.name);
255
+ setHeaders(head);
256
+ setRows(body);
257
+ setMapping(guessMapping(fields, head));
258
+ }
259
+
260
+ function toInput(cells: string[]): Record<string, unknown> {
261
+ const input: Record<string, unknown> = {};
262
+ for (const [key, def] of mapped) {
263
+ const value = toValue(def, cells[Number(mapping[key])] ?? "");
264
+ // An empty cell leaves the field out entirely, so its default applies as on create.
265
+ if (value !== undefined) input[key] = value;
266
+ }
267
+ return input;
268
+ }
269
+
270
+ async function runImport() {
271
+ setRunning(true);
272
+ setImportError("");
273
+ setSummary(null);
274
+ setProgress(0);
275
+
276
+ const failures: RowFailure[] = [];
277
+ let created = 0;
278
+
279
+ for (let start = 0; start < rows.length; start += BATCH_SIZE) {
280
+ const batch = rows.slice(start, start + BATCH_SIZE);
281
+ const accepted: Record<string, unknown>[] = [];
282
+ const lines: number[] = [];
283
+
284
+ batch.forEach((cells, offset) => {
285
+ // The header is line 1, so the first record is line 2 of the file.
286
+ const line = start + offset + 2;
287
+ const input = toInput(cells);
288
+ const parsed = validators.create.safeParse(input);
289
+ if (parsed.success) {
290
+ accepted.push(input);
291
+ lines.push(line);
292
+ return;
293
+ }
294
+ const issues = Object.entries(issuesByField(parsed.error.issues));
295
+ failures.push({
296
+ line,
297
+ error: issues.map(([key, message]) => (key === "_form" ? message : `${key}: ${message}`)).join("; "),
298
+ });
299
+ });
300
+
301
+ if (accepted.length > 0) {
302
+ const result = await importRecordsAction(resource.name, accepted);
303
+ if (!result.ok) {
304
+ setImportError(result.error);
305
+ setSummary({ created, total: rows.length, failures });
306
+ setRunning(false);
307
+ if (created > 0) router.refresh();
308
+ return;
309
+ }
310
+ created += result.data.created;
311
+ for (const failure of result.data.failed) {
312
+ failures.push({ line: lines[failure.row - 1] ?? start + failure.row + 1, error: failure.error });
313
+ }
314
+ }
315
+
316
+ setProgress(Math.round(((start + batch.length) / rows.length) * 100));
317
+ }
318
+
319
+ failures.sort((first, second) => first.line - second.line);
320
+ setSummary({ created, total: rows.length, failures });
321
+ setRunning(false);
322
+ if (created > 0) router.refresh();
323
+ }
324
+
325
+ function downloadFailures(failures: RowFailure[]) {
326
+ const csv = [
327
+ [...headers, "Import error"],
328
+ ...failures.map((failure) => [...(rows[failure.line - 2] ?? []), failure.error]),
329
+ ]
330
+ .map((cells) => cells.map(csvCell).join(","))
331
+ .join("\r\n");
332
+
333
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
334
+ const link = document.createElement("a");
335
+ link.href = url;
336
+ link.download = `${fileName.replace(/\.csv$/i, "") || resource.slug}-failed.csv`;
337
+ link.click();
338
+ URL.revokeObjectURL(url);
339
+ }
340
+
341
+ return (
342
+ <Dialog
343
+ open={open}
344
+ onOpenChange={(next) => {
345
+ setOpen(next);
346
+ if (!next) reset();
347
+ }}
348
+ >
349
+ <DialogTrigger asChild>
350
+ <Button variant="outline">
351
+ <UploadIcon data-icon="inline-start" />
352
+ Import CSV
353
+ </Button>
354
+ </DialogTrigger>
355
+
356
+ <DialogContent className="max-h-[85svh] overflow-y-auto sm:max-w-2xl">
357
+ <DialogHeader>
358
+ <DialogTitle>Import {resource.pluralLabel.toLowerCase()}</DialogTitle>
359
+ <DialogDescription>
360
+ Upload a CSV, match its columns to fields, then import. Every row is created as a new {resource.label.toLowerCase()}; nothing
361
+ already saved is changed.
362
+ </DialogDescription>
363
+ </DialogHeader>
364
+
365
+ <div className="grid gap-6">
366
+ <div className="flex flex-col gap-2">
367
+ <Dropzone
368
+ onFiles={onFile}
369
+ accept=".csv,text/csv"
370
+ disabled={running}
371
+ label={fileName ? fileName : "Drop a CSV here"}
372
+ hint={fileName ? `${rows.length.toLocaleString()} rows ready to map` : "One row per " + resource.label.toLowerCase() + ", with a header row on top"}
373
+ />
374
+ <Button type="button" variant="link" size="sm" className="h-auto w-fit p-0" onClick={downloadTemplate}>
375
+ <DownloadIcon data-icon="inline-start" />
376
+ Download a template with an example row
377
+ </Button>
378
+ </div>
379
+
380
+ {fileError && (
381
+ <Alert variant="destructive">
382
+ <AlertDescription>{fileError}</AlertDescription>
383
+ </Alert>
384
+ )}
385
+
386
+ {rows.length > 0 && !summary && (
387
+ <>
388
+ <div className="grid gap-4">
389
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
390
+ <h3 className="text-sm font-medium">Match columns</h3>
391
+ <p className="text-sm text-muted-foreground">
392
+ {rows.length} {rows.length === 1 ? "row" : "rows"} in {fileName}
393
+ </p>
394
+ </div>
395
+ <div className="grid gap-4 sm:grid-cols-2">
396
+ {fields.map(([key, def]) => (
397
+ <Field key={key}>
398
+ <FieldLabel htmlFor={`import-map-${key}`}>
399
+ {def.label}
400
+ {needsColumn(def) && (
401
+ <span className="ml-0.5 text-muted-foreground" aria-hidden>
402
+ *
403
+ </span>
404
+ )}
405
+ </FieldLabel>
406
+ <Select
407
+ value={mapping[key] ?? SKIP}
408
+ disabled={running}
409
+ onValueChange={(value) => setMapping((current) => ({ ...current, [key]: value }))}
410
+ >
411
+ <SelectTrigger id={`import-map-${key}`} className="w-full">
412
+ <SelectValue />
413
+ </SelectTrigger>
414
+ <SelectContent>
415
+ <SelectGroup>
416
+ <SelectItem value={SKIP}>Skip</SelectItem>
417
+ {headers.map((header, index) => (
418
+ <SelectItem key={index} value={String(index)}>
419
+ {header || `Column ${index + 1}`}
420
+ </SelectItem>
421
+ ))}
422
+ </SelectGroup>
423
+ </SelectContent>
424
+ </Select>
425
+ </Field>
426
+ ))}
427
+ </div>
428
+ </div>
429
+
430
+ {unmapped.length > 0 && (
431
+ <Alert variant="destructive">
432
+ <AlertTitle>Match every required field first</AlertTitle>
433
+ <AlertDescription>
434
+ {unmapped.map(([, def]) => def.label).join(", ")} {unmapped.length === 1 ? "has" : "have"} no column. Pick one for each,
435
+ or add the column to your file.
436
+ </AlertDescription>
437
+ </Alert>
438
+ )}
439
+
440
+ {mapped.length > 0 && (
441
+ <div className="grid gap-2">
442
+ <h3 className="text-sm font-medium">Preview</h3>
443
+ <div className="overflow-x-auto rounded-md border">
444
+ <Table>
445
+ <TableHeader>
446
+ <TableRow>
447
+ {mapped.map(([key, def]) => (
448
+ <TableHead key={key}>{def.label}</TableHead>
449
+ ))}
450
+ </TableRow>
451
+ </TableHeader>
452
+ <TableBody>
453
+ {rows.slice(0, PREVIEW_ROWS).map((cells, index) => (
454
+ <TableRow key={index}>
455
+ {mapped.map(([key]) => {
456
+ const cell = cells[Number(mapping[key])] ?? "";
457
+ return (
458
+ <TableCell key={key} className="max-w-48 truncate">
459
+ {cell || <span className="text-muted-foreground">—</span>}
460
+ </TableCell>
461
+ );
462
+ })}
463
+ </TableRow>
464
+ ))}
465
+ </TableBody>
466
+ </Table>
467
+ </div>
468
+ {rows.length > PREVIEW_ROWS && (
469
+ <p className="text-sm text-muted-foreground">
470
+ Showing the first {PREVIEW_ROWS} of {rows.length} rows.
471
+ </p>
472
+ )}
473
+ </div>
474
+ )}
475
+ </>
476
+ )}
477
+
478
+ {running && (
479
+ <div className="grid gap-2">
480
+ <Progress value={progress} aria-label={`Importing ${resource.pluralLabel.toLowerCase()}`} />
481
+ <p className="text-sm text-muted-foreground">Importing… {progress}% done. Leave this open until it finishes.</p>
482
+ </div>
483
+ )}
484
+
485
+ {importError && (
486
+ <Alert variant="destructive">
487
+ <AlertTitle>The import stopped</AlertTitle>
488
+ <AlertDescription>{importError}</AlertDescription>
489
+ </Alert>
490
+ )}
491
+
492
+ {summary && (
493
+ <div className="grid gap-4">
494
+ <p className="text-sm">
495
+ Imported {summary.created} of {summary.total} {summary.total === 1 ? "row" : "rows"}.
496
+ </p>
497
+ {summary.failures.length > 0 && (
498
+ <Alert variant="destructive">
499
+ <AlertTitle>
500
+ {summary.failures.length} {summary.failures.length === 1 ? "row was" : "rows were"} skipped
501
+ </AlertTitle>
502
+ <AlertDescription>
503
+ <ul className="grid gap-1">
504
+ {summary.failures.slice(0, LISTED_FAILURES).map((failure) => (
505
+ <li key={failure.line}>
506
+ Line {failure.line}: {failure.error}
507
+ </li>
508
+ ))}
509
+ </ul>
510
+ {summary.failures.length > LISTED_FAILURES && (
511
+ <p>And {summary.failures.length - LISTED_FAILURES} more. Download the failed rows to see them all.</p>
512
+ )}
513
+ </AlertDescription>
514
+ </Alert>
515
+ )}
516
+ </div>
517
+ )}
518
+ </div>
519
+
520
+ <DialogFooter>
521
+ {summary && summary.failures.length > 0 && (
522
+ <Button variant="outline" onClick={() => downloadFailures(summary.failures)}>
523
+ <DownloadIcon data-icon="inline-start" />
524
+ Download failed rows
525
+ </Button>
526
+ )}
527
+ {summary ? (
528
+ <Button onClick={() => setOpen(false)}>Done</Button>
529
+ ) : (
530
+ <Button disabled={!ready || running} onClick={runImport}>
531
+ {running && <Spinner data-icon="inline-start" />}
532
+ Import {rows.length > 0 ? `${rows.length} ` : ""}
533
+ {rows.length === 1 ? "row" : "rows"}
534
+ </Button>
535
+ )}
536
+ </DialogFooter>
537
+ </DialogContent>
538
+ </Dialog>
539
+ );
540
+ }