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