@sjolystinnovation/app-kit 0.19.0 → 0.21.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/README.md CHANGED
@@ -210,35 +210,18 @@ Two rules keep a refresh from being lost:
210
210
  - Run the app as one server process. Parallel session reads share one refresh only within a process.
211
211
  Two replicas can refresh the same token at once, with the same result.
212
212
 
213
- The package does not declare these fields on next-auth's types. The app declares them, so its own
214
- code can read them:
213
+ The package declares these fields on next-auth's types in `@sjolystinnovation/app-kit/next-auth`. The
214
+ app loads the declarations once, from one of its own `.d.ts` files, so its code can read them:
215
215
 
216
216
  ```ts
217
217
  // src/types/next-auth.d.ts
218
- import "next-auth";
219
- import "next-auth/jwt";
220
-
221
- declare module "next-auth" {
222
- interface Session {
223
- user: { id: string; name: string; email: string; roles: string[] };
224
- accessToken: string;
225
- error?: string | undefined;
226
- absoluteExpiresAt?: number;
227
- }
228
- }
229
-
230
- declare module "next-auth/jwt" {
231
- interface JWT {
232
- accessToken?: string;
233
- refreshToken?: string;
234
- refreshAt?: number;
235
- loginAt?: number;
236
- user?: { id: string; name: string; email: string; roles: string[] };
237
- error?: string | undefined;
238
- }
239
- }
218
+ import "@sjolystinnovation/app-kit/next-auth";
240
219
  ```
241
220
 
221
+ The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error` and
222
+ `absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`, `user`
223
+ and `error`.
224
+
242
225
  ### `@sjolystinnovation/app-kit/api`
243
226
 
244
227
  `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
@@ -260,6 +243,17 @@ export const getRecipe = (id: number) =>
260
243
  request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
261
244
  ```
262
245
 
246
+ `mergeCompanyInfo(data, fallback)` gives the company details for public pages: the API's
247
+ `/company` answer, with the app's own value for each field it left blank or could not give. Only
248
+ the fallback's name is required. The legal name falls back to the name, and VAT to not registered.
249
+ `formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered,
250
+ and gives an empty string without a number.
251
+
252
+ ```ts
253
+ // src/lib/companyInfo.ts
254
+ export const getCompanyInfo = async () => mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), COMPANY);
255
+ ```
256
+
263
257
  `requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
264
258
  `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
265
259
  It never throws. On failure the pages' own revalidate window is the fallback.
@@ -338,6 +332,26 @@ export const POST = createRevalidateRoute({
338
332
  });
339
333
  ```
340
334
 
335
+ `defineRevalidateTargets({ locales, targets })`, from `@sjolystinnovation/app-kit/api`, keeps the
336
+ targets in one file that both sides import. `pages` are below each locale segment and `root` paths
337
+ are not. It returns `targets` for fetch tags, `paths` for the route, and `revalidate(target)` for
338
+ the admin services.
339
+
340
+ ```ts
341
+ // src/lib/cacheTags.ts
342
+ export const { targets: REVALIDATE_TARGETS, paths: TARGET_PATHS, revalidate: revalidateTarget } = defineRevalidateTargets({
343
+ locales: LOCALES,
344
+ targets: {
345
+ legal: { pages: ["/terms", "/privacy", "/cookies"], root: ["/sitemap.xml"] },
346
+ // Every page renders branding through the layout, so its tag reaches them all.
347
+ branding: {},
348
+ },
349
+ });
350
+
351
+ // src/app/api/revalidate/route.ts
352
+ export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: ADMIN_ROLE });
353
+ ```
354
+
341
355
  ### `@sjolystinnovation/app-kit/next-config`
342
356
 
343
357
  For `next.config.ts`. It imports nothing but Next's types, so Next loads it with no build step.
@@ -448,10 +462,14 @@ The site gives:
448
462
  - `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
449
463
  hreflang, and Open Graph with the locale's share image unless `images` is given.
450
464
  - `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
451
- listing the others. `lastModified` is left out when unknown.
465
+ listing the others. `lastModified` is left out when unknown. A site without locales gets one
466
+ entry per page.
467
+ - `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
468
+ - `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy` and
469
+ `/cookies` unless `keys` says otherwise, each dated by the timestamp `updatedAt` returns for it.
452
470
  - `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. The private paths are
453
471
  disallowed under every locale segment. A deployment that is not production disallows everything.
454
- - `organizationNode(company, { type, country, image, logo })`, `webSiteNode(locale)`,
472
+ - `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
455
473
  `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)` and `ids`
