@glw907/cairn-cms 0.56.2 → 0.57.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.
Files changed (173) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +929 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +347 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +569 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +17 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +62 -11
  47. package/dist/content/validate.js +27 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +68 -2
  102. package/dist/sveltekit/content-routes.js +461 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  116. package/src/lib/components/EditPage.svelte +347 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +569 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +16 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +78 -13
  138. package/src/lib/content/validate.ts +26 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +573 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -0,0 +1,929 @@
1
+ <!--
2
+ @component
3
+ The admin Media Library screen, a peer of Posts and Pages. It browses every committed media asset,
4
+ shows where each one is used, edits its name and default alt, and deletes it safely. The resting
5
+ surface is a visual contact-sheet grid (a roving-tabindex listbox of tiles), with a list-density
6
+ toggle that flips to an enriched sortable table. One toolbar row carries search, a pick-one triage
7
+ radiogroup (All, Needs alt, Unused), and the density toggle. Filtering, sorting, and a growing
8
+ client window all run over the full loaded set in component state.
9
+
10
+ Activating a tile or row opens a NON-MODAL detail slide-over from the right (the established
11
+ details-slide-over recipe): no scrim, the library stays live and in the a11y tree behind it, Escape
12
+ closes it, focus moves in on open and returns to the originating tile or row on close. It is a
13
+ labelled region, not a dialog, so it never traps focus or inerts the list. It holds the large
14
+ preview, the name and the `media:` reference with a copy button, the alt editor (a describe or
15
+ decorative radiogroup plus the alt field, posting to `?/mediaUpdate` together with the display name
16
+ and slug), the where-used list grouped published-then-branch, the metadata grid, and the actions.
17
+
18
+ Delete opens a two-faced safe-delete alertdialog: a native modal `<dialog>` with no light dismiss.
19
+ The in-use face names the breaking entries and gates Delete behind a typed-slug confirmation; the
20
+ orphan face is a calm confirm. Both post to `?/mediaDelete`. A `form` carrying a fresh
21
+ `MediaDeleteRefusal` re-opens the in-use face on its fresh breaking list.
22
+
23
+ It is node-safe by construction: it types assets with MediaLibraryEntry from the shared node-safe
24
+ projection and pulls in no editor module (the editor-boundary test bars a @codemirror leak).
25
+ -->
26
+ <script lang="ts">
27
+ import { flushSync, tick } from 'svelte';
28
+ import type { MediaLibraryEntry } from '../media/library-entry.js';
29
+ import type { MediaLibraryData, ContentFormFailure } from '../sveltekit/content-routes.js';
30
+ import type { UsageEntry } from '../media/usage.js';
31
+ import { publicPath } from '../media/naming.js';
32
+ import { mediaToken } from '../media/reference.js';
33
+ import CsrfField from './CsrfField.svelte';
34
+ import CairnLogo from './CairnLogo.svelte';
35
+ import {
36
+ SearchIcon,
37
+ UploadIcon,
38
+ LayoutGridIcon,
39
+ ListIcon,
40
+ CheckIcon,
41
+ TriangleAlertIcon,
42
+ ImageOffIcon,
43
+ Trash2Icon,
44
+ ChevronDownIcon,
45
+ ChevronRightIcon,
46
+ XIcon,
47
+ CopyIcon,
48
+ FileTextIcon,
49
+ ClockIcon,
50
+ Link2OffIcon,
51
+ } from './admin-icons.js';
52
+
53
+ interface Props {
54
+ /** The media library load's data: the unioned assets, the per-hash usage overlay, and a
55
+ * degraded-load error. */
56
+ data: MediaLibraryData;
57
+ /** The last media action's result. A `?/mediaDelete` refusal carries the fresh breaking list
58
+ * the in-use face re-opens on; a `?/mediaUpdate` failure carries the error the slide-over
59
+ * surfaces. The route exports one `form`, so this is the merged `ContentFormFailure`. */
60
+ form?: ContentFormFailure | null;
61
+ }
62
+
63
+ let { data, form }: Props = $props();
64
+
65
+ // --- the per-hash usage facts the screen joins onto each asset ---
66
+ /** The distinct-entry usage count for an asset; zero when the asset has no usage key. */
67
+ function usageCount(hash: string): number {
68
+ return data.usage[hash]?.count ?? 0;
69
+ }
70
+ /** Empty alt is the needs-alt signal (the asset carries no caption field, so this is the only
71
+ * per-asset alt fact). A non-image asset would read Not applicable, but the delivery route is
72
+ * image-only today, so every committed asset here is an image. */
73
+ function needsAlt(asset: MediaLibraryEntry): boolean {
74
+ return asset.alt.trim() === '';
75
+ }
76
+
77
+ // --- the live count line and the triage counts, over the FULL loaded set ---
78
+ const usedCount = $derived(data.assets.filter((a) => usageCount(a.hash) > 0).length);
79
+ const triageCounts = $derived({
80
+ all: data.assets.length,
81
+ needsAlt: data.assets.filter((a) => needsAlt(a)).length,
82
+ // Unused: no usage entry, or a count of zero.
83
+ unused: data.assets.filter((a) => usageCount(a.hash) === 0).length,
84
+ });
85
+
86
+ // The type facet (Images, Documents) is a designed-in seam: it stays hidden until the library
87
+ // holds more than one top-level content type. The delivery route is image-only today, so this is
88
+ // present without dead UI. (No selection state yet; the seam is the visibility computation.)
89
+ const distinctTypes = $derived.by(() => {
90
+ const seen = new Set<string>();
91
+ for (const a of data.assets) seen.add(a.contentType.split('/')[0] ?? '');
92
+ return seen;
93
+ });
94
+ const showFacet = $derived(distinctTypes.size > 1);
95
+
96
+ // --- the toolbar state ---
97
+ type Triage = 'all' | 'needs-alt' | 'unused';
98
+ type Density = 'grid' | 'list';
99
+ let query = $state('');
100
+ let triage = $state<Triage>('all');
101
+ let density = $state<Density>('grid');
102
+
103
+ // The triage segments, in display order, each naming its value, label, and live count.
104
+ const segments: { value: Triage; label: string; count: () => number }[] = [
105
+ { value: 'all', label: 'All', count: () => triageCounts.all },
106
+ { value: 'needs-alt', label: 'Needs alt', count: () => triageCounts.needsAlt },
107
+ { value: 'unused', label: 'Unused', count: () => triageCounts.unused },
108
+ ];
109
+
110
+ // The triage radiogroup's roving tabindex and ARIA radio keyboard pattern: the selected radio is
111
+ // the only tab stop, and Arrow/Home/End move the selection and the focus, mirroring the grid's
112
+ // roving listbox. A declared radiogroup owes this keyboard model.
113
+ let segEls = $state<HTMLButtonElement[]>([]);
114
+ function selectTriage(value: Triage) {
115
+ triage = value;
116
+ }
117
+ function onTriageKeydown(e: KeyboardEvent, i: number) {
118
+ let next = i;
119
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % segments.length;
120
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + segments.length) % segments.length;
121
+ else if (e.key === 'Home') next = 0;
122
+ else if (e.key === 'End') next = segments.length - 1;
123
+ else return;
124
+ e.preventDefault();
125
+ selectTriage(segments[next].value);
126
+ segEls[next]?.focus();
127
+ }
128
+
129
+ function matchesTriage(asset: MediaLibraryEntry): boolean {
130
+ switch (triage) {
131
+ case 'needs-alt':
132
+ return needsAlt(asset);
133
+ case 'unused':
134
+ return usageCount(asset.hash) === 0;
135
+ default:
136
+ return true;
137
+ }
138
+ }
139
+
140
+ // Search spans the display name and the alt over the FULL set. MediaLibraryEntry carries no
141
+ // caption field, so there is nothing further to search; the toolbar copy says "name or alt".
142
+ const filtered = $derived.by(() => {
143
+ const q = query.trim().toLowerCase();
144
+ return data.assets.filter((a) => {
145
+ if (!matchesTriage(a)) return false;
146
+ if (!q) return true;
147
+ return a.displayName.toLowerCase().includes(q) || a.alt.toLowerCase().includes(q);
148
+ });
149
+ });
150
+
151
+ // --- sorting (the list density's Added column) ---
152
+ let sortAsc = $state(false); // newest-first by default, the usual CMS convention
153
+ const sorted = $derived.by(() => {
154
+ // Lexical compare on the ISO createdAt is chronological; copy first so the source order holds.
155
+ return [...filtered].sort((a, b) => {
156
+ const cmp = a.createdAt.localeCompare(b.createdAt);
157
+ return sortAsc ? cmp : -cmp;
158
+ });
159
+ });
160
+ function toggleSort() {
161
+ sortAsc = !sortAsc;
162
+ }
163
+ const addedSort = $derived(sortAsc ? 'ascending' : 'descending');
164
+
165
+ // --- the client pagination window (a growing visible count, never infinite scroll) ---
166
+ const PAGE = 24;
167
+ let shown = $state(PAGE);
168
+ // Reset the window whenever the filtered set changes so a narrowing filter never strands the
169
+ // window past the result count. (Reading `sorted.length` ties this to filter/sort/search.)
170
+ $effect(() => {
171
+ void sorted.length;
172
+ shown = PAGE;
173
+ });
174
+ const visible = $derived(sorted.slice(0, shown));
175
+ const hasMore = $derived(shown < sorted.length);
176
+ function loadMore() {
177
+ shown = Math.min(shown + PAGE, sorted.length);
178
+ }
179
+
180
+ // --- selection, the slide-over, and the safe-delete dialog ---
181
+ // `selected` is the asset the slide-over (and the alertdialog) render off. The table's per-row
182
+ // trash opens the alertdialog straight to the right face for that asset (requestDelete) without
183
+ // opening the slide-over; a tile or row activation opens the slide-over (openAsset).
184
+ let selected = $state<MediaLibraryEntry | null>(null);
185
+ // True while the dialog was opened straight from a row trash without the slide-over, so the
186
+ // {#if selected} slide-over stays closed for a delete-only intent.
187
+ let deleteOnly = $state(false);
188
+
189
+ // The element that opened the slide-over (a tile or a row trigger), so focus returns to it on
190
+ // close (the non-modal region recipe: focus moves in on open, back to the origin on close).
191
+ let panelOrigin: HTMLElement | null = null;
192
+ let closeButton = $state<HTMLButtonElement | null>(null);
193
+ let deleteDialog = $state<HTMLDialogElement | null>(null);
194
+
195
+ function openAsset(asset: MediaLibraryEntry, origin?: HTMLElement | null) {
196
+ panelOrigin = origin ?? (document.activeElement as HTMLElement | null);
197
+ deleteOnly = false;
198
+ selected = asset;
199
+ // flushSync mounts the panel synchronously so its close button exists before we move focus in.
200
+ flushSync();
201
+ closeButton?.focus();
202
+ }
203
+ /** Close the slide-over and return focus to the tile or row that opened it. */
204
+ function closePanel() {
205
+ selected = null;
206
+ deleteOnly = false;
207
+ panelOrigin?.focus();
208
+ panelOrigin = null;
209
+ }
210
+ // Escape closes the slide-over (the non-modal region recipe). A window listener carries it, the
211
+ // way EditPage's details panel does, so the non-interactive region needs no keyboard handler. The
212
+ // dialog (when open) claims Escape natively, so the panel handles it only when no dialog is up.
213
+ function onWindowKeydown(e: KeyboardEvent) {
214
+ if (e.key === 'Escape' && selected && !deleteDialog?.open) {
215
+ e.preventDefault();
216
+ closePanel();
217
+ }
218
+ }
219
+
220
+ // The per-row delete intent opens the alertdialog directly on the right face for that asset.
221
+ function requestDelete(asset: MediaLibraryEntry) {
222
+ deleteOnly = true;
223
+ selected = asset;
224
+ openDeleteDialog();
225
+ }
226
+ // The slide-over's Delete button opens the same dialog for the already-selected asset.
227
+ function openDeleteDialog() {
228
+ confirmSlugInput = '';
229
+ flushSync();
230
+ deleteDialog?.showModal();
231
+ }
232
+ function closeDeleteDialog() {
233
+ deleteDialog?.close();
234
+ confirmSlugInput = '';
235
+ // A row-only delete leaves no slide-over to return to, so clear the selection on cancel.
236
+ if (deleteOnly) {
237
+ deleteOnly = false;
238
+ selected = null;
239
+ }
240
+ }
241
+
242
+ // --- the where-used overlay the slide-over and the dialog read, grouped published-then-branch ---
243
+ function usageEntries(hash: string): UsageEntry[] {
244
+ return data.usage[hash]?.entries ?? [];
245
+ }
246
+ /** Published rows first, then the edit-branch rows. */
247
+ function publishedRows(hash: string): UsageEntry[] {
248
+ return usageEntries(hash).filter((e) => e.origin.kind === 'published');
249
+ }
250
+ function branchRows(hash: string): UsageEntry[] {
251
+ return usageEntries(hash).filter((e) => e.origin.kind === 'branch');
252
+ }
253
+ const branchNameOf = (e: UsageEntry): string => (e.origin.kind === 'branch' ? e.origin.branch : '');
254
+
255
+ // --- the safe-delete dialog's face and its type-to-confirm gate ---
256
+ // The breaking list the dialog shows: the FRESH list from a refusal when one is present for this
257
+ // asset, else the load-time overlay. The fresh server list supersedes a stale load-time count.
258
+ const refusalForSelected = $derived(
259
+ form && form.hash && selected && form.hash === selected.hash ? form : null,
260
+ );
261
+ // The slide-over's error alert covers two failures that leave no in-use dialog to re-open: a pure
262
+ // ?/mediaUpdate failure (only `error`, no `hash`) and a hash-bearing delete refusal that is NOT an
263
+ // in-use block (a 404 "not committed", with `hash` but no `usage`). An in-use refusal (usage rows)
264
+ // re-opens the dialog instead, so it is excluded here.
265
+ const hasUsage = $derived((form?.usage?.length ?? 0) > 0);
266
+ const updateError = $derived(form?.error && !hasUsage ? form.error : null);
267
+ const breakingRows = $derived.by((): UsageEntry[] => {
268
+ if (refusalForSelected?.usage) return refusalForSelected.usage;
269
+ return selected ? usageEntries(selected.hash) : [];
270
+ });
271
+ // The face is chosen by whether the asset is in use at open: in-use names what breaks and gates
272
+ // Delete on a typed slug; orphan is a calm confirm. A refusal's fresh list also forces in-use.
273
+ const deleteInUse = $derived(breakingRows.length > 0);
274
+ const deleteBreakingPublished = $derived(breakingRows.filter((e) => e.origin.kind === 'published'));
275
+ const deleteBreakingBranch = $derived(breakingRows.filter((e) => e.origin.kind === 'branch'));
276
+
277
+ // The type-to-confirm input. The Delete submit is gated until it equals the asset slug (the one
278
+ // legitimate disable: a visible, typed destructive confirmation, not a hidden requirement).
279
+ let confirmSlugInput = $state('');
280
+ const confirmMatches = $derived(selected !== null && confirmSlugInput === selected.slug);
281
+
282
+ // Forms post full-page (no use:enhance), so on a failure the screen remounts with no selection and
283
+ // the error would render nowhere. This effect re-surfaces the failure from the `form` prop. An
284
+ // in-use delete refusal (usage rows) re-opens the dialog on its fresh breaking list; any other
285
+ // hash-bearing failure (a 404 "not committed", an invalid-slug ?/mediaUpdate) re-selects the asset
286
+ // and opens the slide-over so its error alert renders. The action redirects on success, so a
287
+ // present `form` is always a failure to re-surface.
288
+ //
289
+ // The dialog is always mounted and its body reads breakingRows/deleteInUse reactively, so set the
290
+ // state then call showModal() directly. tick() (NOT flushSync, which Svelte's flush_sync_in_effect
291
+ // guard rejects inside an effect on a newer 5.x) flushes the new `selected` before showModal so the
292
+ // dialog body renders the fresh asset.
293
+ $effect(() => {
294
+ if (!form || !form.hash) return;
295
+ const target = data.assets.find((a) => a.hash === form!.hash);
296
+ if (!target) return;
297
+ if (form.usage && form.usage.length > 0) {
298
+ // The in-use face, re-opened on the server's fresh breaking list.
299
+ if (deleteDialog && !deleteDialog.open) {
300
+ deleteOnly = true;
301
+ selected = target;
302
+ confirmSlugInput = '';
303
+ void tick().then(() => deleteDialog?.showModal());
304
+ }
305
+ } else if (!selected) {
306
+ // A hash-bearing failure that is not an in-use block: re-select the asset and open the
307
+ // slide-over so updateError renders. Guarded on `!selected` so it runs once, not on every edit.
308
+ deleteOnly = false;
309
+ selected = target;
310
+ }
311
+ });
312
+
313
+ // --- the copy-reference affordance, announced politely ---
314
+ let copyNotice = $state('');
315
+ function copyReference(token: string) {
316
+ void navigator.clipboard?.writeText(token).then(
317
+ () => {
318
+ copyNotice = 'Reference copied to the clipboard.';
319
+ },
320
+ () => {
321
+ copyNotice = 'Could not copy the reference.';
322
+ },
323
+ );
324
+ }
325
+
326
+ // --- the alt editor's describe/decorative model (the 2b capture-card model) ---
327
+ // Seeded from the selected asset each time the slide-over opens: a non-empty alt is "describe", an
328
+ // empty alt is "decorative" only when the author last chose it, else unset. The Library has no
329
+ // stored decorative flag, so an empty alt reads as unset (needs-alt), matching MediaCaptureCard.
330
+ let altMode = $state<'describe' | 'decorative' | null>(null);
331
+ let altText = $state('');
332
+ let nameInput = $state('');
333
+ let slugInput = $state('');
334
+ // Reseed the editable fields whenever the selected asset changes.
335
+ $effect(() => {
336
+ const a = selected;
337
+ if (!a) return;
338
+ altText = a.alt;
339
+ altMode = a.alt.trim() !== '' ? 'describe' : null;
340
+ nameInput = a.displayName;
341
+ slugInput = a.slug;
342
+ });
343
+ // The submitted alt: a described image carries its text, a decorative or left-blank submits empty
344
+ // (matching MediaCaptureCard's needs-alt-debt model).
345
+ const submittedAlt = $derived(altMode === 'describe' ? altText : '');
346
+
347
+ // --- the roving tabindex over the grid's visible tiles ---
348
+ // One tabstop for the listbox: the active index is the only option with tabindex 0; arrows,
349
+ // Home, and End move it; Enter/Space activate. The active index is clamped as filtering changes
350
+ // the visible set, so a focused option that filters out moves to a valid neighbor.
351
+ let activeIndex = $state(0);
352
+ $effect(() => {
353
+ const max = Math.max(0, visible.length - 1);
354
+ if (activeIndex > max) activeIndex = max;
355
+ });
356
+
357
+ let tileEls = $state<HTMLElement[]>([]);
358
+ function focusTile(i: number) {
359
+ activeIndex = i;
360
+ tileEls[i]?.focus();
361
+ }
362
+ function onGridKeydown(e: KeyboardEvent, i: number) {
363
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
364
+ e.preventDefault();
365
+ focusTile(Math.min(i + 1, visible.length - 1));
366
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
367
+ e.preventDefault();
368
+ focusTile(Math.max(i - 1, 0));
369
+ } else if (e.key === 'Home') {
370
+ e.preventDefault();
371
+ focusTile(0);
372
+ } else if (e.key === 'End') {
373
+ e.preventDefault();
374
+ focusTile(visible.length - 1);
375
+ } else if (e.key === 'Enter' || e.key === ' ') {
376
+ e.preventDefault();
377
+ openAsset(visible[i], tileEls[i]);
378
+ }
379
+ }
380
+
381
+ // --- the broken-thumbnail affordance: a tile/row whose R2 object 404s still lists ---
382
+ // The set of hashes whose thumbnail failed to load, so the dead asset can be cleared.
383
+ let brokenHashes = $state(new Set<string>());
384
+ function markBroken(hash: string) {
385
+ if (brokenHashes.has(hash)) return;
386
+ const next = new Set(brokenHashes);
387
+ next.add(hash);
388
+ brokenHashes = next;
389
+ }
390
+
391
+ // --- display helpers ---
392
+ const dateFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
393
+ function formatAdded(iso: string): string {
394
+ const parsed = new Date(iso);
395
+ return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
396
+ }
397
+ function formatBytes(bytes: number): string {
398
+ if (bytes < 1024) return `${bytes} B`;
399
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
400
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
401
+ }
402
+ /** The total stored bytes, for the count line. */
403
+ const totalBytes = $derived(data.assets.reduce((sum, a) => sum + a.bytes, 0));
404
+ /** Dimensions plus type for the list row metadata line. */
405
+ function dimensions(asset: MediaLibraryEntry): string {
406
+ return asset.width && asset.height ? `${asset.width}×${asset.height}` : '';
407
+ }
408
+ function typeLabel(asset: MediaLibraryEntry): string {
409
+ return asset.ext.toUpperCase();
410
+ }
411
+ function thumbSrc(asset: MediaLibraryEntry): string {
412
+ return publicPath(asset.slug, asset.hash, asset.ext, 'slug');
413
+ }
414
+
415
+ // The selected-cue check glyph for the triage radiogroup (WCAG 1.4.1): hue never carries the
416
+ // chosen state alone, the same non-color cue the ConceptList triage uses.
417
+ function segButtonClass(on: boolean): string {
418
+ return `inline-flex items-center gap-1.5 px-3 py-1 text-[0.8125rem] font-normal ${on ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
419
+ }
420
+ function densityButtonClass(on: boolean): string {
421
+ return `inline-flex items-center justify-center rounded-md p-1.5 ${on ? 'bg-primary/10 text-primary' : 'text-[var(--color-muted)] hover:bg-base-content/[0.06]'}`;
422
+ }
423
+
424
+ const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
425
+ </script>
426
+
427
+ <svelte:window onkeydown={onWindowKeydown} />
428
+
429
+ <!-- The office header recipe: the Media eyebrow, the display-face heading, a live count line, and
430
+ the Upload primary action top-right. -->
431
+ <header class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
432
+ <div class="flex flex-col gap-0.5">
433
+ <span class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Media</span>
434
+ <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">Media library</h1>
435
+ <p class="text-sm text-[var(--color-muted)]">
436
+ {triageCounts.all} {triageCounts.all === 1 ? 'image' : 'images'}, {usedCount} used on the site<span class="px-1.5" aria-hidden="true">&middot;</span>{formatBytes(totalBytes)} stored
437
+ </p>
438
+ </div>
439
+ <!-- TODO(Task 7+): wire a real Library upload (no media-only upload action exists in 3c; the 2b
440
+ upload commits to an entry's branch at save). This is a working, focusable button shell, never
441
+ a faked upload. -->
442
+ <button type="button" class="btn btn-primary btn-sm shrink-0">
443
+ <UploadIcon class="h-4 w-4" /> Upload
444
+ </button>
445
+ </header>
446
+
447
+ {#if data.error}
448
+ <div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
449
+ {/if}
450
+
451
+ {#if data.assets.length === 0}
452
+ <!-- The empty state owns the content area (the office recipe): the mark, the copy, and an Upload
453
+ CTA over a dropzone line. Triage and search stay hidden until there is content. -->
454
+ <div class="flex min-h-[52vh] flex-col items-center justify-center gap-4 px-6 py-14 text-center">
455
+ <CairnLogo class="h-12 w-12 text-primary opacity-30" />
456
+ <div class="space-y-1">
457
+ <p class="font-semibold text-base-content font-[family-name:var(--font-display)] text-xl">No media yet</p>
458
+ <p class="mx-auto max-w-[40ch] text-sm text-[var(--color-muted)]">
459
+ Upload an image and it shows up here, ready to drop into a post or set as a hero.
460
+ </p>
461
+ </div>
462
+ <div class="mt-1 flex flex-col items-center gap-2 rounded-box border border-dashed border-[var(--cairn-card-border)] px-7 py-5 text-[var(--color-muted)]">
463
+ <button type="button" class="btn btn-primary btn-sm">
464
+ <UploadIcon class="h-4 w-4" /> Upload an image
465
+ </button>
466
+ <span class="text-xs">or drop a file anywhere on this page</span>
467
+ </div>
468
+ </div>
469
+ {:else}
470
+ <!-- One toolbar row: search (left, flexes), the triage radiogroup, the type facet (seam), and the
471
+ grid/list density toggle (right). -->
472
+ <div class="mb-4 flex flex-wrap items-center gap-3">
473
+ <label class="input input-sm min-w-0 flex-1 sm:max-w-xs">
474
+ <SearchIcon class="h-4 w-4 opacity-60" aria-hidden="true" />
475
+ <input type="search" aria-label="Search the media library" bind:value={query} placeholder="Search name or alt" />
476
+ </label>
477
+
478
+ <!-- The triage is a pick-one radiogroup: aria-checked, never aria-pressed. -->
479
+ <div role="radiogroup" aria-label="Filter assets" class="bg-base-100 inline-flex items-center overflow-hidden rounded-lg border border-[var(--cairn-card-border)]">
480
+ {#each segments as seg, i (seg.value)}
481
+ <button
482
+ bind:this={segEls[i]}
483
+ type="button"
484
+ role="radio"
485
+ aria-checked={triage === seg.value}
486
+ tabindex={triage === seg.value ? 0 : -1}
487
+ class="{segButtonClass(triage === seg.value)} {i > 0 ? 'border-l border-[var(--cairn-card-border)]' : ''}"
488
+ onclick={() => selectTriage(seg.value)}
489
+ onkeydown={(e) => onTriageKeydown(e, i)}
490
+ >
491
+ {#if triage === seg.value}<CheckIcon class="h-3 w-3" aria-hidden="true" />{/if}
492
+ {seg.label}<span class="tabular-nums">{seg.count()}</span>
493
+ </button>
494
+ {/each}
495
+ </div>
496
+
497
+ {#if showFacet}
498
+ <!-- The type facet seam, shown only past one distinct stored type. It is presentational in
499
+ this slice (images-only delivery), so it carries no live filter selection yet. -->
500
+ <div role="radiogroup" aria-label="Filter by type" class="bg-base-100 inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[0.8125rem] text-[var(--color-muted)]">
501
+ <span class="text-xs">Type</span>
502
+ <button type="button" role="radio" aria-checked="true" class="font-medium text-primary">All</button>
503
+ </div>
504
+ {/if}
505
+
506
+ <span class="flex-1"></span>
507
+
508
+ <div role="group" aria-label="Layout density" class="bg-base-100 inline-flex items-center gap-1 rounded-lg border border-[var(--cairn-card-border)] p-0.5">
509
+ <button type="button" aria-label="Grid view" aria-pressed={density === 'grid'} class={densityButtonClass(density === 'grid')} onclick={() => (density = 'grid')}>
510
+ <LayoutGridIcon class="h-4 w-4" />
511
+ </button>
512
+ <button type="button" aria-label="List view" aria-pressed={density === 'list'} class={densityButtonClass(density === 'list')} onclick={() => (density = 'list')}>
513
+ <ListIcon class="h-4 w-4" />
514
+ </button>
515
+ </div>
516
+ </div>
517
+
518
+ {#if sorted.length === 0}
519
+ <!-- A filter or search narrowed the set to zero; the assets exist, none match. -->
520
+ <div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
521
+ <SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
522
+ <p class="text-sm text-[var(--color-muted)]">No media match this filter.</p>
523
+ </div>
524
+ {:else if density === 'grid'}
525
+ <!-- The grid: a roving-tabindex listbox of tiles. One tabstop; arrows move the roving index;
526
+ Enter/Space open. Each tile names the asset, its alt status (a glyph plus a label, never hue
527
+ alone), and a compact usage marker. -->
528
+ <ul role="listbox" aria-label="Media library" class="grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3 lg:grid-cols-4">
529
+ {#each visible as asset, i (asset.hash)}
530
+ {@const used = usageCount(asset.hash)}
531
+ {@const missing = needsAlt(asset)}
532
+ <li role="presentation" class="contents">
533
+ <div
534
+ bind:this={tileEls[i]}
535
+ role="option"
536
+ aria-selected={selected?.hash === asset.hash}
537
+ tabindex={i === activeIndex ? 0 : -1}
538
+ aria-label="{asset.displayName}. {missing ? 'Needs alt text' : 'Described'}. {used > 0 ? `Found in ${used} ${used === 1 ? 'entry' : 'entries'}` : 'No references found'}."
539
+ class="group flex cursor-pointer flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-primary/70 {selected?.hash === asset.hash ? 'ring-2 ring-primary/70' : ''}"
540
+ onclick={(e) => openAsset(asset, e.currentTarget)}
541
+ onkeydown={(e) => onGridKeydown(e, i)}
542
+ >
543
+ <div class="relative flex aspect-[4/3] items-center justify-center bg-base-200/60">
544
+ <!-- The usage marker, top-right: a used count, or the warning-ink Unused chip. -->
545
+ {#if used > 0}
546
+ <span class="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full border border-[var(--cairn-card-border)] bg-base-100/90 px-2 py-0.5 text-[0.625rem] font-semibold text-[var(--color-muted)]">used {used}</span>
547
+ {:else}
548
+ <span class="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full border border-[var(--cairn-card-border)] bg-base-100/90 px-2 py-0.5 text-[0.625rem] font-semibold text-[var(--cairn-warning-ink)]">Unused</span>
549
+ {/if}
550
+ {#if brokenHashes.has(asset.hash)}
551
+ <span data-cairn-broken class="flex flex-col items-center gap-1 text-[var(--color-subtle)]">
552
+ <ImageOffIcon class="h-7 w-7" aria-hidden="true" />
553
+ <span class="text-[0.625rem]">Image missing</span>
554
+ </span>
555
+ {:else}
556
+ <img
557
+ src={thumbSrc(asset)}
558
+ alt=""
559
+ aria-hidden="true"
560
+ class="max-h-full max-w-full object-contain"
561
+ onerror={() => markBroken(asset.hash)}
562
+ />
563
+ {/if}
564
+ </div>
565
+ <div class="flex items-center justify-between gap-2 border-t border-[var(--cairn-card-border)] px-2.5 py-2">
566
+ <span class="cairn-ml-name min-w-0 flex-1 truncate text-[0.8125rem] font-medium">{asset.displayName}</span>
567
+ {#if missing}
568
+ <span class="inline-flex items-center gap-1 text-[var(--cairn-warning-ink)]" role="img" aria-label="Needs alt text">
569
+ <TriangleAlertIcon class="h-3.5 w-3.5" aria-hidden="true" />
570
+ <span class="text-[0.625rem] font-medium">Needs alt</span>
571
+ </span>
572
+ {:else}
573
+ <span class="inline-flex items-center gap-1 text-[var(--color-positive-ink)]" role="img" aria-label="Described">
574
+ <CheckIcon class="h-3.5 w-3.5" aria-hidden="true" />
575
+ </span>
576
+ {/if}
577
+ </div>
578
+ </div>
579
+ </li>
580
+ {/each}
581
+ </ul>
582
+ {:else}
583
+ <!-- The list density: a real table. Each row opens the detail (sets `selected`); the Added
584
+ column sorts through a real <th><button> with aria-sort; the per-row delete is always
585
+ visible and sets the pending-delete intent Task 7 reads. -->
586
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-x-auto shadow-[var(--cairn-shadow)]">
587
+ <table class="table">
588
+ <thead>
589
+ <tr class="border-base-300">
590
+ <th class={headerLabel}>Asset</th>
591
+ <th class="{headerLabel} w-32">Alt status</th>
592
+ <th class="{headerLabel} w-40">Used</th>
593
+ <th class="w-24 text-right" aria-sort={addedSort}>
594
+ <button type="button" class="ml-auto inline-flex items-center gap-1 {headerLabel} hover:text-base-content" aria-label="Sort by date added" onclick={toggleSort}>
595
+ Added
596
+ <ChevronDownIcon class="h-3 w-3 {sortAsc ? 'rotate-180' : ''}" aria-hidden="true" />
597
+ </button>
598
+ </th>
599
+ <th class="w-12 text-right"><span class="sr-only">Actions</span></th>
600
+ </tr>
601
+ </thead>
602
+ <tbody>
603
+ {#each visible as asset (asset.hash)}
604
+ {@const used = usageCount(asset.hash)}
605
+ {@const missing = needsAlt(asset)}
606
+ <tr class="transition-colors hover:bg-base-200/60 {selected?.hash === asset.hash ? 'bg-primary/[0.06]' : ''}">
607
+ <td class="max-w-0">
608
+ <button type="button" class="flex w-full items-center gap-3 text-left" onclick={(e) => openAsset(asset, e.currentTarget)}>
609
+ <span class="relative flex h-10 w-14 flex-none items-center justify-center overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-200/60">
610
+ {#if brokenHashes.has(asset.hash)}
611
+ <ImageOffIcon data-cairn-broken class="h-4 w-4 text-[var(--color-subtle)]" aria-hidden="true" />
612
+ {:else}
613
+ <img src={thumbSrc(asset)} alt="" aria-hidden="true" class="h-full w-full object-cover" onerror={() => markBroken(asset.hash)} />
614
+ {/if}
615
+ </span>
616
+ <span class="flex min-w-0 flex-col">
617
+ <span class="cairn-ml-name truncate text-sm font-semibold">{asset.displayName}</span>
618
+ <span class="truncate text-[0.75rem] text-[var(--color-muted)] tabular-nums">
619
+ {#if dimensions(asset)}{dimensions(asset)}<span class="px-1" aria-hidden="true">&middot;</span>{/if}{formatBytes(asset.bytes)}<span class="px-1" aria-hidden="true">&middot;</span>{typeLabel(asset)}
620
+ </span>
621
+ </span>
622
+ </button>
623
+ </td>
624
+ <td class="w-32">
625
+ {#if missing}
626
+ <span class="inline-flex items-center gap-1 text-[0.75rem] font-medium text-[var(--cairn-warning-ink)]">
627
+ <TriangleAlertIcon class="h-3.5 w-3.5" aria-hidden="true" /> Needs alt
628
+ </span>
629
+ {:else}
630
+ <span class="inline-flex items-center gap-1 text-[0.75rem] font-medium text-[var(--color-positive-ink)]">
631
+ <CheckIcon class="h-3.5 w-3.5" aria-hidden="true" /> Described
632
+ </span>
633
+ {/if}
634
+ </td>
635
+ <td class="w-40 text-[0.8125rem]">
636
+ {#if used > 0}
637
+ <span class="text-base-content">found in {used}</span>
638
+ {:else}
639
+ <span class="text-[var(--color-muted)]">no references found</span>
640
+ {/if}
641
+ </td>
642
+ <td class="w-24 text-right text-sm tabular-nums text-[var(--color-muted)]">{formatAdded(asset.createdAt)}</td>
643
+ <td class="w-12 text-right">
644
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Delete {asset.displayName}" onclick={() => requestDelete(asset)}>
645
+ <Trash2Icon class="h-4 w-4 text-error" />
646
+ </button>
647
+ </td>
648
+ </tr>
649
+ {/each}
650
+ </tbody>
651
+ </table>
652
+ </div>
653
+ {/if}
654
+
655
+ {#if sorted.length > 0}
656
+ <!-- The announced count plus the managed Load more (never infinite scroll). One persistent
657
+ polite region carries "Showing N of M". -->
658
+ <div class="sr-only" role="status" aria-live="polite">Showing {visible.length} of {sorted.length} {sorted.length === 1 ? 'image' : 'images'}.</div>
659
+ <div class="mt-4 flex flex-col items-center gap-2">
660
+ <span class="text-sm text-[var(--color-muted)]">Showing {visible.length} of {sorted.length}</span>
661
+ {#if hasMore}
662
+ <button type="button" class="btn btn-sm" onclick={loadMore}>Load more</button>
663
+ {/if}
664
+ </div>
665
+ {/if}
666
+ {/if}
667
+
668
+ <!-- A persistent polite region announces a copy-reference result. -->
669
+ <div class="sr-only" role="status" aria-live="polite">{copyNotice}</div>
670
+
671
+ {#if selected && !deleteOnly}
672
+ {@const asset = selected}
673
+ {@const reference = mediaToken({ slug: asset.slug, hash: asset.hash })}
674
+ <!-- The NON-MODAL detail slide-over: no scrim, the library stays live behind it. It is a labelled
675
+ region, not a dialog, so the list stays in the a11y tree and the tab order. Escape closes it
676
+ and focus returns to the originating tile or row (the region-with-focus-management recipe).
677
+ Below the narrow breakpoint the same panel reads as a bottom sheet (the responsive treatment). -->
678
+ <aside
679
+ role="region"
680
+ aria-label="{asset.displayName} details"
681
+ class="fixed inset-x-0 bottom-0 z-30 flex max-h-[85vh] flex-col rounded-t-2xl border-t border-[var(--cairn-card-border)] bg-base-100 shadow-[var(--cairn-shadow)] sm:inset-x-auto sm:bottom-0 sm:right-0 sm:top-16 sm:max-h-none sm:w-[22rem] sm:rounded-t-none sm:border-l sm:border-t-0"
682
+ >
683
+ <div class="flex items-center justify-between border-b border-[var(--cairn-card-border)] px-4 py-3.5">
684
+ <h2 class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Asset</h2>
685
+ <button bind:this={closeButton} type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close details" onclick={closePanel}>
686
+ <XIcon class="h-3.5 w-3.5" aria-hidden="true" />
687
+ </button>
688
+ </div>
689
+
690
+ <div class="flex flex-col gap-5 overflow-y-auto p-4">
691
+ <!-- The large preview, object-fit contain on the quiet mat, with the broken-image affordance. -->
692
+ <div class="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-200/60">
693
+ {#if brokenHashes.has(asset.hash)}
694
+ <span data-cairn-broken class="flex flex-col items-center gap-1 text-[var(--color-subtle)]">
695
+ <ImageOffIcon class="h-8 w-8" aria-hidden="true" />
696
+ <span class="text-xs">Image missing</span>
697
+ </span>
698
+ {:else}
699
+ <img src={thumbSrc(asset)} alt="" aria-hidden="true" class="max-h-full max-w-full object-contain" onerror={() => markBroken(asset.hash)} />
700
+ {/if}
701
+ </div>
702
+
703
+ <!-- The name and the media: reference with a copy button. -->
704
+ <div class="flex flex-col gap-1.5">
705
+ <span class="text-[1.0625rem] font-semibold leading-tight break-words">{asset.displayName}</span>
706
+ <span class="flex items-center gap-1.5">
707
+ <code class="min-w-0 break-all font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--color-muted)]">{reference}</code>
708
+ <button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Copy reference" onclick={() => copyReference(reference)}>
709
+ <CopyIcon class="h-3.5 w-3.5" aria-hidden="true" />
710
+ </button>
711
+ </span>
712
+ </div>
713
+
714
+ <!-- The metadata edit form: the display name, the slug, and the default alt, posting one Save
715
+ to ?/mediaUpdate. The alt is the asset DEFAULT for new placements, never a rewrite of
716
+ the alt already committed in existing placements (decision 6). -->
717
+ <form method="POST" action="?/mediaUpdate" class="flex flex-col gap-4">
718
+ <CsrfField />
719
+ <input type="hidden" name="hash" value={asset.hash} />
720
+
721
+ <label class="flex flex-col gap-1">
722
+ <span class="text-[0.8125rem] font-medium">Name</span>
723
+ <input class="input input-sm" name="displayName" bind:value={nameInput} autocomplete="off" />
724
+ </label>
725
+ <label class="flex flex-col gap-1">
726
+ <span class="text-[0.8125rem] font-medium">URL slug</span>
727
+ <input class="input input-sm font-[family-name:var(--font-editor)]" name="slug" bind:value={slugInput} autocomplete="off" />
728
+ </label>
729
+
730
+ <!-- The alt editor: the describe/decorative radiogroup (the 2b model) plus the alt field.
731
+ Alt is debt: Save is never gated on it, and a left-blank or a decorative both submit an
732
+ empty alt. The submitted value rides a hidden input so the disabled-or-absent textarea
733
+ never strands the field. -->
734
+ <fieldset class="flex flex-col gap-2" aria-describedby="cairn-ml-alt-note">
735
+ <legend class="text-[0.8125rem] font-medium">Default alt text</legend>
736
+ <p id="cairn-ml-alt-note" class="text-xs text-[var(--color-muted)]">
737
+ The default for the next time this image is placed. It does not change the alt on pages that already use it. You can save without it and add it later.
738
+ </p>
739
+ <input type="hidden" name="alt" value={submittedAlt} />
740
+ <label class="flex cursor-pointer items-center gap-2">
741
+ <input type="radio" class="radio radio-sm" name="cairn-ml-alt-mode" value="describe" bind:group={altMode} />
742
+ <span class="text-sm">Describe it</span>
743
+ </label>
744
+ {#if altMode === 'describe'}
745
+ <textarea class="textarea textarea-sm ml-6 w-[calc(100%-1.5rem)]" aria-label="Alt text description" rows="2" bind:value={altText}></textarea>
746
+ {/if}
747
+ <label class="flex cursor-pointer items-center gap-2">
748
+ <input type="radio" class="radio radio-sm" name="cairn-ml-alt-mode" value="decorative" bind:group={altMode} />
749
+ <span class="text-sm">Decorative</span>
750
+ </label>
751
+ </fieldset>
752
+
753
+ {#if updateError}
754
+ <p role="alert" class="text-xs text-[var(--cairn-error-ink)]">{updateError}</p>
755
+ {/if}
756
+
757
+ <div class="flex justify-end">
758
+ <button type="submit" class="btn btn-sm btn-primary">Save</button>
759
+ </div>
760
+ </form>
761
+
762
+ <!-- Where used, grouped published-then-branch. Each entry links to its editor; a branch entry
763
+ names its branch. No entries shows the no-references treatment (never a bare "unused"). -->
764
+ <div class="flex flex-col gap-3">
765
+ <div class="flex items-baseline justify-between">
766
+ <span class={headerLabel}>Where used</span>
767
+ {#if usageEntries(asset.hash).length > 0}
768
+ <span class="text-xs text-[var(--color-muted)]">{usageEntries(asset.hash).length} {usageEntries(asset.hash).length === 1 ? 'entry' : 'entries'}</span>
769
+ {/if}
770
+ </div>
771
+
772
+ {#if usageEntries(asset.hash).length === 0}
773
+ <div class="flex items-start gap-2.5 rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
774
+ <Link2OffIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
775
+ <span class="text-[0.8125rem] leading-relaxed">No references found. Deleting this changes nothing readers see.</span>
776
+ </div>
777
+ {:else}
778
+ {#if publishedRows(asset.hash).length > 0}
779
+ <div class="flex flex-col gap-1.5">
780
+ <span class="text-[0.6875rem] font-semibold text-[var(--color-muted)]">Published on the site</span>
781
+ <ul class="flex list-none flex-col gap-1 p-0">
782
+ {#each publishedRows(asset.hash) as entry (entry.concept + '/' + entry.id)}
783
+ <li>
784
+ <a href="/admin/{entry.concept}/{entry.id}" class="flex items-center gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-100 px-2.5 py-2 no-underline hover:border-primary/40">
785
+ <FileTextIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
786
+ <span class="min-w-0 flex-1 truncate text-[0.8125rem] font-medium">{entry.title}</span>
787
+ <ChevronRightIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)] opacity-60" aria-hidden="true" />
788
+ </a>
789
+ </li>
790
+ {/each}
791
+ </ul>
792
+ </div>
793
+ {/if}
794
+ {#if branchRows(asset.hash).length > 0}
795
+ <div class="flex flex-col gap-1.5">
796
+ <span class="text-[0.6875rem] font-semibold text-[var(--color-muted)]">In an unpublished edit</span>
797
+ <ul class="flex list-none flex-col gap-1 p-0">
798
+ {#each branchRows(asset.hash) as entry (entry.concept + '/' + entry.id + branchNameOf(entry))}
799
+ <li>
800
+ <a href="/admin/{entry.concept}/{entry.id}" class="flex items-center gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-100 px-2.5 py-2 no-underline hover:border-primary/40">
801
+ <FileTextIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
802
+ <span class="flex min-w-0 flex-1 flex-col">
803
+ <span class="truncate text-[0.8125rem] font-medium">{entry.title}</span>
804
+ <span class="truncate font-[family-name:var(--font-editor)] text-[0.625rem] text-[var(--cairn-warning-ink)]">{branchNameOf(entry)}</span>
805
+ </span>
806
+ <ChevronRightIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)] opacity-60" aria-hidden="true" />
807
+ </a>
808
+ </li>
809
+ {/each}
810
+ </ul>
811
+ </div>
812
+ {/if}
813
+ {/if}
814
+ </div>
815
+
816
+ <!-- The metadata grid. -->
817
+ <div>
818
+ <span class={headerLabel}>Details</span>
819
+ <dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3.5 gap-y-1.5 text-[0.8125rem]">
820
+ {#if dimensions(asset)}
821
+ <dt class="text-[var(--color-muted)]">Dimensions</dt>
822
+ <dd class="m-0 text-right tabular-nums">{dimensions(asset)}</dd>
823
+ {/if}
824
+ <dt class="text-[var(--color-muted)]">Size</dt>
825
+ <dd class="m-0 text-right tabular-nums">{formatBytes(asset.bytes)}</dd>
826
+ <dt class="text-[var(--color-muted)]">Type</dt>
827
+ <dd class="m-0 text-right">{typeLabel(asset)}</dd>
828
+ <dt class="text-[var(--color-muted)]">Added</dt>
829
+ <dd class="m-0 text-right tabular-nums">{formatAdded(asset.createdAt)}</dd>
830
+ </dl>
831
+ </div>
832
+
833
+ <!-- The actions. Replace is deferred (no Replace control in this slice). -->
834
+ <div class="flex gap-2.5 border-t border-[var(--cairn-card-border)] pt-4">
835
+ <button type="button" class="btn btn-sm flex-1 border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={openDeleteDialog}>
836
+ <Trash2Icon class="h-4 w-4" aria-hidden="true" /> Delete
837
+ </button>
838
+ </div>
839
+ </div>
840
+ </aside>
841
+ {/if}
842
+
843
+ <!-- The two-faced safe-delete alertdialog: a native modal <dialog> (the focus trap is native), with
844
+ NO light dismiss (no method="dialog" backdrop). The in-use face names the breaking entries and
845
+ gates Delete behind the typed-slug confirmation; the orphan face is a calm confirm. Both post
846
+ hash to ?/mediaDelete; the in-use face also posts confirmSlug. -->
847
+ <dialog
848
+ bind:this={deleteDialog}
849
+ class="modal"
850
+ role="alertdialog"
851
+ aria-labelledby="cairn-ml-delete-title"
852
+ aria-describedby="cairn-ml-delete-desc"
853
+ oncancel={closeDeleteDialog}
854
+ >
855
+ {#if selected}
856
+ {@const asset = selected}
857
+ <div class="modal-box max-w-lg">
858
+ <div class="mb-3 flex items-start gap-3">
859
+ <span class="flex h-9 w-9 flex-none items-center justify-center rounded-box {deleteInUse ? 'bg-[var(--cairn-error-tint)] text-[var(--cairn-error-ink)]' : 'bg-base-content/[0.07] text-[var(--color-muted)]'}" aria-hidden="true">
860
+ {#if deleteInUse}<TriangleAlertIcon class="h-5 w-5" />{:else}<Trash2Icon class="h-5 w-5" />{/if}
861
+ </span>
862
+ <div class="flex-1">
863
+ <h2 id="cairn-ml-delete-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">Delete {asset.displayName}?</h2>
864
+ <p id="cairn-ml-delete-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
865
+ {#if deleteInUse}
866
+ Deleting this breaks the image in {breakingRows.length} {breakingRows.length === 1 ? 'entry' : 'entries'}. Type the name to delete it anyway.
867
+ {:else}
868
+ No references found. Deleting this changes nothing readers see.
869
+ {/if}
870
+ </p>
871
+ </div>
872
+ </div>
873
+
874
+ <div class="flex flex-col gap-3">
875
+ {#if deleteInUse}
876
+ <div>
877
+ <span class="mb-2 inline-flex items-center gap-1.5 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
878
+ <XIcon class="h-3.5 w-3.5" aria-hidden="true" /> These would break
879
+ </span>
880
+ <ul class="flex max-h-44 list-none flex-col gap-1 overflow-y-auto rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-2">
881
+ {#if deleteBreakingPublished.length > 0}
882
+ <li class="px-1.5 pb-0.5 pt-1 text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">Published on the site</li>
883
+ {#each deleteBreakingPublished as entry (entry.concept + '/' + entry.id)}
884
+ <li><a href="/admin/{entry.concept}/{entry.id}" class="flex items-center gap-2 rounded px-1.5 py-1 text-[0.8125rem] font-medium no-underline hover:bg-[var(--cairn-error-ink)]/10">{entry.title}</a></li>
885
+ {/each}
886
+ {/if}
887
+ {#if deleteBreakingBranch.length > 0}
888
+ <li class="px-1.5 pb-0.5 pt-1 text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">In an unpublished edit</li>
889
+ {#each deleteBreakingBranch as entry (entry.concept + '/' + entry.id + branchNameOf(entry))}
890
+ <li>
891
+ <a href="/admin/{entry.concept}/{entry.id}" class="flex flex-col rounded px-1.5 py-1 no-underline hover:bg-[var(--cairn-error-ink)]/10">
892
+ <span class="text-[0.8125rem] font-medium">{entry.title}</span>
893
+ <span class="font-[family-name:var(--font-editor)] text-[0.6rem] text-[var(--cairn-warning-ink)]">{branchNameOf(entry)}</span>
894
+ </a>
895
+ </li>
896
+ {/each}
897
+ {/if}
898
+ </ul>
899
+ </div>
900
+ {/if}
901
+
902
+ <div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
903
+ <ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
904
+ <span>Every version stays in git history, so a developer can bring this back later.</span>
905
+ </div>
906
+
907
+ <form method="POST" action="?/mediaDelete" class="flex flex-col gap-3">
908
+ <CsrfField />
909
+ <input type="hidden" name="hash" value={asset.hash} />
910
+ {#if deleteInUse}
911
+ <input type="hidden" name="confirmSlug" value={confirmSlugInput} />
912
+ <div class="flex flex-col gap-1.5">
913
+ <label class="text-[0.875rem]" for="cairn-ml-confirm">Type <code class="rounded bg-[var(--cairn-code-chip)] px-1.5 py-0.5 font-[family-name:var(--font-editor)] text-[0.8125rem] font-semibold">{asset.slug}</code> to delete it anyway.</label>
914
+ <input id="cairn-ml-confirm" class="input input-sm border-[var(--cairn-error-border)] font-[family-name:var(--font-editor)]" autocomplete="off" placeholder="Type the asset slug" bind:value={confirmSlugInput} />
915
+ </div>
916
+ {/if}
917
+ <div class="flex justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
918
+ <button type="button" class="btn btn-sm" onclick={closeDeleteDialog}>Cancel</button>
919
+ {#if deleteInUse}
920
+ <button type="submit" class="btn btn-sm btn-error" disabled={!confirmMatches}>Delete anyway</button>
921
+ {:else}
922
+ <button type="submit" class="btn btn-sm btn-error">Delete it</button>
923
+ {/if}
924
+ </div>
925
+ </form>
926
+ </div>
927
+ </div>
928
+ {/if}
929
+ </dialog>