@glw907/cairn-cms 0.56.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/README.md +10 -4
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/ComponentForm.svelte +175 -46
- package/dist/components/ComponentForm.svelte.d.ts +22 -8
- package/dist/components/ComponentInsertDialog.svelte +379 -26
- package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
- package/dist/components/EditPage.svelte +477 -15
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +358 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +51 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +1045 -28
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +19 -0
- package/dist/components/markdown-directives.js +52 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/component-grammar.d.ts +20 -0
- package/dist/render/component-grammar.js +47 -3
- package/dist/render/component-validate.js +22 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +15 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +8 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +477 -15
- package/src/lib/components/MarkdownEditor.svelte +358 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +57 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +38 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The at-caret media insert popover: the single entry point for placing an image. It composes the
|
|
4
|
+
capture card (MediaCaptureCard) and the combobox picker (MediaPicker), routes by the opening signal,
|
|
5
|
+
and drives the optimistic upload loop.
|
|
6
|
+
|
|
7
|
+
Routing (locked decision 4): open('capture', file) goes straight to the capture card with the bytes
|
|
8
|
+
in hand (the paste and drag path); open('chooser') leads with the upload drop zone and choose-file as
|
|
9
|
+
the persistent primary, with the picker below under "or reuse an image" (the toolbar-button path).
|
|
10
|
+
|
|
11
|
+
The optimistic loop: on a capture record the popover lands a placeholder at the caret (a local object
|
|
12
|
+
URL, so the author sees the image at once), runs ingestFile then buildUploadRequest then sendUpload,
|
|
13
|
+
and on the success envelope swaps the placeholder for the committed  text. A
|
|
14
|
+
dedup result still inserts but notes "Reused an existing image"; a typed failure cancels the
|
|
15
|
+
placeholder and shows the card with a Retry; an opaque or status-0 response is a session-expired
|
|
16
|
+
signal. The placeholder is a widget, never doc text, so a failed or expired upload leaves the source
|
|
17
|
+
exactly as it was (open risk 2).
|
|
18
|
+
|
|
19
|
+
The popover is headless by default (trigger=false): the host opens it through the exported open(). It
|
|
20
|
+
moves focus in on open, traps Tab, and restores focus to the editor on close or Escape through
|
|
21
|
+
editor.focusEditor() (the selection is intact, since opening only blurred the editor). Below the
|
|
22
|
+
narrow breakpoint it falls back to a full-height bottom sheet (the admin design system's modal-sizing
|
|
23
|
+
rule). The CSRF token is read from the admin context.
|
|
24
|
+
-->
|
|
25
|
+
<script lang="ts">
|
|
26
|
+
import { getContext, tick } from 'svelte';
|
|
27
|
+
import { CSRF_CONTEXT_KEY } from './csrf-context.js';
|
|
28
|
+
import MediaCaptureCard from './MediaCaptureCard.svelte';
|
|
29
|
+
import MediaPicker, { type MediaLibraryEntry, type MediaSelection } from './MediaPicker.svelte';
|
|
30
|
+
import {
|
|
31
|
+
ingestFile,
|
|
32
|
+
buildUploadRequest,
|
|
33
|
+
sendUpload,
|
|
34
|
+
ingestFailureKind,
|
|
35
|
+
failureCard,
|
|
36
|
+
type IngestFailureCard,
|
|
37
|
+
} from './client-ingest.js';
|
|
38
|
+
import { deserialize } from '$app/forms';
|
|
39
|
+
import { uploadOutcome, type UploadEnvelope, type UploadFailureKind } from './media-upload-outcome.js';
|
|
40
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
41
|
+
|
|
42
|
+
// The placeholder api type is referenced inline (import('...').Type), never a static
|
|
43
|
+
// `import type ... from`, so no static edge to the dynamically-imported editor-placeholder module
|
|
44
|
+
// sits in this client component (the editor-boundary test bars that edge by a textual `from` scan).
|
|
45
|
+
type ImagePlaceholderApi = import('./editor-placeholder.js').ImagePlaceholderApi;
|
|
46
|
+
|
|
47
|
+
/** The record the capture card emits, the same shape MediaCaptureCard.oncapture carries. */
|
|
48
|
+
interface CaptureRecord {
|
|
49
|
+
file: File;
|
|
50
|
+
displayName: string;
|
|
51
|
+
alt: string;
|
|
52
|
+
decorative: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
/** The concept the entry belongs to (the upload action's route param). */
|
|
57
|
+
conceptId: string;
|
|
58
|
+
/** The entry id (the upload action's route param). */
|
|
59
|
+
id: string;
|
|
60
|
+
/** The merged committed-plus-uploaded media library, keyed by content hash, for the picker. */
|
|
61
|
+
library: Record<string, MediaLibraryEntry>;
|
|
62
|
+
/** The editor seams the popover drives: caret anchoring, focus restore, the placeholder api, and
|
|
63
|
+
* the direct-insert path for a picked image (no upload). */
|
|
64
|
+
editor: {
|
|
65
|
+
caretCoords: () => { left: number; right: number; top: number; bottom: number } | null;
|
|
66
|
+
focusEditor: () => void;
|
|
67
|
+
placeholders: ImagePlaceholderApi;
|
|
68
|
+
insertImage: (alt: string, ref: string) => void;
|
|
69
|
+
};
|
|
70
|
+
/** Called with the server-owned record on a successful upload; the host appends it to its records
|
|
71
|
+
* state and merges it into the library so the source decoration resolves the new reference. */
|
|
72
|
+
onuploaded: (record: MediaEntry) => void;
|
|
73
|
+
/** Render the built-in trigger button. False (the default) mounts headless; the host opens the
|
|
74
|
+
* popover through the exported open(). */
|
|
75
|
+
trigger?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let { conceptId, id, library, editor, onuploaded, trigger = false }: Props = $props();
|
|
79
|
+
|
|
80
|
+
// The CSRF token getter from the admin context (AdminLayout provides it). Undefined outside the
|
|
81
|
+
// shell, where the empty token fails the guard's check, the intended fail-closed signal.
|
|
82
|
+
const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
|
|
83
|
+
|
|
84
|
+
// The view the popover shows. 'capture' is the one-step card with bytes in hand; 'chooser' leads
|
|
85
|
+
// with upload and the picker; null is closed.
|
|
86
|
+
type View = 'capture' | 'chooser';
|
|
87
|
+
let view = $state<View | null>(null);
|
|
88
|
+
let captureFile = $state<File | null>(null);
|
|
89
|
+
|
|
90
|
+
// The card a failed loop surfaces: an ingest-taxonomy card (its own message) or the envelope-only
|
|
91
|
+
// generic card. The two share the failed-card shape, so one alias names both.
|
|
92
|
+
type FailureCard = IngestFailureCard | { status: 'failed'; kind: UploadFailureKind; message: string };
|
|
93
|
+
|
|
94
|
+
// The transient status of the in-flight or failed loop, surfaced under the active view. 'reused'
|
|
95
|
+
// briefly notes a dedup collapse; the failure and expired states carry a message and a retry.
|
|
96
|
+
type Status =
|
|
97
|
+
| { kind: 'idle' }
|
|
98
|
+
| { kind: 'reused' }
|
|
99
|
+
| { kind: 'failed'; card: FailureCard; retry: () => void }
|
|
100
|
+
| { kind: 'expired' };
|
|
101
|
+
let status = $state<Status>({ kind: 'idle' });
|
|
102
|
+
|
|
103
|
+
// The anchor coordinates captured on open, so the popover positions at the caret even after focus
|
|
104
|
+
// leaves the editor. Null falls back to a centered position.
|
|
105
|
+
let anchor = $state<{ left: number; right: number; top: number; bottom: number } | null>(null);
|
|
106
|
+
let panel = $state<HTMLDivElement | null>(null);
|
|
107
|
+
let fileInput = $state<HTMLInputElement | null>(null);
|
|
108
|
+
// The primary control of each terminal status block, bound so focus lands on the action when the
|
|
109
|
+
// block renders (the focused capture-card submit unmounts when the loop starts, so focus would
|
|
110
|
+
// otherwise drop to <body>).
|
|
111
|
+
let retryButton = $state<HTMLButtonElement | null>(null);
|
|
112
|
+
let expiredCloseButton = $state<HTMLButtonElement | null>(null);
|
|
113
|
+
let reusedDoneButton = $state<HTMLButtonElement | null>(null);
|
|
114
|
+
|
|
115
|
+
// When the loop settles into a terminal state, move focus to that state's primary control (Retry,
|
|
116
|
+
// Close, or Done) so the keyboard/screen-reader user lands on the action and the Tab trap plus
|
|
117
|
+
// Escape stay in play. The message blocks carry role="alert"/role="status" so the transition is
|
|
118
|
+
// also announced (WCAG 4.1.3, 2.4.3). Keyed on status.kind, after the block renders.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
const kind = status.kind;
|
|
121
|
+
if (kind !== 'failed' && kind !== 'expired' && kind !== 'reused') return;
|
|
122
|
+
void tick().then(() => {
|
|
123
|
+
if (kind === 'failed') retryButton?.focus();
|
|
124
|
+
else if (kind === 'expired') expiredCloseButton?.focus();
|
|
125
|
+
else reusedDoneButton?.focus();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Open the popover. 'capture' with a file goes straight to the capture card (paste/drag); 'chooser'
|
|
131
|
+
* leads with the upload zone and the picker (the toolbar button). Anchors to the caret and moves
|
|
132
|
+
* focus in.
|
|
133
|
+
*/
|
|
134
|
+
export function open(signal: 'chooser' | 'capture', file?: File): void {
|
|
135
|
+
anchor = editor.caretCoords();
|
|
136
|
+
status = { kind: 'idle' };
|
|
137
|
+
if (signal === 'capture' && file) {
|
|
138
|
+
captureFile = file;
|
|
139
|
+
view = 'capture';
|
|
140
|
+
} else {
|
|
141
|
+
captureFile = null;
|
|
142
|
+
view = 'chooser';
|
|
143
|
+
}
|
|
144
|
+
// Move focus into the panel once it renders.
|
|
145
|
+
void tick().then(() => panel?.focus());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function close() {
|
|
149
|
+
view = null;
|
|
150
|
+
captureFile = null;
|
|
151
|
+
status = { kind: 'idle' };
|
|
152
|
+
editor.focusEditor();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Trap Tab within the panel and close on Escape, restoring focus to the editor.
|
|
156
|
+
function onKeydown(e: KeyboardEvent) {
|
|
157
|
+
if (e.key === 'Escape') {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
close();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (e.key !== 'Tab' || !panel) return;
|
|
163
|
+
const focusable = panel.querySelectorAll<HTMLElement>(
|
|
164
|
+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
165
|
+
);
|
|
166
|
+
if (focusable.length === 0) return;
|
|
167
|
+
const first = focusable[0];
|
|
168
|
+
const last = focusable[focusable.length - 1];
|
|
169
|
+
const activeEl = document.activeElement;
|
|
170
|
+
if (e.shiftKey && (activeEl === first || activeEl === panel)) {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
last.focus();
|
|
173
|
+
} else if (!e.shiftKey && activeEl === last) {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
first.focus();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The picker path: a picked asset inserts its reference directly at the caret with no upload, then
|
|
180
|
+
// the popover closes. This is the reuse-an-existing path.
|
|
181
|
+
function onPick(sel: MediaSelection) {
|
|
182
|
+
editor.insertImage(sel.alt, sel.ref);
|
|
183
|
+
close();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A chosen file (the choose-file fallback in the chooser) routes to the capture card, the same one
|
|
187
|
+
// a paste or drag opens, so every byte path runs the one capture-then-upload flow.
|
|
188
|
+
function onChosenFile(e: Event) {
|
|
189
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
190
|
+
const file = input.files?.[0];
|
|
191
|
+
if (file) {
|
|
192
|
+
captureFile = file;
|
|
193
|
+
view = 'capture';
|
|
194
|
+
status = { kind: 'idle' };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The optimistic upload loop, on a capture-card record. It lands a placeholder, runs the ingest and
|
|
199
|
+
// upload, and resolves the placeholder to the committed reference or cancels it on any failure, so
|
|
200
|
+
// the source is never left with a half-written token.
|
|
201
|
+
async function runUpload(record: CaptureRecord) {
|
|
202
|
+
const objectUrl = URL.createObjectURL(record.file);
|
|
203
|
+
const pid = editor.placeholders.begin(objectUrl);
|
|
204
|
+
// Close the byte-capture view now; the placeholder carries the progress in the source.
|
|
205
|
+
view = null;
|
|
206
|
+
captureFile = null;
|
|
207
|
+
|
|
208
|
+
// Drop the in-flight placeholder, leaving the source exactly as it was, and free the object URL.
|
|
209
|
+
// Both unsuccessful paths (a typed failure, an expired session) end here before setting status.
|
|
210
|
+
const discardPlaceholder = () => {
|
|
211
|
+
editor.placeholders.cancel(pid);
|
|
212
|
+
URL.revokeObjectURL(objectUrl);
|
|
213
|
+
};
|
|
214
|
+
const fail = (card: FailureCard) => {
|
|
215
|
+
discardPlaceholder();
|
|
216
|
+
status = { kind: 'failed', card, retry: () => void runUpload(record) };
|
|
217
|
+
};
|
|
218
|
+
const expire = () => {
|
|
219
|
+
discardPlaceholder();
|
|
220
|
+
status = { kind: 'expired' };
|
|
221
|
+
};
|
|
222
|
+
// The author-facing card for an envelope-only generic refusal (a deserialize throw or an
|
|
223
|
+
// operational reason with no author-actionable specifics).
|
|
224
|
+
const genericCard = () => fail({ status: 'failed', kind: 'generic', message: GENERIC_FAILURE_MESSAGE });
|
|
225
|
+
|
|
226
|
+
// Stage progress, not real byte counts: fetch cannot report upload bytes, so the bar reads the
|
|
227
|
+
// ingest/upload LIFECYCLE (begin ~0.1 set by the field, ingesting ~0.4, uploading ~0.85, resolve
|
|
228
|
+
// clears it). Honest stage progress, never a fabricated timer.
|
|
229
|
+
editor.placeholders.progress(pid, 0.4);
|
|
230
|
+
let ingested: Awaited<ReturnType<typeof ingestFile>>;
|
|
231
|
+
try {
|
|
232
|
+
ingested = await ingestFile(record.file);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
fail(failureCard(ingestFailureKind(err)));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
editor.placeholders.progress(pid, 0.85);
|
|
239
|
+
const { url, init } = buildUploadRequest({
|
|
240
|
+
conceptId,
|
|
241
|
+
id,
|
|
242
|
+
bytes: ingested.blob,
|
|
243
|
+
contentType: ingested.contentType,
|
|
244
|
+
csrf: csrf?.() ?? '',
|
|
245
|
+
filename: record.file.name,
|
|
246
|
+
alt: record.alt,
|
|
247
|
+
displayName: record.displayName,
|
|
248
|
+
width: ingested.width,
|
|
249
|
+
height: ingested.height,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
let res: Response;
|
|
253
|
+
try {
|
|
254
|
+
res = await sendUpload(url, init);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
fail(failureCard(ingestFailureKind(err)));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The guard's expired-session 303 under redirect: 'manual' surfaces as an opaque, status-0
|
|
261
|
+
// response; treat it as session-expired before parsing a body that is not there.
|
|
262
|
+
if (res.type === 'opaqueredirect' || res.status === 0) {
|
|
263
|
+
expire();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// deserialize returns the generic ActionResult; the upload action's success data is an
|
|
268
|
+
// UploadResult and its failure data carries an error string, so the result matches UploadEnvelope.
|
|
269
|
+
// The cast names that known shape for the pure mapper (the redirect/status-0 case is handled above).
|
|
270
|
+
// An unexpected server response (a 500/502/504 from a worker crash, OOM, or an edge timeout) is an
|
|
271
|
+
// HTML error page, not a devalue-encoded result, so deserialize throws. Catch it and route the
|
|
272
|
+
// throw through fail() with the generic card, so the placeholder cancels and a Retry is offered.
|
|
273
|
+
let outcome: ReturnType<typeof uploadOutcome>;
|
|
274
|
+
try {
|
|
275
|
+
outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
|
|
276
|
+
} catch {
|
|
277
|
+
genericCard();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (outcome.kind === 'session-expired') {
|
|
281
|
+
expire();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (outcome.kind === 'failed') {
|
|
285
|
+
// An ingest-taxonomy kind reuses failureCard's own message; the envelope-only `generic` kind
|
|
286
|
+
// carries its own plain message. Either way the card shows the message with a Retry.
|
|
287
|
+
if (outcome.failure === 'generic') genericCard();
|
|
288
|
+
else fail(failureCard(outcome.failure));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Success: swap the placeholder for the committed reference in one transaction, hand the record
|
|
293
|
+
// up, and close. A dedup reuse still inserts the existing reference (the decision) and briefly
|
|
294
|
+
// notes it.
|
|
295
|
+
editor.placeholders.resolveTo(pid, record.alt, outcome.reference);
|
|
296
|
+
onuploaded(outcome.record);
|
|
297
|
+
URL.revokeObjectURL(objectUrl);
|
|
298
|
+
if (outcome.reused) {
|
|
299
|
+
status = { kind: 'reused' };
|
|
300
|
+
} else {
|
|
301
|
+
close();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The author-facing message for an envelope-only generic refusal (a binding-missing, a csrf, a
|
|
306
|
+
// length-required: operational refusals with no author-actionable specifics). The ingest-taxonomy
|
|
307
|
+
// kinds carry their own messages through failureCard.
|
|
308
|
+
const GENERIC_FAILURE_MESSAGE = 'The upload could not be completed. Please try again.';
|
|
309
|
+
|
|
310
|
+
// The popover's anchored position: just below the caret line, clamped into the viewport. A null
|
|
311
|
+
// anchor centers it. The full-height sheet at the narrow breakpoint is the CSS fallback.
|
|
312
|
+
const positionStyle = $derived(
|
|
313
|
+
anchor
|
|
314
|
+
? `left: ${Math.max(8, Math.min(anchor.left, (typeof window !== 'undefined' ? window.innerWidth : 1024) - 360))}px; top: ${anchor.bottom + 6}px;`
|
|
315
|
+
: 'left: 50%; top: 4rem; transform: translateX(-50%);',
|
|
316
|
+
);
|
|
317
|
+
</script>
|
|
318
|
+
|
|
319
|
+
{#if trigger}
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
class="btn btn-sm btn-ghost"
|
|
323
|
+
aria-haspopup="dialog"
|
|
324
|
+
aria-label="Insert image"
|
|
325
|
+
onclick={() => open('chooser')}
|
|
326
|
+
>
|
|
327
|
+
Insert image
|
|
328
|
+
</button>
|
|
329
|
+
{/if}
|
|
330
|
+
|
|
331
|
+
{#if view !== null || status.kind === 'failed' || status.kind === 'expired' || status.kind === 'reused'}
|
|
332
|
+
<!-- The light-dismiss backdrop: a click outside closes a non-destructive popover. A real button so
|
|
333
|
+
it carries a role and a keyboard activation; tabindex -1 keeps it out of the focus trap, and
|
|
334
|
+
Escape on the panel is the keyboard dismiss. -->
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
class="cairn-media-popover-backdrop"
|
|
338
|
+
tabindex="-1"
|
|
339
|
+
aria-label="Close"
|
|
340
|
+
onclick={close}
|
|
341
|
+
></button>
|
|
342
|
+
<div
|
|
343
|
+
bind:this={panel}
|
|
344
|
+
class="cairn-media-popover"
|
|
345
|
+
style={positionStyle}
|
|
346
|
+
role="dialog"
|
|
347
|
+
aria-modal="true"
|
|
348
|
+
aria-label="Insert image"
|
|
349
|
+
tabindex="-1"
|
|
350
|
+
onkeydown={onKeydown}
|
|
351
|
+
>
|
|
352
|
+
<div class="mb-2 flex items-center justify-between gap-2">
|
|
353
|
+
<h2 class="text-sm font-semibold">Insert image</h2>
|
|
354
|
+
<button type="button" class="btn btn-ghost btn-xs" aria-label="Close" onclick={close}>✕</button>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
{#if status.kind === 'expired'}
|
|
358
|
+
<!-- role="alert" (assertive): the upload failed mid-flight after the capture card unmounted, so
|
|
359
|
+
the state transition must announce. Focus moves to Close (the $effect above). -->
|
|
360
|
+
<div class="flex flex-col gap-2" data-testid="cairn-media-expired" role="alert">
|
|
361
|
+
<p class="text-sm">Your session has expired. Please sign in again to add an image.</p>
|
|
362
|
+
<div class="flex justify-end">
|
|
363
|
+
<button bind:this={expiredCloseButton} type="button" class="btn btn-sm" onclick={close}>Close</button>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
{:else if status.kind === 'failed'}
|
|
367
|
+
<!-- role="alert" (assertive): a failure must interrupt. Focus moves to Retry (the $effect). -->
|
|
368
|
+
<div class="flex flex-col gap-2" data-testid="cairn-media-failed" role="alert">
|
|
369
|
+
<p class="text-sm">{status.card.message}</p>
|
|
370
|
+
<div class="flex justify-end gap-2">
|
|
371
|
+
<button type="button" class="btn btn-ghost btn-sm" onclick={close}>Cancel</button>
|
|
372
|
+
<button bind:this={retryButton} type="button" class="btn btn-primary btn-sm" onclick={status.retry}>Retry</button>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
{:else if status.kind === 'reused'}
|
|
376
|
+
<!-- role="status" (polite): a reuse is a success note, not an interruption. Focus moves to
|
|
377
|
+
Done so the keyboard user lands on the one action. -->
|
|
378
|
+
<div class="flex flex-col gap-2" data-testid="cairn-media-reused" role="status">
|
|
379
|
+
<p class="text-sm">Reused an existing image.</p>
|
|
380
|
+
<div class="flex justify-end">
|
|
381
|
+
<button bind:this={reusedDoneButton} type="button" class="btn btn-primary btn-sm" onclick={close}>Done</button>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
{:else if view === 'capture' && captureFile}
|
|
385
|
+
<MediaCaptureCard file={captureFile} oncapture={runUpload} />
|
|
386
|
+
{:else if view === 'chooser'}
|
|
387
|
+
<div class="flex flex-col gap-3">
|
|
388
|
+
<!-- Upload-first: the persistent primary path. -->
|
|
389
|
+
<div class="flex flex-col gap-1">
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
class="btn btn-primary btn-sm w-full"
|
|
393
|
+
onclick={() => fileInput?.click()}
|
|
394
|
+
>
|
|
395
|
+
Upload an image
|
|
396
|
+
</button>
|
|
397
|
+
<input
|
|
398
|
+
bind:this={fileInput}
|
|
399
|
+
type="file"
|
|
400
|
+
accept="image/*"
|
|
401
|
+
class="sr-only"
|
|
402
|
+
aria-label="Choose an image to upload"
|
|
403
|
+
onchange={onChosenFile}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
<p class="text-center text-xs text-[var(--color-muted)]">or reuse an image</p>
|
|
407
|
+
<MediaPicker {library} onselect={onPick} />
|
|
408
|
+
</div>
|
|
409
|
+
{/if}
|
|
410
|
+
</div>
|
|
411
|
+
{/if}
|
|
412
|
+
|
|
413
|
+
<style>
|
|
414
|
+
.cairn-media-popover-backdrop {
|
|
415
|
+
position: fixed;
|
|
416
|
+
inset: 0;
|
|
417
|
+
z-index: 40;
|
|
418
|
+
}
|
|
419
|
+
.cairn-media-popover {
|
|
420
|
+
position: fixed;
|
|
421
|
+
z-index: 41;
|
|
422
|
+
width: 22rem;
|
|
423
|
+
max-width: calc(100vw - 1rem);
|
|
424
|
+
max-height: min(28rem, 80vh);
|
|
425
|
+
overflow: auto;
|
|
426
|
+
border-radius: var(--radius-box, 0.75rem);
|
|
427
|
+
border: 1px solid var(--cairn-card-border, oklch(90% 0.01 75));
|
|
428
|
+
background: var(--color-base-100, white);
|
|
429
|
+
padding: 0.875rem;
|
|
430
|
+
/* The theme-adaptive elevation var, not a fixed shadow: in light the soft shadow carries the
|
|
431
|
+
lift, in dark the hairline border defines the panel where a shadow barely shows. */
|
|
432
|
+
box-shadow: var(--cairn-shadow, 0 12px 32px -8px oklch(0% 0 0 / 0.25));
|
|
433
|
+
}
|
|
434
|
+
/* Below the narrow breakpoint the popover becomes a full-height bottom sheet (the design system's
|
|
435
|
+
modal-sizing rule: filling the height is correct only on a small viewport). */
|
|
436
|
+
@media (max-width: 640px) {
|
|
437
|
+
.cairn-media-popover {
|
|
438
|
+
left: 0 !important;
|
|
439
|
+
right: 0;
|
|
440
|
+
top: auto !important;
|
|
441
|
+
bottom: 0;
|
|
442
|
+
transform: none !important;
|
|
443
|
+
width: 100%;
|
|
444
|
+
max-width: 100%;
|
|
445
|
+
max-height: 90vh;
|
|
446
|
+
border-radius: var(--radius-box, 0.75rem) var(--radius-box, 0.75rem) 0 0;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
</style>
|