@glw907/cairn-cms 0.59.0 → 0.60.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.
- package/CHANGELOG.md +60 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +12 -41
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +486 -0
- package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +786 -918
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +688 -789
- package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
- package/dist/components/MarkdownHelpDialog.svelte +8 -12
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +355 -0
- package/dist/components/TidyReview.svelte.d.ts +47 -0
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +768 -0
- package/dist/components/editor-tidy.d.ts +31 -0
- package/dist/components/editor-tidy.js +199 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +16 -0
- package/dist/components/markdown-directives.js +34 -0
- package/dist/components/objective-errors.d.ts +30 -0
- package/dist/components/objective-errors.js +113 -0
- package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/dist/components/spellcheck-worker.d.ts +80 -0
- package/dist/components/spellcheck-worker.js +161 -0
- package/dist/components/spellcheck.d.ts +148 -0
- package/dist/components/spellcheck.js +553 -0
- package/dist/components/tidy-categorize.d.ts +67 -0
- package/dist/components/tidy-categorize.js +392 -0
- package/dist/components/tidy-diff.d.ts +60 -0
- package/dist/components/tidy-diff.js +147 -0
- package/dist/components/tidy-validate.d.ts +37 -0
- package/dist/components/tidy-validate.js +174 -0
- package/dist/content/compose.d.ts +1 -1
- package/dist/content/compose.js +11 -0
- package/dist/content/site-dictionary.d.ts +31 -0
- package/dist/content/site-dictionary.js +82 -0
- package/dist/content/types.d.ts +25 -0
- package/dist/delivery/CairnHead.svelte +8 -11
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +55 -6
- package/dist/doctor/index.js +2 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +98 -0
- package/dist/nav/site-config.js +132 -0
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +6 -2
- package/dist/sveltekit/cairn-admin.d.ts +13 -1
- package/dist/sveltekit/cairn-admin.js +22 -3
- package/dist/sveltekit/content-routes.d.ts +135 -1
- package/dist/sveltekit/content-routes.js +351 -3
- package/dist/sveltekit/tidy-prompt.d.ts +11 -0
- package/dist/sveltekit/tidy-prompt.js +118 -0
- package/package.json +11 -2
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +553 -0
- package/src/lib/components/EditPage.svelte +371 -2
- package/src/lib/components/MarkdownEditor.svelte +168 -1
- package/src/lib/components/TidyReview.svelte +463 -0
- package/src/lib/components/cairn-admin.css +25 -0
- package/src/lib/components/editor-tidy.ts +241 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +35 -0
- package/src/lib/components/objective-errors.ts +155 -0
- package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/src/lib/components/spellcheck-worker.ts +279 -0
- package/src/lib/components/spellcheck.ts +693 -0
- package/src/lib/components/tidy-categorize.ts +460 -0
- package/src/lib/components/tidy-diff.ts +196 -0
- package/src/lib/components/tidy-validate.ts +202 -0
- package/src/lib/content/compose.ts +11 -1
- package/src/lib/content/site-dictionary.ts +84 -0
- package/src/lib/content/types.ts +25 -0
- package/src/lib/doctor/checks-local.ts +59 -5
- package/src/lib/doctor/index.ts +2 -0
- package/src/lib/log/events.ts +7 -1
- package/src/lib/nav/site-config.ts +197 -0
- package/src/lib/sveltekit/admin-dispatch.ts +7 -3
- package/src/lib/sveltekit/cairn-admin.ts +32 -4
- package/src/lib/sveltekit/content-routes.ts +504 -4
- package/src/lib/sveltekit/tidy-prompt.ts +153 -0
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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">
|