456
474
  for structured data.
457
475
 
@@ -460,6 +478,15 @@ The site gives:
460
478
  export const dynamic = "force-dynamic";
461
479
  export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
462
480
 
481
+ // src/app/sitemap.ts
482
+ export const revalidate = 3600;
483
+ export default async () => [
484
+ ...site.localeEntries(""),
485
+ ...(await site.legalEntries({
486
+ updatedAt: async (key, locale) => (await publicGet<LegalPage>(`/content/legal/${key}?locale=${locale}`))?.updatedAt,
487
+ })),
488
+ ];
489
+
463
490
  // in a page
464
491
  <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
465
492
  ```
@@ -467,8 +494,8 @@ export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRo
467
494
  `isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production` and unset or blank count as
468
495
  production, so a missing variable never deindexes the live site. `robots.ts` needs
469
496
  `dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
470
- `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `SHARE_IMAGE_SIZE`, `normalizePath`, `latest` and
471
- `parseTimestamp`.
497
+ `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`,
498
+ `normalizePath`, `latest` and `parseTimestamp`.
472
499
 
473
500
  ### `@sjolystinnovation/app-kit/seo/og`
474
501
 
@@ -594,6 +621,110 @@ if (loading) return <Spinner />;
594
621
 
595
622
  `formatNok(ore)` formats øre as kroner, such as `NOK 373,75`.
596
623
 
624
+ ### `@sjolystinnovation/app-kit/admin`
625
+
626
+ `CompanySettingsForm` is the admin form for the business details the public pages show, saved
627
+ through `createSettingsService`. Its children show below the form once the settings have loaded,
628
+ such as the `BrandingManager`. `strings` holds every label, hint and message, English by default.
629
+
630
+ ```tsx
631
+ <CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
632
+ <BrandingManager save={BrandingService.update} />
633
+ </CompanySettingsForm>
634
+ ```
635
+
636
+ The admin area. `AdminShell` is the frame: a title and a tab bar above the page, behind a
637
+ `RoleGate`. It takes `RoleGate`'s `role`, `fallback`, and `denied` or `deniedHref`. The tab whose
638
+ `href` is the current path is active, with `aria-current="page"`.
639
+
640
+ ```tsx
641
+ // src/app/[locale]/(protected)/admin/layout.tsx
642
+ "use client";
643
+ import { AdminShell } from "@sjolystinnovation/app-kit/admin";
644
+
645
+ export default function AdminLayout({ children }: { children: React.ReactNode }) {
646
+ const { dict, locale } = useDictionary();
647
+ const tabs = [
648
+ { href: `/${locale}/admin/users`, label: dict.admin.users },
649
+ { href: `/${locale}/admin/legal`, label: dict.admin.legal },
650
+ ];
651
+ return (
652
+ <AdminShell role="Admin" title={dict.admin.title} tabs={tabs} deniedHref={`/${locale}`}>
653
+ {children}
654
+ </AdminShell>
655
+ );
656
+ }
657
+ ```
658
+
659
+ `StatHistoryChart` draws the admin stats over time: a line per series, a scale from zero to the
660
+ highest value shown, and legend buttons that hide and show each line. `data` is one row per day,
661
+ oldest first, with `date` as `YYYY-MM-DD`. A missing value counts as zero. Each series names the
662
+ field it plots and its label, and takes the next colour in `STAT_SERIES_COLORS` unless it has its
663
+ own. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
664
+
665
+ ```tsx
666
+ <StatHistoryChart
667
+ data={history}
668
+ series={[
669
+ { key: "totalUsers", label: dict.stats.users },
670
+ { key: "contentImages", label: dict.stats.images },
671
+ ]}
672
+ strings={{ empty: dict.admin.noHistory, chart: dict.admin.overTime }}
673
+ />
674
+ ```
675
+
676
+ `AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
677
+ behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
678
+ email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
679
+ may give. Inline functions are fine. The panel does not fetch again when they change. It reports
680
+ with `toast`, so the app needs sonner and a `Toaster`.
681
+
682
+ ```tsx
683
+ import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
684
+
685
+ <AdminUsersPanel service={adminUsers} getRoles={AdminService.getRoles} strings={dict.adminUsers} />
686
+ ```
687
+
688
+ | Prop | Type | Description |
689
+ | --- | --- | --- |
690
+ | `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
691
+ | `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
692
+ | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
693
+ | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}` are filled in where a text names them. |
694
+
695
+ `LegalPagesEditor` edits the legal pages one page and one locale at a time, through
696
+ `createLegalService`. A locale tab whose page has no content yet gets a dot. `LocaleTabs` is the
697
+ same tab bar on its own, for other translated forms.
698
+
699
+ ```tsx
700
+ <LegalPagesEditor
701
+ service={LegalService}
702
+ keys={LEGAL_PAGE_KEYS}
703
+ keyLabels={{ terms: dict.footer.terms, privacy: dict.footer.privacy, cookies: dict.footer.cookies }}
704
+ locales={LOCALES}
705
+ localeLabels={LOCALE_LABELS}
706
+ strings={dict.legalEditor}
707
+ />
708
+ ```
709
+
710
+ ### `@sjolystinnovation/app-kit/legal`
711
+
712
+ `LegalPageView` renders a terms, privacy or cookies page as an admin wrote it: the title, the date
713
+ it last changed in `dateLocale`, and the Markdown body. With no page it shows `title` and says the
714
+ page is missing. It is a server component and needs the same peers as `markdown`. The route file
715
+ stays in the app, since Next reads `revalidate` and the metadata from it.
716
+
717
+ ```tsx
718
+ // src/app/[locale]/terms/page.tsx
719
+ export const revalidate = 3600;
720
+
721
+ export default async function TermsPage({ params }: { params: Promise<{ locale: Locale }> }) {
722
+ const { locale } = await params;
723
+ const page = await publicGet<LegalPage>(`/content/legal/terms?locale=${locale}`, { tags: [REVALIDATE_TARGETS.legal] });
724
+ return <LegalPageView page={page} title={dict.footer.terms} homeHref={`/${locale}`} dateLocale="nb-NO" strings={dict.legal} />;
725
+ }
726
+ ```
727
+
597
728
  ### `@sjolystinnovation/app-kit/session`
598
729
 
599
730
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -839,6 +970,25 @@ A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
839
970
  `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
