@glw907/cairn-cms 0.2.0 → 0.3.1

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.
@@ -1,18 +1,130 @@
1
1
  <script lang="ts">
2
- // Neutral admin chrome robots noindex + a centered container, scoped to /admin. Shared
3
- // across sites so the admin tool looks identical everywhere (only siteName, supplied by
4
- // pages, varies). Each site's `admin/+layout.svelte` is a one-line shim around this.
2
+ // Neutral admin chrome, shared across sites so the tool looks identical everywhere (only the
3
+ // adapter's siteName varies). When signed in it's a responsive DaisyUI drawer+navbar shell
4
+ // (`drawer lg:drawer-open` sidebar pinned on desktop, slide-over + hamburger on mobile),
5
+ // patterned on scosman/CMSaasStarter's `(admin)/(menu)` layout. The nav is data-driven and
6
+ // role-gated, so a new surface is one entry in `nav` (plus its route + component). Signed out
7
+ // (the login page lives under this layout) it falls back to a minimal centered shell.
8
+ // Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
5
9
  import type { Snippet } from 'svelte';
10
+ import type { Editor } from '../auth';
6
11
 
7
- let { children }: { children: Snippet } = $props();
12
+ let {
13
+ data,
14
+ children,
15
+ }: {
16
+ data: { siteName: string; editor: Editor | null; pathname: string };
17
+ children: Snippet;
18
+ } = $props();
19
+
20
+ interface NavItem {
21
+ href: string;
22
+ label: string;
23
+ icon: Snippet;
24
+ active: boolean;
25
+ /** Owner-only surface — hidden from regular editors. */
26
+ owner?: boolean;
27
+ }
28
+
29
+ const nav = $derived<NavItem[]>([
30
+ {
31
+ href: '/admin',
32
+ label: 'Content',
33
+ icon: contentIcon,
34
+ active: data.pathname === '/admin' || data.pathname.startsWith('/admin/edit'),
35
+ },
36
+ {
37
+ href: '/admin/admins',
38
+ label: 'Editors',
39
+ icon: editorsIcon,
40
+ owner: true,
41
+ active: data.pathname.startsWith('/admin/admins'),
42
+ },
43
+ ]);
44
+ const visibleNav = $derived(nav.filter((item) => !item.owner || data.editor?.role === 'owner'));
45
+
46
+ // Close the slide-over after a nav tap on mobile (no-op on desktop where it's pinned open).
47
+ function closeDrawer(): void {
48
+ const toggle = document.getElementById('admin-drawer');
49
+ if (toggle instanceof HTMLInputElement) toggle.checked = false;
50
+ }
8
51
  </script>
9
52
 
