@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.
Files changed (64) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +751 -280
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/page.tsx +6 -4
  6. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  7. package/templates/app/app/dashboard/import-actions.ts +91 -0
  8. package/templates/app/app/dashboard/layout.tsx +42 -39
  9. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  10. package/templates/app/app/dashboard/page.tsx +106 -25
  11. package/templates/app/app/verify-email/page.tsx +33 -0
  12. package/templates/app/components/account/security-settings.tsx +6 -9
  13. package/templates/app/components/auth/otp-input.tsx +95 -0
  14. package/templates/app/components/auth/password-field.tsx +116 -0
  15. package/templates/app/components/auth/password-forms.tsx +76 -79
  16. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  17. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  18. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  19. package/templates/app/components/auth/use-resend.ts +44 -0
  20. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  21. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  22. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
  23. package/templates/app/components/dashboard/export-button.tsx +41 -0
  24. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  25. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  26. package/templates/app/components/dashboard/page-header.tsx +57 -0
  27. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  28. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  29. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  30. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  31. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
  32. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  33. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  34. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  35. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  36. package/templates/app/db/flare-schema.ts +33 -1
  37. package/templates/app/lib/analytics.ts +121 -0
  38. package/templates/app/lib/audit.ts +80 -0
  39. package/templates/app/lib/auth-config.ts +38 -33
  40. package/templates/app/lib/auth.ts +34 -0
  41. package/templates/app/lib/cache.ts +83 -83
  42. package/templates/app/lib/csv.ts +24 -0
  43. package/templates/app/lib/dashboard-nav.ts +15 -0
  44. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  45. package/templates/app/lib/password-rules.ts +159 -0
  46. package/templates/app/lib/site.ts +56 -47
  47. package/templates/app/migrations/0000_init.sql +15 -3
  48. package/templates/app/migrations/meta/0000_snapshot.json +97 -1
  49. package/templates/app/migrations/meta/_journal.json +1 -1
  50. package/templates/app/worker-configuration.d.ts +15764 -0
  51. package/templates/locks/package-lock.json +9189 -0
  52. package/templates/locks/pnpm-lock.yaml +6347 -0
  53. package/templates/app/app/admin/layout.tsx +0 -32
  54. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  55. package/templates/app/lib/admin-nav.ts +0 -15
  56. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  57. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  58. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  59. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  60. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  61. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  62. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  63. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  64. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,302 +1,302 @@
