@flaredev/cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/index.js +498 -220
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +73 -5
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/app/globals.css +0 -6
  13. package/templates/app/app/layout.tsx +37 -28
  14. package/templates/app/components/account/account-nav.tsx +39 -0
  15. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  16. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  19. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  20. package/templates/app/components/dashboard/editable.ts +12 -0
  21. package/templates/app/components/dashboard/fields/file-field.tsx +310 -302
  22. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  23. package/templates/app/components/dashboard/import-dialog.tsx +62 -9
  24. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  25. package/templates/app/components/dashboard/local-time.tsx +77 -0
  26. package/templates/app/components/dashboard/query.ts +2 -0
  27. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  28. package/templates/app/components/dashboard/record-detail.tsx +203 -0
  29. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  30. package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
  31. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  32. package/templates/app/components/dashboard/resource-form.tsx +165 -165
  33. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  34. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  35. package/templates/app/components/dashboard/resource-table.tsx +408 -331
  36. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  37. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  38. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  39. package/templates/app/components/ui/sonner.tsx +33 -13
  40. package/templates/app/db/flare-schema.ts +22 -0
  41. package/templates/app/lib/api.ts +12 -0
  42. package/templates/app/lib/dashboard.ts +165 -138
  43. package/templates/app/lib/views.ts +29 -0
  44. package/templates/app/migrations/0000_init.sql +11 -0
  45. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  46. package/templates/app/vite.config.ts +41 -31
  47. package/templates/locks/package-lock.json +9 -9
  48. package/templates/locks/pnpm-lock.yaml +12 -12
  49. package/templates/locks/yarn.lock +3548 -0
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
+ import { Columns3Icon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuCheckboxItem,
9
+ DropdownMenuContent,
10
+ DropdownMenuGroup,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from "@/components/ui/dropdown-menu";
15
+ import { hrefWith } from "./query";
16
+
17
+ export interface ColumnOption {
18
+ key: string;
19
+ label: string;
20
+ }
21
+
22
+ /**
23
+ * Which columns the table shows. The choice goes in the URL (`?columns=name,email`)
24
+ * rather than into storage, so a view someone sends you looks the way it looked to them.
25
+ */
26
+ export function ColumnMenu({ columns, visible }: { columns: ColumnOption[]; visible: string[] }) {
27
+ const router = useRouter();
28
+ const pathname = usePathname();
29
+ const params = new URLSearchParams(useSearchParams().toString());
30
+ const shown = new Set(visible);
31
+
32
+ const toggle = (key: string) => {
33
+ const next = new Set(shown);
34
+ if (!next.delete(key)) next.add(key);
35
+ // Nothing selected would leave a table of empty rows; keep at least one column.
36
+ if (next.size === 0) return;
37
+ const all = columns.every((column) => next.has(column.key));
38
+ router.push(hrefWith(pathname, params, { columns: all ? null : columns.filter((column) => next.has(column.key)).map((column) => column.key).join(",") }));
39
+ };
40
+
41
+ return (
42
+ <DropdownMenu>
43
+ <DropdownMenuTrigger asChild>
44
+ <Button variant="outline" size="icon" aria-label="Choose columns">
45
+ <Columns3Icon />
46
+ </Button>
47
+ </DropdownMenuTrigger>
48
+ <DropdownMenuContent align="end" className="w-52">
49
+ <DropdownMenuLabel>Columns</DropdownMenuLabel>
50
+ <DropdownMenuSeparator />
51
+ <DropdownMenuGroup>
52
+ {columns.map((column) => (
53
+ <DropdownMenuCheckboxItem
54
+ key={column.key}
55
+ checked={shown.has(column.key)}
56
+ onCheckedChange={() => toggle(column.key)}
57
+ onSelect={(event) => event.preventDefault()}
58
+ >
59
+ {column.label}
60
+ </DropdownMenuCheckboxItem>
61
+ ))}
62
+ </DropdownMenuGroup>
63
+ </DropdownMenuContent>
64
+ </DropdownMenu>
65
+ );
66
+ }
@@ -1,78 +1,115 @@
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 DashboardHeader({ 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
- }
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import Link from "next/link";
5
+ import { BellIcon, MoonIcon, SunIcon } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
9
+ import { Separator } from "@/components/ui/separator";
10
+ import { SidebarTrigger } from "@/components/ui/sidebar";
11
+ import { cn } from "@/lib/utils";
12
+ import { UserAvatar, UserMenuItems, type DashboardUser } from "./user-menu";
13
+
14
+ export type Theme = "light" | "dark" | "system";
15
+
16
+ export interface Notice {
17
+ id: string;
18
+ title: string;
19
+ description?: string;
20
+ href?: string;
21
+ /** "warning" marks the bell, so it only pulls the eye when something needs doing. */
22
+ tone?: "info" | "warning";
23
+ }
24
+
25
+ /** Persist the choice for SSR (so the first paint matches) and apply it now. */
26
+ function applyTheme(theme: Theme) {
27
+ document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
28
+ const root = document.documentElement;
29
+ root.classList.remove("light", "dark");
30
+ if (theme !== "system") root.classList.add(theme);
31
+ }
32
+
33
+ export function DashboardHeader({ user, initialTheme, notices = [] }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[] }) {
34
+ const [theme, setTheme] = useState<Theme>(initialTheme);
35
+ const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
36
+ const needsAttention = notices.some((notice) => notice.tone === "warning");
37
+
38
+ return (
39
+ <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
40
+ <SidebarTrigger />
41
+ <Separator orientation="vertical" className="mr-1 h-4" />
42
+
43
+ <div className="ml-auto flex items-center gap-1">
44
+ <Popover>
45
+ <PopoverTrigger asChild>
46
+ <Button variant="ghost" size="icon" aria-label={notices.length ? `Notifications (${notices.length})` : "Notifications"} className="relative">
47
+ <BellIcon />
48
+ {notices.length > 0 && (
49
+ <span
50
+ aria-hidden="true"
51
+ className={cn("absolute top-1.5 right-1.5 size-2 rounded-full ring-2 ring-background", needsAttention ? "bg-warning" : "bg-primary")}
52
+ />
53
+ )}
54
+ </Button>
55
+ </PopoverTrigger>
56
+ <PopoverContent align="end" className="w-80 p-0">
57
+ <div className="border-b px-4 py-3 text-sm font-medium">Notifications</div>
58
+ {notices.length === 0 ? (
59
+ <p className="px-4 py-6 text-center text-sm text-muted-foreground">Nothing needs you right now.</p>
60
+ ) : (
61
+ <ul className="max-h-80 overflow-y-auto">
62
+ {notices.map((notice) => {
63
+ const body = (
64
+ <>
65
+ <span className="flex items-center gap-2 text-sm font-medium">
66
+ <span className={cn("size-1.5 shrink-0 rounded-full", notice.tone === "warning" ? "bg-warning" : "bg-primary")} aria-hidden="true" />
67
+ {notice.title}
68
+ </span>
69
+ {notice.description && <span className="block pl-3.5 text-xs text-muted-foreground">{notice.description}</span>}
70
+ </>
71
+ );
72
+ return (
73
+ <li key={notice.id} className="border-b last:border-b-0">
74
+ {notice.href ? (
75
+ <Link href={notice.href} className="block px-4 py-3 hover:bg-muted">
76
+ {body}
77
+ </Link>
78
+ ) : (
79
+ <div className="px-4 py-3">{body}</div>
80
+ )}
81
+ </li>
82
+ );
83
+ })}
84
+ </ul>
85
+ )}
86
+ </PopoverContent>
87
+ </Popover>
88
+
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+ aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
93
+ onClick={() => {
94
+ const next: Theme = dark ? "light" : "dark";
95
+ setTheme(next);
96
+ applyTheme(next);
97
+ }}
98
+ >
99
+ {dark ? <SunIcon /> : <MoonIcon />}
100
+ </Button>
101
+
102
+ <DropdownMenu>
103
+ <DropdownMenuTrigger asChild>
104
+ <Button variant="ghost" size="icon" aria-label="Account" className="rounded-full">
105
+ <UserAvatar user={user} className="size-7" />
106
+ </Button>
107
+ </DropdownMenuTrigger>
108
+ <DropdownMenuContent align="end" className="w-60">
109
+ <UserMenuItems user={user} />
110
+ </DropdownMenuContent>
111
+ </DropdownMenu>
112
+ </div>
113
+ </header>
114
+ );
115
+ }
@@ -1,11 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { usePathname } from "next/navigation";
4
+ import { usePathname, useSearchParams } from "next/navigation";
5
5
  import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
6
+ import { ChevronsUpDownIcon } from "lucide-react";
7
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
6
8
  import {
7
9
  Sidebar,
8
10
  SidebarContent,
11
+ SidebarFooter,
9
12
  SidebarGroup,
10
13
  SidebarGroupContent,
11
14
  SidebarGroupLabel,
@@ -13,16 +16,36 @@ import {
13
16
  SidebarMenu,
14
17
  SidebarMenuButton,
15
18
  SidebarMenuItem,
19
+ SidebarMenuSub,
20
+ SidebarMenuSubButton,
21
+ SidebarMenuSubItem,
16
22
  } from "@/components/ui/sidebar";
17
23
  import { resourceIcon } from "./resource-icon";
24
+ import { UserAvatar, UserMenuItems, type DashboardUser } from "./user-menu";
18
25
 
19
26
  export interface NavResource {
20
27
  name: string;
21
28
  pluralLabel: string;
22
29
  slug: string;
23
30
  icon?: string;
31
+ /** Heading it sits under, from the descriptor's `group`. */
32
+ group?: string;
24
33
  /** Optional count badge (off by default). */
25
34
  badge?: number;
35
+ /** This person's saved views of the resource, as "?q=…&filter[…]=…". */
36
+ views?: { id: string; name: string; query: string }[];
37
+ }
38
+
39
+ /** Resources in the order given, under their headings; ungrouped ones come first. */
40
+ function byGroup(resources: NavResource[]): [string, NavResource[]][] {
41
+ const groups = new Map<string, NavResource[]>();
42
+ for (const resource of resources) {
43
+ const heading = resource.group?.trim() || "Resources";
44
+ const existing = groups.get(heading);
45
+ if (existing) existing.push(resource);
46
+ else groups.set(heading, [resource]);
47
+ }
48
+ return [...groups];
26
49
  }
27
50
 
28
51
  /**
@@ -41,6 +64,7 @@ export function DashboardSidebar({
41
64
  resources,
42
65
  links = [],
43
66
  isAdmin = false,
67
+ user,
44
68
  }: {
45
69
  appName: string;
46
70
  resources: NavResource[];
@@ -48,8 +72,11 @@ export function DashboardSidebar({
48
72
  links?: Array<{ label: string; href: string; icon: string }>;
49
73
  /** Whether to show the sections that manage the app itself. */
50
74
  isAdmin?: boolean;
75
+ user: DashboardUser;
51
76
  }) {
52
77
  const pathname = usePathname();
78
+ // A saved view is the query it was saved with, so the one you're on is the one that matches.
79
+ const search = useSearchParams().toString();
53
80
  const manage = [...ACCOUNT_LINKS, ...(isAdmin ? ADMIN_LINKS : [])];
54
81
 
55
82
  return (
@@ -85,29 +112,42 @@ export function DashboardSidebar({
85
112
  </SidebarGroupContent>
86
113
  </SidebarGroup>
87
114
 
88
- {resources.length > 0 && (
89
- <SidebarGroup>
90
- <SidebarGroupLabel>Resources</SidebarGroupLabel>
91
- <SidebarGroupContent>
92
- <SidebarMenu>
93
- {resources.map((resource) => {
94
- const href = `/dashboard/${resource.slug}`;
95
- const Icon = resourceIcon(resource.icon);
96
- return (
97
- <SidebarMenuItem key={resource.name}>
98
- <SidebarMenuButton asChild isActive={pathname === href || pathname.startsWith(`${href}/`)} tooltip={resource.pluralLabel}>
99
- <Link href={href}>
100
- <Icon />
101
- <span>{resource.pluralLabel}</span>
102
- </Link>
103
- </SidebarMenuButton>
104
- </SidebarMenuItem>
105
- );
106
- })}
107
- </SidebarMenu>
108
- </SidebarGroupContent>
109
- </SidebarGroup>
110
- )}
115
+ {byGroup(resources).map(([heading, group]) => (
116
+ <SidebarGroup key={heading}>
117
+ <SidebarGroupLabel>{heading}</SidebarGroupLabel>
118
+ <SidebarGroupContent>
119
+ <SidebarMenu>
120
+ {group.map((resource) => {
121
+ const href = `/dashboard/${resource.slug}`;
122
+ const Icon = resourceIcon(resource.icon);
123
+ return (
124
+ <SidebarMenuItem key={resource.name}>
125
+ <SidebarMenuButton asChild isActive={pathname === href || pathname.startsWith(`${href}/`)} tooltip={resource.pluralLabel}>
126
+ <Link href={href}>
127
+ <Icon />
128
+ <span>{resource.pluralLabel}</span>
129
+ </Link>
130
+ </SidebarMenuButton>
131
+ {resource.views && resource.views.length > 0 && (
132
+ <SidebarMenuSub>
133
+ {resource.views.map((view) => (
134
+ <SidebarMenuSubItem key={view.id}>
135
+ <SidebarMenuSubButton asChild isActive={pathname === href && search === view.query}>
136
+ <Link href={`${href}?${view.query}`}>
137
+ <span>{view.name}</span>
138
+ </Link>
139
+ </SidebarMenuSubButton>
140
+ </SidebarMenuSubItem>
141
+ ))}
142
+ </SidebarMenuSub>
143
+ )}
144
+ </SidebarMenuItem>
145
+ );
146
+ })}
147
+ </SidebarMenu>
148
+ </SidebarGroupContent>
149
+ </SidebarGroup>
150
+ ))}
111
151
 
112
152
  <SidebarGroup>
113
153
  <SidebarGroupLabel>Manage</SidebarGroupLabel>
@@ -161,6 +201,28 @@ export function DashboardSidebar({
161
201
  </SidebarGroup>
162
202
  )}
163
203
  </SidebarContent>
204
+
205
+ <SidebarFooter>
206
+ <SidebarMenu>
207
+ <SidebarMenuItem>
208
+ <DropdownMenu>
209
+ <DropdownMenuTrigger asChild>
210
+ <SidebarMenuButton size="lg" tooltip={user.email}>
211
+ <UserAvatar user={user} className="size-8 rounded-md" />
212
+ <span className="grid min-w-0 flex-1 text-left leading-tight">
213
+ <span className="truncate text-sm font-medium">{user.name || "Signed in"}</span>
214
+ <span className="truncate text-xs text-muted-foreground">{user.email}</span>
215
+ </span>
216
+ <ChevronsUpDownIcon className="ml-auto" />
217
+ </SidebarMenuButton>
218
+ </DropdownMenuTrigger>
219
+ <DropdownMenuContent side="top" align="start" className="w-60">
220
+ <UserMenuItems user={user} />
221
+ </DropdownMenuContent>
222
+ </DropdownMenu>
223
+ </SidebarMenuItem>
224
+ </SidebarMenu>
225
+ </SidebarFooter>
164
226
  </Sidebar>
165
227
  );
166
228
  }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type DragEvent } from "react";
4
+ import { UploadCloudIcon } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface Props {
8
+ /** Called with the files that were dropped or picked. */
9
+ onFiles: (files: File[]) => void;
10
+ /** `accept` for the hidden input, e.g. ".csv,text/csv" or "image/*". */
11
+ accept?: string;
12
+ multiple?: boolean;
13
+ disabled?: boolean;
14
+ /** The line under the heading: what this one takes. */
15
+ hint?: string;
16
+ label?: string;
17
+ className?: string;
18
+ /** Shown inside the zone instead of the prompt, e.g. a preview or a progress bar. */
19
+ children?: React.ReactNode;
20
+ }
21
+
22
+ /**
23
+ * Drop a file, or click to pick one.
24
+ *
25
+ * The input stays in the markup and keeps doing the work — it's what makes the keyboard,
26
+ * screen readers and the file picker behave — while the drop target is the label around
27
+ * it. Dragging over the page doesn't open the browser's own download of the file because
28
+ * both dragover and drop are cancelled here.
29
+ */
30
+ export function Dropzone({ onFiles, accept, multiple = false, disabled, hint, label = "Drop a file here", className, children }: Props) {
31
+ const input = useRef<HTMLInputElement>(null);
32
+ const [over, setOver] = useState(false);
33
+
34
+ const take = (list: FileList | null) => {
35
+ const files = [...(list ?? [])];
36
+ if (files.length > 0) onFiles(multiple ? files : files.slice(0, 1));
37
+ };
38
+
39
+ const stop = (event: DragEvent) => {
40
+ event.preventDefault();
41
+ event.stopPropagation();
42
+ };
43
+
44
+ return (
45
+ <label
46
+ onDragOver={(event) => {
47
+ stop(event);
48
+ if (!disabled) setOver(true);
49
+ }}
50
+ onDragLeave={(event) => {
51
+ stop(event);
52
+ setOver(false);
53
+ }}
54
+ onDrop={(event) => {
55
+ stop(event);
56
+ setOver(false);
57
+ if (!disabled) take(event.dataTransfer.files);
58
+ }}
59
+ className={cn(
60
+ "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors",
61
+ over ? "border-primary bg-primary/5" : "border-border hover:border-primary/50 hover:bg-muted/50",
62
+ disabled && "pointer-events-none opacity-60",
63
+ className,
64
+ )}
65
+ >
66
+ <input
67
+ ref={input}
68
+ type="file"
69
+ accept={accept}
70
+ multiple={multiple}
71
+ disabled={disabled}
72
+ className="sr-only"
73
+ onChange={(event) => {
74
+ take(event.target.files);
75
+ // Picking the same file twice in a row should still fire a change.
76
+ event.target.value = "";
77
+ }}
78
+ />
79
+ {children ?? (
80
+ <>
81
+ <span className="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
82
+ <UploadCloudIcon className="size-5" />
83
+ </span>
84
+ <span className="text-sm font-medium">
85
+ {label} <span className="text-muted-foreground">or click to choose</span>
86
+ </span>
87
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
88
+ </>
89
+ )}
90
+ </label>
91
+ );
92
+ }
@@ -0,0 +1,12 @@
1
+ import type { StoredField } from "@flaredev/core";
2
+
3
+ /**
4
+ * Kinds worth editing in place: one control, one value, no picker or upload.
5
+ *
6
+ * Kept out of inline-cell.tsx because that file is a client component, and a server
7
+ * component can't call a function exported from one.
8
+ */
9
+ export function isInlineEditable(def: StoredField): boolean {
10
+ if (def.kind === "string") return !def.format;
11
+ return ["int", "float", "boolean", "enum"].includes(def.kind);
12
+ }