@flaredev/cli 0.1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/bin/flare.js +2 -0
  4. package/dist/index.js +3664 -0
  5. package/package.json +56 -0
  6. package/templates/app/.dev.vars.example +13 -0
  7. package/templates/app/README.md +9 -0
  8. package/templates/app/_gitignore +10 -0
  9. package/templates/app/app/admin/actions.ts +113 -0
  10. package/templates/app/app/admin/layout.tsx +32 -0
  11. package/templates/app/app/admin/page.tsx +63 -0
  12. package/templates/app/app/api/auth/[...all]/route.ts +4 -0
  13. package/templates/app/app/api/health/route.ts +7 -0
  14. package/templates/app/app/api/storage/route.ts +6 -0
  15. package/templates/app/app/dashboard/page.tsx +18 -0
  16. package/templates/app/app/globals.css +156 -0
  17. package/templates/app/app/layout.tsx +26 -0
  18. package/templates/app/app/page.tsx +19 -0
  19. package/templates/app/app/sign-in/page.tsx +33 -0
  20. package/templates/app/app/sign-up/page.tsx +23 -0
  21. package/templates/app/components/admin/admin-header.tsx +78 -0
  22. package/templates/app/components/admin/fields/date-field.tsx +112 -0
  23. package/templates/app/components/admin/fields/field-widget.tsx +128 -0
  24. package/templates/app/components/admin/fields/file-field.tsx +302 -0
  25. package/templates/app/components/admin/fields/relation-field.tsx +132 -0
  26. package/templates/app/components/admin/query.ts +25 -0
  27. package/templates/app/components/admin/resource-form-page.tsx +55 -0
  28. package/templates/app/components/admin/resource-form.tsx +139 -0
  29. package/templates/app/components/admin/resource-icon.tsx +85 -0
  30. package/templates/app/components/admin/resource-nav.tsx +109 -0
  31. package/templates/app/components/admin/resource-table-toolbar.tsx +95 -0
  32. package/templates/app/components/admin/resource-table.tsx +251 -0
  33. package/templates/app/components/admin/row-actions.tsx +111 -0
  34. package/templates/app/components/auth-form.tsx +120 -0
  35. package/templates/app/components/sign-out-button.tsx +18 -0
  36. package/templates/app/components/ui/alert-dialog.tsx +196 -0
  37. package/templates/app/components/ui/alert.tsx +65 -0
  38. package/templates/app/components/ui/avatar.tsx +108 -0
  39. package/templates/app/components/ui/badge.tsx +53 -0
  40. package/templates/app/components/ui/breadcrumb.tsx +110 -0
  41. package/templates/app/components/ui/button.tsx +65 -0
  42. package/templates/app/components/ui/calendar.tsx +220 -0
  43. package/templates/app/components/ui/card.tsx +92 -0
  44. package/templates/app/components/ui/command.tsx +184 -0
  45. package/templates/app/components/ui/dialog.tsx +158 -0
  46. package/templates/app/components/ui/dropdown-menu.tsx +256 -0
  47. package/templates/app/components/ui/empty.tsx +103 -0
  48. package/templates/app/components/ui/field.tsx +248 -0
  49. package/templates/app/components/ui/input-group.tsx +170 -0
  50. package/templates/app/components/ui/input.tsx +20 -0
  51. package/templates/app/components/ui/label.tsx +23 -0
  52. package/templates/app/components/ui/pagination.tsx +127 -0
  53. package/templates/app/components/ui/popover.tsx +88 -0
  54. package/templates/app/components/ui/progress.tsx +30 -0
  55. package/templates/app/components/ui/select.tsx +189 -0
  56. package/templates/app/components/ui/separator.tsx +27 -0
  57. package/templates/app/components/ui/sheet.tsx +142 -0
  58. package/templates/app/components/ui/sidebar.tsx +726 -0
  59. package/templates/app/components/ui/skeleton.tsx +13 -0
  60. package/templates/app/components/ui/sonner.tsx +38 -0
  61. package/templates/app/components/ui/spinner.tsx +15 -0
  62. package/templates/app/components/ui/switch.tsx +34 -0
  63. package/templates/app/components/ui/table.tsx +115 -0
  64. package/templates/app/components/ui/textarea.tsx +17 -0
  65. package/templates/app/components/ui/tooltip.tsx +56 -0
  66. package/templates/app/components.json +21 -0
  67. package/templates/app/db/auth-schema.ts +118 -0
  68. package/templates/app/db/flare-schema.ts +15 -0
  69. package/templates/app/db/index.ts +10 -0
  70. package/templates/app/db/schema.ts +12 -0
  71. package/templates/app/drizzle.config.ts +8 -0
  72. package/templates/app/hooks/use-mobile.ts +19 -0
  73. package/templates/app/lib/admin-nav.ts +15 -0
  74. package/templates/app/lib/admin.ts +103 -0
  75. package/templates/app/lib/api.ts +24 -0
  76. package/templates/app/lib/auth-client.ts +4 -0
  77. package/templates/app/lib/auth.ts +44 -0
  78. package/templates/app/lib/cache.ts +83 -0
  79. package/templates/app/lib/mail.ts +36 -0
  80. package/templates/app/lib/realtime.ts +45 -0
  81. package/templates/app/lib/security.ts +13 -0
  82. package/templates/app/lib/session.ts +24 -0
  83. package/templates/app/lib/storage.ts +26 -0
  84. package/templates/app/lib/utils.ts +6 -0
  85. package/templates/app/migrations/0000_init.sql +66 -0
  86. package/templates/app/migrations/meta/0000_snapshot.json +438 -0
  87. package/templates/app/migrations/meta/_journal.json +13 -0
  88. package/templates/app/next.config.ts +5 -0
  89. package/templates/app/policies/index.ts +4 -0
  90. package/templates/app/postcss.config.mjs +7 -0
  91. package/templates/app/proxy.ts +20 -0
  92. package/templates/app/resources/index.ts +4 -0
  93. package/templates/app/resources/server.ts +6 -0
  94. package/templates/app/tsconfig.json +22 -0
  95. package/templates/app/vite.config.ts +27 -0
  96. package/templates/app/worker/index.ts +40 -0
  97. package/templates/app/wrangler.jsonc +52 -0
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { LogOutIcon, MoonIcon, SunIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuGroup,
11
+ DropdownMenuItem,
12
+ DropdownMenuLabel,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger,
15
+ } from "@/components/ui/dropdown-menu";
16
+ import { Separator } from "@/components/ui/separator";
17
+ import { SidebarTrigger } from "@/components/ui/sidebar";
18
+
19
+ export type Theme = "light" | "dark" | "system";
20
+
21
+ /** Persist the choice for SSR (so the first paint matches) and apply it now. */
22
+ function applyTheme(theme: Theme) {
23
+ document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
24
+ const root = document.documentElement;
25
+ root.classList.remove("light", "dark");
26
+ if (theme !== "system") root.classList.add(theme);
27
+ }
28
+
29
+ export function AdminHeader({ user, initialTheme }: { user: { name?: string | null; email: string }; initialTheme: Theme }) {
30
+ const [theme, setTheme] = useState<Theme>(initialTheme);
31
+ const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
32
+
33
+ return (
34
+ <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
35
+ <SidebarTrigger />
36
+ <Separator orientation="vertical" className="mr-1 h-4" />
37
+ <div className="ml-auto flex items-center gap-1">
38
+ <Button
39
+ variant="ghost"
40
+ size="icon"
41
+ aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
42
+ onClick={() => {
43
+ const next: Theme = dark ? "light" : "dark";
44
+ setTheme(next);
45
+ applyTheme(next);
46
+ }}
47
+ >
48
+ {dark ? <SunIcon /> : <MoonIcon />}
49
+ </Button>
50
+ <DropdownMenu>
51
+ <DropdownMenuTrigger asChild>
52
+ <Button variant="ghost" size="sm" className="gap-2">
53
+ <span className="max-w-40 truncate">{user.name || user.email}</span>
54
+ </Button>
55
+ </DropdownMenuTrigger>
56
+ <DropdownMenuContent align="end" className="w-56">
57
+ <DropdownMenuLabel className="font-normal">
58
+ <span className="block truncate text-sm font-medium">{user.name || "Signed in"}</span>
59
+ <span className="block truncate text-xs text-muted-foreground">{user.email}</span>
60
+ </DropdownMenuLabel>
61
+ <DropdownMenuSeparator />
62
+ <DropdownMenuGroup>
63
+ <DropdownMenuItem
64
+ onSelect={async () => {
65
+ await authClient.signOut();
66
+ window.location.assign("/sign-in");
67
+ }}
68
+ >
69
+ <LogOutIcon />
70
+ Sign out
71
+ </DropdownMenuItem>
72
+ </DropdownMenuGroup>
73
+ </DropdownMenuContent>
74
+ </DropdownMenu>
75
+ </div>
76
+ </header>
77
+ );
78
+ }
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CalendarIcon, XIcon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Calendar } from "@/components/ui/calendar";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ /** "2026-01-05" ⇄ Date, treated as UTC so the calendar never shifts a day across time zones. */
12
+ const toDate = (value: string) => {
13
+ const date = new Date(`${value}T00:00:00Z`);
14
+ return Number.isNaN(date.getTime()) ? undefined : date;
15
+ };
16
+ const toISODate = (date: Date) =>
17
+ `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
18
+ const display = (value: string, options: Intl.DateTimeFormatOptions) => {
19
+ const date = toDate(value);
20
+ return date ? new Intl.DateTimeFormat("en-US", { timeZone: "UTC", ...options }).format(date) : value;
21
+ };
22
+
23
+ interface Props {
24
+ id: string;
25
+ value: string;
26
+ onChange: (value: string) => void;
27
+ invalid: boolean;
28
+ disabled?: boolean;
29
+ required: boolean;
30
+ placeholder?: string;
31
+ }
32
+
33
+ /** Date picker for `date` fields. Stores "YYYY-MM-DD". */
34
+ export function DateField({ id, value, onChange, invalid, disabled, required, placeholder }: Props) {
35
+ const [open, setOpen] = useState(false);
36
+ return (
37
+ <div className="flex items-center gap-1">
38
+ <Popover open={open} onOpenChange={setOpen}>
39
+ <PopoverTrigger asChild>
40
+ <Button
41
+ id={id}
42
+ type="button"
43
+ variant="outline"
44
+ disabled={disabled}
45
+ aria-invalid={invalid || undefined}
46
+ className={cn("w-full justify-start font-normal", !value && "text-muted-foreground")}
47
+ >
48
+ <CalendarIcon data-icon="inline-start" />
49
+ {value ? display(value, { dateStyle: "medium" }) : (placeholder ?? "Pick a date")}
50
+ </Button>
51
+ </PopoverTrigger>
52
+ <PopoverContent className="w-auto p-0" align="start">
53
+ <Calendar
54
+ mode="single"
55
+ autoFocus
56
+ selected={toDate(value)}
57
+ defaultMonth={toDate(value)}
58
+ onSelect={(date) => {
59
+ if (date) onChange(toISODate(date));
60
+ setOpen(false);
61
+ }}
62
+ />
63
+ </PopoverContent>
64
+ </Popover>
65
+ {value && !required && (
66
+ <Button type="button" variant="ghost" size="icon" aria-label="Clear date" disabled={disabled} onClick={() => onChange("")}>
67
+ <XIcon />
68
+ </Button>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ /** Date + time picker for `datetime` fields. Stores an ISO string with offset (UTC). */
75
+ export function DateTimeField({ id, value, onChange, invalid, disabled, required }: Props) {
76
+ const date = value ? new Date(value) : undefined;
77
+ const valid = date && !Number.isNaN(date.getTime());
78
+ const datePart = valid ? toISODate(date) : "";
79
+ const timePart = valid ? date.toISOString().slice(11, 16) : "";
80
+
81
+ const update = (nextDate: string, nextTime: string) => {
82
+ if (!nextDate) return onChange("");
83
+ const time = nextTime || "00:00";
84
+ const iso = new Date(`${nextDate}T${time}:00Z`);
85
+ onChange(Number.isNaN(iso.getTime()) ? "" : iso.toISOString());
86
+ };
87
+
88
+ return (
89
+ <div className="flex items-center gap-2">
90
+ <div className="flex-1">
91
+ <DateField
92
+ id={id}
93
+ value={datePart}
94
+ onChange={(next) => update(next, timePart)}
95
+ invalid={invalid}
96
+ disabled={disabled}
97
+ required={required}
98
+ placeholder="Pick a date"
99
+ />
100
+ </div>
101
+ <Input
102
+ type="time"
103
+ aria-label="Time (UTC)"
104
+ className="w-32 tabular-nums"
105
+ value={timePart}
106
+ disabled={disabled || !datePart}
107
+ aria-invalid={invalid || undefined}
108
+ onChange={(event) => update(datePart, event.target.value)}
109
+ />
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { optionLabel, type FileField as FileFieldDef, type StoredField } from "@flaredev/core";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6
+ import { Switch } from "@/components/ui/switch";
7
+ import { Textarea } from "@/components/ui/textarea";
8
+ import { DateField, DateTimeField } from "./date-field";
9
+ import { FileField } from "./file-field";
10
+ import { RelationField } from "./relation-field";
11
+
12
+ export interface RelationMeta {
13
+ name: string;
14
+ label: string;
15
+ pluralLabel: string;
16
+ slug: string;
17
+ titleField: string;
18
+ }
19
+
20
+ export interface WidgetProps {
21
+ id: string;
22
+ name: string;
23
+ /** Resource name and field key, for upload actions. */
24
+ resourceName: string;
25
+ fieldKey: string;
26
+ field: StoredField & { label: string };
27
+ value: string | boolean;
28
+ onChange: (value: string | boolean) => void;
29
+ invalid: boolean;
30
+ disabled?: boolean;
31
+ /** belongsTo: the related resource and the current value's title. */
32
+ relation?: RelationMeta & { initialTitle?: string };
33
+ }
34
+
35
+ const NONE = "__none__";
36
+
37
+ /** The input for a field, chosen from its kind in the descriptor. */
38
+ export function FieldWidget(props: WidgetProps) {
39
+ const { id, name, field, value, onChange, invalid, disabled, relation, resourceName, fieldKey } = props;
40
+ const text = typeof value === "string" ? value : "";
41
+ const common = { id, name, disabled, "aria-invalid": invalid || undefined, placeholder: field.placeholder };
42
+
43
+ switch (field.kind) {
44
+ case "text":
45
+ return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
46
+
47
+ case "int":
48
+ case "float":
49
+ return (
50
+ <Input
51
+ {...common}
52
+ type="text"
53
+ inputMode={field.kind === "int" ? "numeric" : "decimal"}
54
+ className="tabular-nums"
55
+ value={text}
56
+ onChange={(event) => onChange(event.target.value)}
57
+ />
58
+ );
59
+
60
+ case "boolean":
61
+ return (
62
+ <Switch
63
+ id={id}
64
+ name={name}
65
+ disabled={disabled}
66
+ aria-invalid={invalid || undefined}
67
+ checked={value === true}
68
+ onCheckedChange={(checked) => onChange(checked)}
69
+ />
70
+ );
71
+
72
+ case "enum":
73
+ return (
74
+ // No selection is `undefined`, so the placeholder shows; "None" clears an optional value.
75
+ <Select value={text || undefined} onValueChange={(next) => onChange(next === NONE ? "" : next)} disabled={disabled} name={name}>
76
+ <SelectTrigger id={id} aria-invalid={invalid || undefined} className="w-full">
77
+ <SelectValue placeholder={field.placeholder ?? `Choose ${field.label.toLowerCase()}`} />
78
+ </SelectTrigger>
79
+ <SelectContent>
80
+ <SelectGroup>
81
+ {!field.required && <SelectItem value={NONE}>None</SelectItem>}
82
+ {field.options.map((option) => (
83
+ <SelectItem key={option} value={option}>
84
+ {optionLabel(field, option)}
85
+ </SelectItem>
86
+ ))}
87
+ </SelectGroup>
88
+ </SelectContent>
89
+ </Select>
90
+ );
91
+
92
+ case "date":
93
+ return (
94
+ <DateField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
95
+ );
96
+
97
+ case "datetime":
98
+ return (
99
+ <DateTimeField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
100
+ );
101
+
102
+ case "belongsTo":
103
+ return relation ? (
104
+ <RelationField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} relation={relation} />
105
+ ) : (
106
+ <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />
107
+ );
108
+
109
+ case "file":
110
+ return (
111
+ <FileField
112
+ id={id}
113
+ value={text}
114
+ onChange={onChange}
115
+ invalid={invalid}
116
+ disabled={disabled}
117
+ field={field as FileFieldDef & { label: string }}
118
+ resourceName={resourceName}
119
+ fieldKey={fieldKey}
120
+ />
121
+ );
122
+
123
+ default: {
124
+ const type = field.kind === "string" && field.format === "email" ? "email" : field.kind === "string" && field.format === "url" ? "url" : "text";
125
+ return <Input {...common} type={type} value={text} onChange={(event) => onChange(event.target.value)} />;
126
+ }
127
+ }
128
+ }
@@ -0,0 +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
+ }