@flaredev/cli 0.2.0 → 0.4.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 (88) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +991 -344
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  6. package/templates/app/app/dashboard/account/page.tsx +11 -31
  7. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  8. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  9. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  10. package/templates/app/app/dashboard/actions.ts +246 -0
  11. package/templates/app/app/dashboard/import-actions.ts +91 -0
  12. package/templates/app/app/dashboard/layout.tsx +66 -39
  13. package/templates/app/app/dashboard/observability/page.tsx +175 -0
  14. package/templates/app/app/dashboard/page.tsx +108 -27
  15. package/templates/app/app/verify-email/page.tsx +33 -0
  16. package/templates/app/components/account/account-nav.tsx +39 -0
  17. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
  18. package/templates/app/components/auth/otp-input.tsx +95 -0
  19. package/templates/app/components/auth/password-field.tsx +116 -0
  20. package/templates/app/components/auth/password-forms.tsx +76 -79
  21. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  22. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  23. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  24. package/templates/app/components/auth/use-resend.ts +44 -0
  25. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  26. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  27. package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
  28. package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
  29. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  30. package/templates/app/components/dashboard/editable.ts +12 -0
  31. package/templates/app/components/dashboard/export-button.tsx +41 -0
  32. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
  33. package/templates/app/components/dashboard/import-dialog.tsx +540 -0
  34. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  35. package/templates/app/components/dashboard/local-time.tsx +77 -0
  36. package/templates/app/components/dashboard/page-header.tsx +57 -0
  37. package/templates/app/components/{admin → dashboard}/query.ts +2 -0
  38. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  39. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  40. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  41. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  42. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  43. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  44. package/templates/app/components/dashboard/resource-stats.tsx +48 -0
  45. package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
  46. package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
  47. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  48. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  49. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  50. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  51. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  52. package/templates/app/components/ui/sonner.tsx +33 -13
  53. package/templates/app/db/flare-schema.ts +55 -1
  54. package/templates/app/lib/analytics.ts +121 -0
  55. package/templates/app/lib/api.ts +12 -0
  56. package/templates/app/lib/audit.ts +80 -0
  57. package/templates/app/lib/auth-config.ts +38 -33
  58. package/templates/app/lib/auth.ts +34 -0
  59. package/templates/app/lib/cache.ts +83 -83
  60. package/templates/app/lib/csv.ts +24 -0
  61. package/templates/app/lib/dashboard-nav.ts +15 -0
  62. package/templates/app/lib/dashboard.ts +165 -0
  63. package/templates/app/lib/password-rules.ts +159 -0
  64. package/templates/app/lib/site.ts +56 -47
  65. package/templates/app/lib/views.ts +29 -0
  66. package/templates/app/migrations/0000_init.sql +25 -2
  67. package/templates/app/migrations/meta/0000_snapshot.json +159 -1
  68. package/templates/app/migrations/meta/_journal.json +1 -1
  69. package/templates/app/vite.config.ts +41 -31
  70. package/templates/app/worker-configuration.d.ts +15764 -0
  71. package/templates/locks/package-lock.json +9189 -0
  72. package/templates/locks/pnpm-lock.yaml +6347 -0
  73. package/templates/locks/yarn.lock +3548 -0
  74. package/templates/app/app/admin/actions.ts +0 -113
  75. package/templates/app/app/admin/layout.tsx +0 -32
  76. package/templates/app/components/admin/admin-header.tsx +0 -78
  77. package/templates/app/components/admin/resource-nav.tsx +0 -120
  78. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  79. package/templates/app/lib/admin-nav.ts +0 -15
  80. package/templates/app/lib/admin.ts +0 -103
  81. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  82. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  83. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  84. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  85. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -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