840
971
  or one that throws, shows `somethingWentWrong`.
841
972
 
973
+ ### `LoginPage`
974
+
975
+ From `@sjolystinnovation/app-kit/ui`. The sign-in page: a title, `CredentialsForm`, a link to reset
976
+ the password and, when the app has sign-up, a link to it. A sign-in goes to `redirectTo` and refreshes
977
+ the page's server data. It uses the same `auth` theme variables as `ResetPasswordFlow`.
978
+
979
+ ```tsx
980
+ <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
981
+ ```
982
+
983
+ | Prop | Type | Description |
984
+ | --- | --- | --- |
985
+ | `redirectTo` | `string` | Where a signed-in user goes. |
986
+ | `resetHref` | `string` | The password reset page. |
987
+ | `registerHref` | `string` | The sign-up page. Without it there is no register link. |
988
+ | `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt` (null leaves it out) and `register`. |
989
+ | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
990
+ | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
991
+
842
992
  ### `ResetPasswordFlow`
843
993
 
844
994
  From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
@@ -1068,6 +1218,39 @@ const parsed = contactSchema.safeParse(form);
1068
1218
  if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
1069
1219
  ```
1070
1220
 
1221
+ ### `@sjolystinnovation/app-kit/testing`
1222
+
1223
+ Test doubles for the modules every app's tests mock: `next-auth/react`, `next/navigation`, `sonner`
1224
+ or `@sjolystinnovation/app-kit/toast`, and the app's axios instance. vitest hoists `vi.mock` above
1225
+ the file's imports, so the factory imports the helpers itself. The test imports the same module to
1226
+ set the state and read the spies. The state lasts for the whole file, so call `resetTestMocks`
1227
+ before each test. It needs vitest 5, as an optional peer dependency.
1228
+
1229
+ ```tsx
1230
+ import { makeSession, navigation, resetTestMocks, setSession } from "@sjolystinnovation/app-kit/testing";
1231
+
1232
+ vi.mock("next-auth/react", async () => (await import("@sjolystinnovation/app-kit/testing")).nextAuthMock());
1233
+ vi.mock("next/navigation", async () => (await import("@sjolystinnovation/app-kit/testing")).nextNavigationMock());
1234
+
1235
+ beforeEach(resetTestMocks);
1236
+
1237
+ it("sends a user without the role away", () => {
1238
+ navigation.pathname = "/no/admin";
1239
+ setSession(makeSession({ roles: [] }));
1240
+ render(<AdminLayout><p>admin work</p></AdminLayout>);
1241
+ expect(navigation.router.replace).toHaveBeenCalledWith("/no");
1242
+ });
1243
+ ```
1244
+
1245
+ - `setSession(session | null | "loading")` and `makeSession({ roles, ...overrides })` set what
1246
+ `useSession` returns. `auth.signIn`, `auth.signOut` and `auth.getSession` are the spies behind
1247
+ next-auth's calls.
1248
+ - `navigation.pathname`, `navigation.searchParams` and `navigation.router` feed `usePathname`,
1249
+ `useSearchParams` and `useRouter`.
1250
+ - `toastMock()` records every toast in `toastSpies`.
1251
+ - `axiosInstanceMock()` is a module whose default export is `axiosSpies`, for
1252
+ `vi.mock("@/services/axiosInstance", ...)`.
1253
+
1071
1254
  ## Theming
1072
1255
 
1073
1256
  The components draw every colour, corner radius and label size from theme variables with a light
@@ -1114,7 +1297,7 @@ selector such as `.dark` for a scoped theme:
1114
1297
  | `--color-toggle-on` | `var(--color-primary)`, the track of a `Toggle` that is on |
1115
1298
  | `--color-toggle-off` | gray 200, the track of a `Toggle` that is off |
1116
1299
  | `--color-toggle-thumb` | white |
1117
- | `--text-auth-title`, `--text-auth-title--line-height` | `var(--text-2xl)` and its line height, the title of `ResetPasswordFlow` |
1300
+ | `--text-auth-title`, `--text-auth-title--line-height` | `var(--text-2xl)` and its line height, the title of `LoginPage` and `ResetPasswordFlow` |
1118
1301
  | `--font-weight-auth-title` | black |
1119
1302
  | `--color-auth-heading` | gray 900 |
1120
1303
  | `--color-auth-text` | gray 600, the line under the title and the intro |
@@ -1122,6 +1305,13 @@ selector such as `.dark` for a scoped theme:
1122
1305
  | `--color-auth-hint` | gray 500, the hint under the password |
1123
1306
  | `--color-auth-link`, `-hover` | gray 900, the sign-in links |
1124
1307
  | `--font-weight-auth-link` | semibold |
1308
+ | `--color-admin-title` | gray 900, the title of `AdminShell` |
1309
+ | `--color-admin-border` | gray 200, the line under the tabs |
1310
+ | `--color-admin-tab`, `-hover` | gray 500 and gray 900 |
1311
+ | `--color-admin-tab-active` | gray 900 |
1312
+ | `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
1313
+ | `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
1314
+ | `--color-chart-label` | gray 400, the chart's numbers and dates |
1125
1315
  | `--radius-button` | `var(--radius-lg)` |
1126
1316
  | `--color-button-primary-bg` | `var(--color-primary)` |
1127
1317
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
@@ -1158,15 +1348,19 @@ with it and has to set the ones it uses itself.
1158
1348
 
1159
1349
  ## Requirements
1160
1350
 
1161
- - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
1162
- app installs itself.
1351
+ - Next.js 16 and React 19, as peer dependencies the app installs itself.
1352
+ - For the root entry, `auth`, `api`, `server`, `session/react`, `ui` and `admin`: next-auth 4.24
1353
+ or later and axios 1.20 or later, as optional peer dependencies. A site without sign-in, which
1354
+ uses only entries such as `seo`, `next-config`, `markdown`, `toast` or `validation`, leaves them
1355
+ out.
1163
1356
  - For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
1357
+ - For `@sjolystinnovation/app-kit/testing`: vitest 5, as an optional peer dependency.
1164
1358
  - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
1165
1359
  copy, because two copies keep two separate toast stores.
1166
1360
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
1167
- - For `@sjolystinnovation/app-kit/ui` and the components in `session/react`: Tailwind CSS 4 and
1168
- `@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
1169
- `primary-foreground` colours, unless their own variables are set.
1361
+ - For `@sjolystinnovation/app-kit/ui`, `@sjolystinnovation/app-kit/admin` and the components in
1362
+ `session/react`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring and the primary button
1363
+ use the theme's `primary` and `primary-foreground` colours, unless their own variables are set.
1170
1364
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
1171
1365
  `node16` and `nodenext` reject.
