@glw907/cairn-cms 0.60.0 → 0.62.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/components/AdminLayout.svelte +152 -229
  3. package/dist/components/CairnAdmin.svelte +13 -42
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +194 -261
  7. package/dist/components/CairnTidySettings.svelte.d.ts +1 -1
  8. package/dist/components/ComponentForm.svelte +110 -185
  9. package/dist/components/ComponentInsertDialog.svelte +163 -283
  10. package/dist/components/ConceptList.svelte +111 -191
  11. package/dist/components/ConfirmPage.svelte +5 -12
  12. package/dist/components/CsrfField.svelte +5 -11
  13. package/dist/components/DeleteDialog.svelte +15 -42
  14. package/dist/components/EditPage.svelte +781 -1205
  15. package/dist/components/EditorToolbar.svelte +108 -170
  16. package/dist/components/HelpHome.svelte +824 -0
  17. package/dist/components/HelpHome.svelte.d.ts +22 -0
  18. package/dist/components/IconPicker.svelte +23 -53
  19. package/dist/components/LinkPicker.svelte +34 -58
  20. package/dist/components/LoginPage.svelte +14 -27
  21. package/dist/components/ManageEditors.svelte +3 -15
  22. package/dist/components/MarkdownEditor.svelte +689 -957
  23. package/dist/components/MarkdownHelpDialog.svelte +12 -27
  24. package/dist/components/MediaCaptureCard.svelte +18 -57
  25. package/dist/components/MediaFigureControl.svelte +32 -71
  26. package/dist/components/MediaHeroField.svelte +210 -329
  27. package/dist/components/MediaInsertPopover.svelte +156 -283
  28. package/dist/components/MediaPicker.svelte +67 -131
  29. package/dist/components/NavTree.svelte +46 -78
  30. package/dist/components/RenameDialog.svelte +16 -43
  31. package/dist/components/ShortcutsDialog.svelte +9 -13
  32. package/dist/components/ShortcutsGrid.svelte +1 -2
  33. package/dist/components/TidyReview.svelte +140 -248
  34. package/dist/components/WebLinkDialog.svelte +19 -40
  35. package/dist/components/cairn-admin.css +4 -0
  36. package/dist/components/client-ingest.d.ts +16 -8
  37. package/dist/components/client-ingest.js +12 -6
  38. package/dist/components/editor-media.js +16 -8
  39. package/dist/components/editor-placeholder.d.ts +4 -2
  40. package/dist/components/editor-tidy.d.ts +24 -12
  41. package/dist/components/editor-tidy.js +8 -4
  42. package/dist/components/index.d.ts +1 -0
  43. package/dist/components/index.js +1 -0
  44. package/dist/components/link-completion.d.ts +12 -6
  45. package/dist/components/link-completion.js +12 -6
  46. package/dist/components/markdown-directives.d.ts +9 -6
  47. package/dist/components/markdown-directives.js +9 -6
  48. package/dist/components/markdown-format.d.ts +7 -2
  49. package/dist/components/markdown-format.js +59 -28
  50. package/dist/components/markdown-reference.d.ts +8 -0
  51. package/dist/components/markdown-reference.js +22 -0
  52. package/dist/components/media-upload-outcome.d.ts +12 -6
  53. package/dist/components/objective-errors.d.ts +8 -4
  54. package/dist/components/objective-errors.js +8 -4
  55. package/dist/components/preview-doc.d.ts +4 -2
  56. package/dist/components/preview-doc.js +4 -2
  57. package/dist/components/spellcheck.d.ts +57 -29
  58. package/dist/components/spellcheck.js +50 -20
  59. package/dist/components/tidy-categorize.d.ts +20 -10
  60. package/dist/components/tidy-categorize.js +16 -8
  61. package/dist/components/tidy-validate.d.ts +12 -6
  62. package/dist/components/tidy-validate.js +20 -10
  63. package/dist/components/topbar-context.d.ts +4 -2
  64. package/dist/content/advisories.d.ts +51 -0
  65. package/dist/content/advisories.js +79 -0
  66. package/dist/content/compose.d.ts +4 -2
  67. package/dist/content/compose.js +1 -0
  68. package/dist/content/excerpt.js +4 -2
  69. package/dist/content/getting-started.d.ts +18 -0
  70. package/dist/content/getting-started.js +12 -0
  71. package/dist/content/links.d.ts +16 -8
  72. package/dist/content/links.js +12 -6
  73. package/dist/content/manifest.d.ts +36 -18
  74. package/dist/content/manifest.js +32 -16
  75. package/dist/content/media-refs.d.ts +4 -2
  76. package/dist/content/media-refs.js +4 -2
  77. package/dist/content/media-rewrite.d.ts +8 -4
  78. package/dist/content/media-rewrite.js +76 -38
  79. package/dist/content/schema.d.ts +20 -10
  80. package/dist/content/site-dictionary.d.ts +4 -2
  81. package/dist/content/site-dictionary.js +8 -4
  82. package/dist/content/types.d.ts +97 -42
  83. package/dist/delivery/CairnHead.svelte +8 -11
  84. package/dist/delivery/content-index.d.ts +16 -8
  85. package/dist/delivery/feeds.js +4 -2
  86. package/dist/delivery/json-ld.d.ts +3 -0
  87. package/dist/delivery/json-ld.js +3 -0
  88. package/dist/delivery/manifest.d.ts +4 -2
  89. package/dist/delivery/manifest.js +4 -2
  90. package/dist/delivery/public-routes.d.ts +12 -6
  91. package/dist/delivery/public-routes.js +4 -2
  92. package/dist/delivery/seo-fields.d.ts +12 -6
  93. package/dist/delivery/seo-fields.js +8 -4
  94. package/dist/delivery/site-indexes.d.ts +4 -2
  95. package/dist/delivery/site-resolver.d.ts +4 -2
  96. package/dist/delivery/site-resolver.js +4 -2
  97. package/dist/doctor/cloudflare-api.d.ts +6 -0
  98. package/dist/doctor/cloudflare-api.js +6 -0
  99. package/dist/doctor/index.d.ts +12 -6
  100. package/dist/doctor/report.d.ts +3 -0
  101. package/dist/doctor/report.js +3 -0
  102. package/dist/doctor/run.d.ts +3 -0
  103. package/dist/doctor/run.js +3 -0
  104. package/dist/doctor/types.d.ts +10 -2
  105. package/dist/doctor/types.js +6 -0
  106. package/dist/doctor/wrangler-config.d.ts +7 -2
  107. package/dist/doctor/wrangler-config.js +3 -0
  108. package/dist/email.d.ts +4 -2
  109. package/dist/env.d.ts +0 -3
  110. package/dist/env.js +0 -3
  111. package/dist/github/branches.d.ts +4 -2
  112. package/dist/github/branches.js +4 -2
  113. package/dist/github/signing.d.ts +1 -1
  114. package/dist/github/signing.js +2 -2
  115. package/dist/log/events.d.ts +1 -1
  116. package/dist/media/bulk-delete-plan.d.ts +8 -4
  117. package/dist/media/config.d.ts +12 -6
  118. package/dist/media/config.js +16 -8
  119. package/dist/media/delivery-bucket.d.ts +4 -2
  120. package/dist/media/library-entry.d.ts +4 -2
  121. package/dist/media/library-entry.js +4 -2
  122. package/dist/media/manifest.d.ts +29 -15
  123. package/dist/media/manifest.js +29 -16
  124. package/dist/media/naming.d.ts +12 -6
  125. package/dist/media/naming.js +24 -12
  126. package/dist/media/orphan-scan.d.ts +4 -2
  127. package/dist/media/reconcile.d.ts +21 -11
  128. package/dist/media/reconcile.js +12 -6
  129. package/dist/media/reference.d.ts +8 -4
  130. package/dist/media/reference.js +12 -6
  131. package/dist/media/rewrite-plan.d.ts +12 -6
  132. package/dist/media/sniff.d.ts +4 -2
  133. package/dist/media/sniff.js +28 -14
  134. package/dist/media/store.d.ts +16 -8
  135. package/dist/media/store.js +4 -2
  136. package/dist/media/transform-url.d.ts +12 -6
  137. package/dist/media/transform-url.js +8 -4
  138. package/dist/media/usage.d.ts +8 -4
  139. package/dist/nav/site-config.d.ts +16 -8
  140. package/dist/render/component-grammar.d.ts +23 -10
  141. package/dist/render/component-grammar.js +19 -8
  142. package/dist/render/component-insert.d.ts +8 -4
  143. package/dist/render/component-insert.js +4 -2
  144. package/dist/render/component-reference.d.ts +4 -2
  145. package/dist/render/component-reference.js +4 -2
  146. package/dist/render/component-validate.d.ts +3 -0
  147. package/dist/render/component-validate.js +3 -0
  148. package/dist/render/glyph.d.ts +4 -2
  149. package/dist/render/glyph.js +4 -2
  150. package/dist/render/pipeline.d.ts +20 -10
  151. package/dist/render/pipeline.js +4 -2
  152. package/dist/render/registry.d.ts +40 -20
  153. package/dist/render/registry.js +16 -8
  154. package/dist/render/rehype-dispatch.d.ts +22 -8
  155. package/dist/render/rehype-dispatch.js +22 -8
  156. package/dist/render/remark-directives.d.ts +3 -0
  157. package/dist/render/remark-directives.js +3 -0
  158. package/dist/render/remark-figure.d.ts +4 -2
  159. package/dist/render/remark-figure.js +4 -2
  160. package/dist/render/resolve-links.d.ts +4 -2
  161. package/dist/render/resolve-links.js +4 -2
  162. package/dist/render/resolve-media.d.ts +16 -8
  163. package/dist/render/resolve-media.js +12 -6
  164. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  165. package/dist/sveltekit/admin-dispatch.js +9 -3
  166. package/dist/sveltekit/auth-routes.d.ts +3 -0
  167. package/dist/sveltekit/auth-routes.js +3 -0
  168. package/dist/sveltekit/cairn-admin.d.ts +16 -5
  169. package/dist/sveltekit/cairn-admin.js +26 -10
  170. package/dist/sveltekit/content-routes.d.ts +191 -86
  171. package/dist/sveltekit/content-routes.js +295 -107
  172. package/dist/sveltekit/editors-routes.d.ts +3 -0
  173. package/dist/sveltekit/editors-routes.js +3 -0
  174. package/dist/sveltekit/guard.d.ts +4 -2
  175. package/dist/sveltekit/guard.js +4 -2
  176. package/dist/sveltekit/https-required-page.d.ts +1 -1
  177. package/dist/sveltekit/https-required-page.js +1 -1
  178. package/dist/sveltekit/index.d.ts +1 -1
  179. package/dist/sveltekit/media-route.d.ts +1 -2
  180. package/dist/sveltekit/media-route.js +13 -8
  181. package/dist/sveltekit/nav-routes.d.ts +7 -2
  182. package/dist/sveltekit/nav-routes.js +3 -0
  183. package/dist/sveltekit/types.d.ts +4 -2
  184. package/dist/vite/index.d.ts +32 -16
  185. package/dist/vite/index.js +52 -26
  186. package/dist/vite/resolve-root.d.ts +8 -4
  187. package/dist/vite/resolve-root.js +4 -2
  188. package/package.json +8 -2
  189. package/src/lib/components/AdminLayout.svelte +22 -0
  190. package/src/lib/components/CairnAdmin.svelte +3 -0
  191. package/src/lib/components/CairnTidySettings.svelte +2 -2
  192. package/src/lib/components/ComponentForm.svelte +0 -1
  193. package/src/lib/components/EditPage.svelte +133 -41
  194. package/src/lib/components/HelpHome.svelte +850 -0
  195. package/src/lib/components/MarkdownHelpDialog.svelte +4 -15
  196. package/src/lib/components/client-ingest.ts +20 -10
  197. package/src/lib/components/editor-media.ts +20 -10
  198. package/src/lib/components/editor-placeholder.ts +12 -6
  199. package/src/lib/components/editor-tidy.ts +28 -14
  200. package/src/lib/components/index.ts +1 -0
  201. package/src/lib/components/link-completion.ts +12 -6
  202. package/src/lib/components/markdown-directives.ts +13 -8
  203. package/src/lib/components/markdown-format.ts +63 -30
  204. package/src/lib/components/markdown-reference.ts +30 -0
  205. package/src/lib/components/media-upload-outcome.ts +12 -6
  206. package/src/lib/components/objective-errors.ts +16 -8
  207. package/src/lib/components/preview-doc.ts +4 -2
  208. package/src/lib/components/spellcheck.ts +92 -40
  209. package/src/lib/components/tidy-categorize.ts +28 -14
  210. package/src/lib/components/tidy-validate.ts +28 -14
  211. package/src/lib/components/topbar-context.ts +4 -2
  212. package/src/lib/content/advisories.ts +141 -0
  213. package/src/lib/content/compose.ts +5 -2
  214. package/src/lib/content/excerpt.ts +4 -2
  215. package/src/lib/content/getting-started.ts +31 -0
  216. package/src/lib/content/links.ts +16 -8
  217. package/src/lib/content/manifest.ts +36 -18
  218. package/src/lib/content/media-refs.ts +4 -2
  219. package/src/lib/content/media-rewrite.ts +100 -50
  220. package/src/lib/content/schema.ts +20 -10
  221. package/src/lib/content/site-dictionary.ts +8 -4
  222. package/src/lib/content/types.ts +97 -42
  223. package/src/lib/delivery/content-index.ts +16 -8
  224. package/src/lib/delivery/feeds.ts +4 -2
  225. package/src/lib/delivery/json-ld.ts +3 -0
  226. package/src/lib/delivery/manifest.ts +4 -2
  227. package/src/lib/delivery/public-routes.ts +16 -8
  228. package/src/lib/delivery/seo-fields.ts +12 -6
  229. package/src/lib/delivery/site-indexes.ts +4 -2
  230. package/src/lib/delivery/site-resolver.ts +4 -2
  231. package/src/lib/doctor/cloudflare-api.ts +6 -0
  232. package/src/lib/doctor/index.ts +12 -6
  233. package/src/lib/doctor/report.ts +3 -0
  234. package/src/lib/doctor/run.ts +3 -0
  235. package/src/lib/doctor/types.ts +10 -2
  236. package/src/lib/doctor/wrangler-config.ts +7 -2
  237. package/src/lib/email.ts +4 -2
  238. package/src/lib/env.ts +0 -3
  239. package/src/lib/github/branches.ts +4 -2
  240. package/src/lib/github/signing.ts +2 -2
  241. package/src/lib/log/events.ts +1 -0
  242. package/src/lib/media/bulk-delete-plan.ts +8 -4
  243. package/src/lib/media/config.ts +24 -12
  244. package/src/lib/media/delivery-bucket.ts +4 -2
  245. package/src/lib/media/library-entry.ts +4 -2
  246. package/src/lib/media/manifest.ts +33 -18
  247. package/src/lib/media/naming.ts +24 -12
  248. package/src/lib/media/orphan-scan.ts +4 -2
  249. package/src/lib/media/reconcile.ts +21 -11
  250. package/src/lib/media/reference.ts +12 -6
  251. package/src/lib/media/rewrite-plan.ts +12 -6
  252. package/src/lib/media/sniff.ts +28 -14
  253. package/src/lib/media/store.ts +16 -8
  254. package/src/lib/media/transform-url.ts +12 -6
  255. package/src/lib/media/usage.ts +8 -4
  256. package/src/lib/nav/site-config.ts +16 -8
  257. package/src/lib/render/component-grammar.ts +23 -10
  258. package/src/lib/render/component-insert.ts +8 -4
  259. package/src/lib/render/component-reference.ts +4 -2
  260. package/src/lib/render/component-validate.ts +3 -0
  261. package/src/lib/render/glyph.ts +4 -2
  262. package/src/lib/render/pipeline.ts +20 -10
  263. package/src/lib/render/registry.ts +44 -22
  264. package/src/lib/render/rehype-dispatch.ts +22 -8
  265. package/src/lib/render/remark-directives.ts +3 -0
  266. package/src/lib/render/remark-figure.ts +4 -2
  267. package/src/lib/render/resolve-links.ts +4 -2
  268. package/src/lib/render/resolve-media.ts +16 -8
  269. package/src/lib/sveltekit/admin-dispatch.ts +10 -4
  270. package/src/lib/sveltekit/auth-routes.ts +3 -0
  271. package/src/lib/sveltekit/cairn-admin.ts +37 -15
  272. package/src/lib/sveltekit/content-routes.ts +492 -197
  273. package/src/lib/sveltekit/editors-routes.ts +3 -0
  274. package/src/lib/sveltekit/guard.ts +4 -2
  275. package/src/lib/sveltekit/https-required-page.ts +1 -1
  276. package/src/lib/sveltekit/index.ts +3 -0
  277. package/src/lib/sveltekit/media-route.ts +13 -8
  278. package/src/lib/sveltekit/nav-routes.ts +7 -2
  279. package/src/lib/sveltekit/types.ts +4 -2
  280. package/src/lib/vite/index.ts +60 -30
  281. package/src/lib/vite/resolve-root.ts +8 -4