+ }
@@ -0,0 +1,115 @@
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
+ }
@@ -0,0 +1,228 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname, useSearchParams } from "next/navigation";
5
+ import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
6
+ import { ChevronsUpDownIcon } from "lucide-react";
7
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
8
+ import {
9
+ Sidebar,
10
+ SidebarContent,
11
+ SidebarFooter,
12
+ SidebarGroup,
13
+ SidebarGroupContent,
14
+ SidebarGroupLabel,
15
+ SidebarHeader,
16
+ SidebarMenu,
17
+ SidebarMenuButton,
18
+ SidebarMenuItem,
19
+ SidebarMenuSub,
20
+ SidebarMenuSubButton,
21
+ SidebarMenuSubItem,
22
+ } from "@/components/ui/sidebar";
23
+ import { resourceIcon } from "./resource-icon";
24
+ import { UserAvatar, UserMenuItems, type DashboardUser } from "./user-menu";
25
+
26
+ export interface NavResource {
27
+ name: string;
28
+ pluralLabel: string;
29
+ slug: string;
30
+ icon?: string;
31
+ /** Heading it sits under, from the descriptor's `group`. */
32
+ group?: string;
33
+ /** Optional count badge (off by default). */
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];
49
+ }
50
+
51
+ /**
52
+ * Sidebar navigation, one item per registered resource. The icon comes from the
53
+ * descriptor's `icon`, the label from its plural label, and the active item from the
54
+ * current path.
55
+ */
56
+ /** Sections every dashboard has, whatever resources the app defines. */
57
+ const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
58
+ // Security has its own page once `flare gen security` runs, which adds its link to
59
+ // lib/dashboard-nav.ts; observability ships with every app.
60
+ const ADMIN_LINKS = [{ href: "/dashboard/observability", label: "Observability", icon: ActivityIcon }];
61
+
62
+ export function DashboardSidebar({
63
+ appName,
64
+ resources,
65
+ links = [],
66
+ isAdmin = false,
67
+ user,
68
+ }: {
69
+ appName: string;
70
+ resources: NavResource[];
71
+ /** Extra pages (see lib/dashboard-nav.ts), shown under "Platform". */
72
+ links?: Array<{ label: string; href: string; icon: string }>;
73
+ /** Whether to show the sections that manage the app itself. */
74
+ isAdmin?: boolean;
75
+ user: DashboardUser;
76
+ }) {
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();
80
+ const manage = [...ACCOUNT_LINKS, ...(isAdmin ? ADMIN_LINKS : [])];
81
+
82
+ return (
83
+ <Sidebar collapsible="icon">
84
+ <SidebarHeader>
85
+ <SidebarMenu>
86
+ <SidebarMenuItem>
87
+ <SidebarMenuButton asChild size="lg" tooltip={appName}>
88
+ <Link href="/dashboard">
89
+ <div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
90
+ <LayoutDashboardIcon className="size-4" />
91
+ </div>
92
+ <span className="truncate font-semibold">{appName}</span>
93
+ </Link>
94
+ </SidebarMenuButton>
95
+ </SidebarMenuItem>
96
+ </SidebarMenu>
97
+ </SidebarHeader>
98
+
99
+ <SidebarContent>
100
+ <SidebarGroup>
101
+ <SidebarGroupContent>
102
+ <SidebarMenu>
103
+ <SidebarMenuItem>
104
+ <SidebarMenuButton asChild isActive={pathname === "/dashboard"} tooltip="Overview">
105
+ <Link href="/dashboard">
106
+ <LayoutDashboardIcon />
107
+ <span>Overview</span>
108
+ </Link>
109
+ </SidebarMenuButton>
110
+ </SidebarMenuItem>
111
+ </SidebarMenu>
112
+ </SidebarGroupContent>
113
+ </SidebarGroup>
114
+
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
+ ))}
151
+
152
+ <SidebarGroup>
153
+ <SidebarGroupLabel>Manage</SidebarGroupLabel>
154
+ <SidebarGroupContent>
155
+ <SidebarMenu>
156
+ {manage.map((link) => (
157
+ <SidebarMenuItem key={link.href}>
158
+ <SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
159
+ <Link href={link.href}>
160
+ <link.icon />
161
+ <span>{link.label}</span>
162
+ </Link>
163
+ </SidebarMenuButton>
164
+ </SidebarMenuItem>
165
+ ))}
166
+ </SidebarMenu>
167
+ </SidebarGroupContent>
168
+ </SidebarGroup>
169
+
170
+ {links.length > 0 && (
171
+ <SidebarGroup>
172
+ <SidebarGroupLabel>Platform</SidebarGroupLabel>
173
+ <SidebarGroupContent>
174
+ <SidebarMenu>
175
+ {links.map((link) => {
176
+ const Icon = resourceIcon(link.icon);
177
+ // Route handlers (/api/...) and other sites are whole pages: open them in a new tab.
178
+ const external = link.href.startsWith("/api/") || /^https?:\/\//.test(link.href);
179
+ const content = (
180
+ <>
181
+ <Icon />
182
+ <span>{link.label}</span>
183
+ </>
184
+ );
185
+ return (
186
+ <SidebarMenuItem key={link.href}>
187
+ <SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
188
+ {external ? (
189
+ <a href={link.href} target="_blank" rel="noreferrer">
190
+ {content}
191
+ </a>
192
+ ) : (
193
+ <Link href={link.href}>{content}</Link>
194
+ )}
195
+ </SidebarMenuButton>
196
+ </SidebarMenuItem>
197
+ );
198
+ })}
199
+ </SidebarMenu>
200
+ </SidebarGroupContent>
201
+ </SidebarGroup>
202
+ )}
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>
226
+ </Sidebar>
227
+ );
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
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { useTransition } from "react";
4
+ import { useSearchParams } from "next/navigation";
5
+ import { DownloadIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { exportRecordsAction } from "@/app/dashboard/actions";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Spinner } from "@/components/ui/spinner";
10
+ import { downloadCsv } from "./table-selection";
11
+
12
+ /**
13
+ * Exports what the table is showing — the same search, filters and sort, not just the
14
+ * page on screen. The server builds the CSV because the browser only ever holds one page.
15
+ */
16
+ export function ExportButton({ resourceName, pluralLabel }: { resourceName: string; pluralLabel: string }) {
17
+ const params = useSearchParams();
18
+ const [pending, startTransition] = useTransition();
19
+
20
+ const run = () =>
21
+ startTransition(async () => {
22
+ const query = new URLSearchParams(params.toString());
23
+ query.delete("page");
24
+ const result = await exportRecordsAction(resourceName, query.toString());
25
+ if (!result.ok) {
26
+ toast.error(result.error);
27
+ return;
28
+ }
29
+ const { csv, rows, truncated } = result.data;
30
+ downloadCsv(`${resourceName.toLowerCase()}-${new Date().toISOString().slice(0, 10)}.csv`, csv);
31
+ if (truncated) toast.warning(`Exported the first ${rows.toLocaleString()} ${pluralLabel.toLowerCase()}. Filter down for the rest.`);
32
+ else toast.success(`Exported ${rows.toLocaleString()} ${pluralLabel.toLowerCase()}.`);
33
+ });
34
+
35
+ return (
36
+ <Button variant="outline" onClick={run} disabled={pending}>
37
+ {pending ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
38
+ Export
39
+ </Button>
40
+ );
41
+ }
@@ -11,7 +11,7 @@ import {
11
11
  sniffMatches,
12
12
  type FileField as FileFieldDef,
13
13
  } from "@flaredev/core";
14
- import { createReadUrlAction, createUploadUrlAction } from "@/app/admin/actions";
14
+ import { createReadUrlAction, createUploadUrlAction } from "@/app/dashboard/actions";
15
15
  import { Button } from "@/components/ui/button";
16
16
  import { Progress } from "@/components/ui/progress";
17
17
  import { cn } from "@/lib/utils";
@@ -185,12 +185,12 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
185
185
  };