1172
1366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -20,18 +20,24 @@
20
20
  "./session/react": "./src/session/react/index.ts",
21
21
  "./forms": "./src/forms/index.ts",
22
22
  "./auth": "./src/auth/index.ts",
23
+ "./next-auth": {
24
+ "types": "./src/next-auth.d.ts"
25
+ },
23
26
  "./api": "./src/api/index.ts",
24
27
  "./server": "./src/server/index.ts",
25
28
  "./ui": "./src/ui/index.ts",
29
+ "./admin": "./src/admin/index.ts",
26
30
  "./toast": "./src/toast/index.ts",
27
31
  "./validation": "./src/validation/index.ts",
28
32
  "./next-config": "./src/next-config/index.ts",
29
33
  "./markdown": "./src/markdown/index.ts",
34
+ "./legal": "./src/legal/index.ts",
30
35
  "./seo": "./src/seo/index.ts",
31
36
  "./seo/og": "./src/seo/og/index.tsx",
32
37
  "./branding": "./src/branding/index.ts",
33
38
  "./branding/server": "./src/branding/server.ts",
34
39
  "./payments": "./src/payments/index.ts",
40
+ "./testing": "./src/testing/index.ts",
35
41
  "./styles.css": "./src/styles.css"
36
42
  },
37
43
  "files": [
@@ -56,12 +62,22 @@
56
62
  "rehype-sanitize": "^6",
57
63
  "remark-gfm": "^4",
58
64
  "sonner": "^2",
65
+ "vitest": "^5",
59
66
  "zod": "^4"
60
67
  },
