@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +991 -344
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -31
- 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 +246 -0
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +66 -39
- package/templates/app/app/dashboard/observability/page.tsx +175 -0
- package/templates/app/app/dashboard/page.tsx +108 -27
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
- package/templates/app/components/dashboard/import-dialog.tsx +540 -0
- 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/page-header.tsx +57 -0
- package/templates/app/components/{admin → 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-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +48 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +55 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/dashboard.ts +165 -0
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +25 -2
- package/templates/app/migrations/meta/0000_snapshot.json +159 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/locks/yarn.lock +3548 -0
- package/templates/app/app/admin/actions.ts +0 -113
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/admin/admin-header.tsx +0 -78
- package/templates/app/components/admin/resource-nav.tsx +0 -120
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- package/templates/app/lib/admin.ts +0 -103
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
|
@@ -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/
|
|
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-
|
|
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={
|
|
191
|
+
<img src={preview} alt="" width={64} height={64} className="size-full object-cover" />
|
|
192
192
|
) : (
|
|
193
|
-
<FileIcon aria-hidden="true" className="size-
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
279
|
-
<UploadIcon aria-hidden="true" className="size-5
|
|
280
|
-
</
|
|
281
|
-
<span className="
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
)}
|