@@ -31,1265 +31,876 @@ orphan face is a calm confirm. Both post to `?/mediaDelete`. A `form` carrying a
31
31
  It is node-safe by construction: it types assets with MediaLibraryEntry from the shared node-safe
32
32
  projection and pulls in no editor module (the editor-boundary test bars a @codemirror leak).
33
33
  -->
34
- <script lang="ts">
35
- import { flushSync, getContext, tick } from 'svelte';
36
- import { deserialize } from '$app/forms';
37
- import { invalidateAll } from '$app/navigation';
38
- import type { MediaLibraryEntry } from '../media/library-entry.js';
39
- import type {
40
- MediaLibraryData,
41
- ContentFormFailure,
42
- MediaReplacePreviewPlan,
43
- MediaReplaceFailure,
44
- MediaReplacePreviewEntry,
45
- MediaAltPreviewPlan,
46
- MediaAltPropagateFailure,
47
- MediaBulkDeleteResult,
48
- MediaOrphanPurgeResult,
49
- MediaBulkFailure,
50
- } from '../sveltekit/content-routes.js';
51
- import type { OrphanScan } from '../media/orphan-scan.js';
52
- import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
53
- import type { AltPlacement } from '../content/media-rewrite.js';
54
- import type { UsageEntry } from '../media/usage.js';
55
- import type { MediaEntry } from '../media/manifest.js';
56
- import { publicPath } from '../media/naming.js';
57
- import { mediaToken } from '../media/reference.js';
58
- import { CSRF_CONTEXT_KEY } from './csrf-context.js';
59
- import {
60
- ingestFile,
61
- buildUploadRequest,
62
- sendUpload,
63
- ingestFailureKind,
64
- failureCard,
65
- type IngestFailureCard,
66
- } from './client-ingest.js';
67
- import { uploadOutcome, type UploadEnvelope } from './media-upload-outcome.js';
68
- import CsrfField from './CsrfField.svelte';
69
- import CairnLogo from './CairnLogo.svelte';
70
- import {
71
- SearchIcon,
72
- UploadIcon,
73
- LayoutGridIcon,
74
- ListIcon,
75
- CheckIcon,
76
- TriangleAlertIcon,
77
- ImageOffIcon,
78
- Trash2Icon,
79
- ChevronDownIcon,
80
- ChevronRightIcon,
81
- XIcon,
82
- CopyIcon,
83
- FileTextIcon,
84
- ClockIcon,
85
- Link2OffIcon,
86
- RefreshCwIcon,
87
- GitBranchIcon,
88
- ArrowRightIcon,
89
- MegaphoneIcon,
90
- DatabaseIcon,
91
- } from './admin-icons.js';
92
-
93
- interface Props {
94
- /** The media library load's data: the unioned assets, the per-hash usage overlay, and a
95
- * degraded-load error. */
96
- data: MediaLibraryData;
97
- /** The last media action's result. A `?/mediaDelete` refusal carries the fresh breaking list
98
- * the in-use face re-opens on; a `?/mediaUpdate` failure carries the error the slide-over
99
- * surfaces. The route exports one `form`, so this is the merged `ContentFormFailure`. */
100
- form?: ContentFormFailure | null;
34
+ <script lang="ts">import { flushSync, getContext, tick } from "svelte";
35
+ import { deserialize } from "$app/forms";
36
+ import { invalidateAll } from "$app/navigation";
37
+ import { publicPath } from "../media/naming.js";
38
+ import { mediaToken } from "../media/reference.js";
39
+ import { CSRF_CONTEXT_KEY } from "./csrf-context.js";
40
+ import {
41
+ ingestFile,
42
+ buildUploadRequest,
43
+ sendUpload,
44
+ ingestFailureKind,
45
+ failureCard
46
+ } from "./client-ingest.js";
47
+ import { uploadOutcome } from "./media-upload-outcome.js";
48
+ import CsrfField from "./CsrfField.svelte";
49
+ import CairnLogo from "./CairnLogo.svelte";
50
+ import {
51
+ SearchIcon,
52
+ UploadIcon,
53
+ LayoutGridIcon,
54
+ ListIcon,
55
+ CheckIcon,
56
+ TriangleAlertIcon,
57
+ ImageOffIcon,
58
+ Trash2Icon,
59
+ ChevronDownIcon,
60
+ ChevronRightIcon,
61
+ XIcon,
62
+ CopyIcon,
63
+ FileTextIcon,
64
+ ClockIcon,
65
+ Link2OffIcon,
66
+ RefreshCwIcon,
67
+ GitBranchIcon,
68
+ ArrowRightIcon,
69
+ MegaphoneIcon,
70
+ DatabaseIcon
71
+ } from "./admin-icons.js";
72
+ let { data, form } = $props();
73
+ const FLASH_MESSAGE = {
74
+ deleted: "Asset deleted.",
75
+ updated: "Changes saved.",
76
+ replaced: "Asset replaced.",
77
+ altPropagated: "Alt text applied.",
78
+ bulkDeleted: "Assets deleted.",
79
+ orphansPurged: "Orphans purged."
80
+ };
81
+ const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : "");
82
+ function usageCount(hash) {
83
+ return data.usage[hash]?.count ?? 0;
84
+ }
85
+ function needsAlt(asset) {
86
+ return asset.alt.trim() === "";
87
+ }
88
+ const usedCount = $derived(data.assets.filter((a) => usageCount(a.hash) > 0).length);
89
+ const triageCounts = $derived({
90
+ all: data.assets.length,
91
+ needsAlt: data.assets.filter((a) => needsAlt(a)).length,
92
+ // No references found: no usage entry, or a count of zero. The internal enum stays `unused`; the
93
+ // visible label reads "No references found" because absence of a found reference is not proof of
94
+ // disuse (cairn cannot see a raw-HTML image or a URL hardcoded into a template).
95
+ unused: data.assets.filter((a) => usageCount(a.hash) === 0).length
96
+ });
97
+ const distinctTypes = $derived.by(() => {
98
+ const seen = /* @__PURE__ */ new Set();
99
+ for (const a of data.assets) seen.add(a.contentType.split("/")[0] ?? "");
100
+ return seen;
101
+ });
102
+ const showFacet = $derived(distinctTypes.size > 1);
103
+ let query = $state("");
104
+ let triage = $state("all");
105
+ let density = $state("grid");
106
+ const segments = [
107
+ { value: "all", label: "All", count: () => triageCounts.all },
108
+ { value: "needs-alt", label: "Needs alt", count: () => triageCounts.needsAlt },
109
+ { value: "unused", label: "No references found", count: () => triageCounts.unused }
110
+ ];
111
+ let segEls = $state([]);
112
+ function selectTriage(value) {
113
+ triage = value;
114
+ }
115
+ function onTriageKeydown(e, i) {
116
+ let next = i;
117
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % segments.length;
118
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + segments.length) % segments.length;
119
+ else if (e.key === "Home") next = 0;
120
+ else if (e.key === "End") next = segments.length - 1;
121
+ else return;
122
+ e.preventDefault();
123
+ selectTriage(segments[next].value);
124
+ segEls[next]?.focus();
125
+ }
126
+ function matchesTriage(asset) {
127
+ switch (triage) {
128
+ case "needs-alt":
129
+ return needsAlt(asset);
130
+ case "unused":
131
+ return usageCount(asset.hash) === 0;
132
+ default:
133
+ return true;
101
134
  }
102
-
103
- let { data, form }: Props = $props();
104
-
105
- // The success flash a redirected action carried back: a safe-delete or a metadata edit. The
106
- // conflict error (data.flashError) renders in the inline error treatment below instead.
107
- const FLASH_MESSAGE = {
108
- deleted: 'Asset deleted.',
109
- updated: 'Changes saved.',
110
- replaced: 'Asset replaced.',
111
- altPropagated: 'Alt text applied.',
112
- bulkDeleted: 'Assets deleted.',
113
- orphansPurged: 'Orphans purged.',
114
- } as const;
115
- const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
116
-
117
- // --- the per-hash usage facts the screen joins onto each asset ---
118
- /** The distinct-entry usage count for an asset; zero when the asset has no usage key. */
119
- function usageCount(hash: string): number {
120
- return data.usage[hash]?.count ?? 0;
121
- }
122
- /** Empty alt is the needs-alt signal (the asset carries no caption field, so this is the only
123
- * per-asset alt fact). A non-image asset would read Not applicable, but the delivery route is
124
- * image-only today, so every committed asset here is an image. */
125
- function needsAlt(asset: MediaLibraryEntry): boolean {
126
- return asset.alt.trim() === '';
127
- }
128
-
129
- // --- the live count line and the triage counts, over the FULL loaded set ---
130
- const usedCount = $derived(data.assets.filter((a) => usageCount(a.hash) > 0).length);
131
- const triageCounts = $derived({
132
- all: data.assets.length,
133
- needsAlt: data.assets.filter((a) => needsAlt(a)).length,
134
- // No references found: no usage entry, or a count of zero. The internal enum stays `unused`; the
135
- // visible label reads "No references found" because absence of a found reference is not proof of
136
- // disuse (cairn cannot see a raw-HTML image or a URL hardcoded into a template).
137
- unused: data.assets.filter((a) => usageCount(a.hash) === 0).length,
135
+ }
136
+ const filtered = $derived.by(() => {
137
+ const q = query.trim().toLowerCase();
138
+ return data.assets.filter((a) => {
139
+ if (!matchesTriage(a)) return false;
140
+ if (!q) return true;
141
+ return a.displayName.toLowerCase().includes(q) || a.alt.toLowerCase().includes(q);
138
142
  });
139
-
140
- // The type facet (Images, Documents) is a designed-in seam: it stays hidden until the library
141
- // holds more than one top-level content type. The delivery route is image-only today, so this is
142
- // present without dead UI. (No selection state yet; the seam is the visibility computation.)
143
- const distinctTypes = $derived.by(() => {
144
- const seen = new Set<string>();
145
- for (const a of data.assets) seen.add(a.contentType.split('/')[0] ?? '');
146
- return seen;
143
+ });
144
+ let sortAsc = $state(false);
145
+ const sorted = $derived.by(() => {
146
+ return [...filtered].sort((a, b) => {
147
+ const cmp = a.createdAt.localeCompare(b.createdAt);
148
+ return sortAsc ? cmp : -cmp;
147
149
  });
148
- const showFacet = $derived(distinctTypes.size > 1);
149
-
150
- // --- the toolbar state ---
151
- type Triage = 'all' | 'needs-alt' | 'unused';
152
- type Density = 'grid' | 'list';
153
- let query = $state('');
154
- let triage = $state<Triage>('all');
155
- let density = $state<Density>('grid');
156
-
157
- // The triage segments, in display order, each naming its value, label, and live count.
158
- const segments: { value: Triage; label: string; count: () => number }[] = [
159
- { value: 'all', label: 'All', count: () => triageCounts.all },
160
- { value: 'needs-alt', label: 'Needs alt', count: () => triageCounts.needsAlt },
161
- { value: 'unused', label: 'No references found', count: () => triageCounts.unused },
162
- ];
163
-
164
- // The triage radiogroup's roving tabindex and ARIA radio keyboard pattern: the selected radio is
165
- // the only tab stop, and Arrow/Home/End move the selection and the focus, mirroring the grid's
166
- // roving listbox. A declared radiogroup owes this keyboard model.
167
- let segEls = $state<HTMLButtonElement[]>([]);
168
- function selectTriage(value: Triage) {
169
- triage = value;
170
- }
171
- function onTriageKeydown(e: KeyboardEvent, i: number) {
172
- let next = i;
173
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % segments.length;
174
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + segments.length) % segments.length;
175
- else if (e.key === 'Home') next = 0;
176
- else if (e.key === 'End') next = segments.length - 1;
177
- else return;
150
+ });
151
+ function toggleSort() {
152
+ sortAsc = !sortAsc;
153
+ }
154
+ const addedSort = $derived(sortAsc ? "ascending" : "descending");
155
+ const PAGE = 24;
156
+ let shown = $state(PAGE);
157
+ $effect(() => {
158
+ void sorted.length;
159
+ shown = PAGE;
160
+ });
161
+ const visible = $derived(sorted.slice(0, shown));
162
+ const hasMore = $derived(shown < sorted.length);
163
+ function loadMore() {
164
+ shown = Math.min(shown + PAGE, sorted.length);
165
+ }
166
+ let selected = $state(null);
167
+ let deleteOnly = $state(false);
168
+ let panelOrigin = null;
169
+ let panelEl = $state(null);
170
+ let closeButton = $state(null);
171
+ let deleteDialog = $state(null);
172
+ function openAsset(asset, origin) {
173
+ panelOrigin = origin ?? document.activeElement;
174
+ deleteOnly = false;
175
+ selected = asset;
176
+ flushSync();
177
+ closeButton?.focus();
178
+ }
179
+ function closePanel() {
180
+ selected = null;
181
+ deleteOnly = false;
182
+ panelOrigin?.focus();
183
+ panelOrigin = null;
184
+ }
185
+ function onWindowKeydown(e) {
186
+ if (e.key !== "Escape") return;
187
+ if (deleteDialog?.open || replaceDialog?.open || altDialog?.open || bulkDialog?.open || orphanDialog?.open) return;
188
+ if (selected && panelEl?.contains(document.activeElement)) {
178
189
  e.preventDefault();
179
- selectTriage(segments[next].value);
180
- segEls[next]?.focus();
181
- }
182
-
183
- function matchesTriage(asset: MediaLibraryEntry): boolean {
184
- switch (triage) {
185
- case 'needs-alt':
186
- return needsAlt(asset);
187
- case 'unused':
188
- return usageCount(asset.hash) === 0;
189
- default:
190
- return true;
191
- }
192
- }
193
-
194
- // Search spans the display name and the alt over the FULL set. MediaLibraryEntry carries no
195
- // caption field, so there is nothing further to search; the toolbar copy says "name or alt".
196
- const filtered = $derived.by(() => {
197
- const q = query.trim().toLowerCase();
198
- return data.assets.filter((a) => {
199
- if (!matchesTriage(a)) return false;
200
- if (!q) return true;
201
- return a.displayName.toLowerCase().includes(q) || a.alt.toLowerCase().includes(q);
202
- });
203
- });
204
-
205
- // --- sorting (the list density's Added column) ---
206
- let sortAsc = $state(false); // newest-first by default, the usual CMS convention
207
- const sorted = $derived.by(() => {
208
- // Lexical compare on the ISO createdAt is chronological; copy first so the source order holds.
209
- return [...filtered].sort((a, b) => {
210
- const cmp = a.createdAt.localeCompare(b.createdAt);
211
- return sortAsc ? cmp : -cmp;
212
- });
213
- });
214
- function toggleSort() {
215
- sortAsc = !sortAsc;
190
+ closePanel();
191
+ return;
216
192
  }
217
- const addedSort = $derived(sortAsc ? 'ascending' : 'descending');
218
-
219
- // --- the client pagination window (a growing visible count, never infinite scroll) ---
220
- const PAGE = 24;
221
- let shown = $state(PAGE);
222
- // Reset the window whenever the filtered set changes so a narrowing filter never strands the
223
- // window past the result count. (Reading `sorted.length` ties this to filter/sort/search.)
224
- $effect(() => {
225
- void sorted.length;
226
- shown = PAGE;
227
- });
228
- const visible = $derived(sorted.slice(0, shown));
229
- const hasMore = $derived(shown < sorted.length);
230
- function loadMore() {
231
- shown = Math.min(shown + PAGE, sorted.length);
193
+ if (selectedCount > 0) {
194
+ const active = document.activeElement;
195
+ const inSearch = active instanceof HTMLInputElement && active.type === "search";
196
+ if (inSearch) return;
197
+ e.preventDefault();
198
+ clearSelection();
232
199
  }
233
-
234
- // --- selection, the slide-over, and the safe-delete dialog ---
235
- // `selected` is the asset the slide-over (and the alertdialog) render off. The table's per-row
236
- // trash opens the alertdialog straight to the right face for that asset (requestDelete) without
237
- // opening the slide-over; a tile or row activation opens the slide-over (openAsset).
238
- let selected = $state<MediaLibraryEntry | null>(null);
239
- // True while the dialog was opened straight from a row trash without the slide-over, so the
240
- // {#if selected} slide-over stays closed for a delete-only intent.
241
- let deleteOnly = $state(false);
242
-
243
- // The element that opened the slide-over (a tile or a row trigger), so focus returns to it on
244
- // close (the non-modal region recipe: focus moves in on open, back to the origin on close).
245
- let panelOrigin: HTMLElement | null = null;
246
- let panelEl = $state<HTMLElement | null>(null);
247
- let closeButton = $state<HTMLButtonElement | null>(null);
248
- let deleteDialog = $state<HTMLDialogElement | null>(null);
249
-
250
- function openAsset(asset: MediaLibraryEntry, origin?: HTMLElement | null) {
251
- panelOrigin = origin ?? (document.activeElement as HTMLElement | null);
200
+ }
201
+ function requestDelete(asset) {
202
+ deleteOnly = true;
203
+ selected = asset;
204
+ openDeleteDialog();
205
+ }
206
+ function openDeleteDialog() {
207
+ confirmSlugInput = "";
208
+ flushSync();
209
+ deleteDialog?.showModal();
210
+ }
211
+ function closeDeleteDialog() {
212
+ deleteDialog?.close();
213
+ confirmSlugInput = "";
214
+ if (deleteOnly) {
252
215
  deleteOnly = false;
253
- selected = asset;
254
- // flushSync mounts the panel synchronously so its close button exists before we move focus in.
255
- flushSync();
256
- closeButton?.focus();
257
- }
258
- /** Close the slide-over and return focus to the tile or row that opened it. */
259
- function closePanel() {
260
216
  selected = null;
261
- deleteOnly = false;
262
- panelOrigin?.focus();
263
- panelOrigin = null;
264
217
  }
265
- // Escape closes the slide-over (the non-modal region recipe). A window listener carries it, the
266
- // way EditPage's details panel does, so the non-interactive region needs no keyboard handler. The
267
- // dialog (when open) claims Escape natively, so the panel handles it only when no dialog is up.
268
- // Escape is also the native clear gesture for the toolbar's type="search" input, so the close
269
- // fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
270
- // panel exactly as the user left it, while an Escape with focus in the panel still closes it.
271
- // Escape precedence (no overlap): an open dialog claims Escape natively (its showModal owns it, so
272
- // this handler stands down while any dialog is open); else an open slide-over with focus inside it
273
- // closes (today's behavior); else a non-empty selection is cleared. The search box keeps its own
274
- // native Escape-to-clear: the selection clear fires only when focus is NOT in the search input.
275
- function onWindowKeydown(e: KeyboardEvent) {
276
- if (e.key !== 'Escape') return;
277
- if (deleteDialog?.open || replaceDialog?.open || altDialog?.open || bulkDialog?.open || orphanDialog?.open) return;
278
- if (selected && panelEl?.contains(document.activeElement)) {
279
- e.preventDefault();
280
- closePanel();
281
- return;
282
- }
283
- if (selectedCount > 0) {
284
- const active = document.activeElement as HTMLElement | null;
285
- const inSearch = active instanceof HTMLInputElement && active.type === 'search';
286
- if (inSearch) return;
287
- e.preventDefault();
288
- clearSelection();
289
- }
218
+ }
219
+ const csrf = getContext(CSRF_CONTEXT_KEY);
220
+ let replaceDialog = $state(null);
221
+ let replaceOrigin = null;
222
+ let replaceCancelButton = $state(null);
223
+ let replaceFileInput = $state(null);
224
+ let replaceStep = $state("upload");
225
+ let replaceUpload = $state({ kind: "idle" });
226
+ let replaceRecord = $state(null);
227
+ let replacePlan = $state(null);
228
+ let replaceFailure = $state(null);
229
+ let replaceConfirmInput = $state("");
230
+ let replaceAsset = $state(null);
231
+ const replaceConfirmMatches = $derived(replaceAsset !== null && replaceConfirmInput === replaceAsset.slug);
232
+ function openReplaceDialog(origin) {
233
+ if (!selected) return;
234
+ replaceOrigin = origin ?? document.activeElement ?? null;
235
+ replaceAsset = selected;
236
+ replaceStep = "upload";
237
+ replaceUpload = { kind: "idle" };
238
+ replaceRecord = null;
239
+ replacePlan = null;
240
+ replaceFailure = null;
241
+ replaceConfirmInput = "";
242
+ void tick().then(() => {
243
+ replaceDialog?.showModal();
244
+ replaceCancelButton?.focus();
245
+ });
246
+ }
247
+ function closeReplaceDialog() {
248
+ replaceDialog?.close();
249
+ replaceAsset = null;
250
+ replaceRecord = null;
251
+ replacePlan = null;
252
+ replaceFailure = null;
253
+ replaceConfirmInput = "";
254
+ replaceUpload = { kind: "idle" };
255
+ replaceOrigin?.focus();
256
+ replaceOrigin = null;
257
+ }
258
+ function onReplaceFileChosen(e) {
259
+ const input = e.currentTarget;
260
+ const file = input.files?.[0];
261
+ if (file) void runReplaceUpload(file);
262
+ }
263
+ async function runReplaceUpload(file) {
264
+ if (!replaceAsset) return;
265
+ replaceUpload = { kind: "working" };
266
+ const genericFail = () => replaceUpload = {
267
+ kind: "failed",
268
+ card: { status: "failed", message: GENERIC_UPLOAD_MESSAGE },
269
+ retry: () => void runReplaceUpload(file)
270
+ };
271
+ let ingested;
272
+ try {
273
+ ingested = await ingestFile(file);
274
+ } catch (err) {
275
+ replaceUpload = { kind: "failed", card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
276
+ return;
290
277
  }
291
-
292
- // The per-row delete intent opens the alertdialog directly on the right face for that asset.
293
- function requestDelete(asset: MediaLibraryEntry) {
294
- deleteOnly = true;
295
- selected = asset;
296
- openDeleteDialog();
278
+ const built = buildUploadRequest({
279
+ conceptId: "",
280
+ id: "",
281
+ bytes: ingested.blob,
282
+ contentType: ingested.contentType,
283
+ csrf: csrf?.() ?? "",
284
+ filename: file.name,
285
+ width: ingested.width,
286
+ height: ingested.height
287
+ });
288
+ let res;
289
+ try {
290
+ res = await sendUpload(REPLACE_UPLOAD_URL, built.init);
291
+ } catch (err) {
292
+ replaceUpload = { kind: "failed", card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
293
+ return;
297
294
  }
298
- // The slide-over's Delete button opens the same dialog for the already-selected asset.
299
- function openDeleteDialog() {
300
- confirmSlugInput = '';
301
- flushSync();
302
- deleteDialog?.showModal();
295
+ if (res.type === "opaqueredirect" || res.status === 0) {
296
+ genericFail();
297
+ return;
303
298
  }
304
- function closeDeleteDialog() {
305
- deleteDialog?.close();
306
- confirmSlugInput = '';
307
- // A row-only delete leaves no slide-over to return to, so clear the selection on cancel.
308
- if (deleteOnly) {
309
- deleteOnly = false;
310
- selected = null;
311
- }
299
+ let outcome;
300
+ try {
301
+ outcome = uploadOutcome(deserialize(await res.text()));
302
+ } catch {
303
+ genericFail();
304
+ return;
312
305
  }
313
-
314
- // --- the Replace flow: a two-step alertdialog (upload, then impact review) over the selected asset ---
315
- // Replace uploads a new file for the selected asset; cairn is content-addressed, so the new file has a
316
- // new hash and every published reference is repointed to it in one commit to main. The dialog opens on
317
- // the quiet upload step, holds the server-owned record on a successful upload, fetches the preview
318
- // (fail-closed), and renders the impact review behind a typed-slug gate. The CSRF token getter comes
319
- // from the admin context, the same seam the insert popover reads.
320
- const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
321
-
322
- type ReplaceStep = 'upload' | 'review' | 'blocked';
323
- // The transient upload status under the upload step: idle, an in-flight ingest/upload, or a typed
324
- // ingest failure card with a retry. Mirrors the insert popover's failed-card grammar.
325
- type ReplaceUpload =
326
- | { kind: 'idle' }
327
- | { kind: 'working' }
328
- | { kind: 'failed'; card: IngestFailureCard | { status: 'failed'; message: string }; retry: () => void };
329
-
330
- let replaceDialog = $state<HTMLDialogElement | null>(null);
331
- // The entry-point button that opened the dialog, so focus restores to it on close (the alertdialog
332
- // recipe, like the delete dialog's slide-over Delete button).
333
- let replaceOrigin: HTMLElement | null = null;
334
- // The Cancel control, the destructive-confirm initial focus.
335
- let replaceCancelButton = $state<HTMLButtonElement | null>(null);
336
- let replaceFileInput = $state<HTMLInputElement | null>(null);
337
- let replaceStep = $state<ReplaceStep>('upload');
338
- let replaceUpload = $state<ReplaceUpload>({ kind: 'idle' });
339
- // The server-owned record the upload returned (the new asset), held for the preview and the apply.
340
- let replaceRecord = $state<MediaEntry | null>(null);
341
- // The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
342
- let replacePlan = $state<MediaReplacePreviewPlan | null>(null);
343
- let replaceFailure = $state<MediaReplaceFailure | null>(null);
344
- // The typed-slug confirm gate, echoing the delete dialog's type-to-confirm.
345
- let replaceConfirmInput = $state('');
346
- // The asset the Replace dialog acts on, pinned at open so a background re-render never swaps it.
347
- let replaceAsset = $state<MediaLibraryEntry | null>(null);
348
- const replaceConfirmMatches = $derived(replaceAsset !== null && replaceConfirmInput === replaceAsset.slug);
349
-
350
- function openReplaceDialog(origin?: HTMLElement | null) {
351
- if (!selected) return;
352
- // The entry-point button passed from the click (focus restores here on close), falling back to the
353
- // active element. A programmatic .click() does not focus its target, so the explicit origin is the
354
- // reliable restore point.
355
- replaceOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
356
- replaceAsset = selected;
357
- replaceStep = 'upload';
358
- replaceUpload = { kind: 'idle' };
359
- replaceRecord = null;
360
- replacePlan = null;
361
- replaceFailure = null;
362
- replaceConfirmInput = '';
363
- // Show the dialog after the step state flushes, then move focus to Cancel.
364
- void tick().then(() => {
365
- replaceDialog?.showModal();
366
- replaceCancelButton?.focus();
367
- });
306
+ if (outcome.kind !== "inserted") {
307
+ genericFail();
308
+ return;
368
309
  }
369
- function closeReplaceDialog() {
370
- replaceDialog?.close();
371
- replaceAsset = null;
372
- replaceRecord = null;
310
+ replaceRecord = outcome.record;
311
+ replaceUpload = { kind: "idle" };
312
+ await runReplacePreview();
313
+ }
314
+ let replacePreviewSeq = 0;
315
+ async function runReplacePreview() {
316
+ if (!replaceAsset || !replaceRecord) return;
317
+ const hash = replaceAsset.hash;
318
+ const seq = ++replacePreviewSeq;
319
+ const blockClosed = (failure) => {
320
+ replaceFailure = failure ?? { error: "", hash, usage: [], foundIn: 0 };
373
321
  replacePlan = null;
374
- replaceFailure = null;
375
- replaceConfirmInput = '';
376
- replaceUpload = { kind: 'idle' };
377
- // Restore focus to the entry-point button (the alertdialog focus-restore recipe).
378
- replaceOrigin?.focus();
379
- replaceOrigin = null;
380
- }
381
-
382
- // The chosen-file handler: route the file through the ingest-and-upload loop, exactly as the insert
383
- // popover does, then fetch the preview. A file is the only path (Pass B is upload-new-only).
384
- function onReplaceFileChosen(e: Event) {
385
- const input = e.currentTarget as HTMLInputElement;
386
- const file = input.files?.[0];
387
- if (file) void runReplaceUpload(file);
388
- }
389
-
390
- // The upload loop for the new file. It ingests (decode/transcode), uploads through the shared
391
- // transport, and on the success envelope holds the new record and runs the preview. A typed ingest or
392
- // upload failure surfaces a retry card on the upload step; an expired session reads as a generic card.
393
- // The upload posts to the media-scoped ?/mediaUpload action: the Library is not entry-scoped, so it
394
- // overrides buildUploadRequest's entry URL while reusing its header-and-body transport verbatim.
395
- async function runReplaceUpload(file: File) {
396
- if (!replaceAsset) return;
397
- replaceUpload = { kind: 'working' };
398
- const genericFail = () =>
399
- (replaceUpload = {
400
- kind: 'failed',
401
- card: { status: 'failed', message: GENERIC_UPLOAD_MESSAGE },
402
- retry: () => void runReplaceUpload(file),
403
- });
404
-
405
- let ingested: Awaited<ReturnType<typeof ingestFile>>;
406
- try {
407
- ingested = await ingestFile(file);
408
- } catch (err) {
409
- replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
410
- return;
411
- }
412
-
413
- const built = buildUploadRequest({
414
- conceptId: '',
415
- id: '',
416
- bytes: ingested.blob,
417
- contentType: ingested.contentType,
418
- csrf: csrf?.() ?? '',
419
- filename: file.name,
420
- width: ingested.width,
421
- height: ingested.height,
322
+ replaceStep = "blocked";
323
+ };
324
+ const body = JSON.stringify({ oldHash: hash, newHash: replaceRecord.hash, slug: replaceAsset.slug });
325
+ let result;
326
+ try {
327
+ const res = await fetch(REPLACE_PREVIEW_URL, {
328
+ method: "POST",
329
+ headers: { "Content-Type": "text/plain", "X-Cairn-CSRF": csrf?.() ?? "" },
330
+ body
422
331
  });
423
- let res: Response;
424
- try {
425
- res = await sendUpload(REPLACE_UPLOAD_URL, built.init);
426
- } catch (err) {
427
- replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
428
- return;
429
- }
430
- // The guard's expired-session 303 under redirect:'manual' surfaces as an opaque, status-0 response.
431
- if (res.type === 'opaqueredirect' || res.status === 0) {
432
- genericFail();
433
- return;
434
- }
435
- let outcome: ReturnType<typeof uploadOutcome>;
436
- try {
437
- outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
438
- } catch {
439
- genericFail();
440
- return;
441
- }
442
- if (outcome.kind !== 'inserted') {
443
- genericFail();
444
- return;
445
- }
446
- // Hold the server-owned record, then fetch the impact preview for (oldHash -> newHash).
447
- replaceRecord = outcome.record;
448
- replaceUpload = { kind: 'idle' };
449
- await runReplacePreview();
450
- }
451
-
452
- // A per-call request token guards the preview fetch against a stale response landing on a closed or
453
- // reopened dialog. Svelte reactivity does not track reads below the first `await`, so each call pins
454
- // its own sequence at entry and bails after the await if a newer call (a reopen, or a "Check usage
455
- // again" double-click) has since superseded it.
456
- let replacePreviewSeq = 0;
457
-
458
- // The preview fetch: POST the (oldHash, newHash, slug) tuple in the 2a transport (a text/plain
459
- // body, the CSRF token in the X-Cairn-CSRF header), parse the SvelteKit ActionResult envelope, and
460
- // route to the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the
461
- // blocked step's "Check usage again". The slug is the OLD asset's: a replace keeps the name and
462
- // changes only the content hash, so the repointed token carries the existing slug, not the new file's.
463
- async function runReplacePreview() {
464
- if (!replaceAsset || !replaceRecord) return;
465
- const hash = replaceAsset.hash;
466
- const seq = ++replacePreviewSeq;
467
- // The fail-closed landing: an unverifiable usage read, an unreachable preview, or an unparseable
468
- // body all route to the blocked step. The passed failure carries the branch-naming error when the
469
- // server returned one; a transport miss carries the empty error (the generic honest line stands in).
470
- const blockClosed = (failure?: MediaReplaceFailure) => {
471
- replaceFailure = failure ?? { error: '', hash, usage: [], foundIn: 0 };
472
- replacePlan = null;
473
- replaceStep = 'blocked';
474
- };
475
-
476
- const body = JSON.stringify({ oldHash: hash, newHash: replaceRecord.hash, slug: replaceAsset.slug });
477
- let result: { type: string; data?: unknown };
478
- try {
479
- const res = await fetch(REPLACE_PREVIEW_URL, {
480
- method: 'POST',
481
- headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
482
- body,
483
- });
484
- result = deserialize(await res.text()) as { type: string; data?: unknown };
485
- } catch {
486
- // Drop a stale response that lost the race to a reopen or a re-run before surfacing the block.
487
- if (seq !== replacePreviewSeq) return;
488
- blockClosed();
489
- return;
490
- }
491
- // The dialog was closed or reopened (for another asset, or via a re-run) while this fetch was in
492
- // flight, so this response is stale: ignore it rather than clobber the live state.
332
+ result = deserialize(await res.text());
333
+ } catch {
493
334
  if (seq !== replacePreviewSeq) return;
494
- if (result.type === 'success' && result.data) {
495
- replacePlan = result.data as MediaReplacePreviewPlan;
496
- replaceFailure = null;
497
- replaceConfirmInput = '';
498
- replaceStep = 'review';
499
- } else {
500
- blockClosed(result.data as MediaReplaceFailure | undefined);
501
- }
335
+ blockClosed();
336
+ return;
502
337
  }
503
-
504
- const GENERIC_UPLOAD_MESSAGE = 'The upload could not be completed. Please try again.';
505
- // The media-scoped upload and preview action URLs, relative to /admin/media. The upload reuses the
506
- // shared ingest transport but the Library has no entry, so it targets ?/mediaUpload rather than the
507
- // entry-scoped ?/upload. The apply form below posts ?/mediaReplace.
508
- const REPLACE_UPLOAD_URL = '?/mediaUpload';
509
- const REPLACE_PREVIEW_URL = '?/mediaReplacePreview';
510
-
511
- // The affected-entry well caps past this many rows; "Show all N" reveals the rest into the same
512
- // scroll container (the a11y contract: aria-expanded + aria-controls).
513
- const REPLACE_ROW_CAP = 8;
514
- let replaceShowAll = $state(false);
515
- // The affected-entry list element, so "Show all" can move focus to the first newly revealed row (the
516
- // one just past the cap) instead of dropping to <body> when the expander button unmounts.
517
- let replaceEntriesList = $state<HTMLElement | null>(null);
518
- $effect(() => {
519
- // Reset the reveal whenever a fresh plan arrives, so a second preview never opens pre-expanded.
520
- void replacePlan;
521
- replaceShowAll = false;
522
- });
523
- // Reveal the capped rows, then move focus to the first newly revealed row (the rev.2 contract). The
524
- // expander unmounts on the flag flip, so without this focus falls to <body>.
525
- function showAllReplaceEntries() {
526
- replaceShowAll = true;
527
- void tick().then(() => (replaceEntriesList?.children[REPLACE_ROW_CAP] as HTMLElement | undefined)?.focus());
338
+ if (seq !== replacePreviewSeq) return;
339
+ if (result.type === "success" && result.data) {
340
+ replacePlan = result.data;
341
+ replaceFailure = null;
342
+ replaceConfirmInput = "";
343
+ replaceStep = "review";
344
+ } else {
345
+ blockClosed(result.data);
528
346
  }
529
- const replaceEntries = $derived(replacePlan?.entries ?? []);
530
- const replaceVisibleEntries = $derived(
531
- replaceShowAll ? replaceEntries : replaceEntries.slice(0, REPLACE_ROW_CAP),
532
- );
533
- const replaceHiddenCount = $derived(Math.max(0, replaceEntries.length - REPLACE_ROW_CAP));
534
- // The server's distinct affected-entry count, read in several places across the review markup and
535
- // the apply button. Coalesced once here so each read stays a plain number.
536
- const replaceAffected = $derived(replacePlan?.affectedCount ?? 0);
537
-
538
- // The where-used summary line for one affected entry, derived from its repointed placements: a hero
539
- // count and a body count, folded into a plain phrase ("Hero and 2 in the body", "1 in the body").
540
- function replaceWhereUsed(entry: MediaReplacePreviewEntry): string {
541
- let hero = 0;
542
- let body = 0;
543
- for (const p of entry.placements) {
544
- if (p.kind === 'hero') hero += 1;
545
- else body += 1;
546
- }
547
- const parts: string[] = [];
548
- if (hero > 0) parts.push(hero === 1 ? 'Hero' : `${hero} heroes`);
549
- if (body > 0) parts.push(`${body} in the body`);
550
- return parts.length > 0 ? parts.join(' and ') : 'Used in this entry';
347
+ }
348
+ const GENERIC_UPLOAD_MESSAGE = "The upload could not be completed. Please try again.";
349
+ const REPLACE_UPLOAD_URL = "?/mediaUpload";
350
+ const REPLACE_PREVIEW_URL = "?/mediaReplacePreview";
351
+ const REPLACE_ROW_CAP = 8;
352
+ let replaceShowAll = $state(false);
353
+ let replaceEntriesList = $state(null);
354
+ $effect(() => {
355
+ void replacePlan;
356
+ replaceShowAll = false;
357
+ });
358
+ function showAllReplaceEntries() {
359
+ replaceShowAll = true;
360
+ void tick().then(() => replaceEntriesList?.children[REPLACE_ROW_CAP]?.focus());
361
+ }
362
+ const replaceEntries = $derived(replacePlan?.entries ?? []);
363
+ const replaceVisibleEntries = $derived(
364
+ replaceShowAll ? replaceEntries : replaceEntries.slice(0, REPLACE_ROW_CAP)
365
+ );
366
+ const replaceHiddenCount = $derived(Math.max(0, replaceEntries.length - REPLACE_ROW_CAP));
367
+ const replaceAffected = $derived(replacePlan?.affectedCount ?? 0);
368
+ function replaceWhereUsed(entry) {
369
+ let hero = 0;
370
+ let body = 0;
371
+ for (const p of entry.placements) {
372
+ if (p.kind === "hero") hero += 1;
373
+ else body += 1;
551
374
  }
552
-
553
- // The specific unreadable branch named by a fail-closed failure, or null for the generic honest line.
554
- // The current MediaReplaceFailure carries only an error string, so a cairn/* branch name is pulled
555
- // from the message when the strict read named one; otherwise the generic variant stands in.
556
- const replaceBlockedBranch = $derived.by(() => {
557
- const match = replaceFailure?.error.match(/cairn\/[^\s.]+/);
558
- return match ? match[0] : null;
375
+ const parts = [];
376
+ if (hero > 0) parts.push(hero === 1 ? "Hero" : `${hero} heroes`);
377
+ if (body > 0) parts.push(`${body} in the body`);
378
+ return parts.length > 0 ? parts.join(" and ") : "Used in this entry";
379
+ }
380
+ const replaceBlockedBranch = $derived.by(() => {
381
+ const match = replaceFailure?.error.match(/cairn\/[^\s.]+/);
382
+ return match ? match[0] : null;
383
+ });
384
+ const ALT_PREVIEW_URL = "?/mediaAltPreview";
385
+ let altDialog = $state(null);
386
+ let altOrigin = null;
387
+ let altCancelButton = $state(null);
388
+ let altStep = $state("review");
389
+ let altPlan = $state(null);
390
+ let altFailure = $state(null);
391
+ let altOverwrite = $state(false);
392
+ let altAsset = $state(null);
393
+ function openAltDialog(origin) {
394
+ if (!selected) return;
395
+ altOrigin = origin ?? document.activeElement ?? null;
396
+ altAsset = selected;
397
+ altStep = "review";
398
+ altPlan = null;
399
+ altFailure = null;
400
+ altOverwrite = false;
401
+ void tick().then(() => {
402
+ altDialog?.showModal();
403
+ altCancelButton?.focus();
559
404
  });
560
-
561
- // --- the Push-alt flow: a one-step review dialog (the everyday register) over the selected asset ---
562
- // Alt propagation pushes the asset's default alt into published placements that lack it, with one
563
- // bucket-level opt-in to also overwrite placements that carry a custom alt. It is reversible and
564
- // frequent, so the dialog is role="dialog" (not alertdialog) with no typed-slug gate; apply is always
565
- // enabled. The preview fetch reuses the 2a transport (a text/plain body, the CSRF token in the
566
- // X-Cairn-CSRF header) and fails closed to a blocked surface when usage cannot be verified.
567
- type AltStep = 'review' | 'blocked';
568
- const ALT_PREVIEW_URL = '?/mediaAltPreview';
569
-
570
- let altDialog = $state<HTMLDialogElement | null>(null);
571
- // The entry-point button that opened the dialog, so focus restores to it on close.
572
- let altOrigin: HTMLElement | null = null;
573
- // The Cancel control, the initial focus on open.
574
- let altCancelButton = $state<HTMLButtonElement | null>(null);
575
- let altStep = $state<AltStep>('review');
576
- // The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
577
- let altPlan = $state<MediaAltPreviewPlan | null>(null);
578
- let altFailure = $state<MediaAltPropagateFailure | null>(null);
579
- // The bucket-level opt-in to also overwrite customized alts. Bound to the one native checkbox.
580
- let altOverwrite = $state(false);
581
- // The asset the dialog acts on, pinned at open so a background re-render never swaps it. The alt it
582
- // pushes is this asset's default alt.
583
- let altAsset = $state<MediaLibraryEntry | null>(null);
584
-
585
- function openAltDialog(origin?: HTMLElement | null) {
586
- if (!selected) return;
587
- altOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
588
- altAsset = selected;
589
- altStep = 'review';
405
+ void runAltPreview();
406
+ }
407
+ function closeAltDialog() {
408
+ altDialog?.close();
409
+ altAsset = null;
410
+ altPlan = null;
411
+ altFailure = null;
412
+ altOverwrite = false;
413
+ altOrigin?.focus();
414
+ altOrigin = null;
415
+ }
416
+ let altPreviewSeq = 0;
417
+ async function runAltPreview() {
418
+ if (!altAsset) return;
419
+ const hash = altAsset.hash;
420
+ const seq = ++altPreviewSeq;
421
+ const blockClosed = (failure) => {
422
+ altFailure = failure ?? { error: "" };
590
423
  altPlan = null;
591
- altFailure = null;
592
- altOverwrite = false;
593
- void tick().then(() => {
594
- altDialog?.showModal();
595
- altCancelButton?.focus();
424
+ altStep = "blocked";
425
+ };
426
+ let result;
427
+ try {
428
+ const res = await fetch(ALT_PREVIEW_URL, {
429
+ method: "POST",
430
+ headers: { "Content-Type": "text/plain", "X-Cairn-CSRF": csrf?.() ?? "" },
431
+ body: JSON.stringify({ hash })
596
432
  });
597
- void runAltPreview();
598
- }
599
- function closeAltDialog() {
600
- altDialog?.close();
601
- altAsset = null;
602
- altPlan = null;
603
- altFailure = null;
604
- altOverwrite = false;
605
- altOrigin?.focus();
606
- altOrigin = null;
607
- }
608
-
609
- // The per-call request token for the alt preview, mirroring the Replace guard: a stale response from
610
- // a closed or reopened dialog (or a "Check usage again" double-click) is dropped after the await.
611
- let altPreviewSeq = 0;
612
-
613
- // The preview fetch: POST the hash in the 2a transport, parse the ActionResult envelope, and route to
614
- // the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the blocked
615
- // step's "Check usage again".
616
- async function runAltPreview() {
617
- if (!altAsset) return;
618
- const hash = altAsset.hash;
619
- const seq = ++altPreviewSeq;
620
- const blockClosed = (failure?: MediaAltPropagateFailure) => {
621
- altFailure = failure ?? { error: '' };
622
- altPlan = null;
623
- altStep = 'blocked';
624
- };
625
- let result: { type: string; data?: unknown };
626
- try {
627
- const res = await fetch(ALT_PREVIEW_URL, {
628
- method: 'POST',
629
- headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
630
- body: JSON.stringify({ hash }),
631
- });
632
- result = deserialize(await res.text()) as { type: string; data?: unknown };
633
- } catch {
634
- if (seq !== altPreviewSeq) return;
635
- blockClosed();
636
- return;
637
- }
638
- // Stale-response guard: a reopen or a re-run superseded this fetch while it was in flight.
433
+ result = deserialize(await res.text());
434
+ } catch {
639
435
  if (seq !== altPreviewSeq) return;
640
- if (result.type === 'success' && result.data) {
641
- altPlan = result.data as MediaAltPreviewPlan;
642
- altFailure = null;
643
- altStep = 'review';
644
- } else {
645
- blockClosed(result.data as MediaAltPropagateFailure | undefined);
646
- }
647
- }
648
-
649
- // The default alt the dialog propagates: the selected asset's stored alt. Empty is guarded by the
650
- // entry point (an asset with no default alt cannot push one), but the dialog reads it defensively.
651
- const altPushed = $derived(altAsset?.alt.trim() ?? '');
652
-
653
- // The three buckets, flattened from the plan's entries: each row carries its entry title, the
654
- // placement kind (the pill), and the placement's before/after. Grouping by bucket keeps each well
655
- // self-contained, the way the mockup lays them out.
656
- type AltRow = { title: string; kind: AltPlacement['kind']; before: string; after: string; key: string };
657
- function altRows(bucket: AltPlacement['bucket']): AltRow[] {
658
- const rows: AltRow[] = [];
659
- for (const entry of altPlan?.entries ?? []) {
660
- entry.placements.forEach((p, i) => {
661
- if (p.bucket !== bucket) return;
662
- rows.push({ title: entry.title, kind: p.kind, before: p.before, after: p.after, key: `${entry.concept}/${entry.id}/${i}` });
663
- });
664
- }
665
- return rows;
436
+ blockClosed();
437
+ return;
666
438
  }
667
- const altFillRows = $derived(altRows('will-fill'));
668
- const altCustomRows = $derived(altRows('customized'));
669
- const altSkipRows = $derived(altRows('decorative-skipped'));
670
-
671
- // The committed total: the will-fill placements always, plus the customized placements only on the
672
- // opt-in. The footer button and the live region read this; the count moves when the opt-in toggles.
673
- const altCounts = $derived(altPlan?.counts ?? { willFill: 0, customized: 0, decorativeSkipped: 0 });
674
- const altTotal = $derived(altCounts.willFill + (altOverwrite ? altCounts.customized : 0));
675
-
676
- // The will-fill bucket caps past this many rows; "Show all N" reveals the rest (aria-expanded +
677
- // aria-controls). The customized bucket lists in full (it is the consequential one).
678
- const ALT_ROW_CAP = 8;
679
- let altShowAll = $state(false);
680
- // The will-fill list element, so "Show all" can move focus to its first newly revealed row.
681
- let altFillList = $state<HTMLElement | null>(null);
682
- $effect(() => {
683
- void altPlan;
684
- altShowAll = false;
685
- });
686
- const altFillVisible = $derived(altShowAll ? altFillRows : altFillRows.slice(0, ALT_ROW_CAP));
687
- const altFillHidden = $derived(Math.max(0, altFillRows.length - ALT_ROW_CAP));
688
- // Reveal the capped will-fill rows, then move focus to the first newly revealed row (the rev.2
689
- // contract: the expander unmounts on the flag flip, so focus would otherwise fall to <body>).
690
- function showAllAltFill() {
691
- altShowAll = true;
692
- void tick().then(() => (altFillList?.children[ALT_ROW_CAP] as HTMLElement | undefined)?.focus());
693
- }
694
-
695
- // --- the where-used overlay the slide-over and the dialog read, grouped published-then-branch ---
696
- function usageEntries(hash: string): UsageEntry[] {
697
- return data.usage[hash]?.entries ?? [];
698
- }
699
- /** Published rows first, then the edit-branch rows. */
700
- function publishedRows(hash: string): UsageEntry[] {
701
- return usageEntries(hash).filter((e) => e.origin.kind === 'published');
439
+ if (seq !== altPreviewSeq) return;
440
+ if (result.type === "success" && result.data) {
441
+ altPlan = result.data;
442
+ altFailure = null;
443
+ altStep = "review";
444
+ } else {
445
+ blockClosed(result.data);
702
446
  }
703
- function branchRows(hash: string): UsageEntry[] {
704
- return usageEntries(hash).filter((e) => e.origin.kind === 'branch');
447
+ }
448
+ const altPushed = $derived(altAsset?.alt.trim() ?? "");
449
+ function altRows(bucket) {
450
+ const rows = [];
451
+ for (const entry of altPlan?.entries ?? []) {
452
+ entry.placements.forEach((p, i) => {
453
+ if (p.bucket !== bucket) return;
454
+ rows.push({ title: entry.title, kind: p.kind, before: p.before, after: p.after, key: `${entry.concept}/${entry.id}/${i}` });
455
+ });
705
456
  }
706
- const branchNameOf = (e: UsageEntry): string => (e.origin.kind === 'branch' ? e.origin.branch : '');
707
-
708
- // --- the safe-delete dialog's face and its type-to-confirm gate ---
709
- // The breaking list the dialog shows: the FRESH list from a refusal when one is present for this
710
- // asset, else the load-time overlay. The fresh server list supersedes a stale load-time count.
711
- const refusalForSelected = $derived(
712
- form && form.hash && selected && form.hash === selected.hash ? form : null,
713
- );
714
- // The slide-over's error alert covers two failures that leave no in-use dialog to re-open: a pure
715
- // ?/mediaUpdate failure (only `error`, no `hash`) and a hash-bearing delete refusal that is NOT an
716
- // in-use block (a 404 "not committed", with `hash` but no `usage`). An in-use refusal (usage rows)
717
- // re-opens the dialog instead, so it is excluded here.
718
- const hasUsage = $derived((form?.usage?.length ?? 0) > 0);
719
- const updateError = $derived(form?.error && !hasUsage ? form.error : null);
720
- const breakingRows = $derived.by((): UsageEntry[] => {
721
- if (refusalForSelected?.usage) return refusalForSelected.usage;
722
- return selected ? usageEntries(selected.hash) : [];
723
- });
724
- // The face is chosen by whether the asset is in use at open: in-use names what breaks and gates
725
- // Delete on a typed slug; orphan is a calm confirm. A refusal's fresh list also forces in-use.
726
- const deleteInUse = $derived(breakingRows.length > 0);
727
- const deleteBreakingPublished = $derived(breakingRows.filter((e) => e.origin.kind === 'published'));
728
- const deleteBreakingBranch = $derived(breakingRows.filter((e) => e.origin.kind === 'branch'));
729
-
730
- // The type-to-confirm input. The Delete submit is gated until it equals the asset slug (the one
731
- // legitimate disable: a visible, typed destructive confirmation, not a hidden requirement).
732
- let confirmSlugInput = $state('');
733
- const confirmMatches = $derived(selected !== null && confirmSlugInput === selected.slug);
734
-
735
- // Forms post full-page (no use:enhance), so on a failure the screen remounts with no selection and
736
- // the error would render nowhere. This effect re-surfaces the failure from the `form` prop. An
737
- // in-use delete refusal (usage rows) re-opens the dialog on its fresh breaking list; any other
738
- // hash-bearing failure (a 404 "not committed", an invalid-slug ?/mediaUpdate) re-selects the asset
739
- // and opens the slide-over so its error alert renders. The action redirects on success, so a
740
- // present `form` is always a failure to re-surface.
741
- //
742
- // The dialog is always mounted and its body reads breakingRows/deleteInUse reactively, so set the
743
- // state then call showModal() directly. tick() (NOT flushSync, which Svelte's flush_sync_in_effect
744
- // guard rejects inside an effect on a newer 5.x) flushes the new `selected` before showModal so the
745
- // dialog body renders the fresh asset.
746
- $effect(() => {
747
- if (!form || !form.hash) return;
748
- const target = data.assets.find((a) => a.hash === form!.hash);
749
- if (!target) return;
750
- if (form.usage && form.usage.length > 0) {
751
- // The in-use face, re-opened on the server's fresh breaking list.
752
- if (deleteDialog && !deleteDialog.open) {
753
- deleteOnly = true;
754
- selected = target;
755
- confirmSlugInput = '';
756
- void tick().then(() => deleteDialog?.showModal());
757
- }
758
- } else if (!selected) {
759
- // A hash-bearing failure that is not an in-use block: re-select the asset and open the
760
- // slide-over so updateError renders. Guarded on `!selected` so it runs once, not on every edit.
761
- deleteOnly = false;
457
+ return rows;
458
+ }
459
+ const altFillRows = $derived(altRows("will-fill"));
460
+ const altCustomRows = $derived(altRows("customized"));
461
+ const altSkipRows = $derived(altRows("decorative-skipped"));
462
+ const altCounts = $derived(altPlan?.counts ?? { willFill: 0, customized: 0, decorativeSkipped: 0 });
463
+ const altTotal = $derived(altCounts.willFill + (altOverwrite ? altCounts.customized : 0));
464
+ const ALT_ROW_CAP = 8;
465
+ let altShowAll = $state(false);
466
+ let altFillList = $state(null);
467
+ $effect(() => {
468
+ void altPlan;
469
+ altShowAll = false;
470
+ });
471
+ const altFillVisible = $derived(altShowAll ? altFillRows : altFillRows.slice(0, ALT_ROW_CAP));
472
+ const altFillHidden = $derived(Math.max(0, altFillRows.length - ALT_ROW_CAP));
473
+ function showAllAltFill() {
474
+ altShowAll = true;
475
+ void tick().then(() => altFillList?.children[ALT_ROW_CAP]?.focus());
476
+ }
477
+ function usageEntries(hash) {
478
+ return data.usage[hash]?.entries ?? [];
479
+ }
480
+ function publishedRows(hash) {
481
+ return usageEntries(hash).filter((e) => e.origin.kind === "published");
482
+ }
483
+ function branchRows(hash) {
484
+ return usageEntries(hash).filter((e) => e.origin.kind === "branch");
485
+ }
486
+ const branchNameOf = (e) => e.origin.kind === "branch" ? e.origin.branch : "";
487
+ const refusalForSelected = $derived(
488
+ form && form.hash && selected && form.hash === selected.hash ? form : null
489
+ );
490
+ const hasUsage = $derived((form?.usage?.length ?? 0) > 0);
491
+ const updateError = $derived(form?.error && !hasUsage ? form.error : null);
492
+ const breakingRows = $derived.by(() => {
493
+ if (refusalForSelected?.usage) return refusalForSelected.usage;
494
+ return selected ? usageEntries(selected.hash) : [];
495
+ });
496
+ const deleteInUse = $derived(breakingRows.length > 0);
497
+ const deleteBreakingPublished = $derived(breakingRows.filter((e) => e.origin.kind === "published"));
498
+ const deleteBreakingBranch = $derived(breakingRows.filter((e) => e.origin.kind === "branch"));
499
+ let confirmSlugInput = $state("");
500
+ const confirmMatches = $derived(selected !== null && confirmSlugInput === selected.slug);
501
+ $effect(() => {
502
+ if (!form || !form.hash) return;
503
+ const target = data.assets.find((a) => a.hash === form.hash);
504
+ if (!target) return;
505
+ if (form.usage && form.usage.length > 0) {
506
+ if (deleteDialog && !deleteDialog.open) {
507
+ deleteOnly = true;
762
508
  selected = target;
509
+ confirmSlugInput = "";
510
+ void tick().then(() => deleteDialog?.showModal());
763
511
  }
764
- });
765
-
766
- // --- the copy-reference affordance, announced politely ---
767
- let copyNotice = $state('');
768
- function copyReference(token: string) {
769
- void navigator.clipboard?.writeText(token).then(
770
- () => {
771
- copyNotice = 'Reference copied to the clipboard.';
772
- },
773
- () => {
774
- copyNotice = 'Could not copy the reference.';
775
- },
776
- );
777
- }
778
-
779
- // --- the alt editor's describe/decorative model (the 2b capture-card model) ---
780
- // Seeded from the selected asset each time the slide-over opens: a non-empty alt is "describe", an
781
- // empty alt is "decorative" only when the author last chose it, else unset. The Library has no
782
- // stored decorative flag, so an empty alt reads as unset (needs-alt), matching MediaCaptureCard.
783
- let altMode = $state<'describe' | 'decorative' | null>(null);
784
- let altText = $state('');
785
- let nameInput = $state('');
786
- let slugInput = $state('');
787
- // Reseed the editable fields whenever the selected asset changes.
788
- $effect(() => {
789
- const a = selected;
790
- if (!a) return;
791
- altText = a.alt;
792
- altMode = a.alt.trim() !== '' ? 'describe' : null;
793
- nameInput = a.displayName;
794
- slugInput = a.slug;
795
- });
796
- // The submitted alt: a described image carries its text, a decorative or left-blank submits empty
797
- // (matching MediaCaptureCard's needs-alt-debt model).
798
- const submittedAlt = $derived(altMode === 'describe' ? altText : '');
799
-
800
- // --- the roving tabindex over the grid's visible tiles ---
801
- // One tabstop for the listbox: the active index is the only option with tabindex 0; arrows,
802
- // Home, and End move it; Enter/Space activate. The active index is clamped as filtering changes
803
- // the visible set, so a focused option that filters out moves to a valid neighbor.
804
- let activeIndex = $state(0);
805
- $effect(() => {
806
- const max = Math.max(0, visible.length - 1);
807
- if (activeIndex > max) activeIndex = max;
808
- });
809
-
810
- let tileEls = $state<HTMLElement[]>([]);
811
- function focusTile(i: number) {
812
- activeIndex = i;
813
- tileEls[i]?.focus();
814
- }
815
-
816
- // --- the multi-select model (the APG multiselectable listbox, shared by the grid and the table) ---
817
- // The selection is a Set of asset hashes, distinct from `selected` (the single asset the slide-over
818
- // renders). Focus and selection are decoupled: roving the active tile never selects, Space/checkbox
819
- // toggles, Shift+Arrow extends a range, Ctrl/Cmd+A selects every visible asset, Escape clears. The
820
- // Set is never mutated in place (no reactivity on Set mutation here); every change reassigns, the
821
- // same pattern markBroken uses below.
822
- let selectedHashes = $state(new Set<string>());
823
- const selectedCount = $derived(selectedHashes.size);
824
- // The anchor index for a Shift+Arrow range, set on a plain toggle (Space or a checkbox/click). Null
825
- // until the first plain selection in the current run.
826
- let selectAnchor = $state<number | null>(null);
827
-
828
- /** Toggle one hash, set the range anchor to its visible index, and reassign the Set. */
829
- function toggleSelect(hash: string) {
830
- const next = new Set(selectedHashes);
831
- if (next.has(hash)) next.delete(hash);
832
- else next.add(hash);
833
- selectedHashes = next;
834
- selectAnchor = visible.findIndex((a) => a.hash === hash);
835
- }
836
- /** Select every hash between the anchor and `to` (inclusive) over the visible set, additively. */
837
- function selectRange(to: number) {
838
- if (selectAnchor === null) selectAnchor = to;
839
- const lo = Math.min(selectAnchor, to);
840
- const hi = Math.max(selectAnchor, to);
841
- const next = new Set(selectedHashes);
842
- for (let j = lo; j <= hi; j++) {
843
- const a = visible[j];
844
- if (a) next.add(a.hash);
845
- }
846
- selectedHashes = next;
847
- }
848
- /** Select every currently-visible asset (Ctrl/Cmd+A and the bar's Select all). */
849
- function selectAllVisible() {
850
- const next = new Set(selectedHashes);
851
- for (const a of visible) next.add(a.hash);
852
- selectedHashes = next;
853
- selectAnchor = 0;
854
- }
855
- /** Empty the selection (the bar's Clear and the Escape clear gesture). */
856
- function clearSelection() {
857
- if (selectedHashes.size === 0) return;
858
- selectedHashes = new Set<string>();
859
- selectAnchor = null;
512
+ } else if (!selected) {
513
+ deleteOnly = false;
514
+ selected = target;
860
515
  }
861
- // Drop any selected hash that has filtered out of the visible set so the count and the bar's scope
862
- // never count an asset the user can no longer see. Reassign only when the set actually shrinks.
863
- $effect(() => {
864
- const live = new Set(visible.map((a) => a.hash));
865
- let changed = false;
866
- for (const h of selectedHashes) {
867
- if (!live.has(h)) {
868
- changed = true;
869
- break;
870
- }
516
+ });
517
+ let copyNotice = $state("");
518
+ function copyReference(token) {
519
+ void navigator.clipboard?.writeText(token).then(
520
+ () => {
521
+ copyNotice = "Reference copied to the clipboard.";
522
+ },
523
+ () => {
524
+ copyNotice = "Could not copy the reference.";
871
525
  }
872
- if (!changed) return;
873
- const next = new Set<string>();
874
- for (const h of selectedHashes) if (live.has(h)) next.add(h);
875
- selectedHashes = next;
876
- });
877
-
878
- // The bar's scope line: how many of the selection are in this view, split by usage so the confirm's
879
- // skip-and-report path is foreshadowed (Task 8 reads the same split).
880
- const selectionScope = $derived.by(() => {
881
- let noRefs = 0;
882
- let used = 0;
883
- for (const a of visible) {
884
- if (!selectedHashes.has(a.hash)) continue;
885
- if (usageCount(a.hash) === 0) noRefs++;
886
- else used++;
887
- }
888
- return { noRefs, used };
889
- });
890
-
891
- // --- the bulk-delete alertdialog: the skip-and-report dry-run, the reversible register, the
892
- // announced progress, and the itemized summary (the rev.2 mockup, panels 3 and 4) ---
893
- // The whole selection is reversible (a git-tracked removal of manifest rows), so the dialog is the
894
- // danger-OUTLINE register with a plain confirm and no typed gate. The display split below is
895
- // advisory: every selected hash is sent and the server re-checks each one strictly, so an asset that
896
- // looks deletable here but turns up in use at delete time is skipped authoritatively, not removed.
897
- type BulkPhase = 'review' | 'deleting' | 'done' | 'error';
898
- let bulkDialog = $state<HTMLDialogElement | null>(null);
899
- // The entry-point (the bar's Delete button), so focus restores to it on close.
900
- let bulkOrigin: HTMLElement | null = null;
901
- // The Cancel control, the destructive-confirm initial focus.
902
- let bulkCancelButton = $state<HTMLButtonElement | null>(null);
903
- // The summary title, focused when the result lands so a screen reader is carried to the outcome.
904
- let bulkSummaryTitle = $state<HTMLElement | null>(null);
905
- let bulkPhase = $state<BulkPhase>('review');
906
- let bulkResult = $state<MediaBulkDeleteResult | null>(null);
907
- let bulkError = $state<string | null>(null);
908
- // The hashes the dialog acts on, pinned at open so a background re-render never shifts the dry-run.
909
- let bulkHashes = $state<string[]>([]);
910
-
911
- // The dry-run split over the DISPLAY index: the no-reference selection is what will be deleted, the
912
- // still-referenced selection is what the server will skip. Both keep the asset row for the screen.
913
- // The selected assets in pick order, dropping any hash absent from the loaded set (the type
914
- // predicate keeps the element type non-nullable so the markup reads asset.slug without a guard).
915
- const bulkSelectedAssets = $derived(
916
- bulkHashes
917
- .map((h) => data.assets.find((a) => a.hash === h))
918
- .filter((a): a is MediaLibraryEntry => a != null),
919
- );
920
- const bulkWillDelete = $derived(bulkSelectedAssets.filter((a) => usageCount(a.hash) === 0));
921
- const bulkWillSkip = $derived(bulkSelectedAssets.filter((a) => usageCount(a.hash) > 0));
922
- // The apply button names the outcome from the split: "Delete N" with no skips, else "Delete N, skip M".
923
- const bulkApplyLabel = $derived(
924
- bulkWillSkip.length === 0
925
- ? `Delete ${bulkWillDelete.length}`
926
- : `Delete ${bulkWillDelete.length}, skip ${bulkWillSkip.length}`,
927
526
  );
928
-
929
- // The skipped summary row reads its display name from the loaded assets; a hash absent from the load
930
- // (deleted out from under the index) falls back to the bare hash so the row is never blank.
931
- function bulkAssetName(hash: string): string {
932
- return data.assets.find((a) => a.hash === hash)?.displayName ?? hash;
527
+ }
528
+ let altMode = $state(null);
529
+ let altText = $state("");
530
+ let nameInput = $state("");
531
+ let slugInput = $state("");
532
+ $effect(() => {
533
+ const a = selected;
534
+ if (!a) return;
535
+ altText = a.alt;
536
+ altMode = a.alt.trim() !== "" ? "describe" : null;
537
+ nameInput = a.displayName;
538
+ slugInput = a.slug;
539
+ });
540
+ const submittedAlt = $derived(altMode === "describe" ? altText : "");
541
+ let activeIndex = $state(0);
542
+ $effect(() => {
543
+ const max = Math.max(0, visible.length - 1);
544
+ if (activeIndex > max) activeIndex = max;
545
+ });
546
+ let tileEls = $state([]);
547
+ function focusTile(i) {
548
+ activeIndex = i;
549
+ tileEls[i]?.focus();
550
+ }
551
+ let selectedHashes = $state(/* @__PURE__ */ new Set());
552
+ const selectedCount = $derived(selectedHashes.size);
553
+ let selectAnchor = $state(null);
554
+ function toggleSelect(hash) {
555
+ const next = new Set(selectedHashes);
556
+ if (next.has(hash)) next.delete(hash);
557
+ else next.add(hash);
558
+ selectedHashes = next;
559
+ selectAnchor = visible.findIndex((a) => a.hash === hash);
560
+ }
561
+ function selectRange(to) {
562
+ if (selectAnchor === null) selectAnchor = to;
563
+ const lo = Math.min(selectAnchor, to);
564
+ const hi = Math.max(selectAnchor, to);
565
+ const next = new Set(selectedHashes);
566
+ for (let j = lo; j <= hi; j++) {
567
+ const a = visible[j];
568
+ if (a) next.add(a.hash);
933
569
  }
934
- // The skip reason line: a still-referenced skip names its fresh where-used count; an uncommitted skip
935
- // says it was not committed (the timing-honest reason the recheck turned up).
936
- function bulkSkipReason(skip: BulkDeleteSkip): string {
937
- if (skip.reason === 'still-referenced') {
938
- const n = skip.usage.length;
939
- return `now found in ${n} ${n === 1 ? 'entry' : 'entries'} on the recheck`;
570
+ selectedHashes = next;
571
+ }
572
+ function selectAllVisible() {
573
+ const next = new Set(selectedHashes);
574
+ for (const a of visible) next.add(a.hash);
575
+ selectedHashes = next;
576
+ selectAnchor = 0;
577
+ }
578
+ function clearSelection() {
579
+ if (selectedHashes.size === 0) return;
580
+ selectedHashes = /* @__PURE__ */ new Set();
581
+ selectAnchor = null;
582
+ }
583
+ $effect(() => {
584
+ const live = new Set(visible.map((a) => a.hash));
585
+ let changed = false;
586
+ for (const h of selectedHashes) {
587
+ if (!live.has(h)) {
588
+ changed = true;
589
+ break;
940
590
  }
941
- return 'was not committed';
942
591
  }
943
-
944
- const BULK_DELETE_URL = '?/mediaBulkDelete';
945
-
946
- function openBulkDialog(origin?: HTMLElement | null) {
947
- if (selectedCount === 0) return;
948
- bulkOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
949
- bulkHashes = [...selectedHashes];
950
- bulkPhase = 'review';
951
- bulkResult = null;
952
- bulkError = null;
953
- void tick().then(() => {
954
- bulkDialog?.showModal();
955
- bulkCancelButton?.focus();
956
- });
592
+ if (!changed) return;
593
+ const next = /* @__PURE__ */ new Set();
594
+ for (const h of selectedHashes) if (live.has(h)) next.add(h);
595
+ selectedHashes = next;
596
+ });
597
+ const selectionScope = $derived.by(() => {
598
+ let noRefs = 0;
599
+ let used = 0;
600
+ for (const a of visible) {
601
+ if (!selectedHashes.has(a.hash)) continue;
602
+ if (usageCount(a.hash) === 0) noRefs++;
603
+ else used++;
957
604
  }
958
- function closeBulkDialog() {
959
- bulkDialog?.close();
960
- bulkPhase = 'review';
961
- bulkResult = null;
962
- bulkError = null;
963
- bulkHashes = [];
964
- bulkOrigin?.focus();
965
- bulkOrigin = null;
605
+ return { noRefs, used };
606
+ });
607
+ let bulkDialog = $state(null);
608
+ let bulkOrigin = null;
609
+ let bulkCancelButton = $state(null);
610
+ let bulkSummaryTitle = $state(null);
611
+ let bulkPhase = $state("review");
612
+ let bulkResult = $state(null);
613
+ let bulkError = $state(null);
614
+ let bulkHashes = $state([]);
615
+ const bulkSelectedAssets = $derived(
616
+ bulkHashes.map((h) => data.assets.find((a) => a.hash === h)).filter((a) => a != null)
617
+ );
618
+ const bulkWillDelete = $derived(bulkSelectedAssets.filter((a) => usageCount(a.hash) === 0));
619
+ const bulkWillSkip = $derived(bulkSelectedAssets.filter((a) => usageCount(a.hash) > 0));
620
+ const bulkApplyLabel = $derived(
621
+ bulkWillSkip.length === 0 ? `Delete ${bulkWillDelete.length}` : `Delete ${bulkWillDelete.length}, skip ${bulkWillSkip.length}`
622
+ );
623
+ function bulkAssetName(hash) {
624
+ return data.assets.find((a) => a.hash === hash)?.displayName ?? hash;
625
+ }
626
+ function bulkSkipReason(skip) {
627
+ if (skip.reason === "still-referenced") {
628
+ const n = skip.usage.length;
629
+ return `now found in ${n} ${n === 1 ? "entry" : "entries"} on the recheck`;
966
630
  }
967
- // Escape (the dialog's cancel event) must not abandon an in-flight delete: while the request is
968
- // running the close is suppressed; in every other phase Escape closes normally.
969
- function onBulkCancel(e: Event) {
970
- if (bulkPhase === 'deleting') {
971
- e.preventDefault();
972
- return;
973
- }
974
- closeBulkDialog();
975
- }
976
- // The Done action after a summary: re-read the load so the deleted rows leave the list, clear the
977
- // selection, then close and reset. invalidateAll re-runs the media load behind the dialog.
978
- async function finishBulkDelete() {
979
- await invalidateAll();
980
- clearSelection();
981
- closeBulkDialog();
982
- }
983
-
984
- // Apply: send every SELECTED hash (repeated `hash` fields) so the server is the gate; it re-checks
985
- // each one strictly and skips the in-use ones authoritatively. The CSRF token rides the X-Cairn-CSRF
986
- // header (the guard accepts it for any unsafe POST), and the ActionResult envelope is read through
987
- // deserialize. A success carries the MediaBulkDeleteResult; a fail-closed 503 or a network throw
988
- // routes to the error phase and a role="alert".
989
- async function applyBulkDelete() {
990
- bulkPhase = 'deleting';
991
- bulkError = null;
992
- const formData = new FormData();
993
- for (const h of bulkHashes) formData.append('hash', h);
994
- let result: { type: string; data?: unknown };
995
- try {
996
- const res = await fetch(BULK_DELETE_URL, {
997
- method: 'POST',
998
- headers: { 'X-Cairn-CSRF': csrf?.() ?? '' },
999
- body: formData,
1000
- });
1001
- result = deserialize(await res.text()) as { type: string; data?: unknown };
1002
- } catch {
1003
- bulkError = 'The delete could not be completed. Please try again.';
1004
- bulkPhase = 'error';
1005
- return;
1006
- }
1007
- if (result.type === 'success' && result.data) {
1008
- bulkResult = result.data as MediaBulkDeleteResult;
1009
- bulkPhase = 'done';
1010
- void tick().then(() => bulkSummaryTitle?.focus());
1011
- } else {
1012
- const failure = result.data as { error?: string } | undefined;
1013
- bulkError = failure?.error ?? 'The delete could not be completed. Please try again.';
1014
- bulkPhase = 'error';
1015
- }
1016
- }
1017
-
1018
- // --- the on-demand orphan scan surface: the entry point, the loading/blocked phases, the
1019
- // two-section result, and the IRREVERSIBLE byte purge (the rev.2 mockup, panels 6, 7, and 8-right) ---
1020
- // Raw R2 bytes have no git history, so this is the one irreversible media action and it is kept
1021
- // structurally apart from the reversible bulk delete above: a separate dialog, a separate selection
1022
- // Set of R2 KEYS (never the asset-hash Set), a solid-danger Purge (not the danger-OUTLINE bulk
1023
- // apply), and a typed-count confirm reserved for this path. The scan fails CLOSED at detection: a
1024
- // 503 routes to the blocked surface (no dry-run, no collect action), because under-reporting orphans
1025
- // could feed an unrecoverable purge.
1026
- type OrphanPhase = 'idle' | 'scanning' | 'result' | 'blocked';
1027
- const ORPHAN_SCAN_URL = '?/mediaOrphanScan';
1028
- const ORPHAN_PURGE_URL = '?/mediaPurge';
1029
-
1030
- let orphanDialog = $state<HTMLDialogElement | null>(null);
1031
- // The "Find orphaned files" entry control, so focus restores to it on close.
1032
- let orphanFindButton = $state<HTMLButtonElement | null>(null);
1033
- // The dialog title, focused on open so a screen reader is carried to the surface.
1034
- let orphanTitle = $state<HTMLElement | null>(null);
1035
- let orphanPhase = $state<OrphanPhase>('idle');
1036
- // The scan result (the result phase) or the fail-closed error message (the blocked phase).
1037
- let orphanScan = $state<OrphanScan | null>(null);
1038
- let orphanBlockedError = $state('');
1039
- // The orphaned-byte selection: a Set of R2 KEYS, distinct from the asset-hash Set above. Never
1040
- // mutated in place; every change reassigns (the reactive-Set rule the rest of the screen follows).
1041
- let orphanKeys = $state(new Set<string>());
1042
- // The section-level select-all checkbox, set to indeterminate in an effect when some-but-not-all rows
1043
- // are selected (a property, not an attribute, so it is driven imperatively).
1044
- let orphanSelectAll = $state<HTMLInputElement | null>(null);
1045
- // The purge confirm: a nested phase inside the result surface, gated by typing the selected count.
1046
- let orphanPurging = $state(false);
1047
- let orphanConfirmInput = $state('');
1048
- // The purge outcome (the summary) or, on a post-action failure, the error for a role="alert".
1049
- let orphanPurgeResult = $state<MediaOrphanPurgeResult | null>(null);
1050
- let orphanPurgeError = $state('');
1051
- let orphanPurgeBusy = $state(false);
1052
-
1053
- const orphanBytes = $derived(orphanScan?.orphanedBytes ?? []);
1054
- const orphanBroken = $derived(orphanScan?.brokenRefs ?? []);
1055
- const orphanSelectedCount = $derived(orphanKeys.size);
1056
- // The typed-count gate: the submit is enabled only when the typed value equals the selected count and
1057
- // at least one byte is selected. The one legitimate disable, a visible typed destructive confirm.
1058
- const orphanConfirmMatches = $derived(orphanSelectedCount > 0 && orphanConfirmInput === String(orphanSelectedCount));
1059
- // The select-all is checked when every byte is selected, indeterminate on a strict subset. Driven
1060
- // imperatively because `indeterminate` is a DOM property with no HTML attribute.
1061
- $effect(() => {
1062
- if (!orphanSelectAll) return;
1063
- const n = orphanSelectedCount;
1064
- const total = orphanBytes.length;
1065
- orphanSelectAll.checked = total > 0 && n === total;
1066
- orphanSelectAll.indeterminate = n > 0 && n < total;
631
+ return "was not committed";
632
+ }
633
+ const BULK_DELETE_URL = "?/mediaBulkDelete";
634
+ function openBulkDialog(origin) {
635
+ if (selectedCount === 0) return;
636
+ bulkOrigin = origin ?? document.activeElement ?? null;
637
+ bulkHashes = [...selectedHashes];
638
+ bulkPhase = "review";
639
+ bulkResult = null;
640
+ bulkError = null;
641
+ void tick().then(() => {
642
+ bulkDialog?.showModal();
643
+ bulkCancelButton?.focus();
1067
644
  });
1068
-
1069
- function openOrphanScan() {
1070
- orphanPhase = 'scanning';
1071
- orphanScan = null;
1072
- orphanBlockedError = '';
1073
- orphanKeys = new Set<string>();
1074
- orphanPurging = false;
1075
- orphanConfirmInput = '';
1076
- orphanPurgeResult = null;
1077
- orphanPurgeError = '';
1078
- orphanPurgeBusy = false;
1079
- void tick().then(() => {
1080
- orphanDialog?.showModal();
1081
- orphanTitle?.focus();
1082
- });
1083
- void runOrphanScan();
1084
- }
1085
- function closeOrphanScan() {
1086
- orphanDialog?.close();
1087
- orphanPhase = 'idle';
1088
- orphanScan = null;
1089
- orphanKeys = new Set<string>();
1090
- orphanPurging = false;
1091
- orphanConfirmInput = '';
1092
- orphanPurgeResult = null;
1093
- orphanPurgeError = '';
1094
- orphanFindButton?.focus();
1095
- }
1096
- // Escape (the dialog's cancel event) must not abandon an in-flight purge: while the irreversible
1097
- // delete is running the close is suppressed; in every other phase Escape closes normally.
1098
- function onOrphanCancel(e: Event) {
1099
- if (orphanPurgeBusy) {
1100
- e.preventDefault();
1101
- return;
1102
- }
1103
- closeOrphanScan();
1104
- }
1105
- // The Done action after a purge: the bytes are gone, so re-read the load (the broken-refs readout is
1106
- // untouched), then close. invalidateAll re-runs the media load behind the dialog.
1107
- async function finishOrphanPurge() {
1108
- await invalidateAll();
1109
- closeOrphanScan();
1110
- }
1111
-
1112
- // Run the scan: POST ?/mediaOrphanScan, parse the ActionResult envelope, and route to the result
1113
- // phase (an OrphanScan) or the fail-closed blocked phase (a 503 MediaBulkFailure or a network
1114
- // throw). The action reads no fields, but a SvelteKit form action rejects a body-less POST with a
1115
- // 415, so send an empty FormData to carry the form content-type. The CSRF token rides the header.
1116
- // Nothing is pre-selected: this feeds an irreversible purge, so the operator picks each byte (or the
1117
- // select-all) deliberately.
1118
- async function runOrphanScan() {
1119
- orphanPhase = 'scanning';
1120
- orphanBlockedError = '';
1121
- let result: { type: string; data?: unknown };
1122
- try {
1123
- const res = await fetch(ORPHAN_SCAN_URL, {
1124
- method: 'POST',
1125
- headers: { 'X-Cairn-CSRF': csrf?.() ?? '' },
1126
- body: new FormData(),
1127
- });
1128
- result = deserialize(await res.text()) as { type: string; data?: unknown };
1129
- } catch {
1130
- // A network throw blocks the scan with the generic blocked surface; orphanBlockedError stays
1131
- // empty (set above), so the surface shows its own framing without a server message.
1132
- orphanPhase = 'blocked';
1133
- return;
1134
- }
1135
- if (result.type === 'success' && result.data) {
1136
- orphanScan = result.data as OrphanScan;
1137
- orphanKeys = new Set<string>();
1138
- orphanPhase = 'result';
1139
- } else {
1140
- const failure = result.data as MediaBulkFailure | undefined;
1141
- orphanBlockedError = failure?.error ?? '';
1142
- orphanPhase = 'blocked';
1143
- }
645
+ }
646
+ function closeBulkDialog() {
647
+ bulkDialog?.close();
648
+ bulkPhase = "review";
649
+ bulkResult = null;
650
+ bulkError = null;
651
+ bulkHashes = [];
652
+ bulkOrigin?.focus();
653
+ bulkOrigin = null;
654
+ }
655
+ function onBulkCancel(e) {
656
+ if (bulkPhase === "deleting") {
657
+ e.preventDefault();
658
+ return;
1144
659
  }
1145
-
1146
- /** Toggle one orphaned-byte key in the selection (reassign-only). */
1147
- function toggleOrphanKey(key: string) {
1148
- const next = new Set(orphanKeys);
1149
- if (next.has(key)) next.delete(key);
1150
- else next.add(key);
1151
- orphanKeys = next;
660
+ closeBulkDialog();
661
+ }
662
+ async function finishBulkDelete() {
663
+ await invalidateAll();
664
+ clearSelection();
665
+ closeBulkDialog();
666
+ }
667
+ async function applyBulkDelete() {
668
+ bulkPhase = "deleting";
669
+ bulkError = null;
670
+ const formData = new FormData();
671
+ for (const h of bulkHashes) formData.append("hash", h);
672
+ let result;
673
+ try {
674
+ const res = await fetch(BULK_DELETE_URL, {
675
+ method: "POST",
676
+ headers: { "X-Cairn-CSRF": csrf?.() ?? "" },
677
+ body: formData
678
+ });
679
+ result = deserialize(await res.text());
680
+ } catch {
681
+ bulkError = "The delete could not be completed. Please try again.";
682
+ bulkPhase = "error";
683
+ return;
1152
684
  }
1153
- /** Select all or clear all orphaned bytes from the section header checkbox. */
1154
- function toggleOrphanAll() {
1155
- orphanKeys = orphanKeys.size === orphanBytes.length ? new Set<string>() : new Set(orphanBytes.map((b) => b.key));
685
+ if (result.type === "success" && result.data) {
686
+ bulkResult = result.data;
687
+ bulkPhase = "done";
688
+ void tick().then(() => bulkSummaryTitle?.focus());
689
+ } else {
690
+ const failure = result.data;
691
+ bulkError = failure?.error ?? "The delete could not be completed. Please try again.";
692
+ bulkPhase = "error";
1156
693
  }
1157
- function clearOrphanSelection() {
1158
- orphanKeys = new Set<string>();
694
+ }
695
+ const ORPHAN_SCAN_URL = "?/mediaOrphanScan";
696
+ const ORPHAN_PURGE_URL = "?/mediaPurge";
697
+ let orphanDialog = $state(null);
698
+ let orphanFindButton = $state(null);
699
+ let orphanTitle = $state(null);
700
+ let orphanPhase = $state("idle");
701
+ let orphanScan = $state(null);
702
+ let orphanBlockedError = $state("");
703
+ let orphanKeys = $state(/* @__PURE__ */ new Set());
704
+ let orphanSelectAll = $state(null);
705
+ let orphanPurging = $state(false);
706
+ let orphanConfirmInput = $state("");
707
+ let orphanPurgeResult = $state(null);
708
+ let orphanPurgeError = $state("");
709
+ let orphanPurgeBusy = $state(false);
710
+ const orphanBytes = $derived(orphanScan?.orphanedBytes ?? []);
711
+ const orphanBroken = $derived(orphanScan?.brokenRefs ?? []);
712
+ const orphanSelectedCount = $derived(orphanKeys.size);
713
+ const orphanConfirmMatches = $derived(orphanSelectedCount > 0 && orphanConfirmInput === String(orphanSelectedCount));
714
+ $effect(() => {
715
+ if (!orphanSelectAll) return;
716
+ const n = orphanSelectedCount;
717
+ const total = orphanBytes.length;
718
+ orphanSelectAll.checked = total > 0 && n === total;
719
+ orphanSelectAll.indeterminate = n > 0 && n < total;
720
+ });
721
+ function openOrphanScan() {
722
+ orphanPhase = "scanning";
723
+ orphanScan = null;
724
+ orphanBlockedError = "";
725
+ orphanKeys = /* @__PURE__ */ new Set();
726
+ orphanPurging = false;
727
+ orphanConfirmInput = "";
728
+ orphanPurgeResult = null;
729
+ orphanPurgeError = "";
730
+ orphanPurgeBusy = false;
731
+ void tick().then(() => {
732
+ orphanDialog?.showModal();
733
+ orphanTitle?.focus();
734
+ });
735
+ void runOrphanScan();
736
+ }
737
+ function closeOrphanScan() {
738
+ orphanDialog?.close();
739
+ orphanPhase = "idle";
740
+ orphanScan = null;
741
+ orphanKeys = /* @__PURE__ */ new Set();
742
+ orphanPurging = false;
743
+ orphanConfirmInput = "";
744
+ orphanPurgeResult = null;
745
+ orphanPurgeError = "";
746
+ orphanFindButton?.focus();
747
+ }
748
+ function onOrphanCancel(e) {
749
+ if (orphanPurgeBusy) {
750
+ e.preventDefault();
751
+ return;
1159
752
  }
1160
-
1161
- // Open the typed-count purge confirm over the current selection.
1162
- function openOrphanPurge() {
1163
- if (orphanSelectedCount === 0) return;
1164
- orphanConfirmInput = '';
1165
- orphanPurgeError = '';
1166
- orphanPurging = true;
753
+ closeOrphanScan();
754
+ }
755
+ async function finishOrphanPurge() {
756
+ await invalidateAll();
757
+ closeOrphanScan();
758
+ }
759
+ async function runOrphanScan() {
760
+ orphanPhase = "scanning";
761
+ orphanBlockedError = "";
762
+ let result;
763
+ try {
764
+ const res = await fetch(ORPHAN_SCAN_URL, {
765
+ method: "POST",
766
+ headers: { "X-Cairn-CSRF": csrf?.() ?? "" },
767
+ body: new FormData()
768
+ });
769
+ result = deserialize(await res.text());
770
+ } catch {
771
+ orphanPhase = "blocked";
772
+ return;
1167
773
  }
1168
- function cancelOrphanPurge() {
1169
- orphanPurging = false;
1170
- orphanConfirmInput = '';
1171
- orphanPurgeError = '';
774
+ if (result.type === "success" && result.data) {
775
+ orphanScan = result.data;
776
+ orphanKeys = /* @__PURE__ */ new Set();
777
+ orphanPhase = "result";
778
+ } else {
779
+ const failure = result.data;
780
+ orphanBlockedError = failure?.error ?? "";
781
+ orphanPhase = "blocked";
1172
782
  }
1173
-
1174
- // The purge: POST ?/mediaPurge with each selected key as a repeated `key` field plus `confirm` set to
1175
- // the typed count. The server re-derives fresh and skips any key claimed since the scan, so the
1176
- // selection here is advisory. The CSRF token rides the X-Cairn-CSRF header; the ActionResult envelope
1177
- // is read through deserialize. A success carries the MediaOrphanPurgeResult; a fail or a network throw
1178
- // surfaces a role="alert".
1179
- async function applyOrphanPurge() {
1180
- if (!orphanConfirmMatches) return;
1181
- orphanPurgeBusy = true;
1182
- orphanPurgeError = '';
1183
- const formData = new FormData();
1184
- for (const key of orphanKeys) formData.append('key', key);
1185
- formData.append('confirm', orphanConfirmInput);
1186
- let result: { type: string; data?: unknown };
1187
- try {
1188
- const res = await fetch(ORPHAN_PURGE_URL, {
1189
- method: 'POST',
1190
- headers: { 'X-Cairn-CSRF': csrf?.() ?? '' },
1191
- body: formData,
1192
- });
1193
- result = deserialize(await res.text()) as { type: string; data?: unknown };
1194
- } catch {
1195
- orphanPurgeBusy = false;
1196
- orphanPurgeError = 'The purge could not be completed. Please try again.';
1197
- return;
1198
- }
783
+ }
784
+ function toggleOrphanKey(key) {
785
+ const next = new Set(orphanKeys);
786
+ if (next.has(key)) next.delete(key);
787
+ else next.add(key);
788
+ orphanKeys = next;
789
+ }
790
+ function toggleOrphanAll() {
791
+ orphanKeys = orphanKeys.size === orphanBytes.length ? /* @__PURE__ */ new Set() : new Set(orphanBytes.map((b) => b.key));
792
+ }
793
+ function clearOrphanSelection() {
794
+ orphanKeys = /* @__PURE__ */ new Set();
795
+ }
796
+ function openOrphanPurge() {
797
+ if (orphanSelectedCount === 0) return;
798
+ orphanConfirmInput = "";
799
+ orphanPurgeError = "";
800
+ orphanPurging = true;
801
+ }
802
+ function cancelOrphanPurge() {
803
+ orphanPurging = false;
804
+ orphanConfirmInput = "";
805
+ orphanPurgeError = "";
806
+ }
807
+ async function applyOrphanPurge() {
808
+ if (!orphanConfirmMatches) return;
809
+ orphanPurgeBusy = true;
810
+ orphanPurgeError = "";
811
+ const formData = new FormData();
812
+ for (const key of orphanKeys) formData.append("key", key);
813
+ formData.append("confirm", orphanConfirmInput);
814
+ let result;
815
+ try {
816
+ const res = await fetch(ORPHAN_PURGE_URL, {
817
+ method: "POST",
818
+ headers: { "X-Cairn-CSRF": csrf?.() ?? "" },
819
+ body: formData
820
+ });
821
+ result = deserialize(await res.text());
822
+ } catch {
1199
823
  orphanPurgeBusy = false;
1200
- if (result.type === 'success' && result.data) {
1201
- orphanPurgeResult = result.data as MediaOrphanPurgeResult;
1202
- orphanPurging = false;
1203
- } else {
1204
- const failure = result.data as MediaBulkFailure | undefined;
1205
- orphanPurgeError = failure?.error ?? 'The purge could not be completed. Please try again.';
1206
- }
1207
- }
1208
-
1209
- // The where-used line for one broken-reference row: a plain "used in N entries" count.
1210
- function brokenWhereUsed(count: number): string {
1211
- if (count === 0) return 'no references found';
1212
- return `used in ${count} ${count === 1 ? 'entry' : 'entries'}`;
1213
- }
1214
-
1215
- function onGridKeydown(e: KeyboardEvent, i: number) {
1216
- // Ctrl/Cmd+A selects every visible asset (the listbox owns the shortcut here).
1217
- if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
1218
- e.preventDefault();
1219
- selectAllVisible();
1220
- return;
1221
- }
1222
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
1223
- e.preventDefault();
1224
- const to = Math.min(i + 1, visible.length - 1);
1225
- if (e.shiftKey) selectRange(to);
1226
- focusTile(to);
1227
- } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
1228
- e.preventDefault();
1229
- const to = Math.max(i - 1, 0);
1230
- if (e.shiftKey) selectRange(to);
1231
- focusTile(to);
1232
- } else if (e.key === 'Home') {
1233
- e.preventDefault();
1234
- focusTile(0);
1235
- } else if (e.key === 'End') {
1236
- e.preventDefault();
1237
- focusTile(visible.length - 1);
1238
- } else if (e.key === ' ') {
1239
- // Space toggles selection of the focused tile; it never activates the slide-over.
1240
- e.preventDefault();
1241
- toggleSelect(visible[i].hash);
1242
- } else if (e.key === 'Enter') {
1243
- // Enter activates: it opens the detail slide-over (selection is Space and the checkbox).
1244
- e.preventDefault();
1245
- openAsset(visible[i], tileEls[i]);
1246
- }
1247
- }
1248
-
1249
- // --- the broken-thumbnail affordance: a tile/row whose R2 object 404s still lists ---
1250
- // The set of hashes whose thumbnail failed to load, so the dead asset can be cleared.
1251
- let brokenHashes = $state(new Set<string>());
1252
- function markBroken(hash: string) {
1253
- if (brokenHashes.has(hash)) return;
1254
- const next = new Set(brokenHashes);
1255
- next.add(hash);
1256
- brokenHashes = next;
1257
- }
1258
-
1259
- // --- display helpers ---
1260
- const dateFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
1261
- function formatAdded(iso: string): string {
1262
- const parsed = new Date(iso);
1263
- return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
1264
- }
1265
- function formatBytes(bytes: number): string {
1266
- if (bytes < 1024) return `${bytes} B`;
1267
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
1268
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1269
- }
1270
- /** The total stored bytes, for the count line. */
1271
- const totalBytes = $derived(data.assets.reduce((sum, a) => sum + a.bytes, 0));
1272
- /** Dimensions plus type for the list row metadata line. */
1273
- function dimensions(asset: MediaLibraryEntry): string {
1274
- return asset.width && asset.height ? `${asset.width}×${asset.height}` : '';
824
+ orphanPurgeError = "The purge could not be completed. Please try again.";
825
+ return;
1275
826
  }
1276
- function typeLabel(asset: MediaLibraryEntry): string {
1277
- return asset.ext.toUpperCase();
1278
- }
1279
- function thumbSrc(asset: MediaLibraryEntry): string {
1280
- return publicPath(asset.slug, asset.hash, asset.ext, 'slug');
827
+ orphanPurgeBusy = false;
828
+ if (result.type === "success" && result.data) {
829
+ orphanPurgeResult = result.data;
830
+ orphanPurging = false;
831
+ } else {
832
+ const failure = result.data;
833
+ orphanPurgeError = failure?.error ?? "The purge could not be completed. Please try again.";
1281
834
  }
1282
-
1283
- // The selected-cue check glyph for the triage radiogroup (WCAG 1.4.1): hue never carries the
1284
- // chosen state alone, the same non-color cue the ConceptList triage uses.
1285
- function segButtonClass(on: boolean): string {
1286
- 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)]'}`;
835
+ }
836
+ function brokenWhereUsed(count) {
837
+ if (count === 0) return "no references found";
838
+ return `used in ${count} ${count === 1 ? "entry" : "entries"}`;
839
+ }
840
+ function onGridKeydown(e, i) {
841
+ if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) {
842
+ e.preventDefault();
843
+ selectAllVisible();
844
+ return;
1287
845
  }
1288
- function densityButtonClass(on: boolean): string {
1289
- 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]'}`;
846
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
847
+ e.preventDefault();
848
+ const to = Math.min(i + 1, visible.length - 1);
849
+ if (e.shiftKey) selectRange(to);
850
+ focusTile(to);
851
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
852
+ e.preventDefault();
853
+ const to = Math.max(i - 1, 0);
854
+ if (e.shiftKey) selectRange(to);
855
+ focusTile(to);
856
+ } else if (e.key === "Home") {
857
+ e.preventDefault();
858
+ focusTile(0);
859
+ } else if (e.key === "End") {
860
+ e.preventDefault();
861
+ focusTile(visible.length - 1);
862
+ } else if (e.key === " ") {
863
+ e.preventDefault();
864
+ toggleSelect(visible[i].hash);
865
+ } else if (e.key === "Enter") {
866
+ e.preventDefault();
867
+ openAsset(visible[i], tileEls[i]);
1290
868
  }
1291
-
1292
- const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
869
+ }
870
+ let brokenHashes = $state(/* @__PURE__ */ new Set());
871
+ function markBroken(hash) {
872
+ if (brokenHashes.has(hash)) return;
873
+ const next = new Set(brokenHashes);
874
+ next.add(hash);
875
+ brokenHashes = next;
876
+ }
877
+ const dateFmt = new Intl.DateTimeFormat(void 0, { month: "short", day: "numeric" });
878
+ function formatAdded(iso) {
879
+ const parsed = new Date(iso);
880
+ return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
881
+ }
882
+ function formatBytes(bytes) {
883
+ if (bytes < 1024) return `${bytes} B`;
884
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
885
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
886
+ }
887
+ const totalBytes = $derived(data.assets.reduce((sum, a) => sum + a.bytes, 0));
888
+ function dimensions(asset) {
889
+ return asset.width && asset.height ? `${asset.width}×${asset.height}` : "";
890
+ }
891
+ function typeLabel(asset) {
892
+ return asset.ext.toUpperCase();
893
+ }
894
+ function thumbSrc(asset) {
895
+ return publicPath(asset.slug, asset.hash, asset.ext, "slug");
896
+ }
897
+ function segButtonClass(on) {
898
+ 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)]"}`;
899
+ }
900
+ function densityButtonClass(on) {
901
+ 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]"}`;
902
+ }
903
+ const headerLabel = "text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]";
1293
904
  </script>
1294
905
 
1295
906
  <svelte:window onkeydown={onWindowKeydown} />