61
68
  "peerDependenciesMeta": {
69
+ "axios": {
70
+ "optional": true
71
+ },
72
+ "next-auth": {
73
+ "optional": true
74
+ },
62
75
  "sonner": {
63
76
  "optional": true
64
77
  },
78
+ "vitest": {
79
+ "optional": true
80
+ },
65
81
  "zod": {
66
82
  "optional": true
67
83
  },
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import type { ReactNode } from "react";
6
+ import { RoleGate, type RoleGateProps } from "../session/react/RoleGate";
7
+
8
+ /** One tab in the admin navigation. */
9
+ export interface AdminTab {
10
+ href: string;
11
+ label: string;
12
+ }
13
+
14
+ type GateProps = RoleGateProps extends infer P ? (P extends unknown ? Omit<P, "children"> : never) : never;
15
+
16
+ export type AdminShellProps = GateProps & {
17
+ title: ReactNode;
18
+ tabs: readonly AdminTab[];
19
+ children: ReactNode;
20
+ };
21
+
22
+ /**
23
+ * The admin area's frame: a title and a tab bar above the page, for users with `role` only. The tab
24
+ * whose `href` is the current path shows as active. The gate props are `RoleGate`'s.
25
+ */
26
+ export function AdminShell({ title, tabs, children, ...gate }: AdminShellProps) {
27
+ const pathname = usePathname();
28
+ const page = (
29
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 py-10">
30
+ <h1 className="text-2xl font-black text-admin-title mb-4">{title}</h1>
31
+ <nav className="flex gap-1 border-b border-admin-border mb-8 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
32
+ {tabs.map((tab) => {
33
+ const active = pathname === tab.href;
34
+ return (
35
+ <Link
36
+ key={tab.href}
37
+ href={tab.href}
38
+ aria-current={active ? "page" : undefined}
39
+ className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors shrink-0 whitespace-nowrap ${
40
+ active ? "border-admin-tab-indicator text-admin-tab-active" : "border-transparent text-admin-tab hover:text-admin-tab-hover"
41
+ }`}
42
+ >
43
+ {tab.label}
44
+ </Link>
45
+ );
46
+ })}
47
+ </nav>
48
+ {children}
49
+ </div>
50
+ );
51
+ // The gate props came in as one of RoleGate's two shapes, so they go back as that shape.
52
+ return <RoleGate {...({ ...gate, children: page } as RoleGateProps)} />;
53
+ }