@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.4.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AccountNav } from "@/components/account/account-nav";
|
|
2
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
3
|
+
import { twoFactorAvailable, authConfig } from "@/lib/auth-config";
|
|
4
|
+
import { enabledSocialProviders } from "@/lib/auth";
|
|
5
|
+
import { requireSession } from "@/lib/session";
|
|
6
|
+
|
|
7
|
+
/** The frame the account's pages share: one header, one set of links. */
|
|
8
|
+
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
|
|
9
|
+
await requireSession("/dashboard/account");
|
|
10
|
+
const security = twoFactorAvailable || authConfig.passkeys || enabledSocialProviders().length > 0;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<PageHeader
|
|
15
|
+
title="Account"
|
|
16
|
+
description="Your profile, how you sign in, and where you're signed in."
|
|
17
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Account" }]}
|
|
18
|
+
/>
|
|
19
|
+
<AccountNav
|
|
20
|
+
links={[
|
|
21
|
+
{ href: "/dashboard/account", label: "Profile" },
|
|
22
|
+
{ href: "/dashboard/account/password", label: "Password" },
|
|
23
|
+
...(security ? [{ href: "/dashboard/account/security", label: "Security" }] : []),
|
|
24
|
+
{ href: "/dashboard/account/sessions", label: "Devices" },
|
|
25
|
+
]}
|
|
26
|
+
/>
|
|
27
|
+
<div className="flex max-w-3xl flex-col gap-6">{children}</div>
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -1,40 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { SecuritySettings } from "@/components/account/security-settings";
|
|
3
|
-
import { PageHeader } from "@/components/dashboard/page-header";
|
|
4
|
-
import { getDb } from "@/db";
|
|
5
|
-
import { account } from "@/db/auth-schema";
|
|
6
|
-
import { enabledSocialProviders } from "@/lib/auth";
|
|
7
|
-
import { authConfig } from "@/lib/auth-config";
|
|
1
|
+
import { Profile } from "@/components/account/sections";
|
|
8
2
|
import { requireSession } from "@/lib/session";
|
|
9
3
|
|
|
10
|
-
export const metadata = { title: "
|
|
4
|
+
export const metadata = { title: "Profile" };
|
|
11
5
|
|
|
12
|
-
export default async function
|
|
6
|
+
export default async function ProfilePage() {
|
|
13
7
|
const { user } = await requireSession("/dashboard/account");
|
|
14
|
-
const credential = await getDb()
|
|
15
|
-
.select({ id: account.id })
|
|
16
|
-
.from(account)
|
|
17
|
-
.where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
|
|
18
|
-
.get();
|
|
19
|
-
|
|
20
8
|
return (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
name: user.name,
|
|
30
|
-
email: user.email,
|
|
31
|
-
emailVerified: user.emailVerified,
|
|
32
|
-
twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
|
|
33
|
-
}}
|
|
34
|
-
hasPassword={Boolean(credential)}
|
|
35
|
-
methods={{ passkeys: authConfig.passkeys, twoFactor: authConfig.twoFactor }}
|
|
36
|
-
socialProviders={enabledSocialProviders()}
|
|
37
|
-
/>
|
|
38
|
-
</>
|
|
9
|
+
<Profile
|
|
10
|
+
user={{
|
|
11
|
+
name: user.name,
|
|
12
|
+
email: user.email,
|
|
13
|
+
emailVerified: user.emailVerified,
|
|
14
|
+
twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
|
|
15
|
+
}}
|
|
16
|
+
/>
|
|
39
17
|
);
|
|
40
18
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { Password } from "@/components/account/sections";
|
|
3
|
+
import { getDb } from "@/db";
|
|
4
|
+
import { account } from "@/db/auth-schema";
|
|
5
|
+
import { requireSession } from "@/lib/session";
|
|
6
|
+
|
|
7
|
+
export const metadata = { title: "Password" };
|
|
8
|
+
|
|
9
|
+
/** Whether this person signs in with a password at all: social and passkey accounts have none. */
|
|
10
|
+
async function hasPassword(userId: string) {
|
|
11
|
+
const credential = await getDb()
|
|
12
|
+
.select({ id: account.id })
|
|
13
|
+
.from(account)
|
|
14
|
+
.where(and(eq(account.userId, userId), eq(account.providerId, "credential")))
|
|
15
|
+
.get();
|
|
16
|
+
return Boolean(credential);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default async function PasswordPage() {
|
|
20
|
+
const { user } = await requireSession("/dashboard/account/password");
|
|
21
|
+
return <Password hasPassword={await hasPassword(user.id)} email={user.email} />;
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
|
|
3
|
+
import { getDb } from "@/db";
|
|
4
|
+
import { account } from "@/db/auth-schema";
|
|
5
|
+
import { enabledSocialProviders } from "@/lib/auth";
|
|
6
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
7
|
+
import { requireSession } from "@/lib/session";
|
|
8
|
+
|
|
9
|
+
export const metadata = { title: "Security" };
|
|
10
|
+
|
|
11
|
+
/** Two-factor, passkeys and the accounts you can sign in with. */
|
|
12
|
+
export default async function SecurityPage() {
|
|
13
|
+
const { user } = await requireSession("/dashboard/account/security");
|
|
14
|
+
const credential = await getDb()
|
|
15
|
+
.select({ id: account.id })
|
|
16
|
+
.from(account)
|
|
17
|
+
.where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
|
|
18
|
+
.get();
|
|
19
|
+
const providers = enabledSocialProviders();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{twoFactorAvailable && (
|
|
24
|
+
<TwoFactor
|
|
25
|
+
enabled={Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled)}
|
|
26
|
+
hasPassword={Boolean(credential)}
|
|
27
|
+
methods={authConfig.twoFactor}
|
|
28
|
+
/>
|
|
29
|
+
)}
|
|
30
|
+
{authConfig.passkeys && <Passkeys />}
|
|
31
|
+
{providers.length > 0 && <ConnectedAccounts providers={providers} />}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Sessions } from "@/components/account/sections";
|
|
2
|
+
import { requireSession } from "@/lib/session";
|
|
3
|
+
|
|
4
|
+
export const metadata = { title: "Devices" };
|
|
5
|
+
|
|
6
|
+
export default async function SessionsPage() {
|
|
7
|
+
await requireSession("/dashboard/account/sessions");
|
|
8
|
+
return <Sessions />;
|
|
9
|
+
}
|
|
@@ -4,6 +4,10 @@ import { revalidatePath } from "next/cache";
|
|
|
4
4
|
import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
|
|
5
5
|
import type { FieldIssue } from "@flaredev/core/server";
|
|
6
6
|
import { diffFields, recordAudit } from "@/lib/audit";
|
|
7
|
+
import { listViews, MAX_VIEWS } from "@/lib/views";
|
|
8
|
+
import { and, eq } from "drizzle-orm";
|
|
9
|
+
import { getDb } from "@/db";
|
|
10
|
+
import { savedView } from "@/db/flare-schema";
|
|
7
11
|
import { toCsv } from "@/lib/csv";
|
|
8
12
|
import { storage } from "@/lib/storage";
|
|
9
13
|
import { can, storedFields, type PolicyAction } from "@flaredev/core";
|
|
@@ -109,6 +113,37 @@ export async function exportRecordsAction(resourceName: string, query: string):
|
|
|
109
113
|
return { ok: true, data: { csv, rows: rows.length, truncated } };
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
/** Keep the table's current query as a named view, for this person. */
|
|
117
|
+
export async function saveViewAction(resourceName: string, name: string, query: string): Promise<ActionResult<{ id: string }>> {
|
|
118
|
+
const denied = await allowed(resourceName, "read");
|
|
119
|
+
if (denied) return denied;
|
|
120
|
+
const { session } = await dashboardSession();
|
|
121
|
+
if (!session) return { ok: false, status: 401, error: "Sign in to save a view." };
|
|
122
|
+
|
|
123
|
+
const label = name.trim().slice(0, 60);
|
|
124
|
+
if (!label) return { ok: false, status: 400, error: "Give the view a name." };
|
|
125
|
+
const existing = await listViews(resourceName);
|
|
126
|
+
if (existing.length >= MAX_VIEWS) {
|
|
127
|
+
return { ok: false, status: 400, error: `That's ${MAX_VIEWS} views for ${resourceName}. Delete one first.` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const [row] = await getDb()
|
|
131
|
+
.insert(savedView)
|
|
132
|
+
.values({ userId: session.user.id, resource: resourceName, name: label, query: query.replace(/^\?/, "").slice(0, 2000) })
|
|
133
|
+
.returning({ id: savedView.id });
|
|
134
|
+
revalidatePath("/dashboard", "layout");
|
|
135
|
+
return { ok: true, data: { id: row!.id } };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Views belong to the person who saved them, so only they can remove one. */
|
|
139
|
+
export async function deleteViewAction(id: string): Promise<ActionResult<{ id: string }>> {
|
|
140
|
+
const { session } = await dashboardSession();
|
|
141
|
+
if (!session) return { ok: false, status: 401, error: "Sign in first." };
|
|
142
|
+
await getDb().delete(savedView).where(and(eq(savedView.id, id), eq(savedView.userId, session.user.id)));
|
|
143
|
+
revalidatePath("/dashboard", "layout");
|
|
144
|
+
return { ok: true, data: { id } };
|
|
145
|
+
}
|
|
146
|
+
|
|
112
147
|
export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
113
148
|
const denied = await allowed(resourceName, "create");
|
|
114
149
|
if (denied) return denied;
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { cookies } from "next/headers";
|
|
2
|
+
import { count, eq } from "drizzle-orm";
|
|
2
3
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
3
4
|
import { Toaster } from "@/components/ui/sonner";
|
|
4
|
-
import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
|
|
5
|
+
import { DashboardHeader, type Notice, type Theme } from "@/components/dashboard/dashboard-header";
|
|
5
6
|
import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
|
|
7
|
+
import { getDb } from "@/db";
|
|
8
|
+
import { passkey } from "@/db/auth-schema";
|
|
9
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
6
10
|
import { dashboardLinks } from "@/lib/dashboard-nav";
|
|
7
11
|
import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
|
|
8
12
|
import { requireSession } from "@/lib/session";
|
|
13
|
+
import { listViews } from "@/lib/views";
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* The frame around every signed-in page: the app's resources in the sidebar, the
|
|
12
|
-
* account menu in the header.
|
|
17
|
+
* account menu in the header and in the sidebar's footer.
|
|
13
18
|
*
|
|
14
19
|
* Everyone signed in gets a dashboard. What's in it depends on the policies: the
|
|
15
20
|
* sidebar lists only the resources this person may read, and the sections that manage
|
|
@@ -22,18 +27,37 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|
|
22
27
|
const collapsed = jar.get("sidebar_state")?.value === "false";
|
|
23
28
|
const role = (user as { role?: string | null }).role ?? "";
|
|
24
29
|
|
|
30
|
+
const views = await listViews();
|
|
25
31
|
const resources = (await visibleResources()).map((resource) => ({
|
|
26
32
|
name: resource.name,
|
|
27
33
|
pluralLabel: resource.pluralLabel,
|
|
28
34
|
slug: resource.slug,
|
|
29
35
|
icon: resource.icon,
|
|
36
|
+
group: resource.group,
|
|
37
|
+
views: views.filter((view) => view.resource === resource.name).map(({ id, name, query }) => ({ id, name, query })),
|
|
30
38
|
}));
|
|
31
39
|
|
|
40
|
+
// What the bell has to say: the things about this account that aren't done yet.
|
|
41
|
+
const [{ passkeys } = { passkeys: 0 }] = await getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
|
|
42
|
+
const notices: Notice[] = [
|
|
43
|
+
...(user.emailVerified
|
|
44
|
+
? []
|
|
45
|
+
: [{ id: "verify-email", title: "Verify your email address", description: "We'll send a new link or code.", href: "/verify-email", tone: "warning" as const }]),
|
|
46
|
+
...(twoFactorAvailable && !(user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled
|
|
47
|
+
? [{ id: "two-factor", title: "Turn on two-factor authentication", description: "A second step after your password.", href: "/dashboard/account/security" }]
|
|
48
|
+
: []),
|
|
49
|
+
...(authConfig.passkeys && passkeys === 0
|
|
50
|
+
? [{ id: "passkey", title: "Add a passkey", description: "Sign in with Face ID, Touch ID or Windows Hello.", href: "/dashboard/account/security" }]
|
|
51
|
+
: []),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const account = { name: user.name, email: user.email, image: (user as { image?: string | null }).image };
|
|
55
|
+
|
|
32
56
|
return (
|
|
33
57
|
<SidebarProvider defaultOpen={!collapsed}>
|
|
34
|
-
<DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} />
|
|
58
|
+
<DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
|
|
35
59
|
<SidebarInset>
|
|
36
|
-
<DashboardHeader user={
|
|
60
|
+
<DashboardHeader user={account} initialTheme={theme} notices={notices} />
|
|
37
61
|
<main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
|
|
38
62
|
</SidebarInset>
|
|
39
63
|
<Toaster theme={theme} />
|
|
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|
|
6
6
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
7
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
8
8
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
9
|
+
import { LocalTime } from "@/components/dashboard/local-time";
|
|
9
10
|
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
10
11
|
import { TrafficChart } from "@/components/dashboard/traffic-chart";
|
|
11
12
|
import { auditCount, recentAudit } from "@/lib/audit";
|
|
@@ -23,8 +24,6 @@ const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
|
|
|
23
24
|
import: "secondary",
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
const when = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
|
27
|
-
|
|
28
27
|
/** How the app is behaving: traffic from Cloudflare, size from the database, and who changed what. */
|
|
29
28
|
export default async function ObservabilityPage() {
|
|
30
29
|
await requireDashboard("/dashboard/observability");
|
|
@@ -151,7 +150,9 @@ export default async function ObservabilityPage() {
|
|
|
151
150
|
</TableCell>
|
|
152
151
|
<TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
|
|
153
152
|
<TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
|
|
154
|
-
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
153
|
+
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
|
|
154
|
+
<LocalTime value={entry.createdAt} />
|
|
155
|
+
</TableCell>
|
|
155
156
|
</TableRow>
|
|
156
157
|
))}
|
|
157
158
|
</TableBody>
|
|
@@ -44,8 +44,8 @@ export default async function DashboardPage() {
|
|
|
44
44
|
const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
|
|
45
45
|
const steps = [
|
|
46
46
|
{ done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
|
|
47
|
-
...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account
|
|
48
|
-
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account
|
|
47
|
+
...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account/security" }] : []),
|
|
48
|
+
...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account/security" }] : []),
|
|
49
49
|
];
|
|
50
50
|
const remaining = steps.filter((step) => !step.done).length;
|
|
51
51
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface AccountLink {
|
|
8
|
+
href: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The account's own navigation. Each part of an account is its own page: a single page
|
|
14
|
+
* of everything means scrolling past two-factor to reach your devices, and no way to
|
|
15
|
+
* send someone straight to the part that needs their attention.
|
|
16
|
+
*/
|
|
17
|
+
export function AccountNav({ links }: { links: AccountLink[] }) {
|
|
18
|
+
const pathname = usePathname();
|
|
19
|
+
return (
|
|
20
|
+
<nav aria-label="Account" className="-mb-px flex gap-1 overflow-x-auto border-b">
|
|
21
|
+
{links.map((link) => {
|
|
22
|
+
const current = pathname === link.href;
|
|
23
|
+
return (
|
|
24
|
+
<Link
|
|
25
|
+
key={link.href}
|
|
26
|
+
href={link.href}
|
|
27
|
+
aria-current={current ? "page" : undefined}
|
|
28
|
+
className={cn(
|
|
29
|
+
"border-b-2 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors",
|
|
30
|
+
current ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{link.label}
|
|
34
|
+
</Link>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
</nav>
|
|
38
|
+
);
|
|
39
|
+
}
|