1
- "use client";
2
-
3
- import { useEffect, useId, useRef, useState } from "react";
4
- import { FileIcon, UploadIcon } from "lucide-react";
5
- import {
6
- FILE_CATEGORIES,
7
- fileMaxBytes,
8
- matchesContentType,
9
- mimeTypesFor,
10
- SNIFF_BYTES,
11
- sniffMatches,
12
- type FileField as FileFieldDef,
13
- } from "@flaredev/core";
14
- import { createReadUrlAction, createUploadUrlAction } from "@/app/admin/actions";
15
- import { Button } from "@/components/ui/button";
16
- import { Progress } from "@/components/ui/progress";
17
- import { cn } from "@/lib/utils";
18
-
19
- interface Props {
20
- id: string;
21
- value: string;
22
- onChange: (value: string) => void;
23
- invalid: boolean;
24
- disabled?: boolean;
25
- field: FileFieldDef & { label: string };
26
- resourceName: string;
27
- fieldKey: string;
28
- }
29
-
30
- type Upload = { name: string; loaded: number; total: number; abort: () => void };
31
-
32
- /** "contacts/avatar/2026/09/<uuid>-me.png" → "me.png" */
33
- const fileName = (key: string) => key.split("/").pop()!.replace(/^[0-9a-f-]{36}-/, "");
34
-
35
- const IMAGE_TYPES = new Set<string>(FILE_CATEGORIES.image);
36
- const isImageKey = (key: string) => /\.(png|jpe?g|gif|webp|avif)$/i.test(key);
37
-
38
- const oneDecimal = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
39
- const whole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
40
-
41
- /** "1.9 MB" — with a non-breaking space so a size never wraps apart. */
42
- function formatBytes(bytes: number): string {
43
- if (bytes < 1024) return `${whole.format(bytes)} B`;
44
- if (bytes < 1024 * 1024) return `${whole.format(bytes / 1024)} KB`;
45
- const megabytes = bytes / 1024 / 1024;
46
- return `${(megabytes < 10 ? oneDecimal : whole).format(megabytes)} MB`;
47
- }
48
-
49
- /** ["image", "pdf"] → "image or PDF" (acronyms stay upper case). */
50
- function describeAccept(accept: readonly string[]): string {
51
- const names = accept.map((category) => (category === "pdf" || category === "csv" ? category.toUpperCase() : category));
52
- return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names.at(-1)}` : names[0]!;
53
- }
54
-
55
- const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
56
-
57
- /** "image/png" → "PNG", used when a file's contents don't match its type. */
58
- const typeName = (type: string) => (type.split("/")[1] ?? type).replace(/^vnd\..*\./, "").toUpperCase();
59
-
60
- /** PUT with progress events, which fetch() can't report for uploads. */
61
- function put(url: string, file: File, onProgress: (loaded: number) => void, signal: { abort?: () => void }) {
62
- return new Promise<void>((resolve, reject) => {
63
- const request = new XMLHttpRequest();
64
- signal.abort = () => request.abort();
65
- request.upload.onprogress = (event) => onProgress(event.loaded);
66
- request.onload = () => {
67
- if (request.status >= 200 && request.status < 300) return resolve();
68
- let message = `Upload failed (${request.status}).`;
69
- try {
70
- message = (JSON.parse(request.responseText) as { error?: string }).error ?? message;
71
- } catch {
72
- // Not JSON: keep the status message.
73
- }
74
- reject(new Error(message));
75
- };
76
- request.onerror = () => reject(new Error("Upload failed. Check your connection and try again."));
77
- request.onabort = () => reject(new DOMException("Upload cancelled.", "AbortError"));
78
- request.open("PUT", url);
79
- request.setRequestHeader("Content-Type", file.type || "application/octet-stream");
80
- request.send(file);
81
- });
82
- }
83
-
84
- /**
85
- * Upload widget for a `file:[…]` field. Checks type, size and contents in the browser
86
- * first (the server repeats every check), uploads straight to storage through a signed
87
- * URL with progress, and stores the object key in the form.
88
- */
89
- export function FileField({ id, value, onChange, invalid, disabled, field, resourceName, fieldKey }: Props) {
90
- const input = useRef<HTMLInputElement>(null);
91
- const helpId = useId();
92
- const instructionId = useId();
93
- const [upload, setUpload] = useState<Upload | null>(null);
94
- const [error, setError] = useState<string | null>(null);
95
- const [dragging, setDragging] = useState(false);
96
- const [preview, setPreview] = useState<string | null>(null);
97
- const [status, setStatus] = useState("");
98
-
99
- const accepted = mimeTypesFor(field.accept);
100
- const maxBytes = fileMaxBytes(field);
101
- const help = `${capitalize(describeAccept(field.accept))}, up to ${formatBytes(maxBytes)}`;
102
- const busy = upload !== null;
103
-
104
- // Thumbnail for a stored image (a local preview replaces it while uploading).
105
- useEffect(() => {
106
- if (!value || !isImageKey(value)) return setPreview(null);
107
- let cancelled = false;
108
- void createReadUrlAction(resourceName, fieldKey, value).then((result) => {
109
- if (!cancelled && result.ok) setPreview(result.data.url);
110
- });
111
- return () => {
112
- cancelled = true;
113
- };
114
- }, [value, resourceName, fieldKey]);
115
-
116
- async function check(file: File): Promise<string | null> {
117
- const type = file.type || "application/octet-stream";
118
- if (!matchesContentType(type, accepted)) return `${field.label} accepts ${describeAccept(field.accept)} files.`;
119
- if (file.size > maxBytes) return `${file.name} is ${formatBytes(file.size)}; the limit is ${formatBytes(maxBytes)}.`;
120
- const head = new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer());
121
- if (sniffMatches(type, head) === false) {
122
- return `${file.name} isn't a valid ${typeName(type)} file. It may have been renamed; choose the original file.`;
123
- }
124
- return null;
125
- }
126
-
127
- async function start(file: File) {
128
- setError(null);
129
- const problem = await check(file);
130
- if (problem) return setError(problem);
131
-
132
- const signal: { abort?: () => void } = {};
133
- const localPreview = IMAGE_TYPES.has(file.type) ? URL.createObjectURL(file) : null;
134
- setUpload({ name: file.name, loaded: 0, total: file.size, abort: () => signal.abort?.() });
135
- if (localPreview) setPreview(localPreview);
136
- setStatus(`Uploading ${file.name}…`);
137
-
138
- try {
139
- const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: file.type, size: file.size });
140
- if (!signed.ok) throw new Error(signed.error);
141
- await put(signed.data.url, file, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
142
- onChange(signed.data.key);
143
- setStatus(`${file.name} uploaded`);
144
- } catch (cause) {
145
- const cancelled = cause instanceof DOMException && cause.name === "AbortError";
146
- if (!cancelled) setError(cause instanceof Error ? cause.message : "Upload failed.");
147
- setStatus(cancelled ? "Upload cancelled" : "Upload failed");
148
- if (localPreview) setPreview(null);
149
- } finally {
150
- if (localPreview) URL.revokeObjectURL(localPreview);
151
- setUpload(null);
152
- if (input.current) input.current.value = "";
153
- }
154
- }
155
-
156
- async function open() {
157
- const result = await createReadUrlAction(resourceName, fieldKey, value);
158
- if (result.ok) window.open(result.data.url, "_blank", "noopener");
159
- else setError(result.error);
160
- }
161
-
162
- function remove() {
163
- setError(null);
164
- setPreview(null);
165
- onChange("");
166
- setStatus("File removed");
167
- }
168
-
169
- const choose = () => input.current?.click();
170
- const percent = upload && upload.total > 0 ? Math.round((upload.loaded / upload.total) * 100) : 0;
171
-
172
- const dropHandlers = {
173
- onDragOver: (event: React.DragEvent) => {
174
- if (disabled || busy) return;
175
- event.preventDefault();
176
- setDragging(true);
177
- },
178
- onDragLeave: () => setDragging(false),
179
- onDrop: (event: React.DragEvent) => {
180
- event.preventDefault();
181
- setDragging(false);
182
- const file = event.dataTransfer.files[0];
183
- if (file && !disabled && !busy) void start(file);
184
- },
185
- };
186
-
187
- const thumbnail = (
188
- <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted">
189
- {preview ? (
190
- // eslint-disable-next-line @next/next/no-img-element -- signed, short-lived URL
191
- <img src={preview} alt="" width={40} height={40} className="size-full object-cover" />
192
- ) : (
193
- <FileIcon aria-hidden="true" className="size-5 text-muted-foreground" />
194
- )}
195
- </div>
196
- );
197
-
198
- return (
199
- <div className="flex flex-col gap-2">
200
- {/* The native input only opens the file chooser; the visible button is the control
201
- the field's label points at, so assistive tech sees one control, not two. */}
202
- <input
203
- ref={input}
204
- type="file"
205
- accept={accepted.join(",")}
206
- className="sr-only"
207
- tabIndex={-1}
208
- aria-hidden="true"
209
- disabled={disabled || busy}
210
- onChange={(event) => {
211
- const file = event.target.files?.[0];
212
- if (file) void start(file);
213
- }}
214
- />
215
-
216
- {upload ? (
217
- <div className="flex items-center gap-3 rounded-lg border p-3">
218
- {thumbnail}
219
- <div className="flex min-w-0 flex-1 flex-col gap-1.5">
220
- <div className="flex items-baseline justify-between gap-2 text-sm">
221
- <span className="truncate font-medium">{upload.name}</span>
222
- <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
223
- {formatBytes(upload.loaded)} of {formatBytes(upload.total)}
224
- </span>
225
- </div>
226
- <Progress value={percent} aria-label={`Uploading ${upload.name}`} />
227
- </div>
228
- <Button type="button" variant="ghost" size="sm" onClick={upload.abort}>
229
- Cancel upload
230
- </Button>
231
- </div>
232
- ) : value ? (
233
- <div
234
- {...dropHandlers}
235
- className={cn("flex items-center gap-3 rounded-lg border p-3", dragging && "border-primary bg-muted")}
236
- data-invalid={invalid || undefined}
237
- >
238
- {thumbnail}
239
- <span className="min-w-0 flex-1 truncate text-sm font-medium">{fileName(value)}</span>
240
- <div className="flex shrink-0 gap-1">
241
- <Button type="button" variant="ghost" size="sm" onClick={open}>
242
- Open<span className="sr-only"> {fileName(value)}</span>
243
- </Button>
244
- {/* The field label points here; aria-label keeps the button named for what it does. */}
245
- <Button
246
- id={id}
247
- type="button"
248
- variant="ghost"
249
- size="sm"
250
- disabled={disabled}
251
- onClick={choose}
252
- aria-label={`Replace ${field.label.toLowerCase()} file`}
253
- >
254
- Replace
255
- </Button>
256
- {!field.required && (
257
- <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={remove}>
258
- Remove<span className="sr-only"> {field.label.toLowerCase()} file</span>
259
- </Button>
260
- )}
261
- </div>
262
- </div>
263
- ) : (
264
- <button
265
- id={id}
266
- type="button"
267
- {...dropHandlers}
268
- onClick={choose}
269
- disabled={disabled}
270
- aria-describedby={`${instructionId} ${helpId}`}
271
- aria-invalid={invalid || Boolean(error) || undefined}
272
- className={cn(
273
- "flex w-full items-center gap-3 rounded-lg border border-dashed p-3 text-left transition-colors outline-none motion-reduce:transition-none",
274
- "hover:bg-muted focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
275
- dragging ? "border-primary bg-muted" : (invalid || error) && "border-destructive",
276
- )}
277
- >
278
- <div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
279
- <UploadIcon aria-hidden="true" className="size-5 text-muted-foreground" />
280
- </div>
281
- <span className="flex flex-col">
282
- <span id={instructionId} className="text-sm font-medium">
283
- {dragging ? "Drop to upload" : "Choose a file or drop it here"}
284
- </span>
285
- <span id={helpId} className="text-xs text-muted-foreground">
286
- {help}
287
- </span>
288
- </span>
289
- </button>
290
- )}
291
-
292
- {error && (
293
- <p role="alert" className="text-sm text-destructive">
294
- {error}
295
- </p>
296
- )}
297
- <span className="sr-only" aria-live="polite">
298
- {status}
299
- </span>
300
- </div>
301
- );
302
- }
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { FileIcon, UploadIcon } from "lucide-react";
5
+ import {
6
+ FILE_CATEGORIES,
7
+ fileMaxBytes,
8
+ matchesContentType,
9
+ mimeTypesFor,
10
+ SNIFF_BYTES,
11
+ sniffMatches,
12
+ type FileField as FileFieldDef,
13
+ } from "@flaredev/core";
14
+ import { createReadUrlAction, createUploadUrlAction } from "@/app/dashboard/actions";
15
+ import { Button } from "@/components/ui/button";
16
+ import { Progress } from "@/components/ui/progress";
17
+ import { cn } from "@/lib/utils";
18
+
19
+ interface Props {
20
+ id: string;
21
+ value: string;
22
+ onChange: (value: string) => void;
23
+ invalid: boolean;
24
+ disabled?: boolean;
25
+ field: FileFieldDef & { label: string };
26
+ resourceName: string;
27
+ fieldKey: string;
28
+ }
29
+
30
+ type Upload = { name: string; loaded: number; total: number; abort: () => void };
31
+
32
+ /** "contacts/avatar/2026/09/<uuid>-me.png" → "me.png" */
33
+ const fileName = (key: string) => key.split("/").pop()!.replace(/^[0-9a-f-]{36}-/, "");
34
+
35
+ const IMAGE_TYPES = new Set<string>(FILE_CATEGORIES.image);
36
+ const isImageKey = (key: string) => /\.(png|jpe?g|gif|webp|avif)$/i.test(key);
37
+
38
+ const oneDecimal = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
39
+ const whole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
40
+
41
+ /** "1.9 MB" — with a non-breaking space so a size never wraps apart. */
42
+ function formatBytes(bytes: number): string {
43
+ if (bytes < 1024) return `${whole.format(bytes)} B`;
44
+ if (bytes < 1024 * 1024) return `${whole.format(bytes / 1024)} KB`;
45
+ const megabytes = bytes / 1024 / 1024;
46
+ return `${(megabytes < 10 ? oneDecimal : whole).format(megabytes)} MB`;
47
+ }
48
+
49
+ /** ["image", "pdf"] → "image or PDF" (acronyms stay upper case). */
50
+ function describeAccept(accept: readonly string[]): string {
51
+ const names = accept.map((category) => (category === "pdf" || category === "csv" ? category.toUpperCase() : category));
52
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names.at(-1)}` : names[0]!;
53
+ }
54
+
55
+ const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
56
+
57
+ /** "image/png" → "PNG", used when a file's contents don't match its type. */
58
+ const typeName = (type: string) => (type.split("/")[1] ?? type).replace(/^vnd\..*\./, "").toUpperCase();
59
+
60
+ /** PUT with progress events, which fetch() can't report for uploads. */
61
+ function put(url: string, file: File, onProgress: (loaded: number) => void, signal: { abort?: () => void }) {
62
+ return new Promise<void>((resolve, reject) => {
63
+ const request = new XMLHttpRequest();
64
+ signal.abort = () => request.abort();
65
+ request.upload.onprogress = (event) => onProgress(event.loaded);
66
+ request.onload = () => {
67
+ if (request.status >= 200 && request.status < 300) return resolve();
68
+ let message = `Upload failed (${request.status}).`;
69
+ try {
70
+ message = (JSON.parse(request.responseText) as { error?: string }).error ?? message;
71
+ } catch {
72
+ // Not JSON: keep the status message.
73
+ }
74
+ reject(new Error(message));
75
+ };
76
+ request.onerror = () => reject(new Error("Upload failed. Check your connection and try again."));
77
+ request.onabort = () => reject(new DOMException("Upload cancelled.", "AbortError"));
78
+ request.open("PUT", url);
79
+ request.setRequestHeader("Content-Type", file.type || "application/octet-stream");
80
+ request.send(file);
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Upload widget for a `file:[…]` field. Checks type, size and contents in the browser
86
+ * first (the server repeats every check), uploads straight to storage through a signed
87
+ * URL with progress, and stores the object key in the form.
88
+ */
89
+ export function FileField({ id, value, onChange, invalid, disabled, field, resourceName, fieldKey }: Props) {
90
+ const input = useRef<HTMLInputElement>(null);
91
+ const helpId = useId();
92
+ const instructionId = useId();
93
+ const [upload, setUpload] = useState<Upload | null>(null);
94
+ const [error, setError] = useState<string | null>(null);
95
+ const [dragging, setDragging] = useState(false);
96
+ const [preview, setPreview] = useState<string | null>(null);
97
+ const [status, setStatus] = useState("");
98
+
99
+ const accepted = mimeTypesFor(field.accept);
100
+ const maxBytes = fileMaxBytes(field);
101
+ const help = `${capitalize(describeAccept(field.accept))}, up to ${formatBytes(maxBytes)}`;
102
+ const busy = upload !== null;
103
+
104
+ // Thumbnail for a stored image (a local preview replaces it while uploading).
105
+ useEffect(() => {
106
+ if (!value || !isImageKey(value)) return setPreview(null);
107
+ let cancelled = false;
108
+ void createReadUrlAction(resourceName, fieldKey, value).then((result) => {
109
+ if (!cancelled && result.ok) setPreview(result.data.url);
110
+ });
111
+ return () => {
112
+ cancelled = true;
113
+ };
114
+ }, [value, resourceName, fieldKey]);
115
+
116
+ async function check(file: File): Promise<string | null> {
117
+ const type = file.type || "application/octet-stream";
118
+ if (!matchesContentType(type, accepted)) return `${field.label} accepts ${describeAccept(field.accept)} files.`;
119
+ if (file.size > maxBytes) return `${file.name} is ${formatBytes(file.size)}; the limit is ${formatBytes(maxBytes)}.`;
120
+ const head = new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer());
121
+ if (sniffMatches(type, head) === false) {
122
+ return `${file.name} isn't a valid ${typeName(type)} file. It may have been renamed; choose the original file.`;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ async function start(file: File) {
128
+ setError(null);
129
+ const problem = await check(file);
130
+ if (problem) return setError(problem);
131
+
132
+ const signal: { abort?: () => void } = {};
133
+ const localPreview = IMAGE_TYPES.has(file.type) ? URL.createObjectURL(file) : null;
134
+ setUpload({ name: file.name, loaded: 0, total: file.size, abort: () => signal.abort?.() });
135
+ if (localPreview) setPreview(localPreview);
136
+ setStatus(`Uploading ${file.name}…`);
137
+
138
+ try {
139
+ const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: file.type, size: file.size });
140
+ if (!signed.ok) throw new Error(signed.error);
141
+ await put(signed.data.url, file, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
142
+ onChange(signed.data.key);
143
+ setStatus(`${file.name} uploaded`);
144
+ } catch (cause) {
145
+ const cancelled = cause instanceof DOMException && cause.name === "AbortError";
146
+ if (!cancelled) setError(cause instanceof Error ? cause.message : "Upload failed.");
147
+ setStatus(cancelled ? "Upload cancelled" : "Upload failed");
148
+ if (localPreview) setPreview(null);
149
+ } finally {
150
+ if (localPreview) URL.revokeObjectURL(localPreview);
151
+ setUpload(null);
152
+ if (input.current) input.current.value = "";
153
+ }
154
+ }
155
+
156
+ async function open() {
157
+ const result = await createReadUrlAction(resourceName, fieldKey, value);
158
+ if (result.ok) window.open(result.data.url, "_blank", "noopener");
159
+ else setError(result.error);
160
+ }
161
+
162
+ function remove() {
163
+ setError(null);
164
+ setPreview(null);
165
+ onChange("");
166
+ setStatus("File removed");
167
+ }
168
+
169
+ const choose = () => input.current?.click();
170
+ const percent = upload && upload.total > 0 ? Math.round((upload.loaded / upload.total) * 100) : 0;
171
+
172
+ const dropHandlers = {
173
+ onDragOver: (event: React.DragEvent) => {
174
+ if (disabled || busy) return;
175
+ event.preventDefault();
176
+ setDragging(true);
177
+ },
178
+ onDragLeave: () => setDragging(false),
179
+ onDrop: (event: React.DragEvent) => {
180
+ event.preventDefault();
181
+ setDragging(false);
182
+ const file = event.dataTransfer.files[0];
183
+ if (file && !disabled && !busy) void start(file);
184
+ },
185
+ };
186
+
187
+ const thumbnail = (
188
+ <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted">
189
+ {preview ? (
190
+ // eslint-disable-next-line @next/next/no-img-element -- signed, short-lived URL
191
+ <img src={preview} alt="" width={40} height={40} className="size-full object-cover" />
192
+ ) : (
193
+ <FileIcon aria-hidden="true" className="size-5 text-muted-foreground" />
194
+ )}
195
+ </div>
196
+ );
197
+
198
+ return (
199
+ <div className="flex flex-col gap-2">
200
+ {/* The native input only opens the file chooser; the visible button is the control
201
+ the field's label points at, so assistive tech sees one control, not two. */}
202
+ <input
203
+ ref={input}
204
+ type="file"
205
+ accept={accepted.join(",")}
206
+ className="sr-only"
207
+ tabIndex={-1}
208
+ aria-hidden="true"
209
+ disabled={disabled || busy}
210
+ onChange={(event) => {
211
+ const file = event.target.files?.[0];
212
+ if (file) void start(file);
213
+ }}
214
+ />
215
+
216
+ {upload ? (
217
+ <div className="flex items-center gap-3 rounded-lg border p-3">
218
+ {thumbnail}
219
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
220
+ <div className="flex items-baseline justify-between gap-2 text-sm">
221
+ <span className="truncate font-medium">{upload.name}</span>
222
+ <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
223
+ {formatBytes(upload.loaded)} of {formatBytes(upload.total)}
224
+ </span>
225
+ </div>
226
+ <Progress value={percent} aria-label={`Uploading ${upload.name}`} />
227
+ </div>
228
+ <Button type="button" variant="ghost" size="sm" onClick={upload.abort}>
229
+ Cancel upload
230
+ </Button>
231
+ </div>
232
+ ) : value ? (
233
+ <div
234
+ {...dropHandlers}
235
+ className={cn("flex items-center gap-3 rounded-lg border p-3", dragging && "border-primary bg-muted")}
236
+ data-invalid={invalid || undefined}
237
+ >
238
+ {thumbnail}
239
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">{fileName(value)}</span>
240
+ <div className="flex shrink-0 gap-1">
241
+ <Button type="button" variant="ghost" size="sm" onClick={open}>
242
+ Open<span className="sr-only"> {fileName(value)}</span>
243
+ </Button>
244
+ {/* The field label points here; aria-label keeps the button named for what it does. */}
245
+ <Button
246
+ id={id}
247
+ type="button"
248
+ variant="ghost"
249
+ size="sm"
250
+ disabled={disabled}
251
+ onClick={choose}
252
+ aria-label={`Replace ${field.label.toLowerCase()} file`}
253
+ >
254
+ Replace
255
+ </Button>
256
+ {!field.required && (
257
+ <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={remove}>
258
+ Remove<span className="sr-only"> {field.label.toLowerCase()} file</span>
259
+ </Button>
260
+ )}
261
+ </div>
262
+ </div>
263
+ ) : (
264
+ <button
265
+ id={id}
266
+ type="button"
267
+ {...dropHandlers}
268
+ onClick={choose}
269
+ disabled={disabled}
270
+ aria-describedby={`${instructionId} ${helpId}`}
271
+ aria-invalid={invalid || Boolean(error) || undefined}
272
+ className={cn(
273
+ "flex w-full items-center gap-3 rounded-lg border border-dashed p-3 text-left transition-colors outline-none motion-reduce:transition-none",
274
+ "hover:bg-muted focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
275
+ dragging ? "border-primary bg-muted" : (invalid || error) && "border-destructive",
276
+ )}
277
+ >
278
+ <div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
279
+ <UploadIcon aria-hidden="true" className="size-5 text-muted-foreground" />
280
+ </div>
281
+ <span className="flex flex-col">
282
+ <span id={instructionId} className="text-sm font-medium">
283
+ {dragging ? "Drop to upload" : "Choose a file or drop it here"}
284
+ </span>
285
+ <span id={helpId} className="text-xs text-muted-foreground">
286
+ {help}
287
+ </span>
288
+ </span>
289
+ </button>
290
+ )}
291
+
292
+ {error && (
293
+ <p role="alert" className="text-sm text-destructive">
294
+ {error}
295
+ </p>
296
+ )}
297
+ <span className="sr-only" aria-live="polite">
298
+ {status}
299
+ </span>
300
+ </div>
301
+ );
302
+ }