@flaredev/cli 0.3.0 → 0.4.1

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