@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
@@ -33,346 +33,227 @@ its own <form>, illegal nested here, and a Name field a hero has no use for). Al
33
33
  save block; a decorative hero resolves alt to the empty string. The upload path mirrors the insert
34
34
  popover's runUpload but resolves to this field, not an editor placeholder.
35
35
  -->
36
- <script lang="ts">
37
- import { getContext, tick, untrack } from 'svelte';
38
- import { CSRF_CONTEXT_KEY } from './csrf-context.js';
39
- import MediaPicker, { type MediaLibraryEntry, type MediaSelection } from './MediaPicker.svelte';
40
- import {
41
- ingestFile,
42
- buildUploadRequest,
43
- sendUpload,
44
- ingestFailureKind,
45
- failureCard,
46
- proposedNameFor,
47
- firstImageFile,
48
- } from './client-ingest.js';
49
- import { deserialize } from '$app/forms';
50
- import { uploadOutcome, type UploadEnvelope } from './media-upload-outcome.js';
51
- import { parseMediaToken } from '../media/reference.js';
52
- import { publicPath } from '../media/naming.js';
53
- import type { MediaEntry } from '../media/manifest.js';
54
-
55
- interface Props {
56
- /** The field descriptor: the form input name base and the visible label. */
57
- field: { name: string; label: string };
58
- /** The initial committed value, from `data.frontmatter[field.name]`. */
59
- value?: { src: string; alt: string; caption?: string; decorative?: boolean };
60
- /** Whether the initial hero is an explicit decorative choice (an empty alt that is not debt).
61
- * Defaults false; a fresh field with an empty alt reads as needs-alt. */
62
- decorative?: boolean;
63
- /** The merged committed-plus-uploaded media library, keyed by content hash. */
64
- mediaLibrary: Record<string, MediaLibraryEntry>;
65
- /** The concept the entry belongs to (the upload action's route param). */
66
- conceptId: string;
67
- /** The entry id (the upload action's route param). */
68
- id: string;
69
- /** Called with the server-owned record on a successful upload; the host merges it into the library
70
- * and the save field, the same wiring the insert popover uses. */
71
- onuploaded: (record: MediaEntry) => void;
72
- /** Called when the committed value changes (a confirm or a remove), so the host sets fieldsDirty.
73
- * The hidden-input writes do not fire the form's oninput, so the field signals dirty explicitly. */
74
- ondirty: () => void;
75
- /** Called once on mount and again whenever this hero's needs-alt status changes, with the current
76
- * signal (a non-decorative hero with an empty alt is needs-alt). The host sums this with the body
77
- * scanner's hits for the needs-alt notice. A frontmatter hero has no body offset, so it is
78
- * reported from the field state, never routed through the body scanner. */
79
- onneedsaltchange?: (needsAlt: boolean) => void;
80
- }
81
-
82
- let {
83
- field,
84
- value,
85
- decorative: decorativeInitial = false,
86
- mediaLibrary,
87
- conceptId,
88
- id,
89
- onuploaded,
90
- ondirty,
91
- onneedsaltchange,
92
- }: Props = $props();
93
-
94
- // The CSRF token getter from the admin context (AdminLayout provides it). Undefined outside the
95
- // shell, where the empty token fails the guard's check, the intended fail-closed signal.
96
- const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
97
-
98
- // A stable id base for the dialog's labelled regions.
99
- const uid = $props.id();
100
- const titleId = `cairn-hero-title-${uid}`;
101
- const altNoteId = `cairn-hero-alt-note-${uid}`;
102
-
103
- // The committed value the hidden inputs bind to. Seeded once from the prop; "Use this image" and
104
- // Remove own it thereafter (untrack marks the read a deliberate one-time seed, not a reactive
105
- // miss). An empty src is the empty state. A save reloads the page, which remounts with the fresh
106
- // prop, so a later prop change is out of scope.
107
- let committedSrc = $state(untrack(() => value?.src ?? ''));
108
- let committedAlt = $state(untrack(() => value?.alt ?? ''));
109
- let committedCaption = $state(untrack(() => value?.caption ?? ''));
110
- // Whether the committed hero is an explicit decorative choice (an empty alt that is not debt).
111
- let committedDecorative = $state(untrack(() => decorativeInitial));
112
-
113
- // The resting/empty split keys off whether a src is set.
114
- const hasHero = $derived(committedSrc.trim() !== '');
115
-
116
- /** Resolve a media: src to its library entry through the content hash. Null when the token does not
117
- * parse or the hash is not in the library. */
118
- function entryForSrc(src: string): MediaLibraryEntry | null {
119
- const ref = parseMediaToken(src);
120
- if (!ref) return null;
121
- return mediaLibrary[ref.hash] ?? null;
122
- }
123
-
124
- // The resting row's resolved entry, thumbnail, and display name.
125
- const committedEntry = $derived(hasHero ? entryForSrc(committedSrc) : null);
126
- const committedThumb = $derived(
127
- committedEntry
128
- ? publicPath(committedEntry.slug, committedEntry.hash, committedEntry.ext, 'slug')
129
- : '',
130
- );
131
- const committedName = $derived(
132
- committedEntry ? committedEntry.displayName || committedEntry.slug || committedEntry.hash : '',
133
- );
134
-
135
- // The resting alt-status: decorative is an explicit choice, an empty alt is needs-alt debt, and a
136
- // non-empty alt is described.
137
- type AltStatus = 'described' | 'needs-alt' | 'decorative';
138
- const committedStatus = $derived<AltStatus>(
139
- committedDecorative ? 'decorative' : committedAlt.trim() !== '' ? 'described' : 'needs-alt',
140
- );
141
-
142
- // Report this hero's needs-alt signal to the host: once on mount and again whenever the committed
143
- // status changes. The host sums the signal with the body scanner's hits for the needs-alt notice.
144
- // Only a set hero can be debt; an empty field (no src) reports false even though committedStatus
145
- // reads 'needs-alt' by default.
146
- const heroNeedsAlt = $derived(hasHero && committedStatus === 'needs-alt');
147
- // Report the signal on mount and on every real change, but read the callback through untrack so a
148
- // fresh callback identity (the host recreates the arrow on each of its own renders, which this very
149
- // call triggers) does not re-run the effect and loop. The effect depends only on the signal value.
150
- $effect(() => {
151
- const signal = heroNeedsAlt;
152
- untrack(() => onneedsaltchange?.(signal));
153
- });
154
-
155
- // ---- the dialog ----
156
- // The dialog element and which view it shows. 'chooser' picks or uploads; 'placement' captures alt
157
- // and caption for a chosen image; null means the working selection is not yet made.
158
- let dialog = $state<HTMLDialogElement | null>(null);
159
- type View = 'chooser' | 'placement';
160
- let view = $state<View>('chooser');
161
-
162
- // The working selection in the placement view: the chosen ref, its resolved thumbnail, the alt
163
- // mode and text, and the caption. Seeded on a pick or a successful upload, copied to the committed
164
- // value only on "Use this image".
165
- let workRef = $state('');
166
- let workThumb = $state('');
167
- let workAltMode = $state<'describe' | 'decorative' | null>(null);
168
- let workAltText = $state('');
169
- let workCaption = $state('');
170
-
171
- // The upload transient state, surfaced in the dialog while an uploaded file is decoded and stored.
172
- type Upload = { kind: 'idle' } | { kind: 'uploading' } | { kind: 'failed'; message: string; retry: () => void };
173
- let upload = $state<Upload>({ kind: 'idle' });
174
-
175
- let fileInput = $state<HTMLInputElement | null>(null);
176
- // The describe-mode alt text input, bound so the needs-alt remediation path (focusAlt) can land
177
- // the author's focus directly on it.
178
- let altInput = $state<HTMLInputElement | null>(null);
179
-
180
- /** Open the dialog to the chooser. Editing a set hero still leads with the placement view seeded
181
- * from the committed value, so an author lands on the alt and caption they already wrote. */
182
- function openDialog(initial: View) {
183
- upload = { kind: 'idle' };
184
- if (initial === 'placement' && hasHero) {
185
- seedPlacementFromCommitted();
186
- view = 'placement';
187
- } else {
188
- view = 'chooser';
189
- }
190
- dialog?.showModal();
36
+ <script lang="ts">import { getContext, tick, untrack } from "svelte";
37
+ import { CSRF_CONTEXT_KEY } from "./csrf-context.js";
38
+ import MediaPicker, {} from "./MediaPicker.svelte";
39
+ import {
40
+ ingestFile,
41
+ buildUploadRequest,
42
+ sendUpload,
43
+ ingestFailureKind,
44
+ failureCard,
45
+ proposedNameFor,
46
+ firstImageFile
47
+ } from "./client-ingest.js";
48
+ import { deserialize } from "$app/forms";
49
+ import { uploadOutcome } from "./media-upload-outcome.js";
50
+ import { parseMediaToken } from "../media/reference.js";
51
+ import { publicPath } from "../media/naming.js";
52
+ let {
53
+ field,
54
+ value,
55
+ decorative: decorativeInitial = false,
56
+ mediaLibrary,
57
+ conceptId,
58
+ id,
59
+ onuploaded,
60
+ ondirty,
61
+ onneedsaltchange
62
+ } = $props();
63
+ const csrf = getContext(CSRF_CONTEXT_KEY);
64
+ const uid = $props.id();
65
+ const titleId = `cairn-hero-title-${uid}`;
66
+ const altNoteId = `cairn-hero-alt-note-${uid}`;
67
+ let committedSrc = $state(untrack(() => value?.src ?? ""));
68
+ let committedAlt = $state(untrack(() => value?.alt ?? ""));
69
+ let committedCaption = $state(untrack(() => value?.caption ?? ""));
70
+ let committedDecorative = $state(untrack(() => decorativeInitial));
71
+ const hasHero = $derived(committedSrc.trim() !== "");
72
+ function entryForSrc(src) {
73
+ const ref = parseMediaToken(src);
74
+ if (!ref) return null;
75
+ return mediaLibrary[ref.hash] ?? null;
76
+ }
77
+ const committedEntry = $derived(hasHero ? entryForSrc(committedSrc) : null);
78
+ const committedThumb = $derived(
79
+ committedEntry ? publicPath(committedEntry.slug, committedEntry.hash, committedEntry.ext, "slug") : ""
80
+ );
81
+ const committedName = $derived(
82
+ committedEntry ? committedEntry.displayName || committedEntry.slug || committedEntry.hash : ""
83
+ );
84
+ const committedStatus = $derived(
85
+ committedDecorative ? "decorative" : committedAlt.trim() !== "" ? "described" : "needs-alt"
86
+ );
87
+ const heroNeedsAlt = $derived(hasHero && committedStatus === "needs-alt");
88
+ $effect(() => {
89
+ const signal = heroNeedsAlt;
90
+ untrack(() => onneedsaltchange?.(signal));
91
+ });
92
+ let dialog = $state(null);
93
+ let view = $state("chooser");
94
+ let workRef = $state("");
95
+ let workThumb = $state("");
96
+ let workAltMode = $state(null);
97
+ let workAltText = $state("");
98
+ let workCaption = $state("");
99
+ let upload = $state({ kind: "idle" });
100
+ let fileInput = $state(null);
101
+ let altInput = $state(null);
102
+ function openDialog(initial) {
103
+ upload = { kind: "idle" };
104
+ if (initial === "placement" && hasHero) {
105
+ seedPlacementFromCommitted();
106
+ view = "placement";
107
+ } else {
108
+ view = "chooser";
191
109
  }
192
-
193
- function closeDialog() {
194
- dialog?.close();
195
- }
196
-
197
- /** The needs-alt remediation path the edit page's notice row calls: open the dialog to the
198
- * placement view seeded from the committed value, switch a non-decorative hero into describe mode
199
- * so the alt text input renders, and land focus on it. This is the "Add alt text" action, the
200
- * frontmatter counterpart to the body notice's select-range jump (a hero has no body offset, so it
201
- * focuses the field's own alt input rather than a source range). */
202
- export async function focusAlt() {
203
- openDialog('placement');
204
- if (workAltMode !== 'decorative') {
205
- workAltMode = 'describe';
206
- }
207
- await tick();
208
- altInput?.focus();
110
+ dialog?.showModal();
111
+ }
112
+ function closeDialog() {
113
+ dialog?.close();
114
+ }
115
+ export async function focusAlt() {
116
+ openDialog("placement");
117
+ if (workAltMode !== "decorative") {
118
+ workAltMode = "describe";
209
119
  }
210
-
211
- /** Seed the placement working state from the committed value (the Edit path). */
212
- function seedPlacementFromCommitted() {
213
- workRef = committedSrc;
214
- workThumb = committedThumb;
215
- workCaption = committedCaption;
216
- if (committedDecorative) {
217
- workAltMode = 'decorative';
218
- workAltText = '';
219
- } else if (committedAlt.trim() !== '') {
220
- workAltMode = 'describe';
221
- workAltText = committedAlt;
222
- } else {
223
- workAltMode = null;
224
- workAltText = '';
225
- }
120
+ await tick();
121
+ altInput?.focus();
122
+ }
123
+ function seedPlacementFromCommitted() {
124
+ workRef = committedSrc;
125
+ workThumb = committedThumb;
126
+ workCaption = committedCaption;
127
+ if (committedDecorative) {
128
+ workAltMode = "decorative";
129
+ workAltText = "";
130
+ } else if (committedAlt.trim() !== "") {
131
+ workAltMode = "describe";
132
+ workAltText = committedAlt;
133
+ } else {
134
+ workAltMode = null;
135
+ workAltText = "";
226
136
  }
227
-
228
- /** Seed the placement working state from a picked library asset: the ref, the resolved thumbnail,
229
- * and the manifest alt prefilled into describe mode when non-empty. */
230
- function onPick(sel: MediaSelection) {
231
- workRef = sel.ref;
232
- workThumb = publicPath(sel.entry.slug, sel.entry.hash, sel.entry.ext, 'slug');
233
- workCaption = '';
234
- if (sel.alt.trim() !== '') {
235
- workAltMode = 'describe';
236
- workAltText = sel.alt;
237
- } else {
238
- workAltMode = null;
239
- workAltText = '';
240
- }
241
- upload = { kind: 'idle' };
242
- view = 'placement';
137
+ }
138
+ function onPick(sel) {
139
+ workRef = sel.ref;
140
+ workThumb = publicPath(sel.entry.slug, sel.entry.hash, sel.entry.ext, "slug");
141
+ workCaption = "";
142
+ if (sel.alt.trim() !== "") {
143
+ workAltMode = "describe";
144
+ workAltText = sel.alt;
145
+ } else {
146
+ workAltMode = null;
147
+ workAltText = "";
243
148
  }
244
-
245
- /** Confirm the working selection into the committed hidden inputs, mark dirty, and close. */
246
- function confirm() {
247
- committedSrc = workRef;
248
- committedDecorative = workAltMode === 'decorative';
249
- committedAlt = workAltMode === 'describe' ? workAltText.trim() : '';
250
- committedCaption = workCaption.trim();
251
- ondirty();
252
- closeDialog();
149
+ upload = { kind: "idle" };
150
+ view = "placement";
151
+ }
152
+ function confirm() {
153
+ committedSrc = workRef;
154
+ committedDecorative = workAltMode === "decorative";
155
+ committedAlt = workAltMode === "describe" ? workAltText.trim() : "";
156
+ committedCaption = workCaption.trim();
157
+ ondirty();
158
+ closeDialog();
159
+ }
160
+ function remove() {
161
+ committedSrc = "";
162
+ committedAlt = "";
163
+ committedCaption = "";
164
+ committedDecorative = false;
165
+ ondirty();
166
+ closeDialog();
167
+ }
168
+ function replace() {
169
+ upload = { kind: "idle" };
170
+ view = "chooser";
171
+ }
172
+ async function runUpload(file) {
173
+ upload = { kind: "uploading" };
174
+ const fail = (message) => {
175
+ upload = { kind: "failed", message, retry: () => void runUpload(file) };
176
+ };
177
+ let ingested;
178
+ try {
179
+ ingested = await ingestFile(file);
180
+ } catch (err) {
181
+ fail(failureCard(ingestFailureKind(err)).message);
182
+ return;
253
183
  }
254
-
255
- /** Remove the hero: clear the committed value, mark dirty, and return to the empty state. */
256
- function remove() {
257
- committedSrc = '';
258
- committedAlt = '';
259
- committedCaption = '';
260
- committedDecorative = false;
261
- ondirty();
262
- closeDialog();
184
+ const { url, init } = buildUploadRequest({
185
+ conceptId,
186
+ id,
187
+ bytes: ingested.blob,
188
+ contentType: ingested.contentType,
189
+ csrf: csrf?.() ?? "",
190
+ filename: file.name,
191
+ // The hero alt is a frontmatter value set in the placement view, independent of the manifest
192
+ // alt, so the upload carries an empty manifest alt.
193
+ alt: "",
194
+ displayName: proposedNameFor(file.name) ?? stem(file.name),
195
+ width: ingested.width,
196
+ height: ingested.height
197
+ });
198
+ let res;
199
+ try {
200
+ res = await sendUpload(url, init);
201
+ } catch (err) {
202
+ fail(failureCard(ingestFailureKind(err)).message);
203
+ return;
263
204
  }
264
-
265
- /** Go back to the chooser to replace the working image. */
266
- function replace() {
267
- upload = { kind: 'idle' };
268
- view = 'chooser';
205
+ if (res.type === "opaqueredirect" || res.status === 0) {
206
+ fail("Your session has expired. Please sign in again to add an image.");
207
+ return;
269
208
  }
270
-
271
- // ---- the upload path ----
272
- // A chosen or dropped file routes here. Unlike the insert popover there is no editor body, so no
273
- // placeholder and no open-risk-2 concern: the field shows a small loading state, and on success it
274
- // seeds the placement view from the server record (empty alt to fill in).
275
- async function runUpload(file: File) {
276
- upload = { kind: 'uploading' };
277
- const fail = (message: string) => {
278
- upload = { kind: 'failed', message, retry: () => void runUpload(file) };
279
- };
280
-
281
- let ingested: Awaited<ReturnType<typeof ingestFile>>;
282
- try {
283
- ingested = await ingestFile(file);
284
- } catch (err) {
285
- fail(failureCard(ingestFailureKind(err)).message);
286
- return;
287
- }
288
-
289
- const { url, init } = buildUploadRequest({
290
- conceptId,
291
- id,
292
- bytes: ingested.blob,
293
- contentType: ingested.contentType,
294
- csrf: csrf?.() ?? '',
295
- filename: file.name,
296
- // The hero alt is a frontmatter value set in the placement view, independent of the manifest
297
- // alt, so the upload carries an empty manifest alt.
298
- alt: '',
299
- displayName: proposedNameFor(file.name) ?? stem(file.name),
300
- width: ingested.width,
301
- height: ingested.height,
302
- });
303
-
304
- let res: Response;
305
- try {
306
- res = await sendUpload(url, init);
307
- } catch (err) {
308
- fail(failureCard(ingestFailureKind(err)).message);
309
- return;
310
- }
311
-
312
- if (res.type === 'opaqueredirect' || res.status === 0) {
313
- fail('Your session has expired. Please sign in again to add an image.');
314
- return;
315
- }
316
-
317
- let outcome: ReturnType<typeof uploadOutcome>;
318
- try {
319
- outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
320
- } catch {
321
- fail('The upload could not be completed. Please try again.');
322
- return;
323
- }
324
- if (outcome.kind === 'session-expired') {
325
- fail('Your session has expired. Please sign in again to add an image.');
326
- return;
327
- }
328
- if (outcome.kind === 'failed') {
329
- fail(
330
- outcome.failure === 'generic'
331
- ? 'The upload could not be completed. Please try again.'
332
- : failureCard(outcome.failure).message,
333
- );
334
- return;
335
- }
336
-
337
- // Success: merge the record up so the library resolves the new reference, then land on the
338
- // placement view seeded from the record with an empty alt to fill in.
339
- onuploaded(outcome.record);
340
- const r = outcome.record;
341
- workRef = outcome.reference;
342
- workThumb = publicPath(r.slug, r.hash, r.ext, 'slug');
343
- workAltMode = null;
344
- workAltText = '';
345
- workCaption = '';
346
- upload = { kind: 'idle' };
347
- view = 'placement';
209
+ let outcome;
210
+ try {
211
+ outcome = uploadOutcome(deserialize(await res.text()));
212
+ } catch {
213
+ fail("The upload could not be completed. Please try again.");
214
+ return;
348
215
  }
349
-
350
- /** The filename stem (extension dropped), the fallback display name for a generic filename. */
351
- function stem(filename: string): string {
352
- const dot = filename.lastIndexOf('.');
353
- return (dot === -1 ? filename : filename.slice(0, dot)).trim() || filename;
216
+ if (outcome.kind === "session-expired") {
217
+ fail("Your session has expired. Please sign in again to add an image.");
218
+ return;
354
219
  }
355
-
356
- function onChosenFile(e: Event) {
357
- const input = e.currentTarget as HTMLInputElement;
358
- const file = input.files?.[0];
359
- if (file) void runUpload(file);
220
+ if (outcome.kind === "failed") {
221
+ fail(
222
+ outcome.failure === "generic" ? "The upload could not be completed. Please try again." : failureCard(outcome.failure).message
223
+ );
224
+ return;
360
225
  }
361
-
362
- // The empty dropzone's drag-and-drop: a dropped image routes straight to the upload path, opening
363
- // the dialog to the loading state. preventDefault stops the browser from navigating to the file.
364
- function onDropzoneDrop(e: DragEvent) {
365
- e.preventDefault();
366
- const file = firstImageFile(e.dataTransfer ?? {});
367
- if (file) {
368
- view = 'chooser';
369
- dialog?.showModal();
370
- void runUpload(file);
371
- }
372
- }
373
- function onDropzoneDragover(e: DragEvent) {
374
- e.preventDefault();
226
+ onuploaded(outcome.record);
227
+ const r = outcome.record;
228
+ workRef = outcome.reference;
229
+ workThumb = publicPath(r.slug, r.hash, r.ext, "slug");
230
+ workAltMode = null;
231
+ workAltText = "";
232
+ workCaption = "";
233
+ upload = { kind: "idle" };
234
+ view = "placement";
235
+ }
236
+ function stem(filename) {
237
+ const dot = filename.lastIndexOf(".");
238
+ return (dot === -1 ? filename : filename.slice(0, dot)).trim() || filename;
239
+ }
240
+ function onChosenFile(e) {
241
+ const input = e.currentTarget;
242
+ const file = input.files?.[0];
243
+ if (file) void runUpload(file);
244
+ }
245
+ function onDropzoneDrop(e) {
246
+ e.preventDefault();
247
+ const file = firstImageFile(e.dataTransfer ?? {});
248
+ if (file) {
249
+ view = "chooser";
250
+ dialog?.showModal();
251
+ void runUpload(file);
375
252
  }
253
+ }
254
+ function onDropzoneDragover(e) {
255
+ e.preventDefault();
256
+ }
376
257
  </script>
377
258
 
378
259
  <div class="flex flex-col gap-1">