53
+ {#snippet contentIcon()}
54
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
55
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
56
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
57
+ </svg>
58
+ {/snippet}
59
+
60
+ {#snippet editorsIcon()}
61
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
62
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
63
+ d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-1.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3.5-2.1" />
64
+ </svg>
65
+ {/snippet}
66
+
10
67
  <svelte:head>
11
68
  <meta name="robots" content="noindex, nofollow" />
12
69
  </svelte:head>
13
70
 
14
- <div class="min-h-screen bg-base-200" data-pagefind-ignore>
15
- <div class="mx-auto max-w-3xl px-4 py-8">
16
- {@render children()}
71
+ {#if data.editor}
72
+ <div class="drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
73
+ <input id="admin-drawer" type="checkbox" class="drawer-toggle" />
74
+
75
+ <div class="drawer-content">
76
+ <!-- Mobile top bar — the desktop sidebar replaces this at lg. -->
77
+ <div class="navbar bg-base-100 lg:hidden">
78
+ <div class="flex-1">
79
+ <span class="px-2 text-xl font-bold">{data.siteName} CMS</span>
80
+ </div>
81
+ <div class="flex-none">
82
+ <label for="admin-drawer" class="btn btn-square btn-ghost" aria-label="Open menu">
83
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
84
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
85
+ </svg>
86
+ </label>
87
+ </div>
88
+ </div>
89
+
90
+ <main class="container px-4 py-6 lg:px-8">
91
+ {@render children()}
92
+ </main>
93
+ </div>
94
+
95
+ <div class="drawer-side z-10">
96
+ <label for="admin-drawer" class="drawer-overlay" aria-label="Close menu"></label>
97
+ <div class="flex min-h-full w-80 flex-col bg-base-100 lg:border-r lg:border-base-300">
98
+ <ul class="menu menu-lg grow p-4">
99
+ <li class="menu-title flex flex-row items-center text-xl font-bold text-base-content">
100
+ <span class="grow">{data.siteName} CMS</span>
101
+ <label for="admin-drawer" class="ml-3 cursor-pointer lg:hidden" aria-label="Close menu">✕</label>
102
+ </li>
103
+ {#each visibleNav as item (item.href)}
104
+ <li>
105
+ <a href={item.href} class={item.active ? 'active' : ''} onclick={closeDrawer}>
106
+ {@render item.icon()}
107
+ {item.label}
108
+ </a>
109
+ </li>
110
+ {/each}
111
+ </ul>
112
+
113
+ <div class="border-t border-base-300 p-4">
114
+ <p class="text-sm font-medium">{data.editor.name}</p>
115
+ <p class="text-xs opacity-60">{data.editor.email}</p>
116
+ <form method="POST" action="/admin/auth/logout" class="mt-3">
117
+ <button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
118
+ </form>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ {:else}
124
+ <!-- Signed out (login page): no nav, just a centered surface. -->
125
+ <div class="min-h-screen bg-base-200" data-pagefind-ignore>
126
+ <div class="mx-auto max-w-3xl px-4 py-8">
127
+ {@render children()}
128
+ </div>
17
129
  </div>
18
- </div>
130
+ {/if}
@@ -1,5 +1,11 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { Editor } from '../auth';
2
3
  type $$ComponentProps = {
4
+ data: {
5
+ siteName: string;
6
+ editor: Editor | null;
7
+ pathname: string;
8
+ };
3
9
  children: Snippet;
4
10
  };
5
11
  declare const AdminLayout: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"AdminLayout.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminLayout.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAErC,KAAK,gBAAgB,GAAI;IAAE,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC;AAsBhD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"AdminLayout.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminLayout.svelte.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAWrC,KAAK,gBAAgB,GAAI;IACtB,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACpE,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAyHJ,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -1,30 +1,17 @@
1
1
  <script lang="ts">
2
2
  // The /admin content list: every collection's files, linking into the editor. Data comes
3
- // from `adminListLoad` (collections) merged with `adminLayoutLoad` (editor, siteName).
4
- import type { Editor } from '../auth';
3
+ // from `adminListLoad` (collections) merged with `adminLayoutLoad` (siteName). The shell
4
+ // (AdminLayout) owns the chrome site title, signed-in identity, nav, sign out — so this
5
+ // page renders only the content body.
5
6
  import type { AdminCollectionList } from '../sveltekit';
6
7
 
7
8
  interface Props {
8
- data: { siteName: string; editor: Editor | null; collections: AdminCollectionList[] };
9
+ data: { collections: AdminCollectionList[] };
9
10
  }
10
11
  let { data }: Props = $props();
11
12
  </script>
12
13
 
13
- <div class="flex items-center justify-between">
14
- <h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
15
- <div class="flex items-center gap-2">
16
- {#if data.editor?.role === 'owner'}
17
- <a href="/admin/admins" class="btn btn-ghost btn-sm">Editors</a>
18
- {/if}
19
- <form method="POST" action="/admin/auth/logout">
20
- <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
21
- </form>
22
- </div>
23
- </div>
24
-
25
- <p class="mt-2 text-sm opacity-70">
26
- Signed in as {data.editor?.name} ({data.editor?.email})
27
- </p>
14
+ <h1 class="text-2xl font-bold">Content</h1>
28
15
 
29
16
  {#each data.collections as collection (collection.type)}
30
17
  <section class="mt-8">
@@ -1,9 +1,6 @@
1
- import type { Editor } from '../auth';
2
1
  import type { AdminCollectionList } from '../sveltekit';
3
2
  interface Props {
4
3
  data: {
5
- siteName: string;
6
- editor: Editor | null;
7
4
  collections: AdminCollectionList[];
8
5
  };
9
6
  }
@@ -1 +1 @@
1
- {"version":3,"file":"AdminList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminList.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGtD,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,WAAW,EAAE,mBAAmB,EAAE,CAAA;KAAE,CAAC;CACvF;AA+CH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"AdminList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminList.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGtD,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,WAAW,EAAE,mBAAmB,EAAE,CAAA;KAAE,CAAC;CAC9C;AAkCH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -73,14 +73,14 @@
73
73
  name={field.name}
74
74
  required={field.required}
75
75
  value={fmString(field.name)}
76
- class="input input-bordered w-full"
76
+ class="input w-full"
77
77
  />
78
78
  </label>
79
79
  {:else if field.type === 'textarea'}
80
80
  <label class="flex flex-col gap-1">
81
81
  <span class="text-sm font-medium">{field.label}</span>
82
82
  <textarea name={field.name} required={field.required} rows={field.rows ?? 4}
83
- class="textarea textarea-bordered w-full">{fmString(field.name)}</textarea>
83
+ class="textarea w-full">{fmString(field.name)}</textarea>
84
84
  </label>
85
85
  {:else if field.type === 'tags'}
86
86
  <div class="flex flex-col gap-1">
@@ -99,7 +99,7 @@
99
99
  <label class="flex flex-col gap-1">
100
100
  <span class="text-sm font-medium">{field.label}</span>
101
101
  <input type="text" name={field.name} value={fmFreeTags(field.name)}
102
- placeholder={field.placeholder ?? 'comma, separated'} class="input input-bordered w-full" />
102
+ placeholder={field.placeholder ?? 'comma, separated'} class="input w-full" />
103
103
  </label>
104
104
  {:else if field.type === 'boolean'}
105
105
  <label class="flex items-center gap-2 text-sm font-medium">
@@ -115,7 +115,7 @@
115
115
  {#if mounted}
116
116
  <MarkdownEditor {carta} bind:value={body} mode="tabs" />
117
117
  {:else}
118
- <textarea bind:value={body} rows="20" class="textarea textarea-bordered w-full font-mono"></textarea>
118
+ <textarea bind:value={body} rows="20" class="textarea w-full font-mono"></textarea>
119
119
  {/if}
120
120
  </div>
121
121
 
@@ -39,7 +39,7 @@
39
39
  required
40
40
  autocomplete="email"
41
41
  placeholder="you@example.com"
42
- class="input input-bordered w-full"
42
+ class="input w-full"
43
43
  />
44
44
  <button type="submit" class="btn btn-primary">Email me a sign-in link</button>
45
45
  </form>
@@ -15,12 +15,9 @@
15
15
  <title>Editors · {data.siteName} CMS</title>
16
16
  </svelte:head>
17
17
 
18
- <div class="flex items-center justify-between gap-4">
19
- <div>
20
- <a href="/admin" class="text-sm opacity-70 hover:underline">← Back</a>
21
- <h1 class="mt-1 text-2xl font-bold">Editors</h1>
22
- <p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
23
- </div>
18
+ <div>
19
+ <h1 class="text-2xl font-bold">Editors</h1>
20
+ <p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
24
21
  </div>
25
22
 
26
23
  {#if data.saved}
@@ -70,15 +67,15 @@
70
67
  <label class="flex flex-col gap-1">
71
68
  <span class="text-sm font-medium">Email</span>
72
69
  <input type="email" name="email" required autocomplete="off" placeholder="you@example.com"
73
- class="input input-bordered w-full" />
70
+ class="input w-full" />
74
71
  </label>
75
72
  <label class="flex flex-col gap-1">
76
73
  <span class="text-sm font-medium">Name</span>
77
- <input type="text" name="name" required placeholder="Display name" class="input input-bordered w-full" />
74
+ <input type="text" name="name" required placeholder="Display name" class="input w-full" />
78
75
  </label>
79
76
  <label class="flex flex-col gap-1">
80
77
  <span class="text-sm font-medium">Role</span>
81
- <select name="role" class="select select-bordered">
78
+ <select name="role" class="select">
82
79
  <option value="editor">editor</option>
83
80
  <option value="owner">owner</option>
84
81
  </select>
@@ -1 +1 @@
1
- {"version":3,"file":"ManageAdmins.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ManageAdmins.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG7C,UAAU,KAAK;IACb,IAAI,EAAE,UAAU,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACzC;AAsFH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"ManageAdmins.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ManageAdmins.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG7C,UAAU,KAAK;IACb,IAAI,EAAE,UAAU,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACzC;AAmFH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -24,15 +24,20 @@ interface PlatformEvent {
24
24
  export interface AdminLayoutData {
25
25
  editor: Editor | null;
26
26
  siteName: string;
27
+ pathname: string;
27
28
  }
28
29
  /**
29
30
  * Branding + session for every admin page. `siteName` flows from the adapter without pulling
30
31
  * its plugin graph into client bundles — the import stays server-side in the layout load.
32
+ * `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
33
+ * (those kit virtual modules have no types outside a kit app, so they can't live in the
34
+ * package); reading `event.url` here also opts the layout load into rerunning on navigation.
31
35
  */
32
36
  export declare function adminLayoutLoad(event: {
33
37
  locals: {
34
38
  editor: Editor | null;
35
39
  };
40
+ url: URL;
36
41
  }, adapter: CairnAdapter): AdminLayoutData;
37
42
  export interface AdminCollectionList {
38
43
  type: string;
@@ -41,7 +46,7 @@ export interface AdminCollectionList {
41
46
  error?: string;
42
47
  }
43
48
  /** List every collection's markdown files. A failed listing degrades to an inline error. */
44
- export declare function adminListLoad(adapter: CairnAdapter): Promise<{
49
+ export declare function adminListLoad(event: PlatformEvent, adapter: CairnAdapter): Promise<{
45
50
  collections: AdminCollectionList[];
46
51
  }>;
47
52
  export interface LoginData {
@@ -63,7 +68,7 @@ export interface EditData {
63
68
  saved: boolean;
64
69
  error: string | null;
65
70
  }
66
- export declare function editLoad(event: {
71
+ export declare function editLoad(event: PlatformEvent & {
67
72
  params: {
68
73
  type: string;
69
74
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/index.ts"],"names":[],"mappings":"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"}
@@ -15,18 +15,45 @@ import { listMarkdown, readRaw, commitFile, installationToken } from '../github'
15
15
  import { serializeMarkdown } from '../content';
16
16
  import { findCollection, frontmatterFromForm } from '../adapter';
17
17
  const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
18
+ /**
19
+ * Mint a GitHub App installation token for *reads* when the App is configured, else undefined
20
+ * (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
21
+ * reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
22
+ * A mint failure degrades gracefully to anonymous rather than 500ing — unlike the commit path,
23
+ * where a missing App is fatal, a read can still succeed unauthenticated.
24
+ */
25
+ async function readToken(env) {
26
+ if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
27
+ return undefined;
28
+ }
29
+ try {
30
+ return await installationToken({
31
+ appId: env.GITHUB_APP_ID,
32
+ installationId: env.GITHUB_APP_INSTALLATION_ID,
33
+ privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
34
+ });
35
+ }
36
+ catch (err) {
37
+ console.error('read token mint failed; falling back to anonymous read:', err);
38
+ return undefined;
39
+ }
40
+ }
18
41
  /**
19
42
  * Branding + session for every admin page. `siteName` flows from the adapter without pulling
20
43
  * its plugin graph into client bundles — the import stays server-side in the layout load.
44
+ * `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
45
+ * (those kit virtual modules have no types outside a kit app, so they can't live in the
46
+ * package); reading `event.url` here also opts the layout load into rerunning on navigation.
21
47
  */
22
48
  export function adminLayoutLoad(event, adapter) {
23
- return { editor: event.locals.editor, siteName: adapter.siteName };
49
+ return { editor: event.locals.editor, siteName: adapter.siteName, pathname: event.url.pathname };
24
50
  }
25
51
  /** List every collection's markdown files. A failed listing degrades to an inline error. */
26
- export async function adminListLoad(adapter) {
52
+ export async function adminListLoad(event, adapter) {
53
+ const token = await readToken(event.platform?.env);
27
54
  const collections = await Promise.all(adapter.collections.map(async ({ type, label, dir }) => {
28
55
  try {
29
- return { type, label, files: await listMarkdown(adapter.backend, dir) };
56
+ return { type, label, files: await listMarkdown(adapter.backend, dir, token) };
30
57
  }
31
58
  catch (err) {
32
59
  // A failed listing (rate limit, network) shouldn't 500 the whole admin.
@@ -45,9 +72,9 @@ export async function editLoad(event, adapter) {
45
72
  const collection = findCollection(adapter, event.params.type);
46
73
  if (!collection)
47
74
  throw error(404, 'Unknown collection');
48
- // Anonymous read — repos are public; the GitHub App token is commit-only (see saveCommit).
75
+ const token = await readToken(event.platform?.env);
49
76
  const path = `${collection.dir}/${event.params.id}.md`;
50
- const raw = await readRaw(adapter.backend, path);
77
+ const raw = await readRaw(adapter.backend, path, token);
51
78
  if (raw === null)
52
79
  throw error(404, 'Content not found');
53
80
  // Split frontmatter from body server-side; the editor form binds to the frontmatter and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,18 +1,130 @@
1
1
  <script lang="ts">
2
- // Neutral admin chrome robots noindex + a centered container, scoped to /admin. Shared
3
- // across sites so the admin tool looks identical everywhere (only siteName, supplied by
4
- // pages, varies). Each site's `admin/+layout.svelte` is a one-line shim around this.
2
+ // Neutral admin chrome, shared across sites so the tool looks identical everywhere (only the
3
+ // adapter's siteName varies). When signed in it's a responsive DaisyUI drawer+navbar shell
4
+ // (`drawer lg:drawer-open` sidebar pinned on desktop, slide-over + hamburger on mobile),
5
+ // patterned on scosman/CMSaasStarter's `(admin)/(menu)` layout. The nav is data-driven and
6
+ // role-gated, so a new surface is one entry in `nav` (plus its route + component). Signed out
7
+ // (the login page lives under this layout) it falls back to a minimal centered shell.
8
+ // Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
5
9
  import type { Snippet } from 'svelte';
10
+ import type { Editor } from '../auth';
6
11
 
7
- let { children }: { children: Snippet } = $props();
12
+ let {
13
+ data,
14
+ children,
15
+ }: {
16
+ data: { siteName: string; editor: Editor | null; pathname: string };
17
+ children: Snippet;
18
+ } = $props();
19
+
20
+ interface NavItem {
21
+ href: string;
22
+ label: string;
23
+ icon: Snippet;
24
+ active: boolean;
25
+ /** Owner-only surface — hidden from regular editors. */
26
+ owner?: boolean;
27
+ }
28
+
29
+ const nav = $derived<NavItem[]>([
30
+ {
31
+ href: '/admin',
32
+ label: 'Content',
33
+ icon: contentIcon,
34
+ active: data.pathname === '/admin' || data.pathname.startsWith('/admin/edit'),
35
+ },
36
+ {
37
+ href: '/admin/admins',
38
+ label: 'Editors',
39
+ icon: editorsIcon,
40
+ owner: true,
41
+ active: data.pathname.startsWith('/admin/admins'),
42
+ },
43
+ ]);
44
+ const visibleNav = $derived(nav.filter((item) => !item.owner || data.editor?.role === 'owner'));
45
+
46
+ // Close the slide-over after a nav tap on mobile (no-op on desktop where it's pinned open).
47
+ function closeDrawer(): void {
48
+ const toggle = document.getElementById('admin-drawer');
49
+ if (toggle instanceof HTMLInputElement) toggle.checked = false;
50
+ }
8
51
  </script>
9
52
 
53
+ {#snippet contentIcon()}
54
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
55
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
56
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
57
+ </svg>
58
+ {/snippet}
59
+
60
+ {#snippet editorsIcon()}
61
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
62
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
63
+ d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-1.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3.5-2.1" />
64
+ </svg>
65
+ {/snippet}
66
+
10
67
  <svelte:head>
11
68
  <meta name="robots" content="noindex, nofollow" />
12
69
  </svelte:head>
13
70
 
14
- <div class="min-h-screen bg-base-200" data-pagefind-ignore>
15
- <div class="mx-auto max-w-3xl px-4 py-8">
16
- {@render children()}
71
+ {#if data.editor}
72
+ <div class="drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
73
+ <input id="admin-drawer" type="checkbox" class="drawer-toggle" />
74
+
75
+ <div class="drawer-content">
76
+ <!-- Mobile top bar — the desktop sidebar replaces this at lg. -->
77
+ <div class="navbar bg-base-100 lg:hidden">
78
+ <div class="flex-1">
79
+ <span class="px-2 text-xl font-bold">{data.siteName} CMS</span>
80
+ </div>
81
+ <div class="flex-none">
82
+ <label for="admin-drawer" class="btn btn-square btn-ghost" aria-label="Open menu">
83
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
84
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
85
+ </svg>
86
+ </label>
87
+ </div>
88
+ </div>
89
+
90
+ <main class="container px-4 py-6 lg:px-8">
91
+ {@render children()}
92
+ </main>
93
+ </div>
94
+
95
+ <div class="drawer-side z-10">
96
+ <label for="admin-drawer" class="drawer-overlay" aria-label="Close menu"></label>
97
+ <div class="flex min-h-full w-80 flex-col bg-base-100 lg:border-r lg:border-base-300">
98
+ <ul class="menu menu-lg grow p-4">
99
+ <li class="menu-title flex flex-row items-center text-xl font-bold text-base-content">
100
+ <span class="grow">{data.siteName} CMS</span>
101
+ <label for="admin-drawer" class="ml-3 cursor-pointer lg:hidden" aria-label="Close menu">✕</label>
102
+ </li>
103
+ {#each visibleNav as item (item.href)}
104
+ <li>
105
+ <a href={item.href} class={item.active ? 'active' : ''} onclick={closeDrawer}>
106
+ {@render item.icon()}
107
+ {item.label}
108
+ </a>
109
+ </li>
110
+ {/each}
111
+ </ul>
112
+
113
+ <div class="border-t border-base-300 p-4">
114
+ <p class="text-sm font-medium">{data.editor.name}</p>
115
+ <p class="text-xs opacity-60">{data.editor.email}</p>
116
+ <form method="POST" action="/admin/auth/logout" class="mt-3">
117
+ <button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">Sign out</button>
118
+ </form>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ {:else}
124
+ <!-- Signed out (login page): no nav, just a centered surface. -->
125
+ <div class="min-h-screen bg-base-200" data-pagefind-ignore>
126
+ <div class="mx-auto max-w-3xl px-4 py-8">
127
+ {@render children()}
128
+ </div>
17
129
  </div>
18
- </div>
130
+ {/if}
@@ -1,30 +1,17 @@
1
1
  <script lang="ts">
2
2
  // The /admin content list: every collection's files, linking into the editor. Data comes
3
- // from `adminListLoad` (collections) merged with `adminLayoutLoad` (editor, siteName).
4
- import type { Editor } from '../auth';
3
+ // from `adminListLoad` (collections) merged with `adminLayoutLoad` (siteName). The shell
4
+ // (AdminLayout) owns the chrome site title, signed-in identity, nav, sign out — so this
5
+ // page renders only the content body.
5
6
  import type { AdminCollectionList } from '../sveltekit';
6
7
 
7
8
  interface Props {
8
- data: { siteName: string; editor: Editor | null; collections: AdminCollectionList[] };
9
+ data: { collections: AdminCollectionList[] };
9
10
  }
10
11
  let { data }: Props = $props();
11
12
  </script>
12
13
 
13
- <div class="flex items-center justify-between">
14
- <h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
15
- <div class="flex items-center gap-2">
16
- {#if data.editor?.role === 'owner'}
17
- <a href="/admin/admins" class="btn btn-ghost btn-sm">Editors</a>
18
- {/if}
19
- <form method="POST" action="/admin/auth/logout">
20
- <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
21
- </form>
22
- </div>
23
- </div>
24
-
25
- <p class="mt-2 text-sm opacity-70">
26
- Signed in as {data.editor?.name} ({data.editor?.email})
27
- </p>
14
+ <h1 class="text-2xl font-bold">Content</h1>
28
15
 
29
16
  {#each data.collections as collection (collection.type)}
30
17
  <section class="mt-8">
@@ -73,14 +73,14 @@
73
73
  name={field.name}
74
74
  required={field.required}
75
75
  value={fmString(field.name)}
76
- class="input input-bordered w-full"
76
+ class="input w-full"
77
77
  />
78
78
  </label>
79
79
  {:else if field.type === 'textarea'}
80
80
  <label class="flex flex-col gap-1">
81
81
  <span class="text-sm font-medium">{field.label}</span>
82
82
  <textarea name={field.name} required={field.required} rows={field.rows ?? 4}
83
- class="textarea textarea-bordered w-full">{fmString(field.name)}</textarea>
83
+ class="textarea w-full">{fmString(field.name)}</textarea>
84
84
  </label>
85
85
  {:else if field.type === 'tags'}
86
86
  <div class="flex flex-col gap-1">
@@ -99,7 +99,7 @@
99
99
  <label class="flex flex-col gap-1">
100
100
  <span class="text-sm font-medium">{field.label}</span>
101
101
  <input type="text" name={field.name} value={fmFreeTags(field.name)}
102
- placeholder={field.placeholder ?? 'comma, separated'} class="input input-bordered w-full" />
102
+ placeholder={field.placeholder ?? 'comma, separated'} class="input w-full" />
103
103
  </label>
104
104
  {:else if field.type === 'boolean'}
105
105
  <label class="flex items-center gap-2 text-sm font-medium">
@@ -115,7 +115,7 @@
115
115
  {#if mounted}
116
116
  <MarkdownEditor {carta} bind:value={body} mode="tabs" />
117
117
  {:else}
118
- <textarea bind:value={body} rows="20" class="textarea textarea-bordered w-full font-mono"></textarea>
118
+ <textarea bind:value={body} rows="20" class="textarea w-full font-mono"></textarea>
119
119
  {/if}
120
120
  </div>
121
121
 
@@ -39,7 +39,7 @@
39
39
  required
40
40
  autocomplete="email"
41
41
  placeholder="you@example.com"
42
- class="input input-bordered w-full"
42
+ class="input w-full"
43
43
  />
44
44
  <button type="submit" class="btn btn-primary">Email me a sign-in link</button>
45
45
  </form>
@@ -15,12 +15,9 @@
15
15
  <title>Editors · {data.siteName} CMS</title>
16
16
  </svelte:head>
17
17
 
18
- <div class="flex items-center justify-between gap-4">
19
- <div>
20
- <a href="/admin" class="text-sm opacity-70 hover:underline">← Back</a>
21
- <h1 class="mt-1 text-2xl font-bold">Editors</h1>
22
- <p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
23
- </div>
18
+ <div>
19
+ <h1 class="text-2xl font-bold">Editors</h1>
20
+ <p class="text-sm opacity-60">Who can sign in to {data.siteName} CMS.</p>
24
21
  </div>
25
22
 
26
23
  {#if data.saved}
@@ -70,15 +67,15 @@
70
67
  <label class="flex flex-col gap-1">
71
68
  <span class="text-sm font-medium">Email</span>
72
69
  <input type="email" name="email" required autocomplete="off" placeholder="you@example.com"
73
- class="input input-bordered w-full" />
70
+ class="input w-full" />
74
71
  </label>
75
72
  <label class="flex flex-col gap-1">
76
73
  <span class="text-sm font-medium">Name</span>
77
- <input type="text" name="name" required placeholder="Display name" class="input input-bordered w-full" />
74
+ <input type="text" name="name" required placeholder="Display name" class="input w-full" />
78
75
  </label>
79
76
  <label class="flex flex-col gap-1">
80
77
  <span class="text-sm font-medium">Role</span>
81
- <select name="role" class="select select-bordered">
78
+ <select name="role" class="select">
82
79
  <option value="editor">editor</option>
83
80
  <option value="owner">owner</option>
84
81
  </select>
@@ -47,22 +47,49 @@ interface PlatformEvent {
47
47
 
48
48
  const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
49
49
 
50
+ /**
51
+ * Mint a GitHub App installation token for *reads* when the App is configured, else undefined
52
+ * (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
53
+ * reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
54
+ * A mint failure degrades gracefully to anonymous rather than 500ing — unlike the commit path,
55
+ * where a missing App is fatal, a read can still succeed unauthenticated.
56
+ */
57
+ async function readToken(env: AdminEnv | undefined): Promise<string | undefined> {
58
+ if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
59
+ return undefined;
60
+ }
61
+ try {
62
+ return await installationToken({
63
+ appId: env.GITHUB_APP_ID,
64
+ installationId: env.GITHUB_APP_INSTALLATION_ID,
65
+ privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
66
+ });
67
+ } catch (err) {
68
+ console.error('read token mint failed; falling back to anonymous read:', err);
69
+ return undefined;
70
+ }
71
+ }
72
+
50
73
  // ── /admin layout ──────────────────────────────────────────────────────────
51
74
 
52
75
  export interface AdminLayoutData {
53
76
  editor: Editor | null;
54
77
  siteName: string;
78
+ pathname: string;
55
79
  }
56
80
 
57
81
  /**
58
82
  * Branding + session for every admin page. `siteName` flows from the adapter without pulling
59
83
  * its plugin graph into client bundles — the import stays server-side in the layout load.
84
+ * `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
85
+ * (those kit virtual modules have no types outside a kit app, so they can't live in the
86
+ * package); reading `event.url` here also opts the layout load into rerunning on navigation.
60
87
  */
61
88
  export function adminLayoutLoad(
62
- event: { locals: { editor: Editor | null } },
89
+ event: { locals: { editor: Editor | null }; url: URL },
63
90
  adapter: CairnAdapter,
64
91
  ): AdminLayoutData {
65
- return { editor: event.locals.editor, siteName: adapter.siteName };
92
+ return { editor: event.locals.editor, siteName: adapter.siteName, pathname: event.url.pathname };
66
93
  }
67
94
 
68
95
  // ── /admin (content list) ────────────────────────────────────────────────────
@@ -75,11 +102,15 @@ export interface AdminCollectionList {
75
102
  }
76
103
 
77
104
  /** List every collection's markdown files. A failed listing degrades to an inline error. */
78
- export async function adminListLoad(adapter: CairnAdapter): Promise<{ collections: AdminCollectionList[] }> {
105
+ export async function adminListLoad(
106
+ event: PlatformEvent,
107
+ adapter: CairnAdapter,
108
+ ): Promise<{ collections: AdminCollectionList[] }> {
109
+ const token = await readToken(event.platform?.env);
79
110
  const collections = await Promise.all(
80
111
  adapter.collections.map(async ({ type, label, dir }): Promise<AdminCollectionList> => {
81
112
  try {
82
- return { type, label, files: await listMarkdown(adapter.backend, dir) };
113
+ return { type, label, files: await listMarkdown(adapter.backend, dir, token) };
83
114
  } catch (err) {
84
115
  // A failed listing (rate limit, network) shouldn't 500 the whole admin.
85
116
  return { type, label, files: [], error: err instanceof Error ? err.message : 'Failed to load' };
@@ -119,15 +150,15 @@ export interface EditData {
119
150
  }
120
151
 
121
152
  export async function editLoad(
122
- event: { params: { type: string; id: string }; url: URL },
153
+ event: PlatformEvent & { params: { type: string; id: string }; url: URL },
123
154
  adapter: CairnAdapter,
124
155
  ): Promise<EditData> {
125
156
  const collection = findCollection(adapter, event.params.type);
126
157
  if (!collection) throw error(404, 'Unknown collection');
127
158
 
128
- // Anonymous read — repos are public; the GitHub App token is commit-only (see saveCommit).
159
+ const token = await readToken(event.platform?.env);
129
160
  const path = `${collection.dir}/${event.params.id}.md`;
130
- const raw = await readRaw(adapter.backend, path);
161
+ const raw = await readRaw(adapter.backend, path, token);
131
162
  if (raw === null) throw error(404, 'Content not found');
132
163
 
133
164
  // Split frontmatter from body server-side; the editor form binds to the frontmatter and