@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,117 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are recorded here, most recent first.
|
|
4
4
|
|
|
5
|
+
## 0.34.0
|
|
6
|
+
|
|
7
|
+
A deployed admin request that arrives over http now gets a clear, branded help page instead of the
|
|
8
|
+
framework's opaque CSRF 403. The magic-link sign-in posts a JS-free form, and the framework rejects a
|
|
9
|
+
form POST unless the request carries a matching https origin, so an admin reached over http cannot sign
|
|
10
|
+
in. The auth guard detects that case on a deployed host and serves a self-contained page that names the
|
|
11
|
+
problem, links to the https version for one-click recovery, and gives the exact Cloudflare fix (Always
|
|
12
|
+
Use HTTPS). The page matches the admin design system in light and dark. Local `wrangler dev` over http
|
|
13
|
+
is exempt.
|
|
14
|
+
|
|
15
|
+
The release also adds a `check:prose` gate (`scripts/check-admin-prose.mjs`, in CI) that scans the admin
|
|
16
|
+
components' user-facing strings for AI-writing tells, since the component copy ships compiled and a
|
|
17
|
+
consuming site's prose tooling never sees it.
|
|
18
|
+
|
|
19
|
+
Consumers may: force HTTPS at the edge (Always Use HTTPS plus HSTS), which the deploy guide now requires.
|
|
20
|
+
The help page is a fallback for the window before that is set, not a substitute.
|
|
21
|
+
|
|
22
|
+
## 0.33.0
|
|
23
|
+
|
|
24
|
+
The admin isolates itself from host chrome. A dev-only guard in the admin and login roots walks the
|
|
25
|
+
ancestor chain on mount and logs one `console.error` when a width-constraining ancestor sits between the
|
|
26
|
+
admin root and `<body>`, the sign that a site's root layout is wrapping the admin in its own nav, footer,
|
|
27
|
+
or container. The guard compiles out of production and changes no rendering. The canonical route pattern
|
|
28
|
+
is documented and demonstrated: a chrome-free root layout plus a URL-transparent `(site)` group that
|
|
29
|
+
holds the public chrome and `app.css`, so the host chrome never wraps `/admin`. The showcase gains a
|
|
30
|
+
`(site)` group with plain-CSS chrome, which proves the admin renders fully styled on a site that uses
|
|
31
|
+
neither Tailwind nor DaisyUI.
|
|
32
|
+
|
|
33
|
+
This closes the global at-rule note carried since the self-styling foundation. The compiled admin sheet
|
|
34
|
+
holds DaisyUI `@keyframes` and Tailwind `@property` rules that are document-global by CSS spec, but the
|
|
35
|
+
sheet is code-split to the admin roots that import it, so it loads only on `/admin`, and the route pattern
|
|
36
|
+
keeps the host's CSS off `/admin` from the other side. A boundary test pins that the admin sheet is
|
|
37
|
+
imported only by the admin roots.
|
|
38
|
+
|
|
39
|
+
Consumers must: keep the host root layout chrome-free and move the public chrome plus `app.css` into a
|
|
40
|
+
`(site)` route group, so the host chrome never wraps `/admin`. A site already on this structure needs no
|
|
41
|
+
change. The dev guard names the problem in the console if a root layout still wraps the admin.
|
|
42
|
+
|
|
43
|
+
## 0.32.0
|
|
44
|
+
|
|
45
|
+
The admin gets a real CMS UX. The concept list is now a searchable, sortable data-table with status
|
|
46
|
+
badges, formatted dates, per-row delete, and pagination. The sidebar carries an icon per nav item and
|
|
47
|
+
a user menu with sign-out. The topbar is sticky and shows breadcrumbs. The admin has a dark mode, with
|
|
48
|
+
a topbar toggle that persists through a cookie and follows the OS preference on a first visit. The admin
|
|
49
|
+
icons are Lucide, added as a runtime dependency.
|
|
50
|
+
|
|
51
|
+
This release also fixes the self-styled admin so its drawer sidebar renders: the stylesheet build now
|
|
52
|
+
flattens CSS nesting before scoping (so DaisyUI's `lg:drawer-open` reveal is not severed from its
|
|
53
|
+
parent), and the admin layout carries `data-theme` on a wrapper so the drawer's own classes are scoped
|
|
54
|
+
descendants. The build gained `lightningcss` as a build-only devDependency for the flatten step; this
|
|
55
|
+
does not affect a consumer's runtime.
|
|
56
|
+
|
|
57
|
+
A frontend-design polish pass then refined the look. The Warm Stone light and dark palettes gained
|
|
58
|
+
clearer surface layering and crisper borders, the sidebar an active state in a soft primary tint, and
|
|
59
|
+
the list table refined column labels, row hover, and cleaner entry-title links. The list now defaults
|
|
60
|
+
to newest-first. A reduced-motion preference is honored inside the admin. A scoped anchor reset
|
|
61
|
+
restores the no-underline, inherit-color default the omitted Preflight used to provide.
|
|
62
|
+
|
|
63
|
+
A design-identity pass then gave the admin its own look. Cairn has a wordmark set in Bricolage
|
|
64
|
+
Grotesque over a body face of Figtree, both self-hosted as variable woff2 under the SIL Open Font
|
|
65
|
+
License, so the admin makes no webfont network call. An app-icon brand tile sits at the top of the
|
|
66
|
+
sidebar with the Cairn cairn-stack mark, a CC0 public-domain glyph, beside a CMS chip. The surfaces
|
|
67
|
+
moved to softer radii and floating cards over a calm warm-neutral ground, with a soft violet lift on
|
|
68
|
+
the primary button. The sidebar and the topbar share one flat header strip, so their intersection
|
|
69
|
+
reads as a single plane.
|
|
70
|
+
|
|
71
|
+
The nav now groups its entries. The core Cairn functions live in one collapsible group, and a
|
|
72
|
+
developer's own admin extensions sit in their own custom-named groups at the same level. Each group's
|
|
73
|
+
open or collapsed state persists through a `cairn-admin-nav-collapsed` cookie that the layout load
|
|
74
|
+
reads for a no-flash first paint, the way the theme cookie already works. A command palette opens with
|
|
75
|
+
Cmd/Ctrl+K or the topbar search box, jumps to any admin destination, and runs a couple of actions like
|
|
76
|
+
the theme toggle. The login and confirm screens carry the same wordmark, voice, and favicon.
|
|
77
|
+
|
|
78
|
+
Two more rendering fixes landed in this window. The login and confirm screens centered on a wrapper
|
|
79
|
+
rather than the themed element, so they now fill the viewport like the rest of the admin. The command
|
|
80
|
+
palette closed its dialog from a result link's own click handler, and closing a native dialog mid-click
|
|
81
|
+
cancelled the navigation, so a destination did nothing; a destination now navigates and the palette
|
|
82
|
+
closes once the new route lands.
|
|
83
|
+
|
|
84
|
+
This is additive for a consumer that mounts the admin through the documented routes. The engine now
|
|
85
|
+
depends on `@lucide/svelte`, which installs transitively, so no consumer action is required. A new
|
|
86
|
+
`listDeleteAction` is available on the content routes for wiring per-row delete on the list page; the
|
|
87
|
+
showcase wires it as the list `?/delete` action.
|
|
88
|
+
|
|
89
|
+
## 0.31.0
|
|
90
|
+
|
|
91
|
+
The admin now ships its own stylesheet. The engine compiles the admin's Tailwind utilities and
|
|
92
|
+
DaisyUI component classes, scoped under the admin `data-theme`, and the admin styles itself on any
|
|
93
|
+
host with no Tailwind or DaisyUI of its own. The compiled sheet leaks no global rule, so it never
|
|
94
|
+
touches the host's pages.
|
|
95
|
+
|
|
96
|
+
Consumers may: remove any Tailwind `@source` entry that existed only to generate the admin's classes;
|
|
97
|
+
the admin no longer depends on the host's Tailwind or DaisyUI build. A host that already provides
|
|
98
|
+
DaisyUI globally keeps working, since the engine's scoped rules are low-specificity (`:where`) and
|
|
99
|
+
the class names match; a later pass moves the admin out of the host's chrome entirely.
|
|
100
|
+
|
|
101
|
+
## 0.30.0
|
|
102
|
+
|
|
103
|
+
Carved a `@glw907/cairn-cms/render` authoring subpath for the component-authoring toolkit. `iconSpan`,
|
|
104
|
+
`cardShell`, `headRow`, the re-homed `isElement`, and the new `strAttr` now live there, so the root barrel
|
|
105
|
+
stays lean and a component `build()` imports its helpers from one obvious place. Added `strAttr(ctx, key)`,
|
|
106
|
+
a string-attribute reader, a configurable `headRow` heading level that defaults to 2, a
|
|
107
|
+
`registry.iconField(name)` accessor, and a `defineRegistry` guard that fails a component declaring
|
|
108
|
+
`defaultIconByRole` with no `type:'icon'` attribute. Dropped `rehypeDispatch` from the public surface, so
|
|
109
|
+
`createRenderer` is the one public render pipeline.
|
|
110
|
+
|
|
111
|
+
Consumers must: import `iconSpan`, `cardShell`, `headRow`, `isElement`, and `strAttr` from
|
|
112
|
+
`@glw907/cairn-cms/render` instead of the package root, and replace any direct `rehypeDispatch` use with
|
|
113
|
+
`createRenderer`. A component that sets `defaultIconByRole` with no `type:'icon'` attribute now fails
|
|
114
|
+
`defineRegistry`; give it an icon attribute or drop `defaultIconByRole`.
|
|
115
|
+
|
|
5
116
|
## 0.29.0
|
|
6
117
|
|
|
7
118
|
Consolidated the URL-identity model. A content entry's id, slug, date, and permalink are now derived in
|
|
@@ -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>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Component, type Snippet } from 'svelte';
|
|
2
2
|
import type { LayoutData } from '../sveltekit/content-routes.js';
|
|
3
3
|
import './cairn-admin.css';
|
|
4
4
|
interface Props {
|
|
@@ -10,9 +10,10 @@ interface Props {
|
|
|
10
10
|
/**
|
|
11
11
|
* The admin shell: a DaisyUI drawer-and-navbar that wraps every authed admin page. The nav is
|
|
12
12
|
* data-driven from the enabled concepts and role-gated (owners see the manage-editors entry). The
|
|
13
|
-
* root sets `data-theme
|
|
14
|
-
*
|
|
13
|
+
* root sets `data-theme` to the resolved light or dark theme (seeded from the SSR'd cookie choice,
|
|
14
|
+
* flipped by the topbar toggle) and imports the self-contained Warm Stone theme, so the admin looks
|
|
15
|
+
* identical on every host regardless of the site's own theme.
|
|
15
16
|
*/
|
|
16
|
-
declare const AdminLayout:
|
|
17
|
+
declare const AdminLayout: Component<Props, {}, "">;
|
|
17
18
|
type AdminLayout = ReturnType<typeof AdminLayout>;
|
|
18
19
|
export default AdminLayout;
|
|
@@ -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>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
/** Utility classes for sizing and color, e.g. `h-7 w-7 text-primary`. */
|
|
3
|
+
class?: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The cairn brand mark: a stack of stones, drawn in `currentColor` so it takes the brand color from its
|
|
7
|
+
* container under either theme. Decorative, so callers pair it with the visible "Cairn" wordmark for the
|
|
8
|
+
* accessible name.
|
|
9
|
+
*
|
|
10
|
+
* Artwork: the "cairn" icon from the Temaki icon set (https://github.com/ideditor/temaki), released into
|
|
11
|
+
* the public domain under CC0 1.0 (no attribution required). Recorded here as provenance.
|
|
12
|
+
*/
|
|
13
|
+
declare const CairnLogo: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type CairnLogo = ReturnType<typeof CairnLogo>;
|
|
15
|
+
export default CairnLogo;
|
|
@@ -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)}
|