@flaredev/cli 0.3.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.
- package/dist/index.js +385 -209
- package/package.json +2 -2
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -33
- package/templates/app/app/dashboard/account/password/page.tsx +22 -0
- package/templates/app/app/dashboard/account/security/page.tsx +34 -0
- package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
- package/templates/app/app/dashboard/actions.ts +35 -0
- package/templates/app/app/dashboard/layout.tsx +28 -4
- package/templates/app/app/dashboard/observability/page.tsx +4 -3
- package/templates/app/app/dashboard/page.tsx +2 -2
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
- package/templates/app/components/dashboard/import-dialog.tsx +60 -7
- package/templates/app/components/dashboard/inline-cell.tsx +165 -0
- package/templates/app/components/dashboard/local-time.tsx +77 -0
- package/templates/app/components/dashboard/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +200 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-stats.tsx +6 -11
- package/templates/app/components/dashboard/resource-table.tsx +405 -331
- package/templates/app/components/dashboard/row-actions.tsx +156 -144
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +22 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/dashboard.ts +165 -138
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +11 -0
- package/templates/app/migrations/meta/0000_snapshot.json +63 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/locks/package-lock.json +3 -3
- package/templates/locks/pnpm-lock.yaml +4 -4
- 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
|
|
5
|
-
import {
|
|
6
|
-
import { Button } from "@/components/ui/button";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</Button>
|
|
55
|
-
</
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|