@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
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { splitPhone, toE164 } from "@flaredev/core";
5
+ import { Input } from "@/components/ui/input";
6
+ import { DialCodePicker } from "./country-field";
7
+
8
+ interface Props {
9
+ id: string;
10
+ name: string;
11
+ label: string;
12
+ value: string;
13
+ onChange: (value: string) => void;
14
+ invalid: boolean;
15
+ disabled?: boolean;
16
+ placeholder?: string;
17
+ }
18
+
19
+ /** The viewer's region from their browser language ("en-UG" → "UG"), else US. */
20
+ function localRegion(): string {
21
+ try {
22
+ return new Intl.Locale(navigator.language).maximize().region ?? "US";
23
+ } catch {
24
+ return "US";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * A `tel` field: a searchable country-code picker and the number. Stores E.164
30
+ * ("+256772123456"); a number pasted with its own "+code" switches the country.
31
+ */
32
+ export function PhoneField({ id, name, label, value, onChange, invalid, disabled, placeholder }: Props) {
33
+ const initial = splitPhone(value);
34
+ const [country, setCountry] = useState(initial.country ?? "US");
35
+ const [national, setNational] = useState(initial.national);
36
+
37
+ // On a new record, start from the viewer's own country (after hydration, so server and client agree).
38
+ useEffect(() => {
39
+ if (!value && !initial.country) setCountry(localRegion());
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, []);
42
+
43
+ const emit = (nextCountry: string, nextNational: string) => onChange(nextNational.trim() ? toE164(nextCountry, nextNational) : "");
44
+
45
+ return (
46
+ <div className="flex">
47
+ <DialCodePicker
48
+ value={country}
49
+ disabled={disabled}
50
+ invalid={invalid}
51
+ label={`Country code for ${label}`}
52
+ onChange={(next) => {
53
+ setCountry(next.code);
54
+ emit(next.code, national);
55
+ }}
56
+ />
57
+ <Input
58
+ id={id}
59
+ name={name}
60
+ type="tel"
61
+ inputMode="tel"
62
+ autoComplete="tel-national"
63
+ className="rounded-l-none tabular-nums"
64
+ placeholder={placeholder ?? "772 123456"}
65
+ disabled={disabled}
66
+ aria-invalid={invalid || undefined}
67
+ value={national}
68
+ onChange={(event) => {
69
+ const text = event.target.value;
70
+ if (text.trim().startsWith("+")) {
71
+ // A full international number: take its country and keep the rest.
72
+ const parsed = splitPhone(toE164(country, text));
73
+ if (parsed.country) {
74
+ setCountry(parsed.country);
75
+ setNational(parsed.national);
76
+ onChange(toE164(parsed.country, parsed.national));
77
+ return;
78
+ }
79
+ }
80
+ setNational(text);
81
+ emit(country, text);
82
+ }}
83
+ />
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,487 @@
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 { Input } from "@/components/ui/input";
30
+ import { Progress } from "@/components/ui/progress";
31
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
32
+ import { Spinner } from "@/components/ui/spinner";
33
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
34
+
35
+ /** Sentinel for "don't import this field": a SelectItem can't carry an empty value. */
36
+ const SKIP = "__skip__";
37
+
38
+ /** Rows per server action call. Small enough to stay well inside a Worker's time budget. */
39
+ const BATCH_SIZE = 500;
40
+
41
+ const PREVIEW_ROWS = 5;
42
+ const LISTED_FAILURES = 10;
43
+
44
+ type MappedField = [string, StoredField & { label: string }];
45
+
46
+ /** A row the import rejected, numbered by its line in the file so it can be found again. */
47
+ interface RowFailure {
48
+ line: number;
49
+ error: string;
50
+ }
51
+
52
+ interface Summary {
53
+ created: number;
54
+ total: number;
55
+ failures: RowFailure[];
56
+ }
57
+
58
+ /**
59
+ * Rows of a CSV. Written here rather than pulled from a package because the format an
60
+ * import needs is small: quoted fields, commas and newlines inside them, and `""` for a
61
+ * quote. Anything a spreadsheet exports parses; exotic dialects are not the point.
62
+ */
63
+ function parseCsv(text: string): string[][] {
64
+ // A BOM from Excel would otherwise become part of the first header.
65
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
66
+ const rows: string[][] = [];
67
+ let row: string[] = [];
68
+ let value = "";
69
+ let quoted = false;
70
+
71
+ for (let index = 0; index < source.length; index += 1) {
72
+ const char = source.charAt(index);
73
+ if (quoted) {
74
+ if (char !== '"') value += char;
75
+ else if (source.charAt(index + 1) === '"') {
76
+ value += '"';
77
+ index += 1;
78
+ } else quoted = false;
79
+ continue;
80
+ }
81
+ if (char === '"') quoted = true;
82
+ else if (char === ",") {
83
+ row.push(value);
84
+ value = "";
85
+ } else if (char === "\n" || char === "\r") {
86
+ if (char === "\r" && source.charAt(index + 1) === "\n") index += 1;
87
+ row.push(value);
88
+ rows.push(row);
89
+ row = [];
90
+ value = "";
91
+ } else value += char;
92
+ }
93
+ // Only a genuine last row survives here, so a trailing newline adds no empty record.
94
+ if (value !== "" || row.length > 0) {
95
+ row.push(value);
96
+ rows.push(row);
97
+ }
98
+ return rows;
99
+ }
100
+
101
+ const csvCell = (value: string) => (/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value);
102
+
103
+ const squash = (text: string) => text.toLowerCase().replace(/[\s_-]+/g, "");
104
+
105
+ /** A field must be mapped when nothing else can supply it: required, with no default. */
106
+ const needsColumn = (def: StoredField) => def.required && !("default" in def && def.default !== undefined);
107
+
108
+ /** An option from a cell holding either the stored value or the label shown in the admin. */
109
+ function matchOption(def: EnumField | MultiSelectField, text: string): string {
110
+ const match = def.options.find(
111
+ (option) => option === text || squash(option) === squash(text) || squash(optionLabel(def, option)) === squash(text),
112
+ );
113
+ return match ?? text;
114
+ }
115
+
116
+ /**
117
+ * A cell as the value its field expects. Text that doesn't convert is passed through
118
+ * unchanged so validation reports it against the field, rather than the import guessing.
119
+ */
120
+ function toValue(def: StoredField, raw: string): unknown {
121
+ const text = raw.trim();
122
+ if (text === "") return undefined;
123
+ switch (def.kind) {
124
+ case "int":
125
+ case "float": {
126
+ const number = Number(text);
127
+ return Number.isFinite(number) ? number : text;
128
+ }
129
+ case "boolean": {
130
+ const lower = text.toLowerCase();
131
+ if (["true", "yes", "1"].includes(lower)) return true;
132
+ if (["false", "no", "0"].includes(lower)) return false;
133
+ return text;
134
+ }
135
+ case "enum":
136
+ return matchOption(def, text);
137
+ case "multiselect":
138
+ return text
139
+ .split(/[;,]/)
140
+ .map((part) => part.trim())
141
+ .filter(Boolean)
142
+ .map((part) => matchOption(def, part));
143
+ default:
144
+ // Dates keep their text: the descriptor's schema decides which formats it takes.
145
+ return text;
146
+ }
147
+ }
148
+
149
+ /** A column per field, matched on header text; anything unrecognised starts as Skip. */
150
+ function guessMapping(fields: MappedField[], headers: string[]): Record<string, string> {
151
+ const taken = new Set<number>();
152
+ const mapping: Record<string, string> = {};
153
+ for (const [key, def] of fields) {
154
+ const names = [squash(key), squash(def.label)];
155
+ const index = headers.findIndex((header, position) => !taken.has(position) && names.includes(squash(header)));
156
+ mapping[key] = index === -1 ? SKIP : String(index);
157
+ if (index !== -1) taken.add(index);
158
+ }
159
+ return mapping;
160
+ }
161
+
162
+ /**
163
+ * Creates records from a CSV: pick a file, match its columns to the resource's fields,
164
+ * check the preview, then import. Rows are validated in the browser with the descriptor's
165
+ * schemas and again on the server, and sent in batches so a long file reports progress
166
+ * and never depends on one very long request.
167
+ */
168
+ export function ImportDialog({ resource }: { resource: Resource }) {
169
+ const router = useRouter();
170
+ const validators = useMemo(() => createValidators(resource), [resource]);
171
+ // Uploads need a signed URL per file, which a CSV cell can't stand in for.
172
+ const fields = useMemo(() => storedFields(resource).filter(([, def]) => def.kind !== "file"), [resource]);
173
+
174
+ const [open, setOpen] = useState(false);
175
+ const [fileName, setFileName] = useState("");
176
+ const [headers, setHeaders] = useState<string[]>([]);
177
+ const [rows, setRows] = useState<string[][]>([]);
178
+ const [mapping, setMapping] = useState<Record<string, string>>({});
179
+ const [fileError, setFileError] = useState("");
180
+ const [importError, setImportError] = useState("");
181
+ const [progress, setProgress] = useState(0);
182
+ const [running, setRunning] = useState(false);
183
+ const [summary, setSummary] = useState<Summary | null>(null);
184
+
185
+ const mapped = fields.filter(([key]) => mapping[key] !== undefined && mapping[key] !== SKIP);
186
+ const unmapped = fields.filter(([key, def]) => needsColumn(def) && (mapping[key] === undefined || mapping[key] === SKIP));
187
+ const ready = rows.length > 0 && mapped.length > 0 && unmapped.length === 0;
188
+
189
+ function reset() {
190
+ setFileName("");
191
+ setHeaders([]);
192
+ setRows([]);
193
+ setMapping({});
194
+ setFileError("");
195
+ setImportError("");
196
+ setProgress(0);
197
+ setSummary(null);
198
+ }
199
+
200
+ async function onFile(event: ChangeEvent<HTMLInputElement>) {
201
+ const file = event.target.files?.[0];
202
+ if (!file) return;
203
+ reset();
204
+
205
+ const [head = [], ...body] = parseCsv(await file.text());
206
+ if (head.length === 0 || body.length === 0) {
207
+ setFileError("That file has no rows to import. A CSV needs a header row and at least one row under it.");
208
+ return;
209
+ }
210
+ setFileName(file.name);
211
+ setHeaders(head);
212
+ setRows(body);
213
+ setMapping(guessMapping(fields, head));
214
+ }
215
+
216
+ function toInput(cells: string[]): Record<string, unknown> {
217
+ const input: Record<string, unknown> = {};
218
+ for (const [key, def] of mapped) {
219
+ const value = toValue(def, cells[Number(mapping[key])] ?? "");
220
+ // An empty cell leaves the field out entirely, so its default applies as on create.
221
+ if (value !== undefined) input[key] = value;
222
+ }
223
+ return input;
224
+ }
225
+
226
+ async function runImport() {
227
+ setRunning(true);
228
+ setImportError("");
229
+ setSummary(null);
230
+ setProgress(0);
231
+
232
+ const failures: RowFailure[] = [];
233
+ let created = 0;
234
+
235
+ for (let start = 0; start < rows.length; start += BATCH_SIZE) {
236
+ const batch = rows.slice(start, start + BATCH_SIZE);
237
+ const accepted: Record<string, unknown>[] = [];
238
+ const lines: number[] = [];
239
+
240
+ batch.forEach((cells, offset) => {
241
+ // The header is line 1, so the first record is line 2 of the file.
242
+ const line = start + offset + 2;
243
+ const input = toInput(cells);
244
+ const parsed = validators.create.safeParse(input);
245
+ if (parsed.success) {
246
+ accepted.push(input);
247
+ lines.push(line);
248
+ return;
249
+ }
250
+ const issues = Object.entries(issuesByField(parsed.error.issues));
251
+ failures.push({
252
+ line,
253
+ error: issues.map(([key, message]) => (key === "_form" ? message : `${key}: ${message}`)).join("; "),
254
+ });
255
+ });
256
+
257
+ if (accepted.length > 0) {
258
+ const result = await importRecordsAction(resource.name, accepted);
259
+ if (!result.ok) {
260
+ setImportError(result.error);
261
+ setSummary({ created, total: rows.length, failures });
262
+ setRunning(false);
263
+ if (created > 0) router.refresh();
264
+ return;
265
+ }
266
+ created += result.data.created;
267
+ for (const failure of result.data.failed) {
268
+ failures.push({ line: lines[failure.row - 1] ?? start + failure.row + 1, error: failure.error });
269
+ }
270
+ }
271
+
272
+ setProgress(Math.round(((start + batch.length) / rows.length) * 100));
273
+ }
274
+
275
+ failures.sort((first, second) => first.line - second.line);
276
+ setSummary({ created, total: rows.length, failures });
277
+ setRunning(false);
278
+ if (created > 0) router.refresh();
279
+ }
280
+
281
+ function downloadFailures(failures: RowFailure[]) {
282
+ const csv = [
283
+ [...headers, "Import error"],
284
+ ...failures.map((failure) => [...(rows[failure.line - 2] ?? []), failure.error]),
285
+ ]
286
+ .map((cells) => cells.map(csvCell).join(","))
287
+ .join("\r\n");
288
+
289
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
290
+ const link = document.createElement("a");
291
+ link.href = url;
292
+ link.download = `${fileName.replace(/\.csv$/i, "") || resource.slug}-failed.csv`;
293
+ link.click();
294
+ URL.revokeObjectURL(url);
295
+ }
296
+
297
+ return (
298
+ <Dialog
299
+ open={open}
300
+ onOpenChange={(next) => {
301
+ setOpen(next);
302
+ if (!next) reset();
303
+ }}
304
+ >
305
+ <DialogTrigger asChild>
306
+ <Button variant="outline">
307
+ <UploadIcon data-icon="inline-start" />
308
+ Import CSV
309
+ </Button>
310
+ </DialogTrigger>
311
+
312
+ <DialogContent className="max-h-[85svh] overflow-y-auto sm:max-w-2xl">
313
+ <DialogHeader>
314
+ <DialogTitle>Import {resource.pluralLabel.toLowerCase()}</DialogTitle>
315
+ <DialogDescription>
316
+ Upload a CSV, match its columns to fields, then import. Every row is created as a new {resource.label.toLowerCase()}; nothing
317
+ already saved is changed.
318
+ </DialogDescription>
319
+ </DialogHeader>
320
+
321
+ <div className="grid gap-6">
322
+ <Field data-invalid={fileError ? true : undefined}>
323
+ <FieldLabel htmlFor="import-file">CSV file</FieldLabel>
324
+ <Input id="import-file" type="file" accept=".csv,text/csv" disabled={running} onChange={onFile} />
325
+ </Field>
326
+
327
+ {fileError && (
328
+ <Alert variant="destructive">
329
+ <AlertDescription>{fileError}</AlertDescription>
330
+ </Alert>
331
+ )}
332
+
333
+ {rows.length > 0 && !summary && (
334
+ <>
335
+ <div className="grid gap-4">
336
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
337
+ <h3 className="text-sm font-medium">Match columns</h3>
338
+ <p className="text-sm text-muted-foreground">
339
+ {rows.length} {rows.length === 1 ? "row" : "rows"} in {fileName}
340
+ </p>
341
+ </div>
342
+ <div className="grid gap-4 sm:grid-cols-2">
343
+ {fields.map(([key, def]) => (
344
+ <Field key={key}>
345
+ <FieldLabel htmlFor={`import-map-${key}`}>
346
+ {def.label}
347
+ {needsColumn(def) && (
348
+ <span className="ml-0.5 text-muted-foreground" aria-hidden>
349
+ *
350
+ </span>
351
+ )}
352
+ </FieldLabel>
353
+ <Select
354
+ value={mapping[key] ?? SKIP}
355
+ disabled={running}
356
+ onValueChange={(value) => setMapping((current) => ({ ...current, [key]: value }))}
357
+ >
358
+ <SelectTrigger id={`import-map-${key}`} className="w-full">
359
+ <SelectValue />
360
+ </SelectTrigger>
361
+ <SelectContent>
362
+ <SelectGroup>
363
+ <SelectItem value={SKIP}>Skip</SelectItem>
364
+ {headers.map((header, index) => (
365
+ <SelectItem key={index} value={String(index)}>
366
+ {header || `Column ${index + 1}`}
367
+ </SelectItem>
368
+ ))}
369
+ </SelectGroup>
370
+ </SelectContent>
371
+ </Select>
372
+ </Field>
373
+ ))}
374
+ </div>
375
+ </div>
376
+
377
+ {unmapped.length > 0 && (
378
+ <Alert variant="destructive">
379
+ <AlertTitle>Match every required field first</AlertTitle>
380
+ <AlertDescription>
381
+ {unmapped.map(([, def]) => def.label).join(", ")} {unmapped.length === 1 ? "has" : "have"} no column. Pick one for each,
382
+ or add the column to your file.
383
+ </AlertDescription>
384
+ </Alert>
385
+ )}
386
+
387
+ {mapped.length > 0 && (
388
+ <div className="grid gap-2">
389
+ <h3 className="text-sm font-medium">Preview</h3>
390
+ <div className="overflow-x-auto rounded-md border">
391
+ <Table>
392
+ <TableHeader>
393
+ <TableRow>
394
+ {mapped.map(([key, def]) => (
395
+ <TableHead key={key}>{def.label}</TableHead>
396
+ ))}
397
+ </TableRow>
398
+ </TableHeader>
399
+ <TableBody>
400
+ {rows.slice(0, PREVIEW_ROWS).map((cells, index) => (
401
+ <TableRow key={index}>
402
+ {mapped.map(([key]) => {
403
+ const cell = cells[Number(mapping[key])] ?? "";
404
+ return (
405
+ <TableCell key={key} className="max-w-48 truncate">
406
+ {cell || <span className="text-muted-foreground">—</span>}
407
+ </TableCell>
408
+ );
409
+ })}
410
+ </TableRow>
411
+ ))}
412
+ </TableBody>
413
+ </Table>
414
+ </div>
415
+ {rows.length > PREVIEW_ROWS && (
416
+ <p className="text-sm text-muted-foreground">
417
+ Showing the first {PREVIEW_ROWS} of {rows.length} rows.
418
+ </p>
419
+ )}
420
+ </div>
421
+ )}
422
+ </>
423
+ )}
424
+
425
+ {running && (
426
+ <div className="grid gap-2">
427
+ <Progress value={progress} aria-label={`Importing ${resource.pluralLabel.toLowerCase()}`} />
428
+ <p className="text-sm text-muted-foreground">Importing… {progress}% done. Leave this open until it finishes.</p>
429
+ </div>
430
+ )}
431
+
432
+ {importError && (
433
+ <Alert variant="destructive">
434
+ <AlertTitle>The import stopped</AlertTitle>
435
+ <AlertDescription>{importError}</AlertDescription>
436
+ </Alert>
437
+ )}
438
+
439
+ {summary && (
440
+ <div className="grid gap-4">
441
+ <p className="text-sm">
442
+ Imported {summary.created} of {summary.total} {summary.total === 1 ? "row" : "rows"}.
443
+ </p>
444
+ {summary.failures.length > 0 && (
445
+ <Alert variant="destructive">
446
+ <AlertTitle>
447
+ {summary.failures.length} {summary.failures.length === 1 ? "row was" : "rows were"} skipped
448
+ </AlertTitle>
449
+ <AlertDescription>
450
+ <ul className="grid gap-1">
451
+ {summary.failures.slice(0, LISTED_FAILURES).map((failure) => (
452
+ <li key={failure.line}>
453
+ Line {failure.line}: {failure.error}
454
+ </li>
455
+ ))}
456
+ </ul>
457
+ {summary.failures.length > LISTED_FAILURES && (
458
+ <p>And {summary.failures.length - LISTED_FAILURES} more. Download the failed rows to see them all.</p>
459
+ )}
460
+ </AlertDescription>
461
+ </Alert>
462
+ )}
463
+ </div>
464
+ )}
465
+ </div>
466
+
467
+ <DialogFooter>
468
+ {summary && summary.failures.length > 0 && (
469
+ <Button variant="outline" onClick={() => downloadFailures(summary.failures)}>
470
+ <DownloadIcon data-icon="inline-start" />
471
+ Download failed rows
472
+ </Button>
473
+ )}
474
+ {summary ? (
475
+ <Button onClick={() => setOpen(false)}>Done</Button>
476
+ ) : (
477
+ <Button disabled={!ready || running} onClick={runImport}>
478
+ {running && <Spinner data-icon="inline-start" />}
479
+ Import {rows.length > 0 ? `${rows.length} ` : ""}
480
+ {rows.length === 1 ? "row" : "rows"}
481
+ </Button>
482
+ )}
483
+ </DialogFooter>
484
+ </DialogContent>
485
+ </Dialog>
486
+ );
487
+ }
@@ -0,0 +1,57 @@
1
+ import Link from "next/link";
2
+ import { ChevronRightIcon } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export interface Crumb {
6
+ label: string;
7
+ href?: string;
8
+ }
9
+
10
+ /**
11
+ * The top of every dashboard page: where you are, what this page is, and what you can
12
+ * do here. Every page uses it, so they all line up.
13
+ */
14
+ export function PageHeader({
15
+ title,
16
+ description,
17
+ crumbs = [],
18
+ actions,
19
+ className,
20
+ }: {
21
+ title: React.ReactNode;
22
+ description?: React.ReactNode;
23
+ crumbs?: Crumb[];
24
+ /** Buttons for this page, right-aligned on a wide screen and below the title on a phone. */
25
+ actions?: React.ReactNode;
26
+ className?: string;
27
+ }) {
28
+ return (
29
+ <div className={cn("flex flex-col gap-3", className)}>
30
+ {crumbs.length > 0 && (
31
+ <nav aria-label="Breadcrumb">
32
+ <ol className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
33
+ {crumbs.map((crumb, index) => (
34
+ <li key={`${crumb.label}-${index}`} className="flex items-center gap-1">
35
+ {index > 0 && <ChevronRightIcon className="size-3.5 opacity-60" aria-hidden="true" />}
36
+ {crumb.href ? (
37
+ <Link href={crumb.href} className="hover:text-foreground">
38
+ {crumb.label}
39
+ </Link>
40
+ ) : (
41
+ <span aria-current="page">{crumb.label}</span>
42
+ )}
43
+ </li>
44
+ ))}
45
+ </ol>
46
+ </nav>
47
+ )}
48
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
49
+ <div className="flex flex-col gap-1">
50
+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
51
+ {description && <p className="text-sm text-muted-foreground">{description}</p>}
52
+ </div>
53
+ {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
54
+ </div>
55
+ </div>
56
+ );
57
+ }