@glw907/cairn-cms 0.29.0 → 0.34.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/CHANGELOG.md +111 -0
- package/dist/components/AdminLayout.svelte +372 -44
- package/dist/components/AdminLayout.svelte.d.ts +5 -4
- package/dist/components/CairnLogo.svelte +28 -0
- package/dist/components/CairnLogo.svelte.d.ts +15 -0
- package/dist/components/ComponentForm.svelte +1 -1
- package/dist/components/ConceptList.svelte +240 -45
- package/dist/components/ConceptList.svelte.d.ts +12 -2
- package/dist/components/ConfirmPage.svelte +20 -3
- package/dist/components/EditPage.svelte +12 -7
- package/dist/components/LoginPage.svelte +27 -5
- package/dist/components/ManageEditors.svelte +8 -5
- package/dist/components/NavTree.svelte +2 -2
- package/dist/components/admin-icons.d.ts +13 -0
- package/dist/components/admin-icons.js +15 -0
- package/dist/components/cairn-admin.css +5516 -37
- package/dist/components/cairn-favicon.d.ts +2 -0
- package/dist/components/cairn-favicon.js +7 -0
- package/dist/components/chrome-guard.d.ts +9 -0
- package/dist/components/chrome-guard.js +55 -0
- package/dist/components/fonts/BricolageGrotesque-OFL.txt +93 -0
- package/dist/components/fonts/Figtree-OFL.txt +93 -0
- package/dist/components/fonts/bricolage-grotesque.woff2 +0 -0
- package/dist/components/fonts/figtree.woff2 +0 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +4 -1
- package/dist/render/authoring.d.ts +3 -0
- package/dist/render/authoring.js +5 -0
- package/dist/render/registry.d.ts +2 -0
- package/dist/render/registry.js +15 -0
- package/dist/render/rehype-dispatch.d.ts +9 -6
- package/dist/render/rehype-dispatch.js +12 -6
- package/dist/render/remark-directives.js +1 -1
- package/dist/sveltekit/content-routes.d.ts +12 -1
- package/dist/sveltekit/content-routes.js +37 -13
- package/dist/sveltekit/guard.js +32 -0
- package/dist/sveltekit/https-required-page.d.ts +5 -0
- package/dist/sveltekit/https-required-page.js +216 -0
- package/package.json +16 -2
- package/src/lib/components/AdminLayout.svelte +372 -44
- package/src/lib/components/CairnLogo.svelte +28 -0
- package/src/lib/components/ComponentForm.svelte +1 -1
- package/src/lib/components/ConceptList.svelte +240 -45
- package/src/lib/components/ConfirmPage.svelte +20 -3
- package/src/lib/components/EditPage.svelte +12 -7
- package/src/lib/components/LoginPage.svelte +27 -5
- package/src/lib/components/ManageEditors.svelte +8 -5
- package/src/lib/components/NavTree.svelte +2 -2
- package/src/lib/components/admin-icons.ts +15 -0
- package/src/lib/components/cairn-admin.css +162 -7
- package/src/lib/components/cairn-favicon.ts +9 -0
- package/src/lib/components/chrome-guard.ts +62 -0
- package/src/lib/components/fonts/BricolageGrotesque-OFL.txt +93 -0
- package/src/lib/components/fonts/Figtree-OFL.txt +93 -0
- package/src/lib/components/fonts/bricolage-grotesque.woff2 +0 -0
- package/src/lib/components/fonts/figtree.woff2 +0 -0
- package/src/lib/index.ts +4 -2
- package/src/lib/render/authoring.ts +7 -0
- package/src/lib/render/registry.ts +20 -0
- package/src/lib/render/rehype-dispatch.ts +13 -6
- package/src/lib/render/remark-directives.ts +1 -1
- package/src/lib/sveltekit/content-routes.ts +51 -14
- package/src/lib/sveltekit/guard.ts +36 -0
- package/src/lib/sveltekit/https-required-page.ts +220 -0
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
@component
|
|
3
3
|
The admin shell: a DaisyUI drawer-and-navbar that wraps every authed admin page. The nav is
|
|
4
4
|
data-driven from the enabled concepts and role-gated (owners see the manage-editors entry). The
|
|
5
|
-
root sets `data-theme
|
|
6
|
-
|
|
5
|
+
root sets `data-theme` to the resolved light or dark theme (seeded from the SSR'd cookie choice,
|
|
6
|
+
flipped by the topbar toggle) and imports the self-contained Warm Stone theme, so the admin looks
|
|
7
|
+
identical on every host regardless of the site's own theme.
|
|
7
8
|
-->
|
|
8
9
|
<script lang="ts">
|
|
9
|
-
import type
|
|
10
|
+
import { onMount, untrack, type Component, type Snippet } from 'svelte';
|
|
10
11
|
import type { LayoutData } from '../sveltekit/content-routes.js';
|
|
12
|
+
import { MenuIcon, LogOutIcon, SunIcon, MoonIcon, ChevronRightIcon, SearchIcon } from './admin-icons.js';
|
|
13
|
+
import CairnLogo from './CairnLogo.svelte';
|
|
14
|
+
import { cairnFaviconHref } from './cairn-favicon.js';
|
|
15
|
+
import { warnIfChromeWrapped } from './chrome-guard.js';
|
|
16
|
+
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
|
17
|
+
import SignpostIcon from '@lucide/svelte/icons/signpost';
|
|
18
|
+
import SettingsIcon from '@lucide/svelte/icons/settings';
|
|
19
|
+
import UsersIcon from '@lucide/svelte/icons/users';
|
|
20
|
+
import BlocksIcon from '@lucide/svelte/icons/blocks';
|
|
21
|
+
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
|
11
22
|
import './cairn-admin.css';
|
|
12
23
|
|
|
13
24
|
interface Props {
|
|
@@ -19,62 +30,379 @@ admin looks identical on every host regardless of the site's own theme.
|
|
|
19
30
|
|
|
20
31
|
let { data, children }: Props = $props();
|
|
21
32
|
|
|
33
|
+
// Persist an admin preference for a year, path-scoped to /admin so the cookie never reaches the
|
|
34
|
+
// host's own pages.
|
|
35
|
+
function writeAdminCookie(name: string, value: string) {
|
|
36
|
+
document.cookie = `${name}=${value}; path=/admin; max-age=31536000; samesite=lax`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A nav entry. `href` makes it a link; without one it is an inert stub (a developer-tool slot the
|
|
40
|
+
// extension mechanism has not wired yet).
|
|
22
41
|
interface NavItem {
|
|
23
|
-
href: string;
|
|
24
42
|
label: string;
|
|
25
|
-
|
|
43
|
+
icon: Component;
|
|
44
|
+
href?: string;
|
|
26
45
|
}
|
|
27
46
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
// The core Cairn functions, all in one group: the content concepts, the nav-menu editor (when the
|
|
48
|
+
// site configures one; a signpost, kept distinct from the Settings gear), the site Settings, and
|
|
49
|
+
// the owner-only Editors.
|
|
50
|
+
const coreItems: NavItem[] = $derived([
|
|
51
|
+
...data.concepts.map((c) => ({ label: c.label, icon: FileTextIcon, href: `/admin/${c.id}` })),
|
|
52
|
+
...(data.navLabel ? [{ label: data.navLabel, icon: SignpostIcon, href: '/admin/nav' }] : []),
|
|
53
|
+
{ label: 'Settings', icon: SettingsIcon, href: '/admin/settings' },
|
|
54
|
+
...(data.canManageEditors ? [{ label: 'Editors', icon: UsersIcon, href: '/admin/editors' }] : []),
|
|
32
55
|
]);
|
|
33
56
|
|
|
34
|
-
|
|
57
|
+
// The developer-extension groups: each custom-named, with its own items, collapsible like the core
|
|
58
|
+
// group. The CairnExtension seam will supply these; until it lands they are inert example stubs that
|
|
59
|
+
// show the shape, multiple named groups kept visually apart from the core functions.
|
|
60
|
+
const extensionGroups: { name: string; items: NavItem[] }[] = [
|
|
61
|
+
{ name: 'Marketing', items: [
|
|
62
|
+
{ label: 'Campaigns', icon: BlocksIcon },
|
|
63
|
+
{ label: 'Audiences', icon: BlocksIcon },
|
|
64
|
+
] },
|
|
65
|
+
{ name: 'Shop', items: [
|
|
66
|
+
{ label: 'Products', icon: BlocksIcon },
|
|
67
|
+
{ label: 'Orders', icon: BlocksIcon },
|
|
68
|
+
] },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// Up to two uppercase initials from the display name, falling back to '?' for an empty name.
|
|
72
|
+
function initialsOf(displayName: string): string {
|
|
73
|
+
const letters = displayName
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.slice(0, 2)
|
|
77
|
+
.map((word) => word[0]?.toUpperCase() ?? '')
|
|
78
|
+
.join('');
|
|
79
|
+
return letters || '?';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const initials = $derived(initialsOf(data.user.displayName));
|
|
35
83
|
|
|
36
84
|
function isActive(href: string): boolean {
|
|
37
85
|
return data.pathname === href || data.pathname.startsWith(`${href}/`);
|
|
38
86
|
}
|
|
87
|
+
|
|
88
|
+
// Which nav groups are collapsed. Seeded once from the SSR'd cookie (so a collapsed group renders
|
|
89
|
+
// collapsed with no flash), then owned by the toggle below, which mirrors each change to the cookie.
|
|
90
|
+
let collapsed = $state(new Set(untrack(() => data.collapsedNav)));
|
|
91
|
+
|
|
92
|
+
function onToggleSection(label: string, open: boolean) {
|
|
93
|
+
const next = new Set(collapsed);
|
|
94
|
+
if (open) next.delete(label);
|
|
95
|
+
else next.add(label);
|
|
96
|
+
collapsed = next;
|
|
97
|
+
const value = [...next].map((entry) => encodeURIComponent(entry)).join(',');
|
|
98
|
+
writeAdminCookie('cairn-admin-nav-collapsed', value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let drawerOpen = $state(false);
|
|
102
|
+
|
|
103
|
+
function onKeydown(e: KeyboardEvent) {
|
|
104
|
+
if (e.key.toLowerCase() === 'b' && (e.metaKey || e.ctrlKey)) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
drawerOpen = !drawerOpen;
|
|
107
|
+
}
|
|
108
|
+
if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
openPalette();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Close the mobile drawer and the command palette whenever the active path changes (a nav click
|
|
115
|
+
// navigated). Closing the palette here, after the navigation lands, avoids racing a synchronous
|
|
116
|
+
// close() against a result link's own navigation, which would cancel it.
|
|
117
|
+
$effect(() => {
|
|
118
|
+
data.pathname;
|
|
119
|
+
drawerOpen = false;
|
|
120
|
+
paletteDialog?.close();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Seed from the SSR'd theme once. The live theme is owned by this state and the toggle, so the
|
|
124
|
+
// initial read of data.theme is intentional and untracked to keep it out of any reactive graph.
|
|
125
|
+
let theme = $state<'cairn-admin' | 'cairn-admin-dark'>(untrack(() => data.theme));
|
|
126
|
+
|
|
127
|
+
// First mount with no persisted choice follows the OS preference. A returning user's cookie was
|
|
128
|
+
// already honored by the layout load (data.theme), so this only fires on a first-ever visit.
|
|
129
|
+
$effect(() => {
|
|
130
|
+
const hasCookie = document.cookie.split('; ').some((c) => c.startsWith('cairn-admin-theme='));
|
|
131
|
+
if (!hasCookie && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
|
|
132
|
+
theme = 'cairn-admin-dark';
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
function toggleTheme() {
|
|
137
|
+
theme = theme === 'cairn-admin' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
138
|
+
writeAdminCookie('cairn-admin-theme', theme);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The command palette: a quick jump-to over the admin's destinations plus a couple of actions, so
|
|
142
|
+
// the topbar carries something productive. Opened by the topbar trigger or Cmd/Ctrl+K.
|
|
143
|
+
interface Command {
|
|
144
|
+
label: string;
|
|
145
|
+
icon: Component;
|
|
146
|
+
href?: string;
|
|
147
|
+
external?: boolean;
|
|
148
|
+
action?: () => void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let paletteDialog = $state<HTMLDialogElement>();
|
|
152
|
+
let paletteList = $state<HTMLUListElement>();
|
|
153
|
+
let paletteQuery = $state('');
|
|
154
|
+
|
|
155
|
+
// The bare data-theme wrapper is the admin root the dev chrome-guard measures from.
|
|
156
|
+
let rootEl = $state<HTMLElement>();
|
|
157
|
+
onMount(() => {
|
|
158
|
+
if (rootEl) warnIfChromeWrapped(rootEl);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const paletteCommands = $derived<Command[]>([
|
|
162
|
+
...coreItems.map((item) => ({ label: item.label, icon: item.icon, href: item.href })),
|
|
163
|
+
{ label: 'View the live site', icon: ExternalLinkIcon, href: '/', external: true },
|
|
164
|
+
theme === 'cairn-admin'
|
|
165
|
+
? { label: 'Switch to dark mode', icon: MoonIcon, action: toggleTheme }
|
|
166
|
+
: { label: 'Switch to light mode', icon: SunIcon, action: toggleTheme },
|
|
167
|
+
]);
|
|
168
|
+
const paletteResults = $derived(
|
|
169
|
+
paletteCommands.filter((c) => c.label.toLowerCase().includes(paletteQuery.trim().toLowerCase())),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
function openPalette() {
|
|
173
|
+
if (paletteDialog?.open) return; // showModal throws on an already-open dialog
|
|
174
|
+
paletteQuery = '';
|
|
175
|
+
paletteDialog?.showModal();
|
|
176
|
+
}
|
|
177
|
+
// An action command (theme toggle). Link commands are real <a> elements that navigate on click, so
|
|
178
|
+
// the Enter shortcut clicks the first result element and both paths share the one navigation.
|
|
179
|
+
function runCommand(cmd: Command) {
|
|
180
|
+
paletteDialog?.close();
|
|
181
|
+
cmd.action?.();
|
|
182
|
+
}
|
|
183
|
+
function submitPalette() {
|
|
184
|
+
(paletteList?.querySelector('a, button') as HTMLElement | null)?.click();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface Crumb {
|
|
188
|
+
label: string;
|
|
189
|
+
href?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Path-derived breadcrumbs: the concept label (from the nav) then the entry id segment. Only the
|
|
193
|
+
// /admin/<concept>/<id> depth shows a trail; a bare concept list shows just the concept.
|
|
194
|
+
const crumbs = $derived.by<Crumb[]>(() => {
|
|
195
|
+
const segs = data.pathname.split('/').filter(Boolean); // ['admin', concept, id?]
|
|
196
|
+
if (segs.length < 2 || segs[0] !== 'admin') return [];
|
|
197
|
+
const conceptId = segs[1];
|
|
198
|
+
const concept = data.concepts.find((c) => c.id === conceptId);
|
|
199
|
+
const out: Crumb[] = [{ label: concept?.label ?? conceptId, href: `/admin/${conceptId}` }];
|
|
200
|
+
if (segs[2]) out.push({ label: decodeURIComponent(segs[2]) });
|
|
201
|
+
return out;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// The browser-tab title: the deepest breadcrumb (the active concept or entry), then the brand.
|
|
205
|
+
const pageTitle = $derived(crumbs.length ? crumbs[crumbs.length - 1].label : 'Admin');
|
|
39
206
|
</script>
|
|
40
207
|
|
|
41
|
-
<
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
208
|
+
<svelte:head>
|
|
209
|
+
<title>{pageTitle} · {data.siteName}</title>
|
|
210
|
+
<link rel="icon" href={cairnFaviconHref} />
|
|
211
|
+
</svelte:head>
|
|
212
|
+
|
|
213
|
+
<svelte:window onkeydown={onKeydown} />
|
|
214
|
+
|
|
215
|
+
<!-- data-theme sits on a bare wrapper, not on the drawer itself: every admin rule is scoped as a
|
|
216
|
+
descendant of the theme root (`:where([data-theme]) .drawer`), so a class on the theme element
|
|
217
|
+
itself never matches. Keeping the drawer and its base/utility classes one level in lets the
|
|
218
|
+
scoped sheet style them. -->
|
|
219
|
+
<div data-theme={theme} bind:this={rootEl}>
|
|
220
|
+
<div class="drawer lg:drawer-open min-h-screen bg-base-200 text-base-content">
|
|
221
|
+
<input id="cairn-drawer" type="checkbox" class="drawer-toggle" bind:checked={drawerOpen} />
|
|
222
|
+
|
|
223
|
+
<div class="drawer-content flex flex-col">
|
|
224
|
+
<!-- The topbar is a flat, opaque continuation of the sidebar's brand band: same surface and the
|
|
225
|
+
same hairline, no shadow, so the two form one clean header strip across the sidebar seam. -->
|
|
226
|
+
<div class="navbar bg-base-100 border-b border-[var(--cairn-card-border)] sticky top-0 z-30 gap-2 px-4 lg:px-8">
|
|
227
|
+
<div class="flex-none lg:hidden">
|
|
228
|
+
<label for="cairn-drawer" aria-label="Open menu" class="btn btn-square btn-ghost">
|
|
229
|
+
<MenuIcon class="h-5 w-5" />
|
|
230
|
+
</label>
|
|
231
|
+
</div>
|
|
232
|
+
<!-- Context on the left: the breadcrumb trail inside an entry, the site name on a bare list.
|
|
233
|
+
Hidden on small screens to leave room for the palette trigger. -->
|
|
234
|
+
<div class="hidden min-w-0 max-w-[30%] flex-none truncate sm:block">
|
|
235
|
+
{#if crumbs.length > 1}
|
|
236
|
+
<nav aria-label="Breadcrumb" class="breadcrumbs text-sm">
|
|
237
|
+
<ul>
|
|
238
|
+
{#each crumbs as crumb (crumb.href ?? crumb.label)}
|
|
239
|
+
<li>{#if crumb.href}<a href={crumb.href}>{crumb.label}</a>{:else}{crumb.label}{/if}</li>
|
|
240
|
+
{/each}
|
|
241
|
+
</ul>
|
|
242
|
+
</nav>
|
|
243
|
+
{:else}
|
|
244
|
+
<span class="font-semibold tracking-tight">{data.siteName}</span>
|
|
245
|
+
{/if}
|
|
246
|
+
</div>
|
|
247
|
+
<!-- The command-palette trigger fills the center: a quick jump-to over the admin, opened here
|
|
248
|
+
or with Cmd/Ctrl+K. -->
|
|
249
|
+
<div class="flex min-w-0 flex-1 justify-center">
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onclick={openPalette}
|
|
253
|
+
class="flex w-full max-w-md items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-200/70 px-3 py-1.5 text-sm text-[var(--color-muted)] transition-colors hover:bg-base-200 hover:text-base-content"
|
|
254
|
+
>
|
|
255
|
+
<SearchIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
256
|
+
<span class="truncate">Search or jump to…</span>
|
|
257
|
+
<kbd class="ml-auto hidden rounded border border-[var(--cairn-card-border)] px-1.5 text-[0.6875rem] font-medium sm:inline">⌘K</kbd>
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="flex-none">
|
|
261
|
+
<button type="button" class="btn btn-square btn-ghost" aria-label="Toggle theme" onclick={toggleTheme}>
|
|
262
|
+
{#if theme === 'cairn-admin'}<MoonIcon class="h-5 w-5" />{:else}<SunIcon class="h-5 w-5" />{/if}
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
52
265
|
</div>
|
|
53
|
-
|
|
54
|
-
<
|
|
266
|
+
|
|
267
|
+
<main class="flex-1 p-4 lg:p-8">
|
|
268
|
+
{@render children()}
|
|
269
|
+
</main>
|
|
270
|
+
|
|
271
|
+
<dialog bind:this={paletteDialog} class="modal" aria-label="Search or jump to">
|
|
272
|
+
<div class="modal-box max-w-xl self-start p-0 sm:mt-[12vh]">
|
|
273
|
+
<div class="flex items-center gap-2 border-b border-[var(--cairn-card-border)] px-4">
|
|
274
|
+
<SearchIcon class="h-4 w-4 shrink-0 text-[var(--color-muted)]" aria-hidden="true" />
|
|
275
|
+
<input
|
|
276
|
+
bind:value={paletteQuery}
|
|
277
|
+
type="text"
|
|
278
|
+
aria-label="Search or jump to"
|
|
279
|
+
placeholder="Search or jump to…"
|
|
280
|
+
class="w-full bg-transparent py-3.5 text-sm outline-hidden placeholder:text-[var(--color-muted)]"
|
|
281
|
+
onkeydown={(e) => {
|
|
282
|
+
if (e.key === 'Enter') {
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
submitPalette();
|
|
285
|
+
}
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
{#if paletteResults.length}
|
|
290
|
+
<ul bind:this={paletteList} class="menu max-h-[60vh] w-full gap-0.5 overflow-y-auto p-2">
|
|
291
|
+
{#each paletteResults as cmd (cmd.label)}
|
|
292
|
+
<li>
|
|
293
|
+
{#if cmd.href}
|
|
294
|
+
<!-- An internal link navigates and the pathname effect closes the palette once the route lands,
|
|
295
|
+
so it carries no onclick (closing here would cancel the navigation). An external link
|
|
296
|
+
opens a new tab and leaves this page, so it closes the palette itself. -->
|
|
297
|
+
<a
|
|
298
|
+
href={cmd.href}
|
|
299
|
+
target={cmd.external ? '_blank' : undefined}
|
|
300
|
+
rel={cmd.external ? 'noopener' : undefined}
|
|
301
|
+
onclick={cmd.external ? () => paletteDialog?.close() : undefined}
|
|
302
|
+
>
|
|
303
|
+
<cmd.icon class="h-4 w-4 text-[var(--color-muted)]" aria-hidden="true" />
|
|
304
|
+
{cmd.label}
|
|
305
|
+
{#if cmd.external}<ExternalLinkIcon class="ml-auto h-3.5 w-3.5 opacity-50" aria-hidden="true" />{/if}
|
|
306
|
+
</a>
|
|
307
|
+
{:else}
|
|
308
|
+
<button type="button" onclick={() => runCommand(cmd)}>
|
|
309
|
+
<cmd.icon class="h-4 w-4 text-[var(--color-muted)]" aria-hidden="true" />
|
|
310
|
+
{cmd.label}
|
|
311
|
+
</button>
|
|
312
|
+
{/if}
|
|
313
|
+
</li>
|
|
314
|
+
{/each}
|
|
315
|
+
</ul>
|
|
316
|
+
{:else}
|
|
317
|
+
<p class="px-4 py-6 text-center text-sm text-[var(--color-muted)]">No matches for "{paletteQuery}".</p>
|
|
318
|
+
{/if}
|
|
319
|
+
</div>
|
|
320
|
+
<form method="dialog" class="modal-backdrop"><button tabindex="-1" aria-label="Close">close</button></form>
|
|
321
|
+
</dialog>
|
|
55
322
|
</div>
|
|
56
323
|
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
324
|
+
<div class="drawer-side">
|
|
325
|
+
<label for="cairn-drawer" aria-label="Close menu" class="drawer-overlay"></label>
|
|
326
|
+
<nav class="bg-base-100 flex min-h-full w-64 flex-col border-r border-[var(--cairn-card-border)]" aria-label="Site content">
|
|
327
|
+
<!-- Brand band, the same height as the topbar. The mark sits in a filled "app-icon" tile, which
|
|
328
|
+
anchors the corner as a deliberate brand object rather than a washed box. The logo and
|
|
329
|
+
wordmark link to the admin home. -->
|
|
330
|
+
<div class="flex h-16 flex-none items-center border-b border-[var(--cairn-card-border)] px-3">
|
|
331
|
+
<a href="/admin" aria-label="Cairn admin home" class="flex items-center gap-2.5 rounded-field px-2 py-1.5 transition-colors hover:bg-base-content/[0.05]">
|
|
332
|
+
<span class="flex h-8 w-8 items-center justify-center rounded-xl bg-primary text-primary-content shadow-sm">
|
|
333
|
+
<CairnLogo class="h-5 w-5" />
|
|
334
|
+
</span>
|
|
335
|
+
<span class="text-xl font-bold tracking-[-0.01em] font-[family-name:var(--font-display)]">Cairn</span>
|
|
336
|
+
<span class="rounded-md border border-base-300 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-[0.12em] text-[var(--color-muted)]">CMS</span>
|
|
337
|
+
</a>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<div class="flex-1 space-y-1 overflow-y-auto py-4">
|
|
341
|
+
{#snippet navSection(label: string, items: NavItem[])}
|
|
342
|
+
<details class="px-2" open={!collapsed.has(label)} ontoggle={(e) => onToggleSection(label, e.currentTarget.open)}>
|
|
343
|
+
<summary class="group/sec flex cursor-pointer select-none items-center gap-2 rounded-field bg-base-content/[0.04] py-2 pl-5 pr-3 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)] transition-colors hover:bg-base-content/[0.08] hover:text-base-content">
|
|
344
|
+
<span class="truncate">{label}</span>
|
|
345
|
+
<ChevronRightIcon class="cairn-caret ml-auto h-3 w-3 shrink-0 opacity-50 transition-opacity group-hover/sec:opacity-90" aria-hidden="true" />
|
|
346
|
+
</summary>
|
|
347
|
+
<ul class="menu menu-sm mt-1 w-full gap-0.5 p-0">
|
|
348
|
+
{#each items as item (item.href ?? item.label)}
|
|
349
|
+
<li>
|
|
350
|
+
{#if item.href}
|
|
351
|
+
<a
|
|
352
|
+
href={item.href}
|
|
353
|
+
class={isActive(item.href)
|
|
354
|
+
? 'bg-primary/10 font-semibold text-primary'
|
|
355
|
+
: 'font-medium text-[var(--color-subtle)]'}
|
|
356
|
+
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
357
|
+
>
|
|
358
|
+
<item.icon class="h-4 w-4" aria-hidden="true" />
|
|
359
|
+
{item.label}
|
|
360
|
+
</a>
|
|
361
|
+
{:else}
|
|
362
|
+
<span
|
|
363
|
+
class="cursor-default font-medium text-[var(--color-muted)] opacity-60"
|
|
364
|
+
aria-disabled="true"
|
|
365
|
+
title="A slot for a site developer's own admin tool. Not wired yet."
|
|
366
|
+
>
|
|
367
|
+
<item.icon class="h-4 w-4" aria-hidden="true" />
|
|
368
|
+
{item.label}
|
|
369
|
+
</span>
|
|
370
|
+
{/if}
|
|
371
|
+
</li>
|
|
372
|
+
{/each}
|
|
373
|
+
</ul>
|
|
374
|
+
</details>
|
|
375
|
+
{/snippet}
|
|
61
376
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
377
|
+
<!-- Core is the built-in Cairn functions; each developer group sits at the same level. All
|
|
378
|
+
are peer collapsible sections. The extension groups are inert stubs until the
|
|
379
|
+
CairnExtension seam supplies them. -->
|
|
380
|
+
{@render navSection('Core', coreItems)}
|
|
381
|
+
{#each extensionGroups as group (group.name)}
|
|
382
|
+
{@render navSection(group.name, group.items)}
|
|
383
|
+
{/each}
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<div class="flex-none border-t border-[var(--cairn-card-border)] px-5 py-4">
|
|
387
|
+
<div class="flex items-center gap-3">
|
|
388
|
+
<div class="avatar avatar-placeholder">
|
|
389
|
+
<div class="bg-neutral text-neutral-content w-9 rounded-full">
|
|
390
|
+
<span class="text-sm">{initials}</span>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
<div class="min-w-0 flex-1">
|
|
394
|
+
<div class="truncate text-sm font-medium">{data.user.displayName}</div>
|
|
395
|
+
<div class="truncate text-xs text-[var(--color-muted)]">{data.user.email}</div>
|
|
396
|
+
<div class="text-xs capitalize text-[var(--color-subtle)]">{data.user.role}</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<form method="POST" action="/admin/auth/logout" class="mt-4">
|
|
400
|
+
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">
|
|
401
|
+
<LogOutIcon class="h-4 w-4" /> Sign out
|
|
402
|
+
</button>
|
|
403
|
+
</form>
|
|
404
|
+
</div>
|
|
405
|
+
</nav>
|
|
406
|
+
</div>
|
|
79
407
|
</div>
|
|
80
408
|
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The cairn brand mark: a stack of stones, drawn in `currentColor` so it takes the brand color from its
|
|
4
|
+
container under either theme. Decorative, so callers pair it with the visible "Cairn" wordmark for the
|
|
5
|
+
accessible name.
|
|
6
|
+
|
|
7
|
+
Artwork: the "cairn" icon from the Temaki icon set (https://github.com/ideditor/temaki), released into
|
|
8
|
+
the public domain under CC0 1.0 (no attribution required). Recorded here as provenance.
|
|
9
|
+
-->
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Utility classes for sizing and color, e.g. `h-7 w-7 text-primary`. */
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
let { class: className = '' }: Props = $props();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<svg
|
|
19
|
+
class={className}
|
|
20
|
+
viewBox="0 0 15 15"
|
|
21
|
+
fill="currentColor"
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
24
|
+
>
|
|
25
|
+
<path
|
|
26
|
+
d="M6.28 14C5.56 14 1 13.89 1 12.91C1 11.46 2.16 11.07 3.2 10.81C4.36 10.51 13.18 9.77 13.76 10.07C14.46 10.43 13.52 12.49 12.44 12.77C11.28 13.07 10.21 14 8.48 14C7.05 14 9.69 14 6.28 14ZM6.92 4.5C6.67 4.5 5 4.43 5 3.88C5 3.07 5.75 2.51 5.96 2.35C6.36 2.03 6.32 1.62 6.54 1.27C6.84 0.79 7.61 0.5 7.88 0.5C8.1 0.5 8.75 0.9 9.23 1.42C9.45 1.66 10 2.77 10 3.12C10 4.22 9.36 4.5 8.85 4.5C8.33 4.5 8.15 4.5 6.92 4.5ZM3.68 8.22C3 7.73 3.67 6.86 4.57 6.21C5.38 5.63 5.92 5.96 6.79 5.7C8.33 5.24 9.02 5.72 9.02 5.72L10.9 6.82C12.03 7.63 10.99 7.67 10.38 8.56C9.79 9.42 8.18 9.11 7.42 9.33C6.78 9.53 5.75 9.71 4.62 8.9L3.68 8.22Z"
|
|
27
|
+
/>
|
|
28
|
+
</svg>
|
|
@@ -183,7 +183,7 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
183
183
|
{#each repeatableSlots as slot (slot.name)}
|
|
184
184
|
{@const items = slotItems(slot.name)}
|
|
185
185
|
{@const ids = slotIds(slot.name)}
|
|
186
|
-
<fieldset class="rounded-box border border-
|
|
186
|
+
<fieldset class="rounded-box border border-[var(--cairn-card-border)] flex flex-col gap-2 p-2">
|
|
187
187
|
<legend class="text-sm font-medium">{slot.label}</legend>
|
|
188
188
|
<!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. -->
|
|
189
189
|
{#each ids as id, i (id)}
|