186
186
 
187
187
  const thumbnail = (
188
- <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted">
188
+ <div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted">
189
189
  {preview ? (
190
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" />
191
+ <img src={preview} alt="" width={64} height={64} className="size-full object-cover" />
192
192
  ) : (
193
- <FileIcon aria-hidden="true" className="size-5 text-muted-foreground" />
193
+ <FileIcon aria-hidden="true" className="size-6 text-muted-foreground" />
194
194
  )}
195
195
  </div>
196
196
  );
@@ -220,7 +220,7 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
220
220
  <div className="flex items-baseline justify-between gap-2 text-sm">
221
221
  <span className="truncate font-medium">{upload.name}</span>
222
222
  <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
223
- {formatBytes(upload.loaded)} of {formatBytes(upload.total)}
223
+ {percent}% · {formatBytes(upload.loaded)} of {formatBytes(upload.total)}
224
224
  </span>
225
225
  </div>
226
226
  <Progress value={percent} aria-label={`Uploading ${upload.name}`} />
@@ -270,21 +270,25 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
270
270
  aria-describedby={`${instructionId} ${helpId}`}
271
271
  aria-invalid={invalid || Boolean(error) || undefined}
272
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",
273
+ "flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors outline-none motion-reduce:transition-none",
274
+ "hover:border-primary/50 hover:bg-muted/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
275
+ dragging ? "border-primary bg-primary/5" : (invalid || error) && "border-destructive",
276
276
  )}
277
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>
278
+ <span className="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
279
+ <UploadIcon aria-hidden="true" className="size-5" />
280
+ </span>
281
+ <span id={instructionId} className="text-sm font-medium">
282
+ {dragging ? (
283
+ "Drop to upload"
284
+ ) : (
285
+ <>
286
+ Drop a file here <span className="text-muted-foreground">or click to choose</span>
287
+ </>
288
+ )}
289
+ </span>
290
+ <span id={helpId} className="text-xs text-muted-foreground">
291
+ {help}
288
292
  </span>
289
293
  </button>
290
294
  )}