@flaredev/cli 0.2.0 → 0.3.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 +751 -280
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/page.tsx +6 -4
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -39
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +106 -25
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +6 -9
- 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/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -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 +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- 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/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -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/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/migrations/0000_init.sql +15 -3
- package/templates/app/migrations/meta/0000_snapshot.json +97 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- 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/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- /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}/query.ts +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
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { LogOutIcon, MoonIcon, SunIcon } from "lucide-react";
|
|
5
|
-
import { authClient } from "@/lib/auth-client";
|
|
6
|
-
import { Button } from "@/components/ui/button";
|
|
7
|
-
import {
|
|
8
|
-
DropdownMenu,
|
|
9
|
-
DropdownMenuContent,
|
|
10
|
-
DropdownMenuGroup,
|
|
11
|
-
DropdownMenuItem,
|
|
12
|
-
DropdownMenuLabel,
|
|
13
|
-
DropdownMenuSeparator,
|
|
14
|
-
DropdownMenuTrigger,
|
|
15
|
-
} from "@/components/ui/dropdown-menu";
|
|
16
|
-
import { Separator } from "@/components/ui/separator";
|
|
17
|
-
import { SidebarTrigger } from "@/components/ui/sidebar";
|
|
18
|
-
|
|
19
|
-
export type Theme = "light" | "dark" | "system";
|
|
20
|
-
|
|
21
|
-
/** Persist the choice for SSR (so the first paint matches) and apply it now. */
|
|
22
|
-
function applyTheme(theme: Theme) {
|
|
23
|
-
document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
|
|
24
|
-
const root = document.documentElement;
|
|
25
|
-
root.classList.remove("light", "dark");
|
|
26
|
-
if (theme !== "system") root.classList.add(theme);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function
|
|
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 { 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,120 +1,166 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { usePathname } from "next/navigation";
|
|
5
|
-
import { LayoutDashboardIcon } from "lucide-react";
|
|
6
|
-
import {
|
|
7
|
-
Sidebar,
|
|
8
|
-
SidebarContent,
|
|
9
|
-
SidebarGroup,
|
|
10
|
-
SidebarGroupContent,
|
|
11
|
-
SidebarGroupLabel,
|
|
12
|
-
SidebarHeader,
|
|
13
|
-
SidebarMenu,
|
|
14
|
-
SidebarMenuButton,
|
|
15
|
-
SidebarMenuItem,
|
|
16
|
-
} from "@/components/ui/sidebar";
|
|
17
|
-
import { resourceIcon } from "./resource-icon";
|
|
18
|
-
|
|
19
|
-
export interface NavResource {
|
|
20
|
-
name: string;
|
|
21
|
-
pluralLabel: string;
|
|
22
|
-
slug: string;
|
|
23
|
-
icon?: string;
|
|
24
|
-
/** Optional count badge (off by default). */
|
|
25
|
-
badge?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Sidebar navigation, one item per registered resource. The icon comes from the
|
|
30
|
-
* descriptor's `icon`, the label from its plural label, and the active item from the
|
|
31
|
-
* current path.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarGroup,
|
|
10
|
+
SidebarGroupContent,
|
|
11
|
+
SidebarGroupLabel,
|
|
12
|
+
SidebarHeader,
|
|
13
|
+
SidebarMenu,
|
|
14
|
+
SidebarMenuButton,
|
|
15
|
+
SidebarMenuItem,
|
|
16
|
+
} from "@/components/ui/sidebar";
|
|
17
|
+
import { resourceIcon } from "./resource-icon";
|
|
18
|
+
|
|
19
|
+
export interface NavResource {
|
|
20
|
+
name: string;
|
|
21
|
+
pluralLabel: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
icon?: string;
|
|
24
|
+
/** Optional count badge (off by default). */
|
|
25
|
+
badge?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sidebar navigation, one item per registered resource. The icon comes from the
|
|
30
|
+
* descriptor's `icon`, the label from its plural label, and the active item from the
|
|
31
|
+
* current path.
|
|
32
|
+
*/
|
|
33
|
+
/** Sections every dashboard has, whatever resources the app defines. */
|
|
34
|
+
const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
|
|
35
|
+
// Security has its own page once `flare gen security` runs, which adds its link to
|
|
36
|
+
// lib/dashboard-nav.ts; observability ships with every app.
|
|
37
|
+
const ADMIN_LINKS = [{ href: "/dashboard/observability", label: "Observability", icon: ActivityIcon }];
|
|
38
|
+
|
|
39
|
+
export function DashboardSidebar({
|
|
40
|
+
appName,
|
|
41
|
+
resources,
|
|
42
|
+
links = [],
|
|
43
|
+
isAdmin = false,
|
|
44
|
+
}: {
|
|
45
|
+
appName: string;
|
|
46
|
+
resources: NavResource[];
|
|
47
|
+
/** Extra pages (see lib/dashboard-nav.ts), shown under "Platform". */
|
|
48
|
+
links?: Array<{ label: string; href: string; icon: string }>;
|
|
49
|
+
/** Whether to show the sections that manage the app itself. */
|
|
50
|
+
isAdmin?: boolean;
|
|
51
|
+
}) {
|
|
52
|
+
const pathname = usePathname();
|
|
53
|
+
const manage = [...ACCOUNT_LINKS, ...(isAdmin ? ADMIN_LINKS : [])];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Sidebar collapsible="icon">
|
|
57
|
+
<SidebarHeader>
|
|
58
|
+
<SidebarMenu>
|
|
59
|
+
<SidebarMenuItem>
|
|
60
|
+
<SidebarMenuButton asChild size="lg" tooltip={appName}>
|
|
61
|
+
<Link href="/dashboard">
|
|
62
|
+
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
63
|
+
<LayoutDashboardIcon className="size-4" />
|
|
64
|
+
</div>
|
|
65
|
+
<span className="truncate font-semibold">{appName}</span>
|
|
66
|
+
</Link>
|
|
67
|
+
</SidebarMenuButton>
|
|
68
|
+
</SidebarMenuItem>
|
|
69
|
+
</SidebarMenu>
|
|
70
|
+
</SidebarHeader>
|
|
71
|
+
|
|
72
|
+
<SidebarContent>
|
|
73
|
+
<SidebarGroup>
|
|
74
|
+
<SidebarGroupContent>
|
|
75
|
+
<SidebarMenu>
|
|
76
|
+
<SidebarMenuItem>
|
|
77
|
+
<SidebarMenuButton asChild isActive={pathname === "/dashboard"} tooltip="Overview">
|
|
78
|
+
<Link href="/dashboard">
|
|
79
|
+
<LayoutDashboardIcon />
|
|
80
|
+
<span>Overview</span>
|
|
81
|
+
</Link>
|
|
82
|
+
</SidebarMenuButton>
|
|
83
|
+
</SidebarMenuItem>
|
|
84
|
+
</SidebarMenu>
|
|
85
|
+
</SidebarGroupContent>
|
|
86
|
+
</SidebarGroup>
|
|
87
|
+
|
|
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
|
+
)}
|
|
111
|
+
|
|
112
|
+
<SidebarGroup>
|
|
113
|
+
<SidebarGroupLabel>Manage</SidebarGroupLabel>
|
|
114
|
+
<SidebarGroupContent>
|
|
115
|
+
<SidebarMenu>
|
|
116
|
+
{manage.map((link) => (
|
|
117
|
+
<SidebarMenuItem key={link.href}>
|
|
118
|
+
<SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
|
|
119
|
+
<Link href={link.href}>
|
|
120
|
+
<link.icon />
|
|
121
|
+
<span>{link.label}</span>
|
|
122
|
+
</Link>
|
|
123
|
+
</SidebarMenuButton>
|
|
124
|
+
</SidebarMenuItem>
|
|
125
|
+
))}
|
|
126
|
+
</SidebarMenu>
|
|
127
|
+
</SidebarGroupContent>
|
|
128
|
+
</SidebarGroup>
|
|
129
|
+
|
|
130
|
+
{links.length > 0 && (
|
|
131
|
+
<SidebarGroup>
|
|
132
|
+
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
133
|
+
<SidebarGroupContent>
|
|
134
|
+
<SidebarMenu>
|
|
135
|
+
{links.map((link) => {
|
|
136
|
+
const Icon = resourceIcon(link.icon);
|
|
137
|
+
// Route handlers (/api/...) and other sites are whole pages: open them in a new tab.
|
|
138
|
+
const external = link.href.startsWith("/api/") || /^https?:\/\//.test(link.href);
|
|
139
|
+
const content = (
|
|
140
|
+
<>
|
|
141
|
+
<Icon />
|
|
142
|
+
<span>{link.label}</span>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
return (
|
|
146
|
+
<SidebarMenuItem key={link.href}>
|
|
147
|
+
<SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
|
|
148
|
+
{external ? (
|
|
149
|
+
<a href={link.href} target="_blank" rel="noreferrer">
|
|
150
|
+
{content}
|
|
151
|
+
</a>
|
|
152
|
+
) : (
|
|
153
|
+
<Link href={link.href}>{content}</Link>
|
|
154
|
+
)}
|
|
155
|
+
</SidebarMenuButton>
|
|
156
|
+
</SidebarMenuItem>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</SidebarMenu>
|
|
160
|
+
</SidebarGroupContent>
|
|
161
|
+
</SidebarGroup>
|
|
162
|
+
)}
|
|
163
|
+
</SidebarContent>
|
|
164
|
+
</Sidebar>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -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
|
+
}
|