@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +985 -337
- package/package.json +3 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +40 -0
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -0
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +123 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +522 -0
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +306 -0
- package/templates/app/components/auth/sign-up-form.tsx +93 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +139 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
- package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +38 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +115 -6
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +47 -4
- package/templates/app/migrations/meta/0000_snapshot.json +311 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/auth-form.tsx +0 -120
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /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/
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { optionLabel, parseMultiValue, type MultiSelectField as MultiSelectDef } from "@flaredev/core";
|
|
4
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
id: string;
|
|
9
|
+
field: MultiSelectDef & { label: string };
|
|
10
|
+
/** Form state: a JSON array of the picked values. */
|
|
11
|
+
value: string;
|
|
12
|
+
onChange: (value: string) => void;
|
|
13
|
+
invalid: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A `multiselect` field: one checkbox per option, in the descriptor's order. */
|
|
18
|
+
export function MultiSelectField({ id, field, value, onChange, invalid, disabled }: Props) {
|
|
19
|
+
const picked = parseMultiValue(value);
|
|
20
|
+
const toggle = (option: string, on: boolean) => {
|
|
21
|
+
const next = field.options.filter((item) => (item === option ? on : picked.includes(item)));
|
|
22
|
+
onChange(JSON.stringify(next));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div id={id} role="group" aria-label={field.label} aria-invalid={invalid || undefined} className="grid gap-2.5 sm:grid-cols-2">
|
|
27
|
+
{field.options.map((option) => {
|
|
28
|
+
const optionId = `${id}-${option}`;
|
|
29
|
+
return (
|
|
30
|
+
<div key={option} className="flex items-center gap-2">
|
|
31
|
+
<Checkbox
|
|
32
|
+
id={optionId}
|
|
33
|
+
checked={picked.includes(option)}
|
|
34
|
+
onCheckedChange={(checked) => toggle(option, checked === true)}
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
aria-invalid={invalid || undefined}
|
|
37
|
+
/>
|
|
38
|
+
<Label htmlFor={optionId} className="font-normal">
|
|
39
|
+
{optionLabel(field, option)}
|
|
40
|
+
</Label>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|