@glw907/cairn-cms 0.38.0 → 0.41.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 +94 -0
- package/README.md +7 -6
- package/dist/components/AdminLayout.svelte +53 -0
- package/dist/components/ComponentInsertDialog.svelte +27 -13
- package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
- package/dist/components/ConceptList.svelte +22 -3
- package/dist/components/DeleteDialog.svelte +18 -7
- package/dist/components/DeleteDialog.svelte.d.ts +11 -1
- package/dist/components/EditPage.svelte +604 -75
- package/dist/components/EditPage.svelte.d.ts +8 -1
- package/dist/components/EditorToolbar.svelte +206 -29
- package/dist/components/EditorToolbar.svelte.d.ts +12 -4
- package/dist/components/LinkPicker.svelte +14 -6
- package/dist/components/LinkPicker.svelte.d.ts +9 -2
- package/dist/components/MarkdownEditor.svelte +80 -34
- package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
- package/dist/components/MarkdownHelpDialog.svelte +58 -0
- package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
- package/dist/components/RenameDialog.svelte +13 -4
- package/dist/components/RenameDialog.svelte.d.ts +9 -1
- package/dist/components/WebLinkDialog.svelte +89 -0
- package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
- package/dist/components/cairn-admin.css +353 -4
- package/dist/components/editor-highlight.d.ts +9 -0
- package/dist/components/editor-highlight.js +62 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/components/markdown-directives.d.ts +7 -0
- package/dist/components/markdown-directives.js +22 -0
- package/dist/components/markdown-format.d.ts +1 -1
- package/dist/components/markdown-format.js +91 -12
- package/dist/content/pending.d.ts +9 -0
- package/dist/content/pending.js +24 -0
- package/dist/diagnostics/conditions.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/github/branches.d.ts +11 -0
- package/dist/github/branches.js +75 -0
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/log/events.d.ts +1 -1
- package/dist/sveltekit/content-routes.d.ts +22 -1
- package/dist/sveltekit/content-routes.js +320 -72
- package/package.json +8 -5
- package/src/lib/components/AdminLayout.svelte +53 -0
- package/src/lib/components/ComponentInsertDialog.svelte +27 -13
- package/src/lib/components/ConceptList.svelte +22 -3
- package/src/lib/components/DeleteDialog.svelte +18 -7
- package/src/lib/components/EditPage.svelte +604 -75
- package/src/lib/components/EditorToolbar.svelte +206 -29
- package/src/lib/components/LinkPicker.svelte +14 -6
- package/src/lib/components/MarkdownEditor.svelte +80 -34
- package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
- package/src/lib/components/RenameDialog.svelte +13 -4
- package/src/lib/components/WebLinkDialog.svelte +89 -0
- package/src/lib/components/cairn-admin.css +26 -4
- package/src/lib/components/editor-highlight.ts +67 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/components/markdown-directives.ts +23 -0
- package/src/lib/components/markdown-format.ts +118 -13
- package/src/lib/content/pending.ts +24 -0
- package/src/lib/diagnostics/conditions.ts +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/github/branches.ts +83 -0
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/log/events.ts +4 -0
- package/src/lib/sveltekit/content-routes.ts +400 -73
|
@@ -2,18 +2,31 @@
|
|
|
2
2
|
@component
|
|
3
3
|
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
4
4
|
markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
|
|
5
|
-
`?/save` action
|
|
5
|
+
`?/save` action. The title field is hoisted above the editor card as the document title; the
|
|
6
|
+
remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
|
|
7
|
+
toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
|
|
8
|
+
swap the editing surface for the rendered preview inside the same card; every visit lands on
|
|
9
|
+
Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
|
|
10
|
+
and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
|
|
11
|
+
pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
|
|
12
|
+
transient flashes, and the editor card's footer holds the word count and the Markdown help.
|
|
6
13
|
-->
|
|
7
14
|
<script lang="ts">
|
|
8
15
|
import { untrack } from 'svelte';
|
|
16
|
+
import { beforeNavigate } from '$app/navigation';
|
|
17
|
+
import { page } from '$app/state';
|
|
9
18
|
import CsrfField from './CsrfField.svelte';
|
|
10
19
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
11
|
-
import
|
|
20
|
+
import EditorToolbar from './EditorToolbar.svelte';
|
|
21
|
+
import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
|
|
12
22
|
import LinkPicker from './LinkPicker.svelte';
|
|
23
|
+
import WebLinkDialog from './WebLinkDialog.svelte';
|
|
13
24
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
14
25
|
import RenameDialog from './RenameDialog.svelte';
|
|
26
|
+
import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
|
|
15
27
|
import { cairnLinkCompletionSource } from './link-completion.js';
|
|
16
|
-
import { unwrapCairnLink } from './markdown-format.js';
|
|
28
|
+
import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
|
|
29
|
+
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
17
30
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
18
31
|
import type { IconSet } from '../render/glyph.js';
|
|
19
32
|
import type { EditData } from '../sveltekit/content-routes.js';
|
|
@@ -46,10 +59,146 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
46
59
|
// True from the moment the save form submits until the navigation it triggers replaces the page,
|
|
47
60
|
// so the Save button shows a calm "Saving…" state instead of looking inert.
|
|
48
61
|
let saving = $state(false);
|
|
49
|
-
|
|
62
|
+
// The same working state for the Publish button, which rides the edit form via formaction. The
|
|
63
|
+
// submit handler reads the submitter to flip the right one, so Save never reads "Saving…" while
|
|
64
|
+
// a publish is in flight.
|
|
65
|
+
let publishing = $state(false);
|
|
66
|
+
function onEditSubmit(e: SubmitEvent) {
|
|
67
|
+
const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
|
|
68
|
+
if (formaction === '?/publish') publishing = true;
|
|
69
|
+
else saving = true;
|
|
70
|
+
}
|
|
71
|
+
// Either in-flight submit disables both buttons, so a second click cannot fire a second POST
|
|
72
|
+
// while the first navigation is still pending.
|
|
73
|
+
const busy = $derived(saving || publishing);
|
|
74
|
+
// True once a non-edit POST (discard, delete, rename) submits. Those forms navigate the
|
|
75
|
+
// document without flipping busy, so without this the leave guard would fire mid-discard while
|
|
76
|
+
// the page is still dirty, which is the primary discard scenario.
|
|
77
|
+
let leaving = $state(false);
|
|
78
|
+
|
|
79
|
+
// Dirty tracking. The body compares against the text the page loaded with (or the edited body a
|
|
80
|
+
// blocked save returned, which seeded the editor); the uncontrolled sidebar fields flip a flag
|
|
81
|
+
// on any input event, and the navigation a save triggers reloads the page, which resets both.
|
|
82
|
+
const bodyDirty = $derived(body !== (form?.body ?? data.body));
|
|
83
|
+
let fieldsDirty = $state(false);
|
|
84
|
+
const dirty = $derived(bodyDirty || fieldsDirty);
|
|
85
|
+
// What the header's save-state indicator says.
|
|
86
|
+
const saveState = $derived(dirty ? 'Unsaved changes' : data.saved ? 'Saved' : '');
|
|
87
|
+
function onFormInput(e: Event) {
|
|
88
|
+
const target = e.target as Element | null;
|
|
89
|
+
// Two kinds of input event bubble through the form without being frontmatter edits: the link
|
|
90
|
+
// picker's search box (its dialog sits in the toolbar snippet) and the editing surface's
|
|
91
|
+
// contenteditable. Skipping the surface keeps body edits owned by bodyDirty, so undoing back
|
|
92
|
+
// to the committed text reads clean again.
|
|
93
|
+
if (target?.closest('dialog, #cairn-pane-write')) return;
|
|
94
|
+
fieldsDirty = true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
|
|
98
|
+
let editForm = $state<HTMLFormElement | null>(null);
|
|
99
|
+
|
|
100
|
+
// The SvelteKit half of the leave guard. Registered at component init (beforeNavigate wraps
|
|
101
|
+
// onMount, so it must run synchronously here) and auto-unregistered on destroy. A submit's own
|
|
102
|
+
// navigation passes through because busy flips before it starts, and a non-edit POST's because
|
|
103
|
+
// leaving does.
|
|
104
|
+
beforeNavigate((navigation) => {
|
|
105
|
+
// A full-page unload (refresh, tab close, external link): per SvelteKit semantics, cancel()
|
|
106
|
+
// on a leave navigation is what asks the browser for its native dialog, so no confirm()
|
|
107
|
+
// here or two prompts would stack. The beforeunload listener below is deliberate
|
|
108
|
+
// belt-and-braces, not the dialog's source.
|
|
109
|
+
if (navigation.willUnload) {
|
|
110
|
+
if (dirty && !busy && !leaving) navigation.cancel();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (dirty && !busy && !leaving && !confirm('You have unsaved changes. Leave anyway?'))
|
|
114
|
+
navigation.cancel();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// The browser half of the leave guard plus the page-wide save shortcut. The handlers read the
|
|
118
|
+
// current dirty and busy values at event time, so the effect itself tracks nothing and runs once.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
121
|
+
if (dirty && !busy && !leaving) e.preventDefault();
|
|
122
|
+
};
|
|
123
|
+
// Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
|
|
124
|
+
// parentheses, and consumers compile this source with their own svelte.
|
|
125
|
+
const onWindowKeydown = (e: KeyboardEvent) => {
|
|
126
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
127
|
+
if (e.key.toLowerCase() !== 's') return;
|
|
128
|
+
// Always claim the shortcut so the browser's save-page dialog never opens over the admin.
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
// Gate the submit itself: an in-flight POST must not race a second one, a clean page has
|
|
131
|
+
// nothing to save (a no-op save would still cut a pending branch), and a save from inside
|
|
132
|
+
// an open modal would act on a surface the author cannot see.
|
|
133
|
+
if (busy) return;
|
|
134
|
+
if (!dirty && !data.isNew) return;
|
|
135
|
+
if ((e.target as Element | null)?.closest?.('dialog')) return;
|
|
136
|
+
editForm?.requestSubmit();
|
|
137
|
+
};
|
|
138
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
139
|
+
window.addEventListener('keydown', onWindowKeydown);
|
|
140
|
+
return () => {
|
|
141
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
142
|
+
window.removeEventListener('keydown', onWindowKeydown);
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
// The discard confirm, on the DeleteDialog pattern: a native <dialog> holding the POST form.
|
|
146
|
+
let discardDialog = $state<HTMLDialogElement | null>(null);
|
|
147
|
+
// Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
|
|
148
|
+
// landing tab.
|
|
149
|
+
let mode = $state<'write' | 'preview'>('write');
|
|
50
150
|
let previewHtml = $state('');
|
|
151
|
+
// True after a render call threw, so the preview pane can say so instead of going blank.
|
|
152
|
+
let previewFailed = $state(false);
|
|
51
153
|
let insert = $state.raw<(text: string) => void>(() => {});
|
|
52
154
|
let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
|
|
155
|
+
// The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
|
|
156
|
+
// reads it for the Text field's default.
|
|
157
|
+
let getSelection = $state.raw<() => string>(() => '');
|
|
158
|
+
// The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
|
|
159
|
+
let format = $state.raw<(kind: FormatKind) => void>(() => {});
|
|
160
|
+
// A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
|
|
161
|
+
type DialogHandle = { open: () => void };
|
|
162
|
+
// The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
|
|
163
|
+
// (a form nested in a form is invalid HTML the parser repairs by dropping the outer tag, which
|
|
164
|
+
// breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
|
|
165
|
+
let webLinkDialog = $state<DialogHandle | null>(null);
|
|
166
|
+
let linkPicker = $state<DialogHandle | null>(null);
|
|
167
|
+
let insertDialog = $state<DialogHandle | null>(null);
|
|
168
|
+
// The lifecycle dialogs, opened from the header's overflow menu.
|
|
169
|
+
let deleteDialog = $state<DialogHandle | null>(null);
|
|
170
|
+
let renameDialog = $state<DialogHandle | null>(null);
|
|
171
|
+
// The Markdown cheat sheet, opened from the editor card's footer.
|
|
172
|
+
let helpDialog = $state<DialogHandle | null>(null);
|
|
173
|
+
|
|
174
|
+
// Whether the registry offers anything insertable, the same condition the insert dialog lists
|
|
175
|
+
// by, so the toolbar trigger and the dialog appear and disappear together.
|
|
176
|
+
const hasComponents = $derived(insertableDefs(registry).length > 0);
|
|
177
|
+
|
|
178
|
+
// The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
|
|
179
|
+
// when it has never been published); otherwise the live site matches and it reads Published.
|
|
180
|
+
const status = $derived.by(() => {
|
|
181
|
+
if (!data.pending) return 'Published';
|
|
182
|
+
return data.published ? 'Edited' : 'New';
|
|
183
|
+
});
|
|
184
|
+
const statusBadge = $derived.by(() => {
|
|
185
|
+
if (status === 'Edited') return 'badge-warning';
|
|
186
|
+
if (status === 'New') return 'badge-info';
|
|
187
|
+
return 'badge-ghost';
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// The header overflow menu's popover element and its open state, mirrored from the toggle
|
|
191
|
+
// event into aria-expanded on the trigger.
|
|
192
|
+
let actionsMenu = $state<HTMLUListElement | null>(null);
|
|
193
|
+
let actionsOpen = $state(false);
|
|
194
|
+
|
|
195
|
+
// An overflow-menu pick runs its action, then dismisses the popover menu. Opening a modal
|
|
196
|
+
// dialog already closes an auto popover, so the explicit hide fires only when the menu is
|
|
197
|
+
// still up.
|
|
198
|
+
function pickAction(action: () => void) {
|
|
199
|
+
action();
|
|
200
|
+
if (actionsMenu?.matches(':popover-open')) actionsMenu.hidePopover();
|
|
201
|
+
}
|
|
53
202
|
|
|
54
203
|
// The save guard's broken links, from the blocked action result. The fix unwraps a link in the
|
|
55
204
|
// local body, which the bound editor reconciles, so the author re-saves clean.
|
|
@@ -74,24 +223,60 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
74
223
|
// A rename that hit a collision or an invalid slug returns form.renameError.
|
|
75
224
|
const renameError = $derived(form?.renameError ?? '');
|
|
76
225
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
226
|
+
// The entry this surface is editing. SvelteKit reuses the page component across a same-route
|
|
227
|
+
// navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
|
|
228
|
+
// state seeded at init would survive the hop and show entry A's body over entry B's data with
|
|
229
|
+
// the dirty indicator armed. When the identity changes, re-seed the state here; the {#key}
|
|
230
|
+
// block around the template remounts the DOM to match (CodeMirror with its undo history, the
|
|
231
|
+
// uncontrolled sidebar fields, any open dialog). The leave guard still protects the hop:
|
|
232
|
+
// beforeNavigate runs before the navigation completes, so it reads the old dirty value.
|
|
233
|
+
const entryKey = $derived(data.conceptId + '/' + data.id);
|
|
234
|
+
let seededKey = untrack(() => entryKey);
|
|
235
|
+
$effect.pre(() => {
|
|
236
|
+
const key = entryKey;
|
|
237
|
+
if (key === seededKey) return;
|
|
238
|
+
seededKey = key;
|
|
239
|
+
untrack(() => {
|
|
240
|
+
body = form?.body ?? data.body;
|
|
241
|
+
saving = false;
|
|
242
|
+
publishing = false;
|
|
243
|
+
leaving = false;
|
|
244
|
+
fieldsDirty = false;
|
|
245
|
+
mode = 'write';
|
|
246
|
+
previewHtml = '';
|
|
247
|
+
previewFailed = false;
|
|
248
|
+
removedLinks = [];
|
|
249
|
+
});
|
|
83
250
|
});
|
|
84
251
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
252
|
+
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
|
|
253
|
+
// is reactive kit state, so a client-side navigation that swaps the search string re-derives
|
|
254
|
+
// this, and the read is SSR-safe.
|
|
255
|
+
const draftWarning = $derived.by(() => {
|
|
256
|
+
const drafts = page.url.searchParams.get('drafts');
|
|
257
|
+
return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// The one transient feedback strip under the sticky header. The redirect flags are mutually
|
|
261
|
+
// exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
|
|
262
|
+
// A saved flash with a draft warning yields to the warning alert below, the prior behavior.
|
|
263
|
+
const flash = $derived.by(() => {
|
|
264
|
+
if (data.saved && !draftWarning)
|
|
265
|
+
return 'Saved. Your site keeps showing the published version until you publish.';
|
|
266
|
+
if (data.publishedFlash) return 'Published. The live site is rebuilding.';
|
|
267
|
+
if (data.discardedFlash) return 'Changes discarded.';
|
|
92
268
|
if (data.renamed) return `The URL is now ${data.slug}.`;
|
|
93
269
|
return '';
|
|
94
270
|
});
|
|
271
|
+
|
|
272
|
+
// One persistent live region announces the current message, since a {#if}-gated role element
|
|
273
|
+
// inserted fresh is announced inconsistently. A polite region carries the success and draft
|
|
274
|
+
// notices (the flash, plus the draft notice the strip yields to); an assertive region carries
|
|
275
|
+
// the errors. The visible banners below keep their styling but drop their roles, so a message
|
|
276
|
+
// is announced once.
|
|
277
|
+
const politeMessage = $derived(
|
|
278
|
+
draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash,
|
|
279
|
+
);
|
|
95
280
|
const assertiveMessage = $derived.by(() => {
|
|
96
281
|
if (data.error) return data.error;
|
|
97
282
|
if (renameError) return renameError;
|
|
@@ -106,6 +291,34 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
106
291
|
return '';
|
|
107
292
|
});
|
|
108
293
|
|
|
294
|
+
// One line of body text reduced to its prose: inline directives drop wholesale, then the
|
|
295
|
+
// markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
|
|
296
|
+
// as two words instead of mashing the link text into its destination, so a link counts its
|
|
297
|
+
// text plus its URL and the count never undercounts prose.
|
|
298
|
+
function proseOnly(line: string): string {
|
|
299
|
+
let out = '';
|
|
300
|
+
let cursor = 0;
|
|
301
|
+
for (const { from, to } of findInlineDirectives(line)) {
|
|
302
|
+
out += line.slice(cursor, from);
|
|
303
|
+
cursor = to;
|
|
304
|
+
}
|
|
305
|
+
out += line.slice(cursor);
|
|
306
|
+
return out.replace(/[*_~`[\]()#]/g, ' ');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The editor footer's word count, over the local body so it tracks every keystroke. Directive
|
|
310
|
+
// machinery lines and table rows are dropped first and the inline syntax stripped, so the
|
|
311
|
+
// count reads as the author's prose.
|
|
312
|
+
const countedBody = $derived(
|
|
313
|
+
body
|
|
314
|
+
.split('\n')
|
|
315
|
+
.filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
|
|
316
|
+
.map(proseOnly)
|
|
317
|
+
.join('\n'),
|
|
318
|
+
);
|
|
319
|
+
const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
|
|
320
|
+
const wordLabel = $derived(wordCount === 1 ? '1 word' : `${wordCount} words`);
|
|
321
|
+
|
|
109
322
|
// The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
|
|
110
323
|
// returns undefined for a missing target so the render step marks it cairn-broken-link.
|
|
111
324
|
const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
|
|
@@ -113,16 +326,38 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
113
326
|
// The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
|
|
114
327
|
const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
|
|
115
328
|
|
|
116
|
-
|
|
329
|
+
function setMode(m: 'write' | 'preview') {
|
|
330
|
+
mode = m;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Preview is read-only, so the insert controls the page renders into the toolbar disable with
|
|
334
|
+
// the strip's own format buttons.
|
|
335
|
+
const insertDisabled = $derived(mode === 'preview');
|
|
117
336
|
|
|
337
|
+
// The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
|
|
338
|
+
// strip or the surface, without claiming the keys page-wide. The listener attaches
|
|
339
|
+
// programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
|
|
340
|
+
// rule cannot tell apart on a declarative handler.
|
|
341
|
+
let editorCard = $state<HTMLDivElement | null>(null);
|
|
118
342
|
$effect(() => {
|
|
119
|
-
|
|
120
|
-
|
|
343
|
+
const card = editorCard;
|
|
344
|
+
if (!card) return;
|
|
345
|
+
card.addEventListener('keydown', onEditorKeydown);
|
|
346
|
+
return () => card.removeEventListener('keydown', onEditorKeydown);
|
|
121
347
|
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
348
|
+
function onEditorKeydown(e: KeyboardEvent) {
|
|
349
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
350
|
+
const key = e.key.toLowerCase();
|
|
351
|
+
if (key === 'b') {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
format('bold');
|
|
354
|
+
} else if (key === 'i') {
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
format('italic');
|
|
357
|
+
} else if (key === 'k') {
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
webLinkDialog?.open();
|
|
360
|
+
}
|
|
126
361
|
}
|
|
127
362
|
|
|
128
363
|
// Render the design-accurate preview as the body changes, debounced. The site's render is the
|
|
@@ -131,16 +366,22 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
131
366
|
// async render call resolves after a newer one has started, the stale result is discarded.
|
|
132
367
|
let previewRun = 0;
|
|
133
368
|
$effect(() => {
|
|
134
|
-
if (
|
|
369
|
+
if (mode !== 'preview' || !render) return;
|
|
135
370
|
const md = body;
|
|
136
371
|
const resolve = resolveLink; // tracked read in the effect body
|
|
137
372
|
const run = ++previewRun;
|
|
138
373
|
const handle = setTimeout(async () => {
|
|
139
374
|
try {
|
|
140
375
|
const html = await render(md, { resolve });
|
|
141
|
-
if (run === previewRun)
|
|
376
|
+
if (run === previewRun) {
|
|
377
|
+
previewHtml = html;
|
|
378
|
+
previewFailed = false;
|
|
379
|
+
}
|
|
142
380
|
} catch {
|
|
143
|
-
if (run === previewRun)
|
|
381
|
+
if (run === previewRun) {
|
|
382
|
+
previewHtml = '';
|
|
383
|
+
previewFailed = true;
|
|
384
|
+
}
|
|
144
385
|
}
|
|
145
386
|
}, 150);
|
|
146
387
|
return () => clearTimeout(handle);
|
|
@@ -150,38 +391,140 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
150
391
|
function str(v: unknown): string {
|
|
151
392
|
return v == null ? '' : String(v);
|
|
152
393
|
}
|
|
394
|
+
|
|
395
|
+
// The eyebrow legend each sidebar group opens with, one class string for all three.
|
|
396
|
+
const eyebrowClass =
|
|
397
|
+
'mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
|
|
398
|
+
|
|
399
|
+
// The sidebar's grouping. The title field hoists above the editor card as the document title,
|
|
400
|
+
// and a boolean named draft becomes the Visibility group's Hidden toggle (both production
|
|
401
|
+
// adapters use that name); everything else is a Details field.
|
|
402
|
+
const titleField = $derived(data.fields.find((f) => f.name === 'title'));
|
|
403
|
+
const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
|
|
404
|
+
const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
|
|
153
405
|
</script>
|
|
154
406
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
407
|
+
<!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
|
|
408
|
+
reset above); script-level state and the beforeNavigate registration sit outside the block,
|
|
409
|
+
so only the template rebuilds. -->
|
|
410
|
+
{#key entryKey}
|
|
411
|
+
<!-- The form's default button. The header's Publish (while pending) and Save submit the edit form
|
|
412
|
+
from outside it, and the default button for implicit submission (Enter in a single-line
|
|
413
|
+
field) is the FIRST form-owned submit button in tree order, which the header's Publish would
|
|
414
|
+
otherwise be: Enter in the title would publish a half-finished edit. This sr-only button sits
|
|
415
|
+
before the header, carries no formaction (so an implicit submit posts ?/save), and mirrors
|
|
416
|
+
Save's disabled state, so Enter on a clean page submits nothing. -->
|
|
417
|
+
<button
|
|
418
|
+
type="submit"
|
|
419
|
+
form="cairn-edit-form"
|
|
420
|
+
class="sr-only"
|
|
421
|
+
tabindex="-1"
|
|
422
|
+
aria-hidden="true"
|
|
423
|
+
disabled={busy || (!dirty && !data.isNew)}
|
|
424
|
+
>
|
|
425
|
+
Save
|
|
426
|
+
</button>
|
|
427
|
+
|
|
428
|
+
<!-- The sticky action header, a glass ruler: a translucent base-200 veil with backdrop blur the
|
|
429
|
+
page scrolls beneath, never a second opaque band (the admin topbar keeps that role). It sticks
|
|
430
|
+
under the h-16 topbar and bleeds across AdminLayout's content padding (p-4, lg:p-8) with
|
|
431
|
+
matching negative margins, so the veil spans the whole content column. -->
|
|
432
|
+
<header
|
|
433
|
+
class="sticky top-16 z-10 -mx-4 mb-6 border-b border-[var(--cairn-card-border)] bg-base-200/90 px-4 py-3 backdrop-blur lg:-mx-8 lg:px-8"
|
|
434
|
+
>
|
|
435
|
+
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
436
|
+
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
|
437
|
+
<a
|
|
438
|
+
href={`/admin/${data.conceptId}`}
|
|
439
|
+
class="flex shrink-0 items-center gap-0.5 text-sm text-[var(--color-muted)] transition-colors hover:text-base-content"
|
|
440
|
+
>
|
|
441
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
442
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 18-6-6 6-6" />
|
|
443
|
+
</svg>
|
|
444
|
+
{data.label}
|
|
445
|
+
</a>
|
|
446
|
+
<!-- The manuscript heading below is the visible title; repeating it here read as
|
|
447
|
+
duplication, so the header keeps the h1 for assistive tech only. -->
|
|
448
|
+
<h1 class="sr-only">{data.title}</h1>
|
|
449
|
+
<span class="badge badge-sm font-medium {statusBadge}">{status}</span>
|
|
450
|
+
{#if data.frontmatter.draft === true}
|
|
451
|
+
<span class="badge badge-neutral badge-sm font-medium">Hidden</span>
|
|
452
|
+
{/if}
|
|
453
|
+
<!-- The save-state indicator eases in and out; the admin sheet's prefers-reduced-motion rule
|
|
454
|
+
squashes the transition for editors who asked for that. The dot is the quiet unsaved cue. -->
|
|
455
|
+
<span
|
|
456
|
+
class="cairn-save-state flex items-center gap-1.5 text-xs text-[var(--color-muted)] transition-opacity duration-300"
|
|
457
|
+
class:opacity-0={!saveState}
|
|
458
|
+
aria-live="off"
|
|
459
|
+
>
|
|
460
|
+
{#if dirty}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>{/if}
|
|
461
|
+
{saveState}
|
|
462
|
+
</span>
|
|
463
|
+
</div>
|
|
464
|
+
<div class="ml-auto flex items-center gap-2">
|
|
465
|
+
<!-- The overflow menu is a DaisyUI v5 popover dropdown: click to open (never
|
|
466
|
+
focus-in-transit), Escape and light dismiss from the Popover API, and the
|
|
467
|
+
anchor-name/position-anchor pair places the panel under its trigger. -->
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
471
|
+
aria-label="More actions"
|
|
472
|
+
title="More actions"
|
|
473
|
+
aria-expanded={actionsOpen}
|
|
474
|
+
popovertarget="cairn-edit-actions-menu"
|
|
475
|
+
style="anchor-name:--cairn-edit-actions"
|
|
476
|
+
>
|
|
477
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
478
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h.01" />
|
|
479
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 12h.01" />
|
|
480
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 12h.01" />
|
|
481
|
+
</svg>
|
|
482
|
+
</button>
|
|
483
|
+
<ul
|
|
484
|
+
bind:this={actionsMenu}
|
|
485
|
+
popover="auto"
|
|
486
|
+
id="cairn-edit-actions-menu"
|
|
487
|
+
style="position-anchor:--cairn-edit-actions"
|
|
488
|
+
ontoggle={(e) => (actionsOpen = e.newState === 'open')}
|
|
489
|
+
class="dropdown dropdown-end menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
|
|
490
|
+
>
|
|
491
|
+
{#if data.pending}
|
|
492
|
+
<li>
|
|
493
|
+
<button type="button" aria-haspopup="dialog" onclick={() => pickAction(() => discardDialog?.showModal())}>
|
|
494
|
+
Discard changes
|
|
495
|
+
</button>
|
|
496
|
+
</li>
|
|
497
|
+
{/if}
|
|
498
|
+
<li>
|
|
499
|
+
<button type="button" class="text-error" aria-haspopup="dialog" onclick={() => pickAction(() => deleteDialog?.open())}>
|
|
500
|
+
Delete
|
|
501
|
+
</button>
|
|
502
|
+
</li>
|
|
503
|
+
</ul>
|
|
504
|
+
{#if data.pending}
|
|
505
|
+
<!-- Outline keeps Save the single solid primary action; Publish reads as its peer. -->
|
|
506
|
+
<button type="submit" form="cairn-edit-form" formaction="?/publish" class="btn btn-outline btn-primary btn-sm" disabled={busy}>
|
|
507
|
+
{#if publishing}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Publishing…{:else}Publish{/if}
|
|
508
|
+
</button>
|
|
509
|
+
{/if}
|
|
510
|
+
<!-- Save sleeps while the page is clean, agreeing with the header indicator; a new entry
|
|
511
|
+
stays saveable so it can be created as loaded. -->
|
|
512
|
+
<button type="submit" form="cairn-edit-form" class="btn btn-primary btn-sm" disabled={busy || (!dirty && !data.isNew)}>
|
|
513
|
+
{#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
|
|
514
|
+
</button>
|
|
515
|
+
</div>
|
|
174
516
|
</div>
|
|
175
517
|
</header>
|
|
176
518
|
|
|
177
519
|
<div class="sr-only" aria-live="polite">{politeMessage}</div>
|
|
178
520
|
<div class="sr-only" aria-live="assertive">{assertiveMessage}</div>
|
|
179
521
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
522
|
+
<!-- The feedback strip slides in just under the header: @starting-style drives the entry, so the
|
|
523
|
+
motion is pure CSS and the admin sheet's prefers-reduced-motion rule squashes it. -->
|
|
524
|
+
{#if flash}
|
|
525
|
+
<div class="cairn-feedback alert alert-success mb-4 text-sm transition-all duration-300 starting:-translate-y-2 starting:opacity-0">
|
|
526
|
+
{flash}
|
|
527
|
+
</div>
|
|
185
528
|
{/if}
|
|
186
529
|
{#if data.error}
|
|
187
530
|
<div class="alert alert-error mb-4 text-sm">{data.error}</div>
|
|
@@ -221,35 +564,142 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
221
564
|
</div>
|
|
222
565
|
{/if}
|
|
223
566
|
|
|
224
|
-
<form
|
|
567
|
+
<form
|
|
568
|
+
method="POST"
|
|
569
|
+
action="?/save"
|
|
570
|
+
id="cairn-edit-form"
|
|
571
|
+
bind:this={editForm}
|
|
572
|
+
onsubmit={onEditSubmit}
|
|
573
|
+
oninput={onFormInput}
|
|
574
|
+
class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6"
|
|
575
|
+
>
|
|
225
576
|
<CsrfField />
|
|
226
577
|
{#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
|
|
227
578
|
|
|
228
579
|
<div class="lg:order-1">
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
580
|
+
{#if titleField}
|
|
581
|
+
<!-- The hoisted document title: large, borderless, in the display face, so the manuscript
|
|
582
|
+
reads as the protagonist. It submits as name="title", the same field as before. The
|
|
583
|
+
admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there). -->
|
|
584
|
+
<input
|
|
585
|
+
class="cairn-doc-title mb-4 w-full border-0 bg-transparent text-3xl font-bold tracking-tight font-[family-name:var(--font-display)] placeholder:text-[var(--color-muted)]"
|
|
586
|
+
name="title"
|
|
587
|
+
value={str(data.frontmatter.title)}
|
|
588
|
+
placeholder={titleField.label}
|
|
589
|
+
aria-label={titleField.label}
|
|
590
|
+
required={titleField.required}
|
|
236
591
|
/>
|
|
237
|
-
</div>
|
|
238
|
-
{#if showPreview}
|
|
239
|
-
<section
|
|
240
|
-
id="cairn-preview"
|
|
241
|
-
aria-label="Preview"
|
|
242
|
-
class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 prose mt-4 max-w-none p-4 shadow-[var(--cairn-shadow)]"
|
|
243
|
-
>
|
|
244
|
-
{@html previewHtml}
|
|
245
|
-
</section>
|
|
246
592
|
{/if}
|
|
593
|
+
<!-- The editor card: the toolbar strip and the editing surface share one frame, so the editor
|
|
594
|
+
reads as a single object. The card carries the formatting shortcuts for everything in it. -->
|
|
595
|
+
<div
|
|
596
|
+
bind:this={editorCard}
|
|
597
|
+
class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]"
|
|
598
|
+
role="group"
|
|
599
|
+
aria-label="Editor"
|
|
600
|
+
>
|
|
601
|
+
<EditorToolbar {format} {mode} onMode={setMode}>
|
|
602
|
+
{#snippet insertControls()}
|
|
603
|
+
<!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
|
|
604
|
+
dialogs themselves mount outside the edit form at the bottom of this component. -->
|
|
605
|
+
{#if hasComponents}
|
|
606
|
+
<button
|
|
607
|
+
type="button"
|
|
608
|
+
class="btn btn-sm btn-ghost"
|
|
609
|
+
aria-haspopup="dialog"
|
|
610
|
+
aria-label="Insert block"
|
|
611
|
+
disabled={insertDisabled}
|
|
612
|
+
onclick={() => insertDialog?.open()}
|
|
613
|
+
>
|
|
614
|
+
Insert block
|
|
615
|
+
</button>
|
|
616
|
+
{/if}
|
|
617
|
+
<button
|
|
618
|
+
type="button"
|
|
619
|
+
class="btn btn-sm btn-ghost"
|
|
620
|
+
aria-haspopup="dialog"
|
|
621
|
+
aria-label="Web link (Ctrl+K)"
|
|
622
|
+
title="Web link (Ctrl+K)"
|
|
623
|
+
disabled={insertDisabled}
|
|
624
|
+
onclick={() => webLinkDialog?.open()}
|
|
625
|
+
>
|
|
626
|
+
Web link
|
|
627
|
+
</button>
|
|
628
|
+
<button
|
|
629
|
+
type="button"
|
|
630
|
+
class="btn btn-sm btn-ghost"
|
|
631
|
+
aria-haspopup="dialog"
|
|
632
|
+
aria-label="Link to page"
|
|
633
|
+
disabled={insertDisabled}
|
|
634
|
+
onclick={() => linkPicker?.open()}
|
|
635
|
+
>
|
|
636
|
+
Link to page
|
|
637
|
+
</button>
|
|
638
|
+
<button
|
|
639
|
+
type="button"
|
|
640
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
641
|
+
disabled
|
|
642
|
+
aria-label="Image (coming soon)"
|
|
643
|
+
title="Image (coming soon)"
|
|
644
|
+
>
|
|
645
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
646
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
647
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" />
|
|
648
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
649
|
+
</svg>
|
|
650
|
+
</button>
|
|
651
|
+
{/snippet}
|
|
652
|
+
</EditorToolbar>
|
|
653
|
+
<!-- The Write pane stays mounted while Preview shows, so CodeMirror keeps its caret, scroll
|
|
654
|
+
position, and undo history across the tab switch. -->
|
|
655
|
+
<div id="cairn-pane-write" role="tabpanel" aria-labelledby="cairn-tab-write" class:hidden={mode === 'preview'}>
|
|
656
|
+
<MarkdownEditor
|
|
657
|
+
bind:value={body}
|
|
658
|
+
name="body"
|
|
659
|
+
registerInsert={(fn) => (insert = fn)}
|
|
660
|
+
registerInsertLink={(fn) => (insertLink = fn)}
|
|
661
|
+
registerGetSelection={(fn) => (getSelection = fn)}
|
|
662
|
+
registerFormat={(fn) => (format = fn)}
|
|
663
|
+
{completionSources}
|
|
664
|
+
/>
|
|
665
|
+
</div>
|
|
666
|
+
{#if mode === 'preview'}
|
|
667
|
+
<!-- tabindex 0: the pane holds no focusable content, so it is itself a tab stop (the
|
|
668
|
+
tabpanel pattern's completeness requirement). -->
|
|
669
|
+
<div id="cairn-pane-preview" role="tabpanel" aria-labelledby="cairn-tab-preview" tabindex="0" class="prose max-w-none p-4">
|
|
670
|
+
{#if previewHtml}
|
|
671
|
+
{@html previewHtml}
|
|
672
|
+
{:else if previewFailed}
|
|
673
|
+
<p class="text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
|
|
674
|
+
{:else}
|
|
675
|
+
<p class="text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
|
|
676
|
+
{/if}
|
|
677
|
+
</div>
|
|
678
|
+
{/if}
|
|
679
|
+
<!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
|
|
680
|
+
frame never jumps between tabs and the count keeps reading while proofing. -->
|
|
681
|
+
<div class="flex items-center justify-between border-t border-[var(--cairn-card-border)] px-3 py-1 text-xs text-[var(--color-muted)]">
|
|
682
|
+
<span>{wordLabel}</span>
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
class="btn btn-ghost btn-xs font-normal text-[var(--color-muted)]"
|
|
686
|
+
aria-haspopup="dialog"
|
|
687
|
+
onclick={() => helpDialog?.open()}
|
|
688
|
+
>
|
|
689
|
+
Markdown help
|
|
690
|
+
</button>
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
247
693
|
</div>
|
|
248
694
|
|
|
249
695
|
<aside class="lg:order-2 mt-4 lg:mt-0">
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
696
|
+
<!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
|
|
697
|
+
a real legend that screen readers announce with the fields it holds. -->
|
|
698
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">
|
|
699
|
+
{#if detailFields.length}
|
|
700
|
+
<fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
|
|
701
|
+
<legend class={eyebrowClass}>Details</legend>
|
|
702
|
+
{#each detailFields as field (field.name)}
|
|
253
703
|
{#if field.type === 'textarea'}
|
|
254
704
|
{@const f = field as TextareaField}
|
|
255
705
|
<label class="flex flex-col gap-1">
|
|
@@ -306,9 +756,88 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
306
756
|
</label>
|
|
307
757
|
{/if}
|
|
308
758
|
{/each}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
759
|
+
</fieldset>
|
|
760
|
+
{/if}
|
|
761
|
+
{#if draftField}
|
|
762
|
+
<fieldset class="m-0 flex min-w-0 flex-col gap-1 border-0 p-0">
|
|
763
|
+
<legend class={eyebrowClass}>Visibility</legend>
|
|
764
|
+
<label class="label cursor-pointer justify-start gap-2">
|
|
765
|
+
<input class="checkbox checkbox-sm" type="checkbox" name="draft" checked={data.frontmatter.draft === true} />
|
|
766
|
+
<span class="text-sm">Hidden</span>
|
|
767
|
+
</label>
|
|
768
|
+
<p class="text-xs text-[var(--color-muted)]">Hidden entries stay off the site's lists and feeds, even when published.</p>
|
|
769
|
+
</fieldset>
|
|
770
|
+
{/if}
|
|
771
|
+
<fieldset class="m-0 flex min-w-0 flex-col gap-1 border-0 p-0">
|
|
772
|
+
<legend class={eyebrowClass}>Address</legend>
|
|
773
|
+
<div class="flex items-center justify-between gap-2">
|
|
774
|
+
<code class="min-w-0 break-all text-xs text-[var(--color-muted)]">/{data.slug}</code>
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
class="btn btn-ghost btn-sm shrink-0"
|
|
778
|
+
aria-haspopup="dialog"
|
|
779
|
+
onclick={() => renameDialog?.open()}
|
|
780
|
+
>
|
|
781
|
+
Change URL
|
|
782
|
+
</button>
|
|
783
|
+
</div>
|
|
784
|
+
</fieldset>
|
|
785
|
+
</div>
|
|
313
786
|
</aside>
|
|
314
787
|
</form>
|
|
788
|
+
|
|
789
|
+
<!-- The toolbar's insert dialogs, mounted headless outside the edit form: each holds its own
|
|
790
|
+
<form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
|
|
791
|
+
tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
|
|
792
|
+
through their exported open(). -->
|
|
793
|
+
<ComponentInsertDialog bind:this={insertDialog} trigger={false} {registry} {insert} {icons} />
|
|
794
|
+
<WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
|
|
795
|
+
<LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
|
|
796
|
+
|
|
797
|
+
<!-- The lifecycle dialogs, mounted headless: the header's overflow menu drives them through their
|
|
798
|
+
exported open(). Their POST forms flip the leaving flag so the leave guard stands down. -->
|
|
799
|
+
<RenameDialog
|
|
800
|
+
bind:this={renameDialog}
|
|
801
|
+
trigger={false}
|
|
802
|
+
conceptId={data.conceptId}
|
|
803
|
+
id={data.id}
|
|
804
|
+
label={data.label}
|
|
805
|
+
slug={data.slug}
|
|
806
|
+
onsubmitting={() => (leaving = true)}
|
|
807
|
+
/>
|
|
808
|
+
<MarkdownHelpDialog bind:this={helpDialog} />
|
|
809
|
+
<DeleteDialog
|
|
810
|
+
bind:this={deleteDialog}
|
|
811
|
+
trigger={false}
|
|
812
|
+
conceptId={data.conceptId}
|
|
813
|
+
id={data.id}
|
|
814
|
+
label={data.label}
|
|
815
|
+
inboundLinks={data.inboundLinks}
|
|
816
|
+
pending={data.pending}
|
|
817
|
+
onsubmitting={() => (leaving = true)}
|
|
818
|
+
/>
|
|
819
|
+
|
|
820
|
+
{#if data.pending}
|
|
821
|
+
<dialog class="modal" aria-labelledby="cairn-discard-dialog-title" bind:this={discardDialog}>
|
|
822
|
+
<div class="modal-box">
|
|
823
|
+
<div class="mb-3 flex items-center justify-between">
|
|
824
|
+
<h2 id="cairn-discard-dialog-title" class="text-base font-semibold">Discard the unpublished changes?</h2>
|
|
825
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => discardDialog?.close()}>✕</button>
|
|
826
|
+
</div>
|
|
827
|
+
{#if data.published}
|
|
828
|
+
<p class="mb-3 text-sm">This restores the live version. The changes cannot be recovered.</p>
|
|
829
|
+
{:else}
|
|
830
|
+
<p class="mb-3 text-sm">This entry has never been published, so discarding deletes it. Nothing can be recovered.</p>
|
|
831
|
+
{/if}
|
|
832
|
+
<form method="POST" action="?/discard" class="flex justify-end gap-2" onsubmit={() => (leaving = true)}>
|
|
833
|
+
<CsrfField />
|
|
834
|
+
<button type="button" class="btn btn-sm" onclick={() => discardDialog?.close()}>Cancel</button>
|
|
835
|
+
<button type="submit" class="btn btn-sm btn-error">Discard</button>
|
|
836
|
+
</form>
|
|
837
|
+
</div>
|
|
838
|
+
<form method="dialog" class="modal-backdrop">
|
|
839
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
840
|
+
</form>
|
|
841
|
+
</dialog>
|
|
842
|
+
{/if}
|
|
843
|
+
{/key}
|