@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.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/bin/flare.js +2 -0
- package/dist/index.js +3664 -0
- package/package.json +56 -0
- package/templates/app/.dev.vars.example +13 -0
- package/templates/app/README.md +9 -0
- package/templates/app/_gitignore +10 -0
- package/templates/app/app/admin/actions.ts +113 -0
- package/templates/app/app/admin/layout.tsx +32 -0
- package/templates/app/app/admin/page.tsx +63 -0
- package/templates/app/app/api/auth/[...all]/route.ts +4 -0
- package/templates/app/app/api/health/route.ts +7 -0
- package/templates/app/app/api/storage/route.ts +6 -0
- package/templates/app/app/dashboard/page.tsx +18 -0
- package/templates/app/app/globals.css +156 -0
- package/templates/app/app/layout.tsx +26 -0
- package/templates/app/app/page.tsx +19 -0
- package/templates/app/app/sign-in/page.tsx +33 -0
- package/templates/app/app/sign-up/page.tsx +23 -0
- package/templates/app/components/admin/admin-header.tsx +78 -0
- package/templates/app/components/admin/fields/date-field.tsx +112 -0
- package/templates/app/components/admin/fields/field-widget.tsx +128 -0
- package/templates/app/components/admin/fields/file-field.tsx +302 -0
- package/templates/app/components/admin/fields/relation-field.tsx +132 -0
- package/templates/app/components/admin/query.ts +25 -0
- package/templates/app/components/admin/resource-form-page.tsx +55 -0
- package/templates/app/components/admin/resource-form.tsx +139 -0
- package/templates/app/components/admin/resource-icon.tsx +85 -0
- package/templates/app/components/admin/resource-nav.tsx +109 -0
- package/templates/app/components/admin/resource-table-toolbar.tsx +95 -0
- package/templates/app/components/admin/resource-table.tsx +251 -0
- package/templates/app/components/admin/row-actions.tsx +111 -0
- package/templates/app/components/auth-form.tsx +120 -0
- package/templates/app/components/sign-out-button.tsx +18 -0
- package/templates/app/components/ui/alert-dialog.tsx +196 -0
- package/templates/app/components/ui/alert.tsx +65 -0
- package/templates/app/components/ui/avatar.tsx +108 -0
- package/templates/app/components/ui/badge.tsx +53 -0
- package/templates/app/components/ui/breadcrumb.tsx +110 -0
- package/templates/app/components/ui/button.tsx +65 -0
- package/templates/app/components/ui/calendar.tsx +220 -0
- package/templates/app/components/ui/card.tsx +92 -0
- package/templates/app/components/ui/command.tsx +184 -0
- package/templates/app/components/ui/dialog.tsx +158 -0
- package/templates/app/components/ui/dropdown-menu.tsx +256 -0
- package/templates/app/components/ui/empty.tsx +103 -0
- package/templates/app/components/ui/field.tsx +248 -0
- package/templates/app/components/ui/input-group.tsx +170 -0
- package/templates/app/components/ui/input.tsx +20 -0
- package/templates/app/components/ui/label.tsx +23 -0
- package/templates/app/components/ui/pagination.tsx +127 -0
- package/templates/app/components/ui/popover.tsx +88 -0
- package/templates/app/components/ui/progress.tsx +30 -0
- package/templates/app/components/ui/select.tsx +189 -0
- package/templates/app/components/ui/separator.tsx +27 -0
- package/templates/app/components/ui/sheet.tsx +142 -0
- package/templates/app/components/ui/sidebar.tsx +726 -0
- package/templates/app/components/ui/skeleton.tsx +13 -0
- package/templates/app/components/ui/sonner.tsx +38 -0
- package/templates/app/components/ui/spinner.tsx +15 -0
- package/templates/app/components/ui/switch.tsx +34 -0
- package/templates/app/components/ui/table.tsx +115 -0
- package/templates/app/components/ui/textarea.tsx +17 -0
- package/templates/app/components/ui/tooltip.tsx +56 -0
- package/templates/app/components.json +21 -0
- package/templates/app/db/auth-schema.ts +118 -0
- package/templates/app/db/flare-schema.ts +15 -0
- package/templates/app/db/index.ts +10 -0
- package/templates/app/db/schema.ts +12 -0
- package/templates/app/drizzle.config.ts +8 -0
- package/templates/app/hooks/use-mobile.ts +19 -0
- package/templates/app/lib/admin-nav.ts +15 -0
- package/templates/app/lib/admin.ts +103 -0
- package/templates/app/lib/api.ts +24 -0
- package/templates/app/lib/auth-client.ts +4 -0
- package/templates/app/lib/auth.ts +44 -0
- package/templates/app/lib/cache.ts +83 -0
- package/templates/app/lib/mail.ts +36 -0
- package/templates/app/lib/realtime.ts +45 -0
- package/templates/app/lib/security.ts +13 -0
- package/templates/app/lib/session.ts +24 -0
- package/templates/app/lib/storage.ts +26 -0
- package/templates/app/lib/utils.ts +6 -0
- package/templates/app/migrations/0000_init.sql +66 -0
- package/templates/app/migrations/meta/0000_snapshot.json +438 -0
- package/templates/app/migrations/meta/_journal.json +13 -0
- package/templates/app/next.config.ts +5 -0
- package/templates/app/policies/index.ts +4 -0
- package/templates/app/postcss.config.mjs +7 -0
- package/templates/app/proxy.ts +20 -0
- package/templates/app/resources/index.ts +4 -0
- package/templates/app/resources/server.ts +6 -0
- package/templates/app/tsconfig.json +22 -0
- package/templates/app/vite.config.ts +27 -0
- package/templates/app/worker/index.ts +40 -0
- 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
|
+
}
|