@glw907/cairn-cms 0.57.1 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/dist/components/CairnMediaLibrary.svelte +2070 -26
- package/dist/components/CairnMediaLibrary.svelte.d.ts +10 -2
- package/dist/components/admin-icons.d.ts +5 -0
- package/dist/components/admin-icons.js +5 -0
- package/dist/components/cairn-admin.css +402 -3
- package/dist/content/media-rewrite.d.ts +65 -0
- package/dist/content/media-rewrite.js +442 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/bulk-delete-plan.d.ts +24 -0
- package/dist/media/bulk-delete-plan.js +25 -0
- package/dist/media/orphan-scan.d.ts +37 -0
- package/dist/media/orphan-scan.js +42 -0
- package/dist/media/reconcile.d.ts +3 -0
- package/dist/media/reconcile.js +3 -2
- package/dist/media/rewrite-plan.d.ts +65 -0
- package/dist/media/rewrite-plan.js +61 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -0
- package/dist/sveltekit/cairn-admin.js +15 -0
- package/dist/sveltekit/content-routes.d.ts +118 -4
- package/dist/sveltekit/content-routes.js +572 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/CairnMediaLibrary.svelte +2070 -26
- package/src/lib/components/admin-icons.ts +5 -0
- package/src/lib/content/media-rewrite.ts +555 -0
- package/src/lib/log/events.ts +6 -1
- package/src/lib/media/bulk-delete-plan.ts +54 -0
- package/src/lib/media/orphan-scan.ts +74 -0
- package/src/lib/media/reconcile.ts +3 -2
- package/src/lib/media/rewrite-plan.ts +122 -0
- package/src/lib/sveltekit/cairn-admin.ts +15 -0
- package/src/lib/sveltekit/content-routes.ts +722 -5
- package/src/lib/sveltekit/index.ts +3 -0
|
@@ -4,8 +4,16 @@ The admin Media Library screen, a peer of Posts and Pages. It browses every comm
|
|
|
4
4
|
shows where each one is used, edits its name and default alt, and deletes it safely. The resting
|
|
5
5
|
surface is a visual contact-sheet grid (a roving-tabindex listbox of tiles), with a list-density
|
|
6
6
|
toggle that flips to an enriched sortable table. One toolbar row carries search, a pick-one triage
|
|
7
|
-
radiogroup (All, Needs alt,
|
|
8
|
-
client window all run over the full loaded set in component state.
|
|
7
|
+
radiogroup (All, Needs alt, No references found), and the density toggle. Filtering, sorting, and a
|
|
8
|
+
growing client window all run over the full loaded set in component state.
|
|
9
|
+
|
|
10
|
+
Multi-select rides a Set of selected hashes, decoupled from the slide-over's single asset and from
|
|
11
|
+
roving focus. The grid is an APG multiselectable listbox (aria-multiselectable, real cell focus):
|
|
12
|
+
Space toggles the focused tile, Shift+Arrow extends a range, Ctrl/Cmd+A selects every visible asset,
|
|
13
|
+
and Escape clears. The list density is a plain selectable table whose leading native-checkbox column
|
|
14
|
+
is the selection signal (no grid role, since it has no grid keyboard model). A sticky action bar
|
|
15
|
+
appears on the first selection with a live count, the scope, Select all in view, Clear, and the
|
|
16
|
+
reversible bulk Delete.
|
|
9
17
|
|
|
10
18
|
Activating a tile or row opens a NON-MODAL detail slide-over from the right (the established
|
|
11
19
|
details-slide-over recipe): no scrim, the library stays live and in the a11y tree behind it, Escape
|
|
@@ -24,12 +32,39 @@ It is node-safe by construction: it types assets with MediaLibraryEntry from the
|
|
|
24
32
|
projection and pulls in no editor module (the editor-boundary test bars a @codemirror leak).
|
|
25
33
|
-->
|
|
26
34
|
<script lang="ts">
|
|
27
|
-
import { flushSync, tick } from 'svelte';
|
|
35
|
+
import { flushSync, getContext, tick } from 'svelte';
|
|
36
|
+
import { deserialize } from '$app/forms';
|
|
37
|
+
import { invalidateAll } from '$app/navigation';
|
|
28
38
|
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
29
|
-
import type {
|
|
39
|
+
import type {
|
|
40
|
+
MediaLibraryData,
|
|
41
|
+
ContentFormFailure,
|
|
42
|
+
MediaReplacePreviewPlan,
|
|
43
|
+
MediaReplaceFailure,
|
|
44
|
+
MediaReplacePreviewEntry,
|
|
45
|
+
MediaAltPreviewPlan,
|
|
46
|
+
MediaAltPropagateFailure,
|
|
47
|
+
MediaBulkDeleteResult,
|
|
48
|
+
MediaOrphanPurgeResult,
|
|
49
|
+
MediaBulkFailure,
|
|
50
|
+
} from '../sveltekit/content-routes.js';
|
|
51
|
+
import type { OrphanScan } from '../media/orphan-scan.js';
|
|
52
|
+
import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
|
|
53
|
+
import type { AltPlacement } from '../content/media-rewrite.js';
|
|
30
54
|
import type { UsageEntry } from '../media/usage.js';
|
|
55
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
31
56
|
import { publicPath } from '../media/naming.js';
|
|
32
57
|
import { mediaToken } from '../media/reference.js';
|
|
58
|
+
import { CSRF_CONTEXT_KEY } from './csrf-context.js';
|
|
59
|
+
import {
|
|
60
|
+
ingestFile,
|
|
61
|
+
buildUploadRequest,
|
|
62
|
+
sendUpload,
|
|
63
|
+
ingestFailureKind,
|
|
64
|
+
failureCard,
|
|
65
|
+
type IngestFailureCard,
|
|
66
|
+
} from './client-ingest.js';
|
|
67
|
+
import { uploadOutcome, type UploadEnvelope } from './media-upload-outcome.js';
|
|
33
68
|
import CsrfField from './CsrfField.svelte';
|
|
34
69
|
import CairnLogo from './CairnLogo.svelte';
|
|
35
70
|
import {
|
|
@@ -48,6 +83,11 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
48
83
|
FileTextIcon,
|
|
49
84
|
ClockIcon,
|
|
50
85
|
Link2OffIcon,
|
|
86
|
+
RefreshCwIcon,
|
|
87
|
+
GitBranchIcon,
|
|
88
|
+
ArrowRightIcon,
|
|
89
|
+
MegaphoneIcon,
|
|
90
|
+
DatabaseIcon,
|
|
51
91
|
} from './admin-icons.js';
|
|
52
92
|
|
|
53
93
|
interface Props {
|
|
@@ -64,7 +104,14 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
64
104
|
|
|
65
105
|
// The success flash a redirected action carried back: a safe-delete or a metadata edit. The
|
|
66
106
|
// conflict error (data.flashError) renders in the inline error treatment below instead.
|
|
67
|
-
const FLASH_MESSAGE = {
|
|
107
|
+
const FLASH_MESSAGE = {
|
|
108
|
+
deleted: 'Asset deleted.',
|
|
109
|
+
updated: 'Changes saved.',
|
|
110
|
+
replaced: 'Asset replaced.',
|
|
111
|
+
altPropagated: 'Alt text applied.',
|
|
112
|
+
bulkDeleted: 'Assets deleted.',
|
|
113
|
+
orphansPurged: 'Orphans purged.',
|
|
114
|
+
} as const;
|
|
68
115
|
const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
|
|
69
116
|
|
|
70
117
|
// --- the per-hash usage facts the screen joins onto each asset ---
|
|
@@ -84,7 +131,9 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
84
131
|
const triageCounts = $derived({
|
|
85
132
|
all: data.assets.length,
|
|
86
133
|
needsAlt: data.assets.filter((a) => needsAlt(a)).length,
|
|
87
|
-
//
|
|
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).
|
|
88
137
|
unused: data.assets.filter((a) => usageCount(a.hash) === 0).length,
|
|
89
138
|
});
|
|
90
139
|
|
|
@@ -109,7 +158,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
109
158
|
const segments: { value: Triage; label: string; count: () => number }[] = [
|
|
110
159
|
{ value: 'all', label: 'All', count: () => triageCounts.all },
|
|
111
160
|
{ value: 'needs-alt', label: 'Needs alt', count: () => triageCounts.needsAlt },
|
|
112
|
-
{ value: 'unused', label: '
|
|
161
|
+
{ value: 'unused', label: 'No references found', count: () => triageCounts.unused },
|
|
113
162
|
];
|
|
114
163
|
|
|
115
164
|
// The triage radiogroup's roving tabindex and ARIA radio keyboard pattern: the selected radio is
|
|
@@ -219,10 +268,24 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
219
268
|
// Escape is also the native clear gesture for the toolbar's type="search" input, so the close
|
|
220
269
|
// fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
|
|
221
270
|
// panel exactly as the user left it, while an Escape with focus in the panel still closes it.
|
|
271
|
+
// Escape precedence (no overlap): an open dialog claims Escape natively (its showModal owns it, so
|
|
272
|
+
// this handler stands down while any dialog is open); else an open slide-over with focus inside it
|
|
273
|
+
// closes (today's behavior); else a non-empty selection is cleared. The search box keeps its own
|
|
274
|
+
// native Escape-to-clear: the selection clear fires only when focus is NOT in the search input.
|
|
222
275
|
function onWindowKeydown(e: KeyboardEvent) {
|
|
223
|
-
if (e.key
|
|
276
|
+
if (e.key !== 'Escape') return;
|
|
277
|
+
if (deleteDialog?.open || replaceDialog?.open || altDialog?.open || bulkDialog?.open || orphanDialog?.open) return;
|
|
278
|
+
if (selected && panelEl?.contains(document.activeElement)) {
|
|
224
279
|
e.preventDefault();
|
|
225
280
|
closePanel();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (selectedCount > 0) {
|
|
284
|
+
const active = document.activeElement as HTMLElement | null;
|
|
285
|
+
const inSearch = active instanceof HTMLInputElement && active.type === 'search';
|
|
286
|
+
if (inSearch) return;
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
clearSelection();
|
|
226
289
|
}
|
|
227
290
|
}
|
|
228
291
|
|
|
@@ -248,6 +311,387 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
248
311
|
}
|
|
249
312
|
}
|
|
250
313
|
|
|
314
|
+
// --- the Replace flow: a two-step alertdialog (upload, then impact review) over the selected asset ---
|
|
315
|
+
// Replace uploads a new file for the selected asset; cairn is content-addressed, so the new file has a
|
|
316
|
+
// new hash and every published reference is repointed to it in one commit to main. The dialog opens on
|
|
317
|
+
// the quiet upload step, holds the server-owned record on a successful upload, fetches the preview
|
|
318
|
+
// (fail-closed), and renders the impact review behind a typed-slug gate. The CSRF token getter comes
|
|
319
|
+
// from the admin context, the same seam the insert popover reads.
|
|
320
|
+
const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
|
|
321
|
+
|
|
322
|
+
type ReplaceStep = 'upload' | 'review' | 'blocked';
|
|
323
|
+
// The transient upload status under the upload step: idle, an in-flight ingest/upload, or a typed
|
|
324
|
+
// ingest failure card with a retry. Mirrors the insert popover's failed-card grammar.
|
|
325
|
+
type ReplaceUpload =
|
|
326
|
+
| { kind: 'idle' }
|
|
327
|
+
| { kind: 'working' }
|
|
328
|
+
| { kind: 'failed'; card: IngestFailureCard | { status: 'failed'; message: string }; retry: () => void };
|
|
329
|
+
|
|
330
|
+
let replaceDialog = $state<HTMLDialogElement | null>(null);
|
|
331
|
+
// The entry-point button that opened the dialog, so focus restores to it on close (the alertdialog
|
|
332
|
+
// recipe, like the delete dialog's slide-over Delete button).
|
|
333
|
+
let replaceOrigin: HTMLElement | null = null;
|
|
334
|
+
// The Cancel control, the destructive-confirm initial focus.
|
|
335
|
+
let replaceCancelButton = $state<HTMLButtonElement | null>(null);
|
|
336
|
+
let replaceFileInput = $state<HTMLInputElement | null>(null);
|
|
337
|
+
let replaceStep = $state<ReplaceStep>('upload');
|
|
338
|
+
let replaceUpload = $state<ReplaceUpload>({ kind: 'idle' });
|
|
339
|
+
// The server-owned record the upload returned (the new asset), held for the preview and the apply.
|
|
340
|
+
let replaceRecord = $state<MediaEntry | null>(null);
|
|
341
|
+
// The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
|
|
342
|
+
let replacePlan = $state<MediaReplacePreviewPlan | null>(null);
|
|
343
|
+
let replaceFailure = $state<MediaReplaceFailure | null>(null);
|
|
344
|
+
// The typed-slug confirm gate, echoing the delete dialog's type-to-confirm.
|
|
345
|
+
let replaceConfirmInput = $state('');
|
|
346
|
+
// The asset the Replace dialog acts on, pinned at open so a background re-render never swaps it.
|
|
347
|
+
let replaceAsset = $state<MediaLibraryEntry | null>(null);
|
|
348
|
+
const replaceConfirmMatches = $derived(replaceAsset !== null && replaceConfirmInput === replaceAsset.slug);
|
|
349
|
+
|
|
350
|
+
function openReplaceDialog(origin?: HTMLElement | null) {
|
|
351
|
+
if (!selected) return;
|
|
352
|
+
// The entry-point button passed from the click (focus restores here on close), falling back to the
|
|
353
|
+
// active element. A programmatic .click() does not focus its target, so the explicit origin is the
|
|
354
|
+
// reliable restore point.
|
|
355
|
+
replaceOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
|
|
356
|
+
replaceAsset = selected;
|
|
357
|
+
replaceStep = 'upload';
|
|
358
|
+
replaceUpload = { kind: 'idle' };
|
|
359
|
+
replaceRecord = null;
|
|
360
|
+
replacePlan = null;
|
|
361
|
+
replaceFailure = null;
|
|
362
|
+
replaceConfirmInput = '';
|
|
363
|
+
// Show the dialog after the step state flushes, then move focus to Cancel.
|
|
364
|
+
void tick().then(() => {
|
|
365
|
+
replaceDialog?.showModal();
|
|
366
|
+
replaceCancelButton?.focus();
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
function closeReplaceDialog() {
|
|
370
|
+
replaceDialog?.close();
|
|
371
|
+
replaceAsset = null;
|
|
372
|
+
replaceRecord = null;
|
|
373
|
+
replacePlan = null;
|
|
374
|
+
replaceFailure = null;
|
|
375
|
+
replaceConfirmInput = '';
|
|
376
|
+
replaceUpload = { kind: 'idle' };
|
|
377
|
+
// Restore focus to the entry-point button (the alertdialog focus-restore recipe).
|
|
378
|
+
replaceOrigin?.focus();
|
|
379
|
+
replaceOrigin = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The chosen-file handler: route the file through the ingest-and-upload loop, exactly as the insert
|
|
383
|
+
// popover does, then fetch the preview. A file is the only path (Pass B is upload-new-only).
|
|
384
|
+
function onReplaceFileChosen(e: Event) {
|
|
385
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
386
|
+
const file = input.files?.[0];
|
|
387
|
+
if (file) void runReplaceUpload(file);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The upload loop for the new file. It ingests (decode/transcode), uploads through the shared
|
|
391
|
+
// transport, and on the success envelope holds the new record and runs the preview. A typed ingest or
|
|
392
|
+
// upload failure surfaces a retry card on the upload step; an expired session reads as a generic card.
|
|
393
|
+
// The upload posts to the media-scoped ?/mediaUpload action: the Library is not entry-scoped, so it
|
|
394
|
+
// overrides buildUploadRequest's entry URL while reusing its header-and-body transport verbatim.
|
|
395
|
+
async function runReplaceUpload(file: File) {
|
|
396
|
+
if (!replaceAsset) return;
|
|
397
|
+
replaceUpload = { kind: 'working' };
|
|
398
|
+
const genericFail = () =>
|
|
399
|
+
(replaceUpload = {
|
|
400
|
+
kind: 'failed',
|
|
401
|
+
card: { status: 'failed', message: GENERIC_UPLOAD_MESSAGE },
|
|
402
|
+
retry: () => void runReplaceUpload(file),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
let ingested: Awaited<ReturnType<typeof ingestFile>>;
|
|
406
|
+
try {
|
|
407
|
+
ingested = await ingestFile(file);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const built = buildUploadRequest({
|
|
414
|
+
conceptId: '',
|
|
415
|
+
id: '',
|
|
416
|
+
bytes: ingested.blob,
|
|
417
|
+
contentType: ingested.contentType,
|
|
418
|
+
csrf: csrf?.() ?? '',
|
|
419
|
+
filename: file.name,
|
|
420
|
+
width: ingested.width,
|
|
421
|
+
height: ingested.height,
|
|
422
|
+
});
|
|
423
|
+
let res: Response;
|
|
424
|
+
try {
|
|
425
|
+
res = await sendUpload(REPLACE_UPLOAD_URL, built.init);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// The guard's expired-session 303 under redirect:'manual' surfaces as an opaque, status-0 response.
|
|
431
|
+
if (res.type === 'opaqueredirect' || res.status === 0) {
|
|
432
|
+
genericFail();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
let outcome: ReturnType<typeof uploadOutcome>;
|
|
436
|
+
try {
|
|
437
|
+
outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
|
|
438
|
+
} catch {
|
|
439
|
+
genericFail();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (outcome.kind !== 'inserted') {
|
|
443
|
+
genericFail();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Hold the server-owned record, then fetch the impact preview for (oldHash -> newHash).
|
|
447
|
+
replaceRecord = outcome.record;
|
|
448
|
+
replaceUpload = { kind: 'idle' };
|
|
449
|
+
await runReplacePreview();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// A per-call request token guards the preview fetch against a stale response landing on a closed or
|
|
453
|
+
// reopened dialog. Svelte reactivity does not track reads below the first `await`, so each call pins
|
|
454
|
+
// its own sequence at entry and bails after the await if a newer call (a reopen, or a "Check usage
|
|
455
|
+
// again" double-click) has since superseded it.
|
|
456
|
+
let replacePreviewSeq = 0;
|
|
457
|
+
|
|
458
|
+
// The preview fetch: POST the (oldHash, newHash, slug) tuple in the 2a transport (a text/plain
|
|
459
|
+
// body, the CSRF token in the X-Cairn-CSRF header), parse the SvelteKit ActionResult envelope, and
|
|
460
|
+
// route to the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the
|
|
461
|
+
// blocked step's "Check usage again". The slug is the OLD asset's: a replace keeps the name and
|
|
462
|
+
// changes only the content hash, so the repointed token carries the existing slug, not the new file's.
|
|
463
|
+
async function runReplacePreview() {
|
|
464
|
+
if (!replaceAsset || !replaceRecord) return;
|
|
465
|
+
const hash = replaceAsset.hash;
|
|
466
|
+
const seq = ++replacePreviewSeq;
|
|
467
|
+
// The fail-closed landing: an unverifiable usage read, an unreachable preview, or an unparseable
|
|
468
|
+
// body all route to the blocked step. The passed failure carries the branch-naming error when the
|
|
469
|
+
// server returned one; a transport miss carries the empty error (the generic honest line stands in).
|
|
470
|
+
const blockClosed = (failure?: MediaReplaceFailure) => {
|
|
471
|
+
replaceFailure = failure ?? { error: '', hash, usage: [], foundIn: 0 };
|
|
472
|
+
replacePlan = null;
|
|
473
|
+
replaceStep = 'blocked';
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const body = JSON.stringify({ oldHash: hash, newHash: replaceRecord.hash, slug: replaceAsset.slug });
|
|
477
|
+
let result: { type: string; data?: unknown };
|
|
478
|
+
try {
|
|
479
|
+
const res = await fetch(REPLACE_PREVIEW_URL, {
|
|
480
|
+
method: 'POST',
|
|
481
|
+
headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
482
|
+
body,
|
|
483
|
+
});
|
|
484
|
+
result = deserialize(await res.text()) as { type: string; data?: unknown };
|
|
485
|
+
} catch {
|
|
486
|
+
// Drop a stale response that lost the race to a reopen or a re-run before surfacing the block.
|
|
487
|
+
if (seq !== replacePreviewSeq) return;
|
|
488
|
+
blockClosed();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// The dialog was closed or reopened (for another asset, or via a re-run) while this fetch was in
|
|
492
|
+
// flight, so this response is stale: ignore it rather than clobber the live state.
|
|
493
|
+
if (seq !== replacePreviewSeq) return;
|
|
494
|
+
if (result.type === 'success' && result.data) {
|
|
495
|
+
replacePlan = result.data as MediaReplacePreviewPlan;
|
|
496
|
+
replaceFailure = null;
|
|
497
|
+
replaceConfirmInput = '';
|
|
498
|
+
replaceStep = 'review';
|
|
499
|
+
} else {
|
|
500
|
+
blockClosed(result.data as MediaReplaceFailure | undefined);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const GENERIC_UPLOAD_MESSAGE = 'The upload could not be completed. Please try again.';
|
|
505
|
+
// The media-scoped upload and preview action URLs, relative to /admin/media. The upload reuses the
|
|
506
|
+
// shared ingest transport but the Library has no entry, so it targets ?/mediaUpload rather than the
|
|
507
|
+
// entry-scoped ?/upload. The apply form below posts ?/mediaReplace.
|
|
508
|
+
const REPLACE_UPLOAD_URL = '?/mediaUpload';
|
|
509
|
+
const REPLACE_PREVIEW_URL = '?/mediaReplacePreview';
|
|
510
|
+
|
|
511
|
+
// The affected-entry well caps past this many rows; "Show all N" reveals the rest into the same
|
|
512
|
+
// scroll container (the a11y contract: aria-expanded + aria-controls).
|
|
513
|
+
const REPLACE_ROW_CAP = 8;
|
|
514
|
+
let replaceShowAll = $state(false);
|
|
515
|
+
// The affected-entry list element, so "Show all" can move focus to the first newly revealed row (the
|
|
516
|
+
// one just past the cap) instead of dropping to <body> when the expander button unmounts.
|
|
517
|
+
let replaceEntriesList = $state<HTMLElement | null>(null);
|
|
518
|
+
$effect(() => {
|
|
519
|
+
// Reset the reveal whenever a fresh plan arrives, so a second preview never opens pre-expanded.
|
|
520
|
+
void replacePlan;
|
|
521
|
+
replaceShowAll = false;
|
|
522
|
+
});
|
|
523
|
+
// Reveal the capped rows, then move focus to the first newly revealed row (the rev.2 contract). The
|
|
524
|
+
// expander unmounts on the flag flip, so without this focus falls to <body>.
|
|
525
|
+
function showAllReplaceEntries() {
|
|
526
|
+
replaceShowAll = true;
|
|
527
|
+
void tick().then(() => (replaceEntriesList?.children[REPLACE_ROW_CAP] as HTMLElement | undefined)?.focus());
|
|
528
|
+
}
|
|
529
|
+
const replaceEntries = $derived(replacePlan?.entries ?? []);
|
|
530
|
+
const replaceVisibleEntries = $derived(
|
|
531
|
+
replaceShowAll ? replaceEntries : replaceEntries.slice(0, REPLACE_ROW_CAP),
|
|
532
|
+
);
|
|
533
|
+
const replaceHiddenCount = $derived(Math.max(0, replaceEntries.length - REPLACE_ROW_CAP));
|
|
534
|
+
// The server's distinct affected-entry count, read in several places across the review markup and
|
|
535
|
+
// the apply button. Coalesced once here so each read stays a plain number.
|
|
536
|
+
const replaceAffected = $derived(replacePlan?.affectedCount ?? 0);
|
|
537
|
+
|
|
538
|
+
// The where-used summary line for one affected entry, derived from its repointed placements: a hero
|
|
539
|
+
// count and a body count, folded into a plain phrase ("Hero and 2 in the body", "1 in the body").
|
|
540
|
+
function replaceWhereUsed(entry: MediaReplacePreviewEntry): string {
|
|
541
|
+
let hero = 0;
|
|
542
|
+
let body = 0;
|
|
543
|
+
for (const p of entry.placements) {
|
|
544
|
+
if (p.kind === 'hero') hero += 1;
|
|
545
|
+
else body += 1;
|
|
546
|
+
}
|
|
547
|
+
const parts: string[] = [];
|
|
548
|
+
if (hero > 0) parts.push(hero === 1 ? 'Hero' : `${hero} heroes`);
|
|
549
|
+
if (body > 0) parts.push(`${body} in the body`);
|
|
550
|
+
return parts.length > 0 ? parts.join(' and ') : 'Used in this entry';
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// The specific unreadable branch named by a fail-closed failure, or null for the generic honest line.
|
|
554
|
+
// The current MediaReplaceFailure carries only an error string, so a cairn/* branch name is pulled
|
|
555
|
+
// from the message when the strict read named one; otherwise the generic variant stands in.
|
|
556
|
+
const replaceBlockedBranch = $derived.by(() => {
|
|
557
|
+
const match = replaceFailure?.error.match(/cairn\/[^\s.]+/);
|
|
558
|
+
return match ? match[0] : null;
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// --- the Push-alt flow: a one-step review dialog (the everyday register) over the selected asset ---
|
|
562
|
+
// Alt propagation pushes the asset's default alt into published placements that lack it, with one
|
|
563
|
+
// bucket-level opt-in to also overwrite placements that carry a custom alt. It is reversible and
|
|
564
|
+
// frequent, so the dialog is role="dialog" (not alertdialog) with no typed-slug gate; apply is always
|
|
565
|
+
// enabled. The preview fetch reuses the 2a transport (a text/plain body, the CSRF token in the
|
|
566
|
+
// X-Cairn-CSRF header) and fails closed to a blocked surface when usage cannot be verified.
|
|
567
|
+
type AltStep = 'review' | 'blocked';
|
|
568
|
+
const ALT_PREVIEW_URL = '?/mediaAltPreview';
|
|
569
|
+
|
|
570
|
+
let altDialog = $state<HTMLDialogElement | null>(null);
|
|
571
|
+
// The entry-point button that opened the dialog, so focus restores to it on close.
|
|
572
|
+
let altOrigin: HTMLElement | null = null;
|
|
573
|
+
// The Cancel control, the initial focus on open.
|
|
574
|
+
let altCancelButton = $state<HTMLButtonElement | null>(null);
|
|
575
|
+
let altStep = $state<AltStep>('review');
|
|
576
|
+
// The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
|
|
577
|
+
let altPlan = $state<MediaAltPreviewPlan | null>(null);
|
|
578
|
+
let altFailure = $state<MediaAltPropagateFailure | null>(null);
|
|
579
|
+
// The bucket-level opt-in to also overwrite customized alts. Bound to the one native checkbox.
|
|
580
|
+
let altOverwrite = $state(false);
|
|
581
|
+
// The asset the dialog acts on, pinned at open so a background re-render never swaps it. The alt it
|
|
582
|
+
// pushes is this asset's default alt.
|
|
583
|
+
let altAsset = $state<MediaLibraryEntry | null>(null);
|
|
584
|
+
|
|
585
|
+
function openAltDialog(origin?: HTMLElement | null) {
|
|
586
|
+
if (!selected) return;
|
|
587
|
+
altOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
|
|
588
|
+
altAsset = selected;
|
|
589
|
+
altStep = 'review';
|
|
590
|
+
altPlan = null;
|
|
591
|
+
altFailure = null;
|
|
592
|
+
altOverwrite = false;
|
|
593
|
+
void tick().then(() => {
|
|
594
|
+
altDialog?.showModal();
|
|
595
|
+
altCancelButton?.focus();
|
|
596
|
+
});
|
|
597
|
+
void runAltPreview();
|
|
598
|
+
}
|
|
599
|
+
function closeAltDialog() {
|
|
600
|
+
altDialog?.close();
|
|
601
|
+
altAsset = null;
|
|
602
|
+
altPlan = null;
|
|
603
|
+
altFailure = null;
|
|
604
|
+
altOverwrite = false;
|
|
605
|
+
altOrigin?.focus();
|
|
606
|
+
altOrigin = null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// The per-call request token for the alt preview, mirroring the Replace guard: a stale response from
|
|
610
|
+
// a closed or reopened dialog (or a "Check usage again" double-click) is dropped after the await.
|
|
611
|
+
let altPreviewSeq = 0;
|
|
612
|
+
|
|
613
|
+
// The preview fetch: POST the hash in the 2a transport, parse the ActionResult envelope, and route to
|
|
614
|
+
// the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the blocked
|
|
615
|
+
// step's "Check usage again".
|
|
616
|
+
async function runAltPreview() {
|
|
617
|
+
if (!altAsset) return;
|
|
618
|
+
const hash = altAsset.hash;
|
|
619
|
+
const seq = ++altPreviewSeq;
|
|
620
|
+
const blockClosed = (failure?: MediaAltPropagateFailure) => {
|
|
621
|
+
altFailure = failure ?? { error: '' };
|
|
622
|
+
altPlan = null;
|
|
623
|
+
altStep = 'blocked';
|
|
624
|
+
};
|
|
625
|
+
let result: { type: string; data?: unknown };
|
|
626
|
+
try {
|
|
627
|
+
const res = await fetch(ALT_PREVIEW_URL, {
|
|
628
|
+
method: 'POST',
|
|
629
|
+
headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
630
|
+
body: JSON.stringify({ hash }),
|
|
631
|
+
});
|
|
632
|
+
result = deserialize(await res.text()) as { type: string; data?: unknown };
|
|
633
|
+
} catch {
|
|
634
|
+
if (seq !== altPreviewSeq) return;
|
|
635
|
+
blockClosed();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
// Stale-response guard: a reopen or a re-run superseded this fetch while it was in flight.
|
|
639
|
+
if (seq !== altPreviewSeq) return;
|
|
640
|
+
if (result.type === 'success' && result.data) {
|
|
641
|
+
altPlan = result.data as MediaAltPreviewPlan;
|
|
642
|
+
altFailure = null;
|
|
643
|
+
altStep = 'review';
|
|
644
|
+
} else {
|
|
645
|
+
blockClosed(result.data as MediaAltPropagateFailure | undefined);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The default alt the dialog propagates: the selected asset's stored alt. Empty is guarded by the
|
|
650
|
+
// entry point (an asset with no default alt cannot push one), but the dialog reads it defensively.
|
|
651
|
+
const altPushed = $derived(altAsset?.alt.trim() ?? '');
|
|
652
|
+
|
|
653
|
+
// The three buckets, flattened from the plan's entries: each row carries its entry title, the
|
|
654
|
+
// placement kind (the pill), and the placement's before/after. Grouping by bucket keeps each well
|
|
655
|
+
// self-contained, the way the mockup lays them out.
|
|
656
|
+
type AltRow = { title: string; kind: AltPlacement['kind']; before: string; after: string; key: string };
|
|
657
|
+
function altRows(bucket: AltPlacement['bucket']): AltRow[] {
|
|
658
|
+
const rows: AltRow[] = [];
|
|
659
|
+
for (const entry of altPlan?.entries ?? []) {
|
|
660
|
+
entry.placements.forEach((p, i) => {
|
|
661
|
+
if (p.bucket !== bucket) return;
|
|
662
|
+
rows.push({ title: entry.title, kind: p.kind, before: p.before, after: p.after, key: `${entry.concept}/${entry.id}/${i}` });
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return rows;
|
|
666
|
+
}
|
|
667
|
+
const altFillRows = $derived(altRows('will-fill'));
|
|
668
|
+
const altCustomRows = $derived(altRows('customized'));
|
|
669
|
+
const altSkipRows = $derived(altRows('decorative-skipped'));
|
|
670
|
+
|
|
671
|
+
// The committed total: the will-fill placements always, plus the customized placements only on the
|
|
672
|
+
// opt-in. The footer button and the live region read this; the count moves when the opt-in toggles.
|
|
673
|
+
const altCounts = $derived(altPlan?.counts ?? { willFill: 0, customized: 0, decorativeSkipped: 0 });
|
|
674
|
+
const altTotal = $derived(altCounts.willFill + (altOverwrite ? altCounts.customized : 0));
|
|
675
|
+
|
|
676
|
+
// The will-fill bucket caps past this many rows; "Show all N" reveals the rest (aria-expanded +
|
|
677
|
+
// aria-controls). The customized bucket lists in full (it is the consequential one).
|
|
678
|
+
const ALT_ROW_CAP = 8;
|
|
679
|
+
let altShowAll = $state(false);
|
|
680
|
+
// The will-fill list element, so "Show all" can move focus to its first newly revealed row.
|
|
681
|
+
let altFillList = $state<HTMLElement | null>(null);
|
|
682
|
+
$effect(() => {
|
|
683
|
+
void altPlan;
|
|
684
|
+
altShowAll = false;
|
|
685
|
+
});
|
|
686
|
+
const altFillVisible = $derived(altShowAll ? altFillRows : altFillRows.slice(0, ALT_ROW_CAP));
|
|
687
|
+
const altFillHidden = $derived(Math.max(0, altFillRows.length - ALT_ROW_CAP));
|
|
688
|
+
// Reveal the capped will-fill rows, then move focus to the first newly revealed row (the rev.2
|
|
689
|
+
// contract: the expander unmounts on the flag flip, so focus would otherwise fall to <body>).
|
|
690
|
+
function showAllAltFill() {
|
|
691
|
+
altShowAll = true;
|
|
692
|
+
void tick().then(() => (altFillList?.children[ALT_ROW_CAP] as HTMLElement | undefined)?.focus());
|
|
693
|
+
}
|
|
694
|
+
|
|
251
695
|
// --- the where-used overlay the slide-over and the dialog read, grouped published-then-branch ---
|
|
252
696
|
function usageEntries(hash: string): UsageEntry[] {
|
|
253
697
|
return data.usage[hash]?.entries ?? [];
|
|
@@ -368,20 +812,435 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
368
812
|
activeIndex = i;
|
|
369
813
|
tileEls[i]?.focus();
|
|
370
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;
|
|
860
|
+
}
|
|
861
|
+
// Drop any selected hash that has filtered out of the visible set so the count and the bar's scope
|
|
862
|
+
// never count an asset the user can no longer see. Reassign only when the set actually shrinks.
|
|
863
|
+
$effect(() => {
|
|
864
|
+
const live = new Set(visible.map((a) => a.hash));
|
|
865
|
+
let changed = false;
|
|
866
|
+
for (const h of selectedHashes) {
|
|
867
|
+
if (!live.has(h)) {
|
|
868
|
+
changed = true;
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
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
|
+
);
|
|
928
|
+
|
|
929
|
+
// The skipped summary row reads its display name from the loaded assets; a hash absent from the load
|
|
930
|
+
// (deleted out from under the index) falls back to the bare hash so the row is never blank.
|
|
931
|
+
function bulkAssetName(hash: string): string {
|
|
932
|
+
return data.assets.find((a) => a.hash === hash)?.displayName ?? hash;
|
|
933
|
+
}
|
|
934
|
+
// The skip reason line: a still-referenced skip names its fresh where-used count; an uncommitted skip
|
|
935
|
+
// says it was not committed (the timing-honest reason the recheck turned up).
|
|
936
|
+
function bulkSkipReason(skip: BulkDeleteSkip): string {
|
|
937
|
+
if (skip.reason === 'still-referenced') {
|
|
938
|
+
const n = skip.usage.length;
|
|
939
|
+
return `now found in ${n} ${n === 1 ? 'entry' : 'entries'} on the recheck`;
|
|
940
|
+
}
|
|
941
|
+
return 'was not committed';
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const BULK_DELETE_URL = '?/mediaBulkDelete';
|
|
945
|
+
|
|
946
|
+
function openBulkDialog(origin?: HTMLElement | null) {
|
|
947
|
+
if (selectedCount === 0) return;
|
|
948
|
+
bulkOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
|
|
949
|
+
bulkHashes = [...selectedHashes];
|
|
950
|
+
bulkPhase = 'review';
|
|
951
|
+
bulkResult = null;
|
|
952
|
+
bulkError = null;
|
|
953
|
+
void tick().then(() => {
|
|
954
|
+
bulkDialog?.showModal();
|
|
955
|
+
bulkCancelButton?.focus();
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
function closeBulkDialog() {
|
|
959
|
+
bulkDialog?.close();
|
|
960
|
+
bulkPhase = 'review';
|
|
961
|
+
bulkResult = null;
|
|
962
|
+
bulkError = null;
|
|
963
|
+
bulkHashes = [];
|
|
964
|
+
bulkOrigin?.focus();
|
|
965
|
+
bulkOrigin = null;
|
|
966
|
+
}
|
|
967
|
+
// Escape (the dialog's cancel event) must not abandon an in-flight delete: while the request is
|
|
968
|
+
// running the close is suppressed; in every other phase Escape closes normally.
|
|
969
|
+
function onBulkCancel(e: Event) {
|
|
970
|
+
if (bulkPhase === 'deleting') {
|
|
971
|
+
e.preventDefault();
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
closeBulkDialog();
|
|
975
|
+
}
|
|
976
|
+
// The Done action after a summary: re-read the load so the deleted rows leave the list, clear the
|
|
977
|
+
// selection, then close and reset. invalidateAll re-runs the media load behind the dialog.
|
|
978
|
+
async function finishBulkDelete() {
|
|
979
|
+
await invalidateAll();
|
|
980
|
+
clearSelection();
|
|
981
|
+
closeBulkDialog();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Apply: send every SELECTED hash (repeated `hash` fields) so the server is the gate; it re-checks
|
|
985
|
+
// each one strictly and skips the in-use ones authoritatively. The CSRF token rides the X-Cairn-CSRF
|
|
986
|
+
// header (the guard accepts it for any unsafe POST), and the ActionResult envelope is read through
|
|
987
|
+
// deserialize. A success carries the MediaBulkDeleteResult; a fail-closed 503 or a network throw
|
|
988
|
+
// routes to the error phase and a role="alert".
|
|
989
|
+
async function applyBulkDelete() {
|
|
990
|
+
bulkPhase = 'deleting';
|
|
991
|
+
bulkError = null;
|
|
992
|
+
const formData = new FormData();
|
|
993
|
+
for (const h of bulkHashes) formData.append('hash', h);
|
|
994
|
+
let result: { type: string; data?: unknown };
|
|
995
|
+
try {
|
|
996
|
+
const res = await fetch(BULK_DELETE_URL, {
|
|
997
|
+
method: 'POST',
|
|
998
|
+
headers: { 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
999
|
+
body: formData,
|
|
1000
|
+
});
|
|
1001
|
+
result = deserialize(await res.text()) as { type: string; data?: unknown };
|
|
1002
|
+
} catch {
|
|
1003
|
+
bulkError = 'The delete could not be completed. Please try again.';
|
|
1004
|
+
bulkPhase = 'error';
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (result.type === 'success' && result.data) {
|
|
1008
|
+
bulkResult = result.data as MediaBulkDeleteResult;
|
|
1009
|
+
bulkPhase = 'done';
|
|
1010
|
+
void tick().then(() => bulkSummaryTitle?.focus());
|
|
1011
|
+
} else {
|
|
1012
|
+
const failure = result.data as { error?: string } | undefined;
|
|
1013
|
+
bulkError = failure?.error ?? 'The delete could not be completed. Please try again.';
|
|
1014
|
+
bulkPhase = 'error';
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// --- the on-demand orphan scan surface: the entry point, the loading/blocked phases, the
|
|
1019
|
+
// two-section result, and the IRREVERSIBLE byte purge (the rev.2 mockup, panels 6, 7, and 8-right) ---
|
|
1020
|
+
// Raw R2 bytes have no git history, so this is the one irreversible media action and it is kept
|
|
1021
|
+
// structurally apart from the reversible bulk delete above: a separate dialog, a separate selection
|
|
1022
|
+
// Set of R2 KEYS (never the asset-hash Set), a solid-danger Purge (not the danger-OUTLINE bulk
|
|
1023
|
+
// apply), and a typed-count confirm reserved for this path. The scan fails CLOSED at detection: a
|
|
1024
|
+
// 503 routes to the blocked surface (no dry-run, no collect action), because under-reporting orphans
|
|
1025
|
+
// could feed an unrecoverable purge.
|
|
1026
|
+
type OrphanPhase = 'idle' | 'scanning' | 'result' | 'blocked';
|
|
1027
|
+
const ORPHAN_SCAN_URL = '?/mediaOrphanScan';
|
|
1028
|
+
const ORPHAN_PURGE_URL = '?/mediaPurge';
|
|
1029
|
+
|
|
1030
|
+
let orphanDialog = $state<HTMLDialogElement | null>(null);
|
|
1031
|
+
// The "Find orphaned files" entry control, so focus restores to it on close.
|
|
1032
|
+
let orphanFindButton = $state<HTMLButtonElement | null>(null);
|
|
1033
|
+
// The dialog title, focused on open so a screen reader is carried to the surface.
|
|
1034
|
+
let orphanTitle = $state<HTMLElement | null>(null);
|
|
1035
|
+
let orphanPhase = $state<OrphanPhase>('idle');
|
|
1036
|
+
// The scan result (the result phase) or the fail-closed error message (the blocked phase).
|
|
1037
|
+
let orphanScan = $state<OrphanScan | null>(null);
|
|
1038
|
+
let orphanBlockedError = $state('');
|
|
1039
|
+
// The orphaned-byte selection: a Set of R2 KEYS, distinct from the asset-hash Set above. Never
|
|
1040
|
+
// mutated in place; every change reassigns (the reactive-Set rule the rest of the screen follows).
|
|
1041
|
+
let orphanKeys = $state(new Set<string>());
|
|
1042
|
+
// The section-level select-all checkbox, set to indeterminate in an effect when some-but-not-all rows
|
|
1043
|
+
// are selected (a property, not an attribute, so it is driven imperatively).
|
|
1044
|
+
let orphanSelectAll = $state<HTMLInputElement | null>(null);
|
|
1045
|
+
// The purge confirm: a nested phase inside the result surface, gated by typing the selected count.
|
|
1046
|
+
let orphanPurging = $state(false);
|
|
1047
|
+
let orphanConfirmInput = $state('');
|
|
1048
|
+
// The purge outcome (the summary) or, on a post-action failure, the error for a role="alert".
|
|
1049
|
+
let orphanPurgeResult = $state<MediaOrphanPurgeResult | null>(null);
|
|
1050
|
+
let orphanPurgeError = $state('');
|
|
1051
|
+
let orphanPurgeBusy = $state(false);
|
|
1052
|
+
|
|
1053
|
+
const orphanBytes = $derived(orphanScan?.orphanedBytes ?? []);
|
|
1054
|
+
const orphanBroken = $derived(orphanScan?.brokenRefs ?? []);
|
|
1055
|
+
const orphanSelectedCount = $derived(orphanKeys.size);
|
|
1056
|
+
// The typed-count gate: the submit is enabled only when the typed value equals the selected count and
|
|
1057
|
+
// at least one byte is selected. The one legitimate disable, a visible typed destructive confirm.
|
|
1058
|
+
const orphanConfirmMatches = $derived(orphanSelectedCount > 0 && orphanConfirmInput === String(orphanSelectedCount));
|
|
1059
|
+
// The select-all is checked when every byte is selected, indeterminate on a strict subset. Driven
|
|
1060
|
+
// imperatively because `indeterminate` is a DOM property with no HTML attribute.
|
|
1061
|
+
$effect(() => {
|
|
1062
|
+
if (!orphanSelectAll) return;
|
|
1063
|
+
const n = orphanSelectedCount;
|
|
1064
|
+
const total = orphanBytes.length;
|
|
1065
|
+
orphanSelectAll.checked = total > 0 && n === total;
|
|
1066
|
+
orphanSelectAll.indeterminate = n > 0 && n < total;
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
function openOrphanScan() {
|
|
1070
|
+
orphanPhase = 'scanning';
|
|
1071
|
+
orphanScan = null;
|
|
1072
|
+
orphanBlockedError = '';
|
|
1073
|
+
orphanKeys = new Set<string>();
|
|
1074
|
+
orphanPurging = false;
|
|
1075
|
+
orphanConfirmInput = '';
|
|
1076
|
+
orphanPurgeResult = null;
|
|
1077
|
+
orphanPurgeError = '';
|
|
1078
|
+
orphanPurgeBusy = false;
|
|
1079
|
+
void tick().then(() => {
|
|
1080
|
+
orphanDialog?.showModal();
|
|
1081
|
+
orphanTitle?.focus();
|
|
1082
|
+
});
|
|
1083
|
+
void runOrphanScan();
|
|
1084
|
+
}
|
|
1085
|
+
function closeOrphanScan() {
|
|
1086
|
+
orphanDialog?.close();
|
|
1087
|
+
orphanPhase = 'idle';
|
|
1088
|
+
orphanScan = null;
|
|
1089
|
+
orphanKeys = new Set<string>();
|
|
1090
|
+
orphanPurging = false;
|
|
1091
|
+
orphanConfirmInput = '';
|
|
1092
|
+
orphanPurgeResult = null;
|
|
1093
|
+
orphanPurgeError = '';
|
|
1094
|
+
orphanFindButton?.focus();
|
|
1095
|
+
}
|
|
1096
|
+
// Escape (the dialog's cancel event) must not abandon an in-flight purge: while the irreversible
|
|
1097
|
+
// delete is running the close is suppressed; in every other phase Escape closes normally.
|
|
1098
|
+
function onOrphanCancel(e: Event) {
|
|
1099
|
+
if (orphanPurgeBusy) {
|
|
1100
|
+
e.preventDefault();
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
closeOrphanScan();
|
|
1104
|
+
}
|
|
1105
|
+
// The Done action after a purge: the bytes are gone, so re-read the load (the broken-refs readout is
|
|
1106
|
+
// untouched), then close. invalidateAll re-runs the media load behind the dialog.
|
|
1107
|
+
async function finishOrphanPurge() {
|
|
1108
|
+
await invalidateAll();
|
|
1109
|
+
closeOrphanScan();
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Run the scan: POST ?/mediaOrphanScan, parse the ActionResult envelope, and route to the result
|
|
1113
|
+
// phase (an OrphanScan) or the fail-closed blocked phase (a 503 MediaBulkFailure or a network
|
|
1114
|
+
// throw). The action reads no fields, but a SvelteKit form action rejects a body-less POST with a
|
|
1115
|
+
// 415, so send an empty FormData to carry the form content-type. The CSRF token rides the header.
|
|
1116
|
+
// Nothing is pre-selected: this feeds an irreversible purge, so the operator picks each byte (or the
|
|
1117
|
+
// select-all) deliberately.
|
|
1118
|
+
async function runOrphanScan() {
|
|
1119
|
+
orphanPhase = 'scanning';
|
|
1120
|
+
orphanBlockedError = '';
|
|
1121
|
+
let result: { type: string; data?: unknown };
|
|
1122
|
+
try {
|
|
1123
|
+
const res = await fetch(ORPHAN_SCAN_URL, {
|
|
1124
|
+
method: 'POST',
|
|
1125
|
+
headers: { 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
1126
|
+
body: new FormData(),
|
|
1127
|
+
});
|
|
1128
|
+
result = deserialize(await res.text()) as { type: string; data?: unknown };
|
|
1129
|
+
} catch {
|
|
1130
|
+
// A network throw blocks the scan with the generic blocked surface; orphanBlockedError stays
|
|
1131
|
+
// empty (set above), so the surface shows its own framing without a server message.
|
|
1132
|
+
orphanPhase = 'blocked';
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (result.type === 'success' && result.data) {
|
|
1136
|
+
orphanScan = result.data as OrphanScan;
|
|
1137
|
+
orphanKeys = new Set<string>();
|
|
1138
|
+
orphanPhase = 'result';
|
|
1139
|
+
} else {
|
|
1140
|
+
const failure = result.data as MediaBulkFailure | undefined;
|
|
1141
|
+
orphanBlockedError = failure?.error ?? '';
|
|
1142
|
+
orphanPhase = 'blocked';
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Toggle one orphaned-byte key in the selection (reassign-only). */
|
|
1147
|
+
function toggleOrphanKey(key: string) {
|
|
1148
|
+
const next = new Set(orphanKeys);
|
|
1149
|
+
if (next.has(key)) next.delete(key);
|
|
1150
|
+
else next.add(key);
|
|
1151
|
+
orphanKeys = next;
|
|
1152
|
+
}
|
|
1153
|
+
/** Select all or clear all orphaned bytes from the section header checkbox. */
|
|
1154
|
+
function toggleOrphanAll() {
|
|
1155
|
+
orphanKeys = orphanKeys.size === orphanBytes.length ? new Set<string>() : new Set(orphanBytes.map((b) => b.key));
|
|
1156
|
+
}
|
|
1157
|
+
function clearOrphanSelection() {
|
|
1158
|
+
orphanKeys = new Set<string>();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Open the typed-count purge confirm over the current selection.
|
|
1162
|
+
function openOrphanPurge() {
|
|
1163
|
+
if (orphanSelectedCount === 0) return;
|
|
1164
|
+
orphanConfirmInput = '';
|
|
1165
|
+
orphanPurgeError = '';
|
|
1166
|
+
orphanPurging = true;
|
|
1167
|
+
}
|
|
1168
|
+
function cancelOrphanPurge() {
|
|
1169
|
+
orphanPurging = false;
|
|
1170
|
+
orphanConfirmInput = '';
|
|
1171
|
+
orphanPurgeError = '';
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// The purge: POST ?/mediaPurge with each selected key as a repeated `key` field plus `confirm` set to
|
|
1175
|
+
// the typed count. The server re-derives fresh and skips any key claimed since the scan, so the
|
|
1176
|
+
// selection here is advisory. The CSRF token rides the X-Cairn-CSRF header; the ActionResult envelope
|
|
1177
|
+
// is read through deserialize. A success carries the MediaOrphanPurgeResult; a fail or a network throw
|
|
1178
|
+
// surfaces a role="alert".
|
|
1179
|
+
async function applyOrphanPurge() {
|
|
1180
|
+
if (!orphanConfirmMatches) return;
|
|
1181
|
+
orphanPurgeBusy = true;
|
|
1182
|
+
orphanPurgeError = '';
|
|
1183
|
+
const formData = new FormData();
|
|
1184
|
+
for (const key of orphanKeys) formData.append('key', key);
|
|
1185
|
+
formData.append('confirm', orphanConfirmInput);
|
|
1186
|
+
let result: { type: string; data?: unknown };
|
|
1187
|
+
try {
|
|
1188
|
+
const res = await fetch(ORPHAN_PURGE_URL, {
|
|
1189
|
+
method: 'POST',
|
|
1190
|
+
headers: { 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
1191
|
+
body: formData,
|
|
1192
|
+
});
|
|
1193
|
+
result = deserialize(await res.text()) as { type: string; data?: unknown };
|
|
1194
|
+
} catch {
|
|
1195
|
+
orphanPurgeBusy = false;
|
|
1196
|
+
orphanPurgeError = 'The purge could not be completed. Please try again.';
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
orphanPurgeBusy = false;
|
|
1200
|
+
if (result.type === 'success' && result.data) {
|
|
1201
|
+
orphanPurgeResult = result.data as MediaOrphanPurgeResult;
|
|
1202
|
+
orphanPurging = false;
|
|
1203
|
+
} else {
|
|
1204
|
+
const failure = result.data as MediaBulkFailure | undefined;
|
|
1205
|
+
orphanPurgeError = failure?.error ?? 'The purge could not be completed. Please try again.';
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// The where-used line for one broken-reference row: a plain "used in N entries" count.
|
|
1210
|
+
function brokenWhereUsed(count: number): string {
|
|
1211
|
+
if (count === 0) return 'no references found';
|
|
1212
|
+
return `used in ${count} ${count === 1 ? 'entry' : 'entries'}`;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
371
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
|
+
}
|
|
372
1222
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
373
1223
|
e.preventDefault();
|
|
374
|
-
|
|
1224
|
+
const to = Math.min(i + 1, visible.length - 1);
|
|
1225
|
+
if (e.shiftKey) selectRange(to);
|
|
1226
|
+
focusTile(to);
|
|
375
1227
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
376
1228
|
e.preventDefault();
|
|
377
|
-
|
|
1229
|
+
const to = Math.max(i - 1, 0);
|
|
1230
|
+
if (e.shiftKey) selectRange(to);
|
|
1231
|
+
focusTile(to);
|
|
378
1232
|
} else if (e.key === 'Home') {
|
|
379
1233
|
e.preventDefault();
|
|
380
1234
|
focusTile(0);
|
|
381
1235
|
} else if (e.key === 'End') {
|
|
382
1236
|
e.preventDefault();
|
|
383
1237
|
focusTile(visible.length - 1);
|
|
384
|
-
} else if (e.key === '
|
|
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).
|
|
385
1244
|
e.preventDefault();
|
|
386
1245
|
openAsset(visible[i], tileEls[i]);
|
|
387
1246
|
}
|
|
@@ -524,6 +1383,19 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
524
1383
|
|
|
525
1384
|
<span class="flex-1"></span>
|
|
526
1385
|
|
|
1386
|
+
<!-- The on-demand orphan scan entry: a quiet bordered office control, NEVER the danger family (it
|
|
1387
|
+
opens a scan, not a purge). The mockup places it beside Upload; the Library has no Upload
|
|
1388
|
+
button in the toolbar, so it sits in the toolbar row near the density toggle. -->
|
|
1389
|
+
<button
|
|
1390
|
+
bind:this={orphanFindButton}
|
|
1391
|
+
type="button"
|
|
1392
|
+
class="btn btn-sm border-[var(--cairn-card-border)] bg-base-100 font-normal text-[var(--color-muted)] hover:bg-base-content/[0.06]"
|
|
1393
|
+
aria-haspopup="dialog"
|
|
1394
|
+
onclick={openOrphanScan}
|
|
1395
|
+
>
|
|
1396
|
+
<DatabaseIcon class="h-4 w-4" aria-hidden="true" /> Find orphaned files
|
|
1397
|
+
</button>
|
|
1398
|
+
|
|
527
1399
|
<div role="group" aria-label="Layout density" class="bg-base-100 inline-flex items-center gap-1 rounded-lg border border-[var(--cairn-card-border)] p-0.5">
|
|
528
1400
|
<button type="button" aria-label="Grid view" aria-pressed={density === 'grid'} class={densityButtonClass(density === 'grid')} onclick={() => (density = 'grid')}>
|
|
529
1401
|
<LayoutGridIcon class="h-4 w-4" />
|
|
@@ -534,6 +1406,21 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
534
1406
|
</div>
|
|
535
1407
|
</div>
|
|
536
1408
|
|
|
1409
|
+
{#if triage === 'unused'}
|
|
1410
|
+
<!-- The facet preamble: a calm dashed report-only aside above the "No references found" set,
|
|
1411
|
+
naming WHY these are candidates and WHAT cairn cannot see, at the point of action. Never the
|
|
1412
|
+
danger family: selecting is not destroying. -->
|
|
1413
|
+
<div class="mb-3 flex items-start gap-2.5 rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200 px-3.5 py-2.5">
|
|
1414
|
+
<FileTextIcon class="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-muted)]" aria-hidden="true" />
|
|
1415
|
+
<p class="text-[0.8125rem] leading-relaxed text-base-content">
|
|
1416
|
+
<b class="font-semibold">No reference found in any tracked branch.</b> Nothing on the site or in an open edit points to these.
|
|
1417
|
+
<span class="mt-0.5 block text-xs text-[var(--color-muted)]">
|
|
1418
|
+
"No references found" is not the same as unused. cairn cannot see a raw-HTML image or a URL hardcoded into a site template, so check anything you are unsure about before deleting it.
|
|
1419
|
+
</span>
|
|
1420
|
+
</p>
|
|
1421
|
+
</div>
|
|
1422
|
+
{/if}
|
|
1423
|
+
|
|
537
1424
|
{#if sorted.length === 0}
|
|
538
1425
|
<!-- A filter or search narrowed the set to zero; the assets exist, none match. -->
|
|
539
1426
|
<div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
|
|
@@ -541,30 +1428,46 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
541
1428
|
<p class="text-sm text-[var(--color-muted)]">No media match this filter.</p>
|
|
542
1429
|
</div>
|
|
543
1430
|
{:else if density === 'grid'}
|
|
544
|
-
<!-- The grid: a roving-tabindex listbox of tiles. One tabstop; arrows move the
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1431
|
+
<!-- The grid: a roving-tabindex multiselectable listbox of tiles. One tabstop; arrows move the
|
|
1432
|
+
roving index; Enter opens the detail; Space toggles selection (focus and selection are
|
|
1433
|
+
decoupled). Each tile carries a native select checkbox, names the asset, its alt status (a
|
|
1434
|
+
glyph plus a label, never hue alone), and a compact usage marker. -->
|
|
1435
|
+
<ul role="listbox" aria-multiselectable="true" aria-label="Media library" class="grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3 lg:grid-cols-4">
|
|
548
1436
|
{#each visible as asset, i (asset.hash)}
|
|
549
1437
|
{@const used = usageCount(asset.hash)}
|
|
550
1438
|
{@const missing = needsAlt(asset)}
|
|
1439
|
+
{@const picked = selectedHashes.has(asset.hash)}
|
|
551
1440
|
<li role="presentation" class="contents">
|
|
552
1441
|
<div
|
|
553
1442
|
bind:this={tileEls[i]}
|
|
554
1443
|
role="option"
|
|
555
|
-
aria-selected={
|
|
1444
|
+
aria-selected={picked}
|
|
556
1445
|
tabindex={i === activeIndex ? 0 : -1}
|
|
557
1446
|
aria-label="{asset.displayName}. {missing ? 'Needs alt text' : 'Described'}. {used > 0 ? `Found in ${used} ${used === 1 ? 'entry' : 'entries'}` : 'No references found'}."
|
|
558
|
-
class="group flex cursor-pointer flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 outline-
|
|
1447
|
+
class="group relative flex cursor-pointer flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 outline-hidden transition-shadow focus-visible:ring-2 focus-visible:ring-primary/70 {picked ? 'ring-2 ring-primary/70' : selected?.hash === asset.hash ? 'ring-2 ring-primary/40' : ''}"
|
|
559
1448
|
onclick={(e) => openAsset(asset, e.currentTarget)}
|
|
560
1449
|
onkeydown={(e) => onGridKeydown(e, i)}
|
|
561
1450
|
>
|
|
1451
|
+
<!-- The selection checkbox, top-left: a real native checkbox in a soft chip so it reads on
|
|
1452
|
+
any thumbnail. Clicking it toggles the selection only; it never opens the slide-over. -->
|
|
1453
|
+
<span class="absolute left-2 top-2 z-10 inline-flex h-6 w-6 items-center justify-center rounded-md bg-base-100/90 shadow-sm">
|
|
1454
|
+
<input
|
|
1455
|
+
type="checkbox"
|
|
1456
|
+
class="checkbox checkbox-sm"
|
|
1457
|
+
checked={picked}
|
|
1458
|
+
aria-label="Select {asset.displayName}"
|
|
1459
|
+
onclick={(e) => e.stopPropagation()}
|
|
1460
|
+
onchange={() => toggleSelect(asset.hash)}
|
|
1461
|
+
/>
|
|
1462
|
+
</span>
|
|
562
1463
|
<div class="relative flex aspect-[4/3] items-center justify-center bg-base-200/60">
|
|
563
|
-
<!-- The usage marker, top-right: a used count, or the warning-ink
|
|
1464
|
+
<!-- The usage marker, top-right: a used count, or the warning-ink "No refs" chip. The
|
|
1465
|
+
category reads "No references found" (renamed from "Unused"): a found reference is
|
|
1466
|
+
not proof of use, and absence of one is not proof of disuse. -->
|
|
564
1467
|
{#if used > 0}
|
|
565
1468
|
<span class="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full border border-[var(--cairn-card-border)] bg-base-100/90 px-2 py-0.5 text-[0.625rem] font-semibold text-[var(--color-muted)]">used {used}</span>
|
|
566
1469
|
{:else}
|
|
567
|
-
<span class="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full border border-[var(--cairn-card-border)] bg-base-100/90 px-2 py-0.5 text-[0.625rem] font-semibold text-[var(--cairn-warning-ink)]">
|
|
1470
|
+
<span class="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full border border-[var(--cairn-card-border)] bg-base-100/90 px-2 py-0.5 text-[0.625rem] font-semibold text-[var(--cairn-warning-ink)]">No refs</span>
|
|
568
1471
|
{/if}
|
|
569
1472
|
{#if brokenHashes.has(asset.hash)}
|
|
570
1473
|
<span data-cairn-broken class="flex flex-col items-center gap-1 text-[var(--color-subtle)]">
|
|
@@ -599,13 +1502,17 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
599
1502
|
{/each}
|
|
600
1503
|
</ul>
|
|
601
1504
|
{:else}
|
|
602
|
-
<!-- The list density: a
|
|
603
|
-
column sorts through a real
|
|
604
|
-
visible
|
|
1505
|
+
<!-- The list density: a plain selectable table. Each row opens the detail (sets `selected`); the
|
|
1506
|
+
Added column sorts through a real header button with aria-sort; the per-row delete is always
|
|
1507
|
+
visible. Multi-select rides the leading native-checkbox column, which is the APG-correct
|
|
1508
|
+
pattern for a selectable table. The earlier role="grid" + aria-multiselectable promised grid
|
|
1509
|
+
keyboard navigation (arrow cell moves, roving tabindex) the table never implemented, so it
|
|
1510
|
+
is dropped: a plain table with a checkbox column is honest and fully usable. -->
|
|
605
1511
|
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-x-auto shadow-[var(--cairn-shadow)]">
|
|
606
1512
|
<table class="table">
|
|
607
1513
|
<thead>
|
|
608
1514
|
<tr class="border-base-300">
|
|
1515
|
+
<th class="w-10"><span class="sr-only">Select</span></th>
|
|
609
1516
|
<th class={headerLabel}>Asset</th>
|
|
610
1517
|
<th class="{headerLabel} w-32">Alt status</th>
|
|
611
1518
|
<th class="{headerLabel} w-40">Used</th>
|
|
@@ -622,7 +1529,17 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
622
1529
|
{#each visible as asset (asset.hash)}
|
|
623
1530
|
{@const used = usageCount(asset.hash)}
|
|
624
1531
|
{@const missing = needsAlt(asset)}
|
|
625
|
-
|
|
1532
|
+
{@const picked = selectedHashes.has(asset.hash)}
|
|
1533
|
+
<tr class="transition-colors hover:bg-base-200/60 {picked ? 'bg-primary/[0.06]' : selected?.hash === asset.hash ? 'bg-primary/[0.03]' : ''}">
|
|
1534
|
+
<td class="w-10">
|
|
1535
|
+
<input
|
|
1536
|
+
type="checkbox"
|
|
1537
|
+
class="checkbox checkbox-sm"
|
|
1538
|
+
checked={picked}
|
|
1539
|
+
aria-label="Select {asset.displayName}"
|
|
1540
|
+
onchange={() => toggleSelect(asset.hash)}
|
|
1541
|
+
/>
|
|
1542
|
+
</td>
|
|
626
1543
|
<td class="max-w-0">
|
|
627
1544
|
<button type="button" class="flex w-full items-center gap-3 text-left" onclick={(e) => openAsset(asset, e.currentTarget)}>
|
|
628
1545
|
<span class="relative flex h-10 w-14 flex-none items-center justify-center overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-200/60">
|
|
@@ -671,6 +1588,45 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
671
1588
|
</div>
|
|
672
1589
|
{/if}
|
|
673
1590
|
|
|
1591
|
+
<!-- The selection-count live region: a dedicated sr-only role=status node that mirrors "N selected"
|
|
1592
|
+
on every toggle. It never shares a node with the flash, copy, or Showing regions, so the three
|
|
1593
|
+
polite regions never collide (the announced count is its own surface). -->
|
|
1594
|
+
<div class="sr-only" role="status" aria-live="polite">{selectedCount > 0 ? `${selectedCount} selected.` : ''}</div>
|
|
1595
|
+
|
|
1596
|
+
{#if selectedCount > 0}
|
|
1597
|
+
<!-- THE STICKY SELECTION ACTION BAR (position: sticky, so it rides the bottom of the scrolling
|
|
1598
|
+
content and never floats off it). It states the count, names the scope, offers Select all in
|
|
1599
|
+
view and Clear, and carries the reversible bulk Delete (a git-tracked removal of manifest
|
|
1600
|
+
rows, so the danger-OUTLINE register; the irreversible byte purge lives on a separate
|
|
1601
|
+
surface and is never reachable from this bar). -->
|
|
1602
|
+
<div
|
|
1603
|
+
role="region"
|
|
1604
|
+
aria-label="Selection actions"
|
|
1605
|
+
class="sticky bottom-3.5 z-20 mx-auto mt-4 flex w-full max-w-[640px] items-center gap-3.5 rounded-2xl border border-[var(--cairn-card-border)] bg-base-100 px-4 py-3 shadow-[var(--cairn-shadow)]"
|
|
1606
|
+
>
|
|
1607
|
+
<span class="shrink-0 text-[0.9375rem] font-bold tabular-nums">{selectedCount}</span>
|
|
1608
|
+
<span class="min-w-0 text-xs leading-snug text-[var(--color-muted)]">
|
|
1609
|
+
<b class="font-semibold text-base-content">{selectedCount} selected</b> in this view<br />
|
|
1610
|
+
{selectionScope.noRefs} with no references, {selectionScope.used} still used
|
|
1611
|
+
</span>
|
|
1612
|
+
<span class="flex-1"></span>
|
|
1613
|
+
{#if selectedCount < visible.length}
|
|
1614
|
+
<button type="button" class="whitespace-nowrap px-1 py-1.5 text-[0.8125rem] font-medium text-primary hover:underline" onclick={selectAllVisible}>
|
|
1615
|
+
Select all {visible.length}
|
|
1616
|
+
</button>
|
|
1617
|
+
{/if}
|
|
1618
|
+
<button type="button" class="whitespace-nowrap rounded-lg border border-base-300 px-2.5 py-2 text-[0.8125rem] font-medium text-[var(--color-subtle)]" onclick={clearSelection}>
|
|
1619
|
+
Clear
|
|
1620
|
+
</button>
|
|
1621
|
+
<!-- The reversible bulk Delete: a git-tracked removal of manifest rows, so the danger-OUTLINE
|
|
1622
|
+
register (the irreversible byte purge lives on a separate surface and keeps the solid fill).
|
|
1623
|
+
It opens the skip-and-report alertdialog over the current selection. -->
|
|
1624
|
+
<button type="button" aria-haspopup="dialog" onclick={(e) => openBulkDialog(e.currentTarget)} class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg border border-[var(--cairn-error-border)] bg-base-100 px-3.5 py-2.5 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
|
|
1625
|
+
<Trash2Icon class="h-3.5 w-3.5" aria-hidden="true" /> Delete {selectedCount}
|
|
1626
|
+
</button>
|
|
1627
|
+
</div>
|
|
1628
|
+
{/if}
|
|
1629
|
+
|
|
674
1630
|
{#if sorted.length > 0}
|
|
675
1631
|
<!-- The announced count plus the managed Load more (never infinite scroll). One persistent
|
|
676
1632
|
polite region carries "Showing N of M". -->
|
|
@@ -850,9 +1806,33 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
850
1806
|
</dl>
|
|
851
1807
|
</div>
|
|
852
1808
|
|
|
853
|
-
<!-- The actions.
|
|
854
|
-
|
|
855
|
-
|
|
1809
|
+
<!-- The actions block (rev.2 decision 7): two quiet text-weight entry points (Replace, Push alt)
|
|
1810
|
+
above the existing danger-bordered Delete. The quiet controls are button:not(.btn) levelled
|
|
1811
|
+
rows, lighter than a bordered button; each carries aria-haspopup="dialog". Push alt's handler
|
|
1812
|
+
lands in Task 8; the button is placed now so the block matches the design. -->
|
|
1813
|
+
<div class="flex flex-col gap-1 border-t border-[var(--cairn-card-border)] pt-4">
|
|
1814
|
+
<span class="{headerLabel} mb-1">Actions</span>
|
|
1815
|
+
<button
|
|
1816
|
+
type="button"
|
|
1817
|
+
data-cairn-replace-open
|
|
1818
|
+
class="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-[0.8125rem] font-medium text-base-content hover:bg-base-content/[0.06]"
|
|
1819
|
+
aria-haspopup="dialog"
|
|
1820
|
+
onclick={(e) => openReplaceDialog(e.currentTarget)}
|
|
1821
|
+
>
|
|
1822
|
+
<RefreshCwIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1823
|
+
Replace image
|
|
1824
|
+
</button>
|
|
1825
|
+
<button
|
|
1826
|
+
type="button"
|
|
1827
|
+
data-cairn-pushalt-open
|
|
1828
|
+
class="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-[0.8125rem] font-medium text-base-content hover:bg-base-content/[0.06]"
|
|
1829
|
+
aria-haspopup="dialog"
|
|
1830
|
+
onclick={(e) => openAltDialog(e.currentTarget)}
|
|
1831
|
+
>
|
|
1832
|
+
<MegaphoneIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1833
|
+
Push alt to placements
|
|
1834
|
+
</button>
|
|
1835
|
+
<button type="button" class="btn btn-sm mt-1.5 border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={openDeleteDialog}>
|
|
856
1836
|
<Trash2Icon class="h-4 w-4" aria-hidden="true" /> Delete
|
|
857
1837
|
</button>
|
|
858
1838
|
</div>
|
|
@@ -868,6 +1848,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
868
1848
|
bind:this={deleteDialog}
|
|
869
1849
|
class="modal"
|
|
870
1850
|
role="alertdialog"
|
|
1851
|
+
aria-modal="true"
|
|
871
1852
|
aria-labelledby="cairn-ml-delete-title"
|
|
872
1853
|
aria-describedby="cairn-ml-delete-desc"
|
|
873
1854
|
oncancel={closeDeleteDialog}
|
|
@@ -947,3 +1928,1066 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
947
1928
|
</div>
|
|
948
1929
|
{/if}
|
|
949
1930
|
</dialog>
|
|
1931
|
+
|
|
1932
|
+
<!-- The Replace alertdialog: a native modal <dialog> (native focus trap + Escape), NO light dismiss.
|
|
1933
|
+
A replace repoints a content hash and can break a draft, so it carries role="alertdialog", the
|
|
1934
|
+
danger register, and a typed-slug gate. Step one is the quiet upload; step two is the impact review
|
|
1935
|
+
gated behind the typed slug; the blocked step is the fail-closed surface (no apply button). -->
|
|
1936
|
+
<dialog
|
|
1937
|
+
bind:this={replaceDialog}
|
|
1938
|
+
data-testid="cairn-replace-dialog"
|
|
1939
|
+
class="modal"
|
|
1940
|
+
role="alertdialog"
|
|
1941
|
+
aria-modal="true"
|
|
1942
|
+
aria-labelledby="cairn-ml-replace-title"
|
|
1943
|
+
aria-describedby="cairn-ml-replace-sub"
|
|
1944
|
+
oncancel={closeReplaceDialog}
|
|
1945
|
+
>
|
|
1946
|
+
{#if replaceAsset}
|
|
1947
|
+
{@const asset = replaceAsset}
|
|
1948
|
+
<div class="modal-box max-w-xl">
|
|
1949
|
+
<div class="mb-3 flex items-start gap-3">
|
|
1950
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--cairn-error-tint)] text-[var(--cairn-error-ink)]" aria-hidden="true">
|
|
1951
|
+
{#if replaceStep === 'blocked'}<TriangleAlertIcon class="h-5 w-5" />{:else}<RefreshCwIcon class="h-5 w-5" />{/if}
|
|
1952
|
+
</span>
|
|
1953
|
+
<div class="flex-1">
|
|
1954
|
+
<h2 id="cairn-ml-replace-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">
|
|
1955
|
+
{#if replaceStep === 'review'}
|
|
1956
|
+
Replace {asset.slug} in {replaceAffected} published {replaceAffected === 1 ? 'entry' : 'entries'}
|
|
1957
|
+
{:else if replaceStep === 'blocked'}
|
|
1958
|
+
Replace is on hold
|
|
1959
|
+
{:else}
|
|
1960
|
+
Replace {asset.displayName}
|
|
1961
|
+
{/if}
|
|
1962
|
+
</h2>
|
|
1963
|
+
<p id="cairn-ml-replace-sub" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
1964
|
+
{#if replaceStep === 'review'}
|
|
1965
|
+
The new file replaces the stored image. Every published entry that uses it is repointed in one commit to main, and readers see the change once the build finishes.
|
|
1966
|
+
{:else if replaceStep === 'blocked'}
|
|
1967
|
+
cairn could not read every place this image is used, so it will not repoint references it cannot see. No file was changed.
|
|
1968
|
+
{:else}
|
|
1969
|
+
Upload a new file. Every published entry that uses this image points to the new one, in one commit to main.
|
|
1970
|
+
{/if}
|
|
1971
|
+
</p>
|
|
1972
|
+
</div>
|
|
1973
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={closeReplaceDialog}>
|
|
1974
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
1975
|
+
</button>
|
|
1976
|
+
</div>
|
|
1977
|
+
|
|
1978
|
+
{#if replaceStep === 'upload'}
|
|
1979
|
+
<!-- Step one: upload a new file (upload-new-only). The asset being replaced stays named above
|
|
1980
|
+
the dropzone, so the author never loses it. Cancel is the initial focus; no apply yet. -->
|
|
1981
|
+
<div class="flex flex-col gap-3">
|
|
1982
|
+
<div class="flex items-center gap-3 rounded-box border border-[var(--cairn-card-border)] bg-base-200/60 p-3">
|
|
1983
|
+
<span class="flex h-12 w-12 flex-none items-center justify-center overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
|
|
1984
|
+
{#if brokenHashes.has(asset.hash)}
|
|
1985
|
+
<ImageOffIcon class="h-5 w-5 text-[var(--color-subtle)]" aria-hidden="true" />
|
|
1986
|
+
{:else}
|
|
1987
|
+
<img src={thumbSrc(asset)} alt="" aria-hidden="true" class="h-full w-full object-cover" onerror={() => markBroken(asset.hash)} />
|
|
1988
|
+
{/if}
|
|
1989
|
+
</span>
|
|
1990
|
+
<span class="flex min-w-0 flex-col gap-0.5">
|
|
1991
|
+
<span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">Replacing</span>
|
|
1992
|
+
<span class="text-sm font-semibold">{asset.displayName}</span>
|
|
1993
|
+
<span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-[var(--color-muted)] tabular-nums">
|
|
1994
|
+
{#if dimensions(asset)}{dimensions(asset)}<span class="px-1" aria-hidden="true">·</span>{/if}{formatBytes(asset.bytes)}
|
|
1995
|
+
</span>
|
|
1996
|
+
</span>
|
|
1997
|
+
</div>
|
|
1998
|
+
|
|
1999
|
+
{#if replaceUpload.kind === 'failed'}
|
|
2000
|
+
<!-- A typed ingest/upload failure: an assertive alert with the message and a Retry. -->
|
|
2001
|
+
<div role="alert" class="flex flex-col items-center gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-4 text-center">
|
|
2002
|
+
<TriangleAlertIcon class="h-6 w-6 text-[var(--cairn-error-ink)]" aria-hidden="true" />
|
|
2003
|
+
<span class="text-[0.8125rem] text-[var(--cairn-error-ink)]">{replaceUpload.card.message}</span>
|
|
2004
|
+
<button type="button" class="btn btn-sm" onclick={replaceUpload.retry}>Try another file</button>
|
|
2005
|
+
</div>
|
|
2006
|
+
{:else if replaceUpload.kind === 'working'}
|
|
2007
|
+
<div role="status" class="flex flex-col items-center gap-2 rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-100 p-5 text-center text-[var(--color-muted)]">
|
|
2008
|
+
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
|
2009
|
+
<span class="text-[0.8125rem]">Preparing the new file...</span>
|
|
2010
|
+
</div>
|
|
2011
|
+
{:else}
|
|
2012
|
+
<div class="flex flex-col items-center gap-1.5 rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-100 p-5 text-center text-[var(--color-muted)]">
|
|
2013
|
+
<UploadIcon class="h-6 w-6 text-primary" aria-hidden="true" />
|
|
2014
|
+
<span class="text-[0.875rem] font-medium text-base-content">Drop the new image, or upload</span>
|
|
2015
|
+
<span class="text-xs">PNG, JPEG, WebP, or HEIC. We convert HEIC for you.</span>
|
|
2016
|
+
<button type="button" class="btn btn-sm btn-primary mt-1.5" onclick={() => replaceFileInput?.click()}>Choose a file</button>
|
|
2017
|
+
<input
|
|
2018
|
+
bind:this={replaceFileInput}
|
|
2019
|
+
type="file"
|
|
2020
|
+
accept="image/*"
|
|
2021
|
+
class="sr-only"
|
|
2022
|
+
aria-label="Choose a new image to replace this asset"
|
|
2023
|
+
onchange={onReplaceFileChosen}
|
|
2024
|
+
/>
|
|
2025
|
+
</div>
|
|
2026
|
+
{/if}
|
|
2027
|
+
</div>
|
|
2028
|
+
<div class="mt-4 flex justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2029
|
+
<button bind:this={replaceCancelButton} type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
|
|
2030
|
+
</div>
|
|
2031
|
+
{:else if replaceStep === 'review'}
|
|
2032
|
+
{@const newRec = replaceRecord}
|
|
2033
|
+
<!-- Step two: the impact review. The from/to strip carries the CORRECTED content-addressed copy
|
|
2034
|
+
(the name stays, only the hash changes); the affected-entry well is expanded by default and
|
|
2035
|
+
scroll-capped; the branch-delta is a calm report-only aside; the typed-slug gates apply. -->
|
|
2036
|
+
<div class="flex flex-col gap-4">
|
|
2037
|
+
{#if newRec}
|
|
2038
|
+
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-box border border-[var(--cairn-card-border)] bg-base-200/60 p-3">
|
|
2039
|
+
<div class="flex min-w-0 flex-col gap-0.5">
|
|
2040
|
+
<span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">Current</span>
|
|
2041
|
+
<span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-[var(--color-muted)] tabular-nums line-through">.{asset.hash}</span>
|
|
2042
|
+
</div>
|
|
2043
|
+
<ArrowRightIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
2044
|
+
<div class="flex min-w-0 flex-col gap-0.5">
|
|
2045
|
+
<span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">New file</span>
|
|
2046
|
+
<span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-primary tabular-nums">.{newRec.hash}</span>
|
|
2047
|
+
</div>
|
|
2048
|
+
<div class="col-span-3 flex items-start gap-2 border-t border-[var(--cairn-card-border)] pt-2.5">
|
|
2049
|
+
<CheckIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
2050
|
+
<span class="text-[0.8125rem] leading-relaxed">The name <code class="rounded bg-[var(--cairn-code-chip)] px-1.5 py-0.5 font-[family-name:var(--font-editor)] text-[0.75rem]">{asset.slug}</code> stays the same. Only the content hash changes, so every published entry is repointed to the new file in one commit.</span>
|
|
2051
|
+
</div>
|
|
2052
|
+
</div>
|
|
2053
|
+
{/if}
|
|
2054
|
+
|
|
2055
|
+
<div>
|
|
2056
|
+
<div class="mb-2 flex items-baseline justify-between">
|
|
2057
|
+
<span class={headerLabel}>Published entries that will be repointed</span>
|
|
2058
|
+
<span class="text-xs tabular-nums text-[var(--color-muted)]">{replaceEntries.length}</span>
|
|
2059
|
+
</div>
|
|
2060
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100">
|
|
2061
|
+
<ul bind:this={replaceEntriesList} id="cairn-ml-replace-entries" class="flex max-h-56 list-none flex-col gap-1 overflow-y-auto p-2">
|
|
2062
|
+
{#each replaceVisibleEntries as entry, i (entry.concept + '/' + entry.id)}
|
|
2063
|
+
<!-- The first row past the cap is a script-only focus target for "Show all" (tabindex
|
|
2064
|
+
-1 keeps it out of the tab order). svelte-ignore: the rule allows a literal -1 but
|
|
2065
|
+
does not see through the per-row conditional that selects which row carries it. -->
|
|
2066
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
2067
|
+
<li class="flex items-start gap-2.5 rounded px-1.5 py-1.5" tabindex={i === REPLACE_ROW_CAP ? -1 : undefined}>
|
|
2068
|
+
<FileTextIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
2069
|
+
<span class="flex min-w-0 flex-col">
|
|
2070
|
+
<span class="truncate text-[0.8125rem] font-medium">{entry.title}</span>
|
|
2071
|
+
<span class="truncate text-[0.6875rem] text-[var(--color-muted)]">{replaceWhereUsed(entry)}</span>
|
|
2072
|
+
</span>
|
|
2073
|
+
</li>
|
|
2074
|
+
{/each}
|
|
2075
|
+
</ul>
|
|
2076
|
+
{#if replaceHiddenCount > 0 && !replaceShowAll}
|
|
2077
|
+
<div class="border-t border-[var(--cairn-card-border)] p-1.5">
|
|
2078
|
+
<button
|
|
2079
|
+
type="button"
|
|
2080
|
+
class="flex w-full items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.75rem] font-medium text-primary hover:bg-primary/[0.08]"
|
|
2081
|
+
aria-expanded={replaceShowAll}
|
|
2082
|
+
aria-controls="cairn-ml-replace-entries"
|
|
2083
|
+
onclick={showAllReplaceEntries}
|
|
2084
|
+
>
|
|
2085
|
+
Show the other {replaceHiddenCount} {replaceHiddenCount === 1 ? 'entry' : 'entries'}
|
|
2086
|
+
</button>
|
|
2087
|
+
</div>
|
|
2088
|
+
{/if}
|
|
2089
|
+
</div>
|
|
2090
|
+
</div>
|
|
2091
|
+
|
|
2092
|
+
{#if (replacePlan?.branchDelta?.length ?? 0) > 0}
|
|
2093
|
+
<!-- The report-only branch delta: open cairn/* edits keep the old file until they publish.
|
|
2094
|
+
Calm dashed base-200, never the danger register. -->
|
|
2095
|
+
<div class="rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
|
|
2096
|
+
<div class="mb-1.5 flex items-center gap-2">
|
|
2097
|
+
<GitBranchIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
2098
|
+
<span class="text-[0.8125rem] font-semibold">Open edits still on the old file</span>
|
|
2099
|
+
<span class="text-xs tabular-nums text-[var(--color-muted)]">{replacePlan?.branchDelta.length ?? 0}</span>
|
|
2100
|
+
</div>
|
|
2101
|
+
<p class="mb-2 text-[0.75rem] leading-relaxed text-[var(--color-muted)]">These edits are on their own branches and are not touched. Each keeps the old file until it is published again.</p>
|
|
2102
|
+
<ul class="flex list-none flex-col gap-1 p-0">
|
|
2103
|
+
{#each replacePlan?.branchDelta ?? [] as delta (delta.branch)}
|
|
2104
|
+
<li class="font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--cairn-warning-ink)]">{delta.branch}</li>
|
|
2105
|
+
{/each}
|
|
2106
|
+
</ul>
|
|
2107
|
+
</div>
|
|
2108
|
+
{/if}
|
|
2109
|
+
|
|
2110
|
+
<div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
|
|
2111
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
2112
|
+
<span>The old file stays in git history. A developer can bring it back. The alt text on each placement is left exactly as it is.</span>
|
|
2113
|
+
</div>
|
|
2114
|
+
|
|
2115
|
+
<div class="flex flex-col gap-1.5">
|
|
2116
|
+
<label class="text-[0.875rem]" for="cairn-ml-replace-confirm">Type <code class="rounded bg-[var(--cairn-code-chip)] px-1.5 py-0.5 font-[family-name:var(--font-editor)] text-[0.8125rem] font-semibold">{asset.slug}</code> to replace the file in all {replaceAffected} {replaceAffected === 1 ? 'entry' : 'entries'}.</label>
|
|
2117
|
+
<input id="cairn-ml-replace-confirm" data-cairn-replace-confirm class="input input-sm border-[var(--cairn-error-border)] font-[family-name:var(--font-editor)]" autocomplete="off" placeholder="Type the asset slug" bind:value={replaceConfirmInput} />
|
|
2118
|
+
</div>
|
|
2119
|
+
</div>
|
|
2120
|
+
|
|
2121
|
+
<!-- A polite live region mirrors the footer impact for a screen reader on the review step. The
|
|
2122
|
+
role="status" matches the Push-alt live region: the stronger, more portable form. -->
|
|
2123
|
+
<div class="sr-only" role="status" aria-live="polite">
|
|
2124
|
+
Replace {asset.slug} in {replaceAffected} published {replaceAffected === 1 ? 'entry' : 'entries'}.{(replacePlan?.branchDelta?.length ?? 0) > 0 ? ` ${replacePlan?.branchDelta.length} open ${(replacePlan?.branchDelta?.length ?? 0) === 1 ? 'edit is' : 'edits are'} not touched.` : ''}
|
|
2125
|
+
</div>
|
|
2126
|
+
|
|
2127
|
+
<form method="POST" action="?/mediaReplace" class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2128
|
+
<CsrfField />
|
|
2129
|
+
<input type="hidden" name="oldHash" value={asset.hash} />
|
|
2130
|
+
<input type="hidden" name="newHash" value={replaceRecord?.hash ?? ''} />
|
|
2131
|
+
<input type="hidden" name="confirmSlug" value={replaceConfirmInput} />
|
|
2132
|
+
<input type="hidden" name="media" value={replaceRecord ? JSON.stringify([replaceRecord]) : '[]'} />
|
|
2133
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
2134
|
+
<GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
|
|
2135
|
+
</span>
|
|
2136
|
+
<button type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
|
|
2137
|
+
<button type="submit" class="btn btn-sm btn-error" disabled={!replaceConfirmMatches}>
|
|
2138
|
+
<RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Replace in {replaceAffected} {replaceAffected === 1 ? 'entry' : 'entries'}
|
|
2139
|
+
</button>
|
|
2140
|
+
</form>
|
|
2141
|
+
{:else}
|
|
2142
|
+
<!-- The fail-closed surface: usage could not be fully verified, so the replace refuses rather
|
|
2143
|
+
than guess. NO apply button (not even disabled), and no typed gate. A quiet "Check usage
|
|
2144
|
+
again" re-runs the scan; the held upload stays ready. -->
|
|
2145
|
+
<div class="flex flex-col gap-3">
|
|
2146
|
+
<div role="status" class="flex flex-col gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3.5">
|
|
2147
|
+
<span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
|
|
2148
|
+
<TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Usage could not be fully verified
|
|
2149
|
+
</span>
|
|
2150
|
+
<p class="text-[0.8125rem] leading-relaxed">
|
|
2151
|
+
{#if replaceBlockedBranch}
|
|
2152
|
+
The published site read cleanly. One edit branch would not load, so cairn cannot tell whether it uses the image too. Replacing now could leave that branch pointing at the old file with no record of it.
|
|
2153
|
+
{:else}
|
|
2154
|
+
The published site could not be fully read, so cairn cannot tell every place this image is used. Replacing now could leave a reference pointing at the old file with no record of it.
|
|
2155
|
+
{/if}
|
|
2156
|
+
</p>
|
|
2157
|
+
{#if replaceBlockedBranch}
|
|
2158
|
+
<p class="inline-flex items-center gap-1.5 text-[0.8125rem]">
|
|
2159
|
+
<XIcon class="h-3.5 w-3.5 flex-none text-[var(--cairn-error-ink)]" aria-hidden="true" />
|
|
2160
|
+
Could not read <code class="font-[family-name:var(--font-editor)] text-[0.75rem]">{replaceBlockedBranch}</code>
|
|
2161
|
+
</p>
|
|
2162
|
+
{:else}
|
|
2163
|
+
<p class="inline-flex items-center gap-1.5 text-[0.8125rem]">
|
|
2164
|
+
<XIcon class="h-3.5 w-3.5 flex-none text-[var(--cairn-error-ink)]" aria-hidden="true" />
|
|
2165
|
+
An edit branch would not load.
|
|
2166
|
+
</p>
|
|
2167
|
+
{/if}
|
|
2168
|
+
<button type="button" class="btn btn-sm self-start border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={runReplacePreview}>
|
|
2169
|
+
<RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Check usage again
|
|
2170
|
+
</button>
|
|
2171
|
+
</div>
|
|
2172
|
+
<div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
|
|
2173
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
2174
|
+
<span>Your uploaded file is held and ready. Once the scan completes, the review opens with the full impact.</span>
|
|
2175
|
+
</div>
|
|
2176
|
+
</div>
|
|
2177
|
+
<div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2178
|
+
<span class="mr-auto text-[0.75rem] text-[var(--color-muted)]">No file was changed.</span>
|
|
2179
|
+
<button type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
|
|
2180
|
+
</div>
|
|
2181
|
+
{/if}
|
|
2182
|
+
</div>
|
|
2183
|
+
{/if}
|
|
2184
|
+
</dialog>
|
|
2185
|
+
|
|
2186
|
+
<!-- The Push-alt review dialog: a native modal <dialog> (native focus trap + Escape), NO light dismiss.
|
|
2187
|
+
Alt fill is reversible and frequent, so it carries role="dialog" (the everyday register, never
|
|
2188
|
+
alertdialog) with NO typed-slug gate; apply is always enabled. The review step lists three buckets
|
|
2189
|
+
(will-fill always applied, customized behind one opt-in, decorative-skipped reported); the blocked
|
|
2190
|
+
step is the fail-closed surface (no apply form). -->
|
|
2191
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
2192
|
+
<!-- The explicit role="dialog" is the native <dialog> default, but it is stated to mark the everyday
|
|
2193
|
+
register against the Replace dialog's role="alertdialog" sibling, and the component test reads it. -->
|
|
2194
|
+
<dialog
|
|
2195
|
+
bind:this={altDialog}
|
|
2196
|
+
data-testid="cairn-alt-dialog"
|
|
2197
|
+
class="modal"
|
|
2198
|
+
role="dialog"
|
|
2199
|
+
aria-modal="true"
|
|
2200
|
+
aria-labelledby="cairn-ml-alt-title"
|
|
2201
|
+
aria-describedby="cairn-ml-alt-sub"
|
|
2202
|
+
oncancel={closeAltDialog}
|
|
2203
|
+
>
|
|
2204
|
+
{#if altAsset}
|
|
2205
|
+
{@const asset = altAsset}
|
|
2206
|
+
<div class="modal-box max-w-xl">
|
|
2207
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2208
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-primary/10 text-primary" aria-hidden="true">
|
|
2209
|
+
<MegaphoneIcon class="h-5 w-5" />
|
|
2210
|
+
</span>
|
|
2211
|
+
<div class="flex-1">
|
|
2212
|
+
<h2 id="cairn-ml-alt-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">
|
|
2213
|
+
{#if altStep === 'blocked'}
|
|
2214
|
+
Push alt is on hold
|
|
2215
|
+
{:else}
|
|
2216
|
+
Fill alt on {altCounts.willFill} {altCounts.willFill === 1 ? 'placement' : 'placements'}
|
|
2217
|
+
{/if}
|
|
2218
|
+
</h2>
|
|
2219
|
+
<p id="cairn-ml-alt-sub" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
2220
|
+
{#if altStep === 'blocked'}
|
|
2221
|
+
cairn could not read every place this image is used, so it will not write alt where it cannot see. Nothing was changed.
|
|
2222
|
+
{:else}
|
|
2223
|
+
This writes the default alt for {asset.displayName} into the published placements that have none. One commit to main. Placements that already have their own alt stay as they are, unless you choose to overwrite them below.
|
|
2224
|
+
{/if}
|
|
2225
|
+
</p>
|
|
2226
|
+
</div>
|
|
2227
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={closeAltDialog}>
|
|
2228
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
2229
|
+
</button>
|
|
2230
|
+
</div>
|
|
2231
|
+
|
|
2232
|
+
{#if altStep === 'review'}
|
|
2233
|
+
<div class="flex flex-col gap-4">
|
|
2234
|
+
<!-- The alt being pushed, shown once so the author confirms the text before applying. -->
|
|
2235
|
+
<div class="flex items-start gap-2.5 rounded-box border border-primary/25 bg-primary/[0.05] p-3 text-[0.8125rem] leading-relaxed">
|
|
2236
|
+
<MegaphoneIcon class="mt-0.5 h-4 w-4 flex-none text-primary" aria-hidden="true" />
|
|
2237
|
+
<span>The alt being pushed: <strong class="font-semibold">{altPushed ? `“${altPushed}”` : '(no default alt set)'}</strong>. Edit it in the panel first if it is not right.</span>
|
|
2238
|
+
</div>
|
|
2239
|
+
|
|
2240
|
+
<div class="flex flex-col gap-3">
|
|
2241
|
+
<!-- WILL FILL: every row's honest (no alt) -> default alt, always applied. -->
|
|
2242
|
+
{#if altFillRows.length > 0}
|
|
2243
|
+
<div class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
|
|
2244
|
+
<div class="flex items-center gap-2.5 p-3">
|
|
2245
|
+
<span class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-md bg-primary/10 text-primary" aria-hidden="true">
|
|
2246
|
+
<CheckIcon class="h-3.5 w-3.5" />
|
|
2247
|
+
</span>
|
|
2248
|
+
<div class="min-w-0 flex-1">
|
|
2249
|
+
<div class="text-[0.8125rem] font-semibold">Will fill the gap</div>
|
|
2250
|
+
<div class="mt-px text-[0.6875rem] leading-snug text-[var(--color-muted)]">These placements have no alt today. The default alt is written in.</div>
|
|
2251
|
+
</div>
|
|
2252
|
+
<span class="flex-none text-[0.8125rem] font-bold tabular-nums text-primary">{altFillRows.length}</span>
|
|
2253
|
+
</div>
|
|
2254
|
+
<ul bind:this={altFillList} id="cairn-ml-alt-fill" class="flex max-h-44 list-none flex-col overflow-y-auto border-t border-[var(--cairn-card-border)] p-0">
|
|
2255
|
+
{#each altFillVisible as row, i (row.key)}
|
|
2256
|
+
<!-- The first row past the cap is the script-only focus target for "Show all"
|
|
2257
|
+
(tabindex -1). svelte-ignore: as above, the conditional hides the literal -1. -->
|
|
2258
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
2259
|
+
<li class="flex items-start gap-2.5 border-t border-[var(--cairn-card-border)]/70 px-3 py-2.5 first:border-t-0" tabindex={i === ALT_ROW_CAP ? -1 : undefined}>
|
|
2260
|
+
<FileTextIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
2261
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
2262
|
+
<div class="flex items-center gap-1.5">
|
|
2263
|
+
<span class="truncate text-[0.8125rem] font-semibold">{row.title}</span>
|
|
2264
|
+
<span class="flex-none rounded-full bg-base-content/[0.06] px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">{row.kind}</span>
|
|
2265
|
+
</div>
|
|
2266
|
+
<div class="flex flex-wrap items-baseline gap-1.5 text-[0.75rem] leading-snug">
|
|
2267
|
+
<span class="italic text-[var(--color-muted)]">(no alt)</span>
|
|
2268
|
+
<ArrowRightIcon class="h-3 w-3 flex-none text-[var(--color-muted)] opacity-65" aria-hidden="true" />
|
|
2269
|
+
<span class="font-medium text-primary">{row.after}</span>
|
|
2270
|
+
</div>
|
|
2271
|
+
</div>
|
|
2272
|
+
</li>
|
|
2273
|
+
{/each}
|
|
2274
|
+
</ul>
|
|
2275
|
+
{#if altFillHidden > 0 && !altShowAll}
|
|
2276
|
+
<div class="border-t border-[var(--cairn-card-border)] p-1.5">
|
|
2277
|
+
<button
|
|
2278
|
+
type="button"
|
|
2279
|
+
class="flex w-full items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.75rem] font-medium text-primary hover:bg-primary/[0.08]"
|
|
2280
|
+
aria-expanded={altShowAll}
|
|
2281
|
+
aria-controls="cairn-ml-alt-fill"
|
|
2282
|
+
onclick={showAllAltFill}
|
|
2283
|
+
>
|
|
2284
|
+
Show the other {altFillHidden} {altFillHidden === 1 ? 'placement' : 'placements'}, all gaining the same alt
|
|
2285
|
+
</button>
|
|
2286
|
+
</div>
|
|
2287
|
+
{/if}
|
|
2288
|
+
</div>
|
|
2289
|
+
|
|
2290
|
+
<!-- The body-vs-hero caveat, anchored beside will-fill where the surprised author looks. -->
|
|
2291
|
+
<div class="flex items-start gap-2 px-0.5 text-[0.75rem] leading-relaxed">
|
|
2292
|
+
<TriangleAlertIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--cairn-warning-ink)]" aria-hidden="true" />
|
|
2293
|
+
<span>A body image has no place to record decorative, so an empty body image always reads as a gap to fill. Only a hero can be skipped as decorative.</span>
|
|
2294
|
+
</div>
|
|
2295
|
+
{/if}
|
|
2296
|
+
|
|
2297
|
+
<!-- HAS CUSTOM ALT: one bucket-level opt-in (a real native checkbox). Before it is checked,
|
|
2298
|
+
each row shows its existing alt plain and "kept"; checking flips to was -> default. -->
|
|
2299
|
+
{#if altCustomRows.length > 0}
|
|
2300
|
+
<div data-cairn-alt-custom class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
|
|
2301
|
+
<div class="flex items-center gap-2.5 p-3">
|
|
2302
|
+
<span class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-md bg-[var(--cairn-warning-ink)]/10 text-[var(--cairn-warning-ink)]" aria-hidden="true">
|
|
2303
|
+
<MegaphoneIcon class="h-3.5 w-3.5" />
|
|
2304
|
+
</span>
|
|
2305
|
+
<div class="min-w-0 flex-1">
|
|
2306
|
+
<div class="text-[0.8125rem] font-semibold">Already has custom alt</div>
|
|
2307
|
+
<div class="mt-px text-[0.6875rem] leading-snug text-[var(--color-muted)]">
|
|
2308
|
+
{altOverwrite ? 'You chose to overwrite these.' : 'Left alone by default. You can overwrite these too.'}
|
|
2309
|
+
</div>
|
|
2310
|
+
</div>
|
|
2311
|
+
<span class="flex-none text-[0.8125rem] font-bold tabular-nums text-[var(--cairn-warning-ink)]">{altCustomRows.length}</span>
|
|
2312
|
+
</div>
|
|
2313
|
+
<!-- The opt-in band, styled in the danger family: overwriting an editor's words is the
|
|
2314
|
+
destructive choice. The checkbox is a REAL native input in the a11y tree. -->
|
|
2315
|
+
<div class="border-t border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3">
|
|
2316
|
+
<label class="flex cursor-pointer items-start gap-2.5">
|
|
2317
|
+
<input
|
|
2318
|
+
type="checkbox"
|
|
2319
|
+
data-cairn-alt-optin
|
|
2320
|
+
class="checkbox checkbox-sm mt-px border-[var(--cairn-error-border)] checked:border-[var(--cairn-error-ink)] checked:bg-[var(--cairn-error-ink)]"
|
|
2321
|
+
aria-describedby="cairn-ml-alt-optin-hint"
|
|
2322
|
+
bind:checked={altOverwrite}
|
|
2323
|
+
/>
|
|
2324
|
+
<span class="text-[0.8125rem] leading-snug text-[var(--cairn-error-ink)]">
|
|
2325
|
+
<span class="font-semibold">Also overwrite {altCustomRows.length === 1 ? 'this 1 placement' : `these ${altCustomRows.length} placements`} with the default alt.</span>
|
|
2326
|
+
<span id="cairn-ml-alt-optin-hint" class="mt-0.5 block">Overwrites the alt these entries already have. Git keeps the old version.</span>
|
|
2327
|
+
</span>
|
|
2328
|
+
</label>
|
|
2329
|
+
</div>
|
|
2330
|
+
<ul class="flex max-h-44 list-none flex-col overflow-y-auto p-0">
|
|
2331
|
+
{#each altCustomRows as row (row.key)}
|
|
2332
|
+
<li class="flex items-start gap-2.5 border-t border-[var(--cairn-card-border)]/70 px-3 py-2.5 first:border-t-0">
|
|
2333
|
+
<FileTextIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
2334
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
2335
|
+
<div class="flex items-center gap-1.5">
|
|
2336
|
+
<span class="truncate text-[0.8125rem] font-semibold">{row.title}</span>
|
|
2337
|
+
<span class="flex-none rounded-full bg-base-content/[0.06] px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">{row.kind}</span>
|
|
2338
|
+
</div>
|
|
2339
|
+
<div class="flex flex-wrap items-baseline gap-1.5 text-[0.75rem] leading-snug">
|
|
2340
|
+
{#if altOverwrite}
|
|
2341
|
+
<span data-cairn-alt-was class="text-base-content line-through decoration-[var(--color-muted)]/55">{`“${row.before}”`}</span>
|
|
2342
|
+
<ArrowRightIcon class="h-3 w-3 flex-none text-[var(--color-muted)] opacity-65" aria-hidden="true" />
|
|
2343
|
+
<span class="font-medium text-primary">{altPushed}</span>
|
|
2344
|
+
{:else}
|
|
2345
|
+
<span class="text-base-content">{`“${row.before}”`}</span>
|
|
2346
|
+
<span class="text-[var(--color-muted)] opacity-65" aria-hidden="true">·</span>
|
|
2347
|
+
<span class="text-[var(--color-muted)]">kept</span>
|
|
2348
|
+
{/if}
|
|
2349
|
+
</div>
|
|
2350
|
+
</div>
|
|
2351
|
+
</li>
|
|
2352
|
+
{/each}
|
|
2353
|
+
</ul>
|
|
2354
|
+
</div>
|
|
2355
|
+
{/if}
|
|
2356
|
+
|
|
2357
|
+
<!-- DECORATIVE HERO, SKIPPED: listed, muted, never an input. -->
|
|
2358
|
+
{#if altSkipRows.length > 0}
|
|
2359
|
+
<div data-cairn-alt-skip class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 opacity-90">
|
|
2360
|
+
<div class="flex items-center gap-2.5 p-3">
|
|
2361
|
+
<span class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-md bg-base-content/[0.07] text-[var(--color-muted)]" aria-hidden="true">
|
|
2362
|
+
<ImageOffIcon class="h-3.5 w-3.5" />
|
|
2363
|
+
</span>
|
|
2364
|
+
<div class="min-w-0 flex-1">
|
|
2365
|
+
<div class="text-[0.8125rem] font-semibold">Marked decorative, skipped</div>
|
|
2366
|
+
<div class="mt-px text-[0.6875rem] leading-snug text-[var(--color-muted)]">A hero set as decorative on purpose. It is left without alt.</div>
|
|
2367
|
+
</div>
|
|
2368
|
+
<span class="flex-none text-[0.8125rem] font-bold tabular-nums text-[var(--color-muted)]">{altSkipRows.length}</span>
|
|
2369
|
+
</div>
|
|
2370
|
+
<ul class="flex list-none flex-col border-t border-[var(--cairn-card-border)] p-0">
|
|
2371
|
+
{#each altSkipRows as row (row.key)}
|
|
2372
|
+
<li class="flex items-center gap-2.5 border-t border-[var(--cairn-card-border)]/70 px-3 py-2 text-[0.75rem] text-[var(--color-muted)] first:border-t-0">
|
|
2373
|
+
<span class="truncate">{row.title}</span>
|
|
2374
|
+
<span class="flex-none rounded-full bg-base-content/[0.06] px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide">{row.kind}</span>
|
|
2375
|
+
</li>
|
|
2376
|
+
{/each}
|
|
2377
|
+
</ul>
|
|
2378
|
+
</div>
|
|
2379
|
+
{/if}
|
|
2380
|
+
</div>
|
|
2381
|
+
|
|
2382
|
+
{#if (altPlan?.branchDelta?.length ?? 0) > 0}
|
|
2383
|
+
<!-- The report-only branch delta: open cairn/* edits keep their own alt until they publish. -->
|
|
2384
|
+
<div class="rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
|
|
2385
|
+
<div class="mb-1.5 flex items-center gap-2">
|
|
2386
|
+
<GitBranchIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
2387
|
+
<span class="text-[0.8125rem] font-semibold">Open edits not touched</span>
|
|
2388
|
+
<span class="text-xs tabular-nums text-[var(--color-muted)]">{altPlan?.branchDelta.length ?? 0}</span>
|
|
2389
|
+
</div>
|
|
2390
|
+
<p class="mb-2 text-[0.75rem] leading-relaxed text-[var(--color-muted)]">These edits are on their own branches and are not changed. Each keeps its alt as the author has it there.</p>
|
|
2391
|
+
<ul class="flex list-none flex-col gap-1 p-0">
|
|
2392
|
+
{#each altPlan?.branchDelta ?? [] as delta (delta.branch)}
|
|
2393
|
+
<li class="font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--cairn-warning-ink)]">{delta.branch}</li>
|
|
2394
|
+
{/each}
|
|
2395
|
+
</ul>
|
|
2396
|
+
</div>
|
|
2397
|
+
{/if}
|
|
2398
|
+
|
|
2399
|
+
<div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
|
|
2400
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
2401
|
+
<span>Every version stays in git history, so any overwrite can be undone.</span>
|
|
2402
|
+
</div>
|
|
2403
|
+
</div>
|
|
2404
|
+
|
|
2405
|
+
<!-- The polite live region announces the moving committed total when the opt-in toggles. -->
|
|
2406
|
+
<div class="sr-only" role="status" aria-live="polite">
|
|
2407
|
+
Now writing alt to {altTotal} {altTotal === 1 ? 'placement' : 'placements'}.{altOverwrite && altCounts.customized > 0 ? ` ${altCounts.willFill} filled, ${altCounts.customized} overwritten.` : ''}
|
|
2408
|
+
</div>
|
|
2409
|
+
|
|
2410
|
+
<form method="POST" action="?/mediaAltPropagate" class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2411
|
+
<CsrfField />
|
|
2412
|
+
<input type="hidden" name="hash" value={asset.hash} />
|
|
2413
|
+
<!-- The opt-in checkbox lives beside the customized rows (outside the form), so its bound
|
|
2414
|
+
state is mirrored here as the posted flag. The server reads form.get('overwrite') === 'on'. -->
|
|
2415
|
+
<input type="hidden" name="overwrite" value={altOverwrite ? 'on' : ''} />
|
|
2416
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
2417
|
+
<GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
|
|
2418
|
+
</span>
|
|
2419
|
+
<button type="button" class="btn btn-sm" onclick={closeAltDialog}>Cancel</button>
|
|
2420
|
+
<button type="submit" class="btn btn-sm btn-primary">
|
|
2421
|
+
<CheckIcon class="h-4 w-4" aria-hidden="true" />
|
|
2422
|
+
{#if altOverwrite && altCounts.customized > 0}
|
|
2423
|
+
Update {altTotal} {altTotal === 1 ? 'placement' : 'placements'}
|
|
2424
|
+
{:else}
|
|
2425
|
+
Fill {altTotal} {altTotal === 1 ? 'placement' : 'placements'}
|
|
2426
|
+
{/if}
|
|
2427
|
+
</button>
|
|
2428
|
+
</form>
|
|
2429
|
+
{:else}
|
|
2430
|
+
<!-- The fail-closed surface: usage could not be fully verified, so the push refuses rather than
|
|
2431
|
+
guess. NO apply form. A quiet "Check usage again" re-runs the scan. The banner on open is
|
|
2432
|
+
role="status" (not alert): no action was attempted yet. MediaAltPropagateFailure carries
|
|
2433
|
+
only `error`, so the generic honest line stands in. -->
|
|
2434
|
+
<div class="flex flex-col gap-3">
|
|
2435
|
+
<div role="status" class="flex flex-col gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3.5">
|
|
2436
|
+
<span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
|
|
2437
|
+
<TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Usage could not be fully verified
|
|
2438
|
+
</span>
|
|
2439
|
+
<p class="text-[0.8125rem] leading-relaxed">
|
|
2440
|
+
cairn could not read every place this image is used, so it cannot tell which placements need alt. Writing now could miss a placement or write over one with no record of it.
|
|
2441
|
+
</p>
|
|
2442
|
+
<button type="button" class="btn btn-sm self-start border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={runAltPreview}>
|
|
2443
|
+
<RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Check usage again
|
|
2444
|
+
</button>
|
|
2445
|
+
</div>
|
|
2446
|
+
<div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
|
|
2447
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
2448
|
+
<span>Nothing was changed. Once the scan completes, the review opens with every placement.</span>
|
|
2449
|
+
</div>
|
|
2450
|
+
</div>
|
|
2451
|
+
<div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2452
|
+
<span class="mr-auto text-[0.75rem] text-[var(--color-muted)]">No alt was changed.</span>
|
|
2453
|
+
<button type="button" class="btn btn-sm" onclick={closeAltDialog}>Cancel</button>
|
|
2454
|
+
</div>
|
|
2455
|
+
{/if}
|
|
2456
|
+
</div>
|
|
2457
|
+
{/if}
|
|
2458
|
+
</dialog>
|
|
2459
|
+
|
|
2460
|
+
<!-- The bulk-delete alertdialog: a native modal <dialog> (native focus trap + Escape), NO light
|
|
2461
|
+
dismiss. The confirm IS the dry-run (the skip-and-report split), so there is no separate preview
|
|
2462
|
+
step. A git-tracked removal is reversible, so the register is danger-OUTLINE with a plain confirm
|
|
2463
|
+
and no typed gate, carrying the git-revert reassurance. Apply posts every selected hash to
|
|
2464
|
+
?/mediaBulkDelete; the server re-checks each one strictly and the itemized summary reports the
|
|
2465
|
+
outcome (succeeded / skipped-with-reason / failed-with-reason). The recheck runs at execution, so
|
|
2466
|
+
there is no review-time tick implying the gate passed. -->
|
|
2467
|
+
<dialog
|
|
2468
|
+
bind:this={bulkDialog}
|
|
2469
|
+
data-testid="cairn-bulk-dialog"
|
|
2470
|
+
class="modal"
|
|
2471
|
+
role="alertdialog"
|
|
2472
|
+
aria-modal="true"
|
|
2473
|
+
aria-labelledby="cairn-ml-bulk-title"
|
|
2474
|
+
aria-describedby="cairn-ml-bulk-desc"
|
|
2475
|
+
oncancel={onBulkCancel}
|
|
2476
|
+
>
|
|
2477
|
+
<div class="modal-box max-w-xl">
|
|
2478
|
+
{#if bulkPhase === 'review'}
|
|
2479
|
+
<!-- THE CENTRAL SAFETY SCREEN: the selection split into what will be deleted and what is held
|
|
2480
|
+
back, careful about timing (the usage shown rode a quick read; each item is re-checked when
|
|
2481
|
+
it deletes, not now). -->
|
|
2482
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2483
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--cairn-error-tint)] text-[var(--cairn-error-ink)]" aria-hidden="true">
|
|
2484
|
+
<Trash2Icon class="h-5 w-5" />
|
|
2485
|
+
</span>
|
|
2486
|
+
<div class="flex-1">
|
|
2487
|
+
<h2 id="cairn-ml-bulk-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">Delete {bulkHashes.length} selected {bulkHashes.length === 1 ? 'image' : 'images'}?</h2>
|
|
2488
|
+
<p id="cairn-ml-bulk-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
2489
|
+
{bulkWillDelete.length} {bulkWillDelete.length === 1 ? 'has' : 'have'} no references and will be deleted.
|
|
2490
|
+
{#if bulkWillSkip.length > 0}{bulkWillSkip.length} {bulkWillSkip.length === 1 ? 'is' : 'are'} still used and will be skipped. {/if}Each one is checked again at delete time, so nothing in use is removed.
|
|
2491
|
+
</p>
|
|
2492
|
+
</div>
|
|
2493
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={closeBulkDialog}>
|
|
2494
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
2495
|
+
</button>
|
|
2496
|
+
</div>
|
|
2497
|
+
|
|
2498
|
+
<div class="flex flex-col gap-3">
|
|
2499
|
+
<!-- The scope strip: the explicit count plus the safety-floor disclosure, timed at execution. -->
|
|
2500
|
+
<div class="flex flex-col gap-2 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
|
|
2501
|
+
<span class="inline-flex items-start gap-2">
|
|
2502
|
+
<CheckIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
2503
|
+
<span><b class="font-semibold">{bulkHashes.length} {bulkHashes.length === 1 ? 'image' : 'images'} selected</b> in the current view.</span>
|
|
2504
|
+
</span>
|
|
2505
|
+
<span class="inline-flex items-start gap-2 text-[var(--color-muted)]">
|
|
2506
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none" aria-hidden="true" />
|
|
2507
|
+
<span>The usage shown here came from a quick read. cairn checks each image again the moment it deletes it, and skips any that turns out to be in use.</span>
|
|
2508
|
+
</span>
|
|
2509
|
+
</div>
|
|
2510
|
+
|
|
2511
|
+
{#if bulkWillDelete.length > 0}
|
|
2512
|
+
<!-- WILL BE DELETED: the no-reference items, each with its slug and the "no references" tag. -->
|
|
2513
|
+
<div>
|
|
2514
|
+
<span class="mb-2 inline-flex items-center gap-2 text-[0.6875rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">
|
|
2515
|
+
Will be deleted <span class="rounded-full bg-base-content/[0.07] px-1.5 py-0.5 tabular-nums">{bulkWillDelete.length}</span>
|
|
2516
|
+
</span>
|
|
2517
|
+
<ul class="flex max-h-44 list-none flex-col gap-1 overflow-y-auto rounded-box border border-[var(--cairn-card-border)] p-2">
|
|
2518
|
+
{#each bulkWillDelete as asset (asset.hash)}
|
|
2519
|
+
<li class="flex items-center gap-2.5 rounded px-1.5 py-1">
|
|
2520
|
+
<div class="min-w-0 flex-1">
|
|
2521
|
+
<div class="truncate text-[0.8125rem] font-semibold">{asset.displayName}</div>
|
|
2522
|
+
<div class="truncate font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--color-muted)]">{asset.slug}.{asset.hash}</div>
|
|
2523
|
+
</div>
|
|
2524
|
+
<span class="flex-none text-[0.6875rem] font-semibold text-[var(--color-muted)]">no references found</span>
|
|
2525
|
+
</li>
|
|
2526
|
+
{/each}
|
|
2527
|
+
</ul>
|
|
2528
|
+
</div>
|
|
2529
|
+
{/if}
|
|
2530
|
+
|
|
2531
|
+
{#if bulkWillSkip.length > 0}
|
|
2532
|
+
<!-- WILL BE SKIPPED: the still-used items, reported with their where-used. A bulk delete never
|
|
2533
|
+
force-removes an in-use asset; it points to the single-item typed-confirm path. The
|
|
2534
|
+
warning register on plain base-100 (a skip is not a failure), text-only. -->
|
|
2535
|
+
<div class="overflow-hidden rounded-box border border-[var(--cairn-card-border)]">
|
|
2536
|
+
<div class="flex items-start gap-2.5 bg-[color-mix(in_oklab,var(--cairn-warning-ink)_8%,var(--color-base-100))] p-3">
|
|
2537
|
+
<TriangleAlertIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--cairn-warning-ink)]" aria-hidden="true" />
|
|
2538
|
+
<div class="text-[0.8125rem] leading-relaxed">
|
|
2539
|
+
<b class="font-semibold text-[var(--cairn-warning-ink)]">{bulkWillSkip.length} will be skipped, still in use</b>
|
|
2540
|
+
<span class="mt-0.5 block text-[0.75rem] text-[var(--color-muted)]">A bulk delete never removes an image that is still referenced. To delete one of these, open it and use Delete with the typed confirm, where you can see and confirm what breaks.</span>
|
|
2541
|
+
</div>
|
|
2542
|
+
</div>
|
|
2543
|
+
<ul class="flex max-h-36 list-none flex-col overflow-y-auto">
|
|
2544
|
+
{#each bulkWillSkip as asset (asset.hash)}
|
|
2545
|
+
{@const where = usageCount(asset.hash)}
|
|
2546
|
+
<li class="flex items-center gap-2.5 border-t border-[color-mix(in_oklab,var(--cairn-card-border)_70%,transparent)] px-3 py-2 first:border-t-0">
|
|
2547
|
+
<span class="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{asset.slug}</span>
|
|
2548
|
+
<span class="flex-none text-[0.6875rem] font-semibold text-[var(--cairn-warning-ink)]">found in {where} {where === 1 ? 'entry' : 'entries'}</span>
|
|
2549
|
+
</li>
|
|
2550
|
+
{/each}
|
|
2551
|
+
</ul>
|
|
2552
|
+
</div>
|
|
2553
|
+
{/if}
|
|
2554
|
+
|
|
2555
|
+
<!-- The recoverability reassurance: a git-tracked removal is reversible. -->
|
|
2556
|
+
<div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
|
|
2557
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
2558
|
+
<span><b class="font-semibold">Every removal is one revertible commit you can undo.</b> The deletes are one commit to <code class="rounded bg-[var(--cairn-code-chip)] px-1 py-0.5 font-[family-name:var(--font-editor)] text-[0.75rem]">main</code>, so a developer can revert it and the images come back.</span>
|
|
2559
|
+
</div>
|
|
2560
|
+
|
|
2561
|
+
<div class="flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2562
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
2563
|
+
<GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
|
|
2564
|
+
</span>
|
|
2565
|
+
<button bind:this={bulkCancelButton} type="button" class="btn btn-sm" onclick={closeBulkDialog}>Cancel</button>
|
|
2566
|
+
<!-- The danger-OUTLINE apply (not the solid fill the irreversible purge reserves), naming the
|
|
2567
|
+
outcome from the split. Disabled only when nothing in the selection is deletable. -->
|
|
2568
|
+
<button type="button" class="btn btn-sm border-[var(--cairn-error-border)] bg-base-100 text-[var(--cairn-error-ink)] hover:bg-[var(--cairn-error-tint)]" disabled={bulkWillDelete.length === 0} onclick={applyBulkDelete}>
|
|
2569
|
+
<Trash2Icon class="h-3.5 w-3.5" aria-hidden="true" /> {bulkApplyLabel}
|
|
2570
|
+
</button>
|
|
2571
|
+
</div>
|
|
2572
|
+
</div>
|
|
2573
|
+
{:else if bulkPhase === 'deleting'}
|
|
2574
|
+
<!-- ANNOUNCED PROGRESS: the per-item recheck against the fresh strict index runs here. The live
|
|
2575
|
+
region is role=status (role=alert is reserved for a post-action failure). No review-time tick. -->
|
|
2576
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2577
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--cairn-error-tint)] text-[var(--cairn-error-ink)]" aria-hidden="true">
|
|
2578
|
+
<Trash2Icon class="h-5 w-5" />
|
|
2579
|
+
</span>
|
|
2580
|
+
<div class="flex-1">
|
|
2581
|
+
<h2 id="cairn-ml-bulk-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">Deleting images</h2>
|
|
2582
|
+
<p id="cairn-ml-bulk-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">Checking each one against a fresh read and removing the ones with no references. This can take a moment across branches.</p>
|
|
2583
|
+
</div>
|
|
2584
|
+
</div>
|
|
2585
|
+
<div class="flex flex-col items-center gap-3 py-4">
|
|
2586
|
+
<RefreshCwIcon class="h-6 w-6 animate-spin text-[var(--color-muted)]" aria-hidden="true" />
|
|
2587
|
+
<span class="text-[0.8125rem] text-[var(--color-muted)]">Checking and deleting {bulkWillDelete.length} {bulkWillDelete.length === 1 ? 'image' : 'images'}...</span>
|
|
2588
|
+
</div>
|
|
2589
|
+
<div class="mt-2 border-t border-[var(--cairn-card-border)] pt-3.5 text-[0.75rem] text-[var(--color-muted)]">Please keep this open until it finishes.</div>
|
|
2590
|
+
<div class="sr-only" role="status" aria-live="polite">Deleting {bulkWillDelete.length} {bulkWillDelete.length === 1 ? 'asset' : 'assets'}...</div>
|
|
2591
|
+
{:else if bulkPhase === 'done' && bulkResult}
|
|
2592
|
+
{@const res = bulkResult}
|
|
2593
|
+
<!-- THE ITEMIZED SUMMARY (the 207-Multi-Status shape): succeeded / skipped-with-reason /
|
|
2594
|
+
failed-with-reason. The skipped reason is timing-honest (a reference turned up on the
|
|
2595
|
+
recheck). The Done action re-reads the load behind the dialog. -->
|
|
2596
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2597
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--color-positive-tint,var(--cairn-card-border))] text-[var(--color-positive-ink)]" aria-hidden="true">
|
|
2598
|
+
<CheckIcon class="h-5 w-5" />
|
|
2599
|
+
</span>
|
|
2600
|
+
<div class="flex-1">
|
|
2601
|
+
<h2 bind:this={bulkSummaryTitle} tabindex="-1" id="cairn-ml-bulk-title" class="text-lg font-bold tracking-tight outline-hidden font-[family-name:var(--font-display)]">Done. {res.deleted.length} deleted{res.skipped.length > 0 ? `, ${res.skipped.length} skipped` : ''}</h2>
|
|
2602
|
+
<p id="cairn-ml-bulk-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
2603
|
+
The {res.deleted.length} {res.deleted.length === 1 ? 'delete is' : 'deletes are'} one commit to <code class="rounded bg-[var(--cairn-code-chip)] px-1 py-0.5 font-[family-name:var(--font-editor)] text-[0.75rem]">main</code>.{#if res.skipped.length > 0} The {res.skipped.length} skipped had a reference turn up on the recheck and {res.skipped.length === 1 ? 'was' : 'were'} left as {res.skipped.length === 1 ? 'it is' : 'they are'}.{/if}
|
|
2604
|
+
</p>
|
|
2605
|
+
</div>
|
|
2606
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close" onclick={() => void finishBulkDelete()}>
|
|
2607
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
2608
|
+
</button>
|
|
2609
|
+
</div>
|
|
2610
|
+
|
|
2611
|
+
<div class="flex flex-col gap-3">
|
|
2612
|
+
<div class="grid grid-cols-3 gap-2 text-center">
|
|
2613
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] p-2.5">
|
|
2614
|
+
<div class="text-xl font-bold tabular-nums text-[var(--color-positive-ink)]">{res.deleted.length}</div>
|
|
2615
|
+
<div class="text-[0.6875rem] uppercase tracking-wide text-[var(--color-muted)]">Deleted</div>
|
|
2616
|
+
</div>
|
|
2617
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] p-2.5">
|
|
2618
|
+
<div class="text-xl font-bold tabular-nums text-[var(--cairn-warning-ink)]">{res.skipped.length}</div>
|
|
2619
|
+
<div class="text-[0.6875rem] uppercase tracking-wide text-[var(--color-muted)]">Skipped</div>
|
|
2620
|
+
</div>
|
|
2621
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] p-2.5">
|
|
2622
|
+
<div class="text-xl font-bold tabular-nums text-[var(--cairn-error-ink)]">{res.failed.length}</div>
|
|
2623
|
+
<div class="text-[0.6875rem] uppercase tracking-wide text-[var(--color-muted)]">Failed</div>
|
|
2624
|
+
</div>
|
|
2625
|
+
</div>
|
|
2626
|
+
|
|
2627
|
+
{#if res.skipped.length > 0}
|
|
2628
|
+
<div class="overflow-hidden rounded-box border border-[var(--cairn-card-border)]">
|
|
2629
|
+
<div class="inline-flex w-full items-center gap-2 bg-[color-mix(in_oklab,var(--cairn-warning-ink)_8%,var(--color-base-100))] p-2.5 text-[0.75rem] font-semibold text-[var(--cairn-warning-ink)]">
|
|
2630
|
+
<TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Skipped, a reference turned up on the recheck
|
|
2631
|
+
</div>
|
|
2632
|
+
<ul class="flex max-h-36 list-none flex-col overflow-y-auto">
|
|
2633
|
+
{#each res.skipped as skip (skip.hash)}
|
|
2634
|
+
<li class="flex items-center gap-2.5 border-t border-[color-mix(in_oklab,var(--cairn-card-border)_70%,transparent)] px-3 py-2 first:border-t-0">
|
|
2635
|
+
<span class="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{bulkAssetName(skip.hash)}</span>
|
|
2636
|
+
<span class="flex-none text-[0.6875rem] text-[var(--color-muted)]">{bulkSkipReason(skip)}</span>
|
|
2637
|
+
</li>
|
|
2638
|
+
{/each}
|
|
2639
|
+
</ul>
|
|
2640
|
+
</div>
|
|
2641
|
+
{/if}
|
|
2642
|
+
|
|
2643
|
+
{#if res.failed.length > 0}
|
|
2644
|
+
<div class="overflow-hidden rounded-box border border-[var(--cairn-error-border)]">
|
|
2645
|
+
<div class="inline-flex w-full items-center gap-2 bg-[var(--cairn-error-tint)] p-2.5 text-[0.75rem] font-semibold text-[var(--cairn-error-ink)]">
|
|
2646
|
+
<TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Failed
|
|
2647
|
+
</div>
|
|
2648
|
+
<ul class="flex max-h-36 list-none flex-col overflow-y-auto">
|
|
2649
|
+
{#each res.failed as fail (fail.hash)}
|
|
2650
|
+
<li class="flex items-center gap-2.5 border-t border-[color-mix(in_oklab,var(--cairn-error-border)_70%,transparent)] px-3 py-2 first:border-t-0">
|
|
2651
|
+
<span class="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{bulkAssetName(fail.hash)}</span>
|
|
2652
|
+
<span class="flex-none text-[0.6875rem] text-[var(--cairn-error-ink)]">{fail.error}</span>
|
|
2653
|
+
</li>
|
|
2654
|
+
{/each}
|
|
2655
|
+
</ul>
|
|
2656
|
+
</div>
|
|
2657
|
+
{/if}
|
|
2658
|
+
|
|
2659
|
+
<div class="flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2660
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
2661
|
+
<GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
|
|
2662
|
+
</span>
|
|
2663
|
+
<button type="button" class="btn btn-sm btn-primary" onclick={() => void finishBulkDelete()}>Done</button>
|
|
2664
|
+
</div>
|
|
2665
|
+
</div>
|
|
2666
|
+
<div class="sr-only" role="status" aria-live="polite">Done. {res.deleted.length} deleted, {res.skipped.length} skipped, {res.failed.length} failed.</div>
|
|
2667
|
+
{:else}
|
|
2668
|
+
<!-- POST-ACTION FAILURE: the fail-closed 503 (the whole batch refused) or a network throw. This
|
|
2669
|
+
is the one place role="alert" belongs (an action was attempted and failed). -->
|
|
2670
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2671
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--cairn-error-tint)] text-[var(--cairn-error-ink)]" aria-hidden="true">
|
|
2672
|
+
<TriangleAlertIcon class="h-5 w-5" />
|
|
2673
|
+
</span>
|
|
2674
|
+
<div class="flex-1">
|
|
2675
|
+
<h2 id="cairn-ml-bulk-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">The delete did not run</h2>
|
|
2676
|
+
<p id="cairn-ml-bulk-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">Nothing was deleted. You can close this and try again.</p>
|
|
2677
|
+
</div>
|
|
2678
|
+
</div>
|
|
2679
|
+
<div role="alert" class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3 text-[0.8125rem] leading-relaxed text-[var(--cairn-error-ink)]">
|
|
2680
|
+
<TriangleAlertIcon class="mt-0.5 h-4 w-4 flex-none" aria-hidden="true" />
|
|
2681
|
+
<span>{bulkError}</span>
|
|
2682
|
+
</div>
|
|
2683
|
+
<div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2684
|
+
<button type="button" class="btn btn-sm" onclick={closeBulkDialog}>Close</button>
|
|
2685
|
+
<button type="button" class="btn btn-sm border-[var(--cairn-error-border)] bg-base-100 text-[var(--cairn-error-ink)]" onclick={() => (bulkPhase = 'review')}>Back to the selection</button>
|
|
2686
|
+
</div>
|
|
2687
|
+
{/if}
|
|
2688
|
+
</div>
|
|
2689
|
+
</dialog>
|
|
2690
|
+
|
|
2691
|
+
<!-- The on-demand orphan scan surface: a native modal <dialog> (native focus trap + Escape), NO light
|
|
2692
|
+
dismiss. The result is the two-section dry-run, the loading state, and the detection-time blocked
|
|
2693
|
+
surface. The irreversible byte purge lives inside this dialog only, kept structurally apart from
|
|
2694
|
+
the reversible bulk delete: a separate selection Set of R2 keys, a solid-danger Purge, and a
|
|
2695
|
+
typed-count confirm. role="dialog" (the everyday register): the scan itself changes nothing, and
|
|
2696
|
+
the irreversible step is gated behind the typed confirm below. -->
|
|
2697
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
2698
|
+
<dialog
|
|
2699
|
+
bind:this={orphanDialog}
|
|
2700
|
+
data-testid="cairn-orphan-dialog"
|
|
2701
|
+
class="modal"
|
|
2702
|
+
role="dialog"
|
|
2703
|
+
aria-modal="true"
|
|
2704
|
+
aria-labelledby="cairn-ml-orphan-title"
|
|
2705
|
+
aria-describedby="cairn-ml-orphan-desc"
|
|
2706
|
+
oncancel={onOrphanCancel}
|
|
2707
|
+
>
|
|
2708
|
+
<div class="modal-box max-w-2xl">
|
|
2709
|
+
{#if orphanPhase === 'scanning'}
|
|
2710
|
+
<!-- LOADING: a polite live region announces the scan is running. The scan is far heavier than the
|
|
2711
|
+
loaded index (an R2 list plus a cross-branch reconcile), so it is on demand, never instant. -->
|
|
2712
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2713
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-base-200 text-[var(--color-muted)]" aria-hidden="true">
|
|
2714
|
+
<DatabaseIcon class="h-5 w-5" />
|
|
2715
|
+
</span>
|
|
2716
|
+
<div class="flex-1">
|
|
2717
|
+
<h2 bind:this={orphanTitle} tabindex="-1" id="cairn-ml-orphan-title" class="text-lg font-bold tracking-tight outline-hidden font-[family-name:var(--font-display)]">Scanning storage</h2>
|
|
2718
|
+
<p id="cairn-ml-orphan-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">Listing every stored file and checking it against the library across the site and every open edit. This can take a moment.</p>
|
|
2719
|
+
</div>
|
|
2720
|
+
</div>
|
|
2721
|
+
<div class="flex flex-col items-center gap-3 py-6">
|
|
2722
|
+
<RefreshCwIcon class="h-6 w-6 animate-spin text-[var(--color-muted)]" aria-hidden="true" />
|
|
2723
|
+
<span class="text-[0.8125rem] text-[var(--color-muted)]">Scanning storage for orphaned files...</span>
|
|
2724
|
+
</div>
|
|
2725
|
+
<div class="sr-only" role="status" aria-live="polite">Scanning storage for orphaned files...</div>
|
|
2726
|
+
{:else if orphanPhase === 'blocked'}
|
|
2727
|
+
<!-- DETECTION-TIME FAIL CLOSED: the scan did not run because an open edit branch could not be
|
|
2728
|
+
read, so cairn cannot be sure which files are truly orphaned. There is NO collect or purge
|
|
2729
|
+
action, not even disabled. The banner is role="status" (no action was attempted). The server
|
|
2730
|
+
returns a generic message, so the framing names an unreadable open edit without naming the
|
|
2731
|
+
specific branch (naming it is a known carry-forward). -->
|
|
2732
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2733
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-base-200 text-[var(--color-muted)]" aria-hidden="true">
|
|
2734
|
+
<DatabaseIcon class="h-5 w-5" />
|
|
2735
|
+
</span>
|
|
2736
|
+
<div class="flex-1">
|
|
2737
|
+
<h2 bind:this={orphanTitle} tabindex="-1" id="cairn-ml-orphan-title" class="text-lg font-bold tracking-tight outline-hidden font-[family-name:var(--font-display)]">The scan could not finish</h2>
|
|
2738
|
+
<p id="cairn-ml-orphan-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">cairn could not read one of your open edits, so it cannot tell which files are truly orphaned. No file was changed.</p>
|
|
2739
|
+
</div>
|
|
2740
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close" onclick={closeOrphanScan}>
|
|
2741
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
2742
|
+
</button>
|
|
2743
|
+
</div>
|
|
2744
|
+
<div role="status" class="flex flex-col gap-3 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3.5 text-[0.8125rem] leading-relaxed">
|
|
2745
|
+
<span class="inline-flex items-center gap-2 font-semibold">
|
|
2746
|
+
<TriangleAlertIcon class="h-4 w-4 flex-none text-[var(--cairn-warning-ink)]" aria-hidden="true" /> Could not read every branch
|
|
2747
|
+
</span>
|
|
2748
|
+
<p class="text-base-content">
|
|
2749
|
+
A file looks orphaned only if no record on any branch points to it. One open edit would not load, so cairn cannot be sure. It will not show a list of files to purge that it might be wrong about.
|
|
2750
|
+
</p>
|
|
2751
|
+
{#if orphanBlockedError}
|
|
2752
|
+
<p class="text-[var(--color-muted)]">{orphanBlockedError}</p>
|
|
2753
|
+
{/if}
|
|
2754
|
+
</div>
|
|
2755
|
+
<div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2756
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">No file was changed.</span>
|
|
2757
|
+
<button type="button" class="btn btn-sm" onclick={closeOrphanScan}>Close</button>
|
|
2758
|
+
<button type="button" class="btn btn-sm border-[var(--cairn-card-border)] bg-base-100" onclick={() => void runOrphanScan()}>
|
|
2759
|
+
<RefreshCwIcon class="h-3.5 w-3.5" aria-hidden="true" /> Check again
|
|
2760
|
+
</button>
|
|
2761
|
+
</div>
|
|
2762
|
+
{:else if orphanPhase === 'result' && orphanPurgeResult}
|
|
2763
|
+
{@const res = orphanPurgeResult}
|
|
2764
|
+
<!-- THE PURGE SUMMARY: the purged count, the keys skipped because their hash was claimed since the
|
|
2765
|
+
scan, and any per-object failure. The Done action re-reads the load (the bytes are gone). -->
|
|
2766
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2767
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--color-positive-tint,var(--cairn-card-border))] text-[var(--color-positive-ink)]" aria-hidden="true">
|
|
2768
|
+
<CheckIcon class="h-5 w-5" />
|
|
2769
|
+
</span>
|
|
2770
|
+
<div class="flex-1">
|
|
2771
|
+
<h2 bind:this={orphanTitle} tabindex="-1" id="cairn-ml-orphan-title" class="text-lg font-bold tracking-tight outline-hidden font-[family-name:var(--font-display)]">Done. {res.purged.length} purged{res.skippedClaimed.length > 0 ? `, ${res.skippedClaimed.length} kept` : ''}</h2>
|
|
2772
|
+
<p id="cairn-ml-orphan-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
2773
|
+
The {res.purged.length} {res.purged.length === 1 ? 'file is' : 'files are'} gone for good.{#if res.skippedClaimed.length > 0} {res.skippedClaimed.length} {res.skippedClaimed.length === 1 ? 'was' : 'were'} kept because the file was claimed by a record since the scan.{/if}
|
|
2774
|
+
</p>
|
|
2775
|
+
</div>
|
|
2776
|
+
</div>
|
|
2777
|
+
{#if res.skippedClaimed.length > 0}
|
|
2778
|
+
<div class="overflow-hidden rounded-box border border-[var(--cairn-card-border)]">
|
|
2779
|
+
<div class="bg-base-200/60 p-2.5 text-[0.75rem] font-semibold text-[var(--color-muted)]">Kept, the file was claimed since the scan</div>
|
|
2780
|
+
<ul class="flex max-h-36 list-none flex-col overflow-y-auto">
|
|
2781
|
+
{#each res.skippedClaimed as key (key)}
|
|
2782
|
+
<li class="border-t border-[color-mix(in_oklab,var(--cairn-card-border)_70%,transparent)] px-3 py-2 font-[family-name:var(--font-editor)] text-[0.75rem] first:border-t-0">{key}</li>
|
|
2783
|
+
{/each}
|
|
2784
|
+
</ul>
|
|
2785
|
+
</div>
|
|
2786
|
+
{/if}
|
|
2787
|
+
{#if res.failed.length > 0}
|
|
2788
|
+
<div class="mt-3 overflow-hidden rounded-box border border-[var(--cairn-error-border)]">
|
|
2789
|
+
<div class="bg-[var(--cairn-error-tint)] p-2.5 text-[0.75rem] font-semibold text-[var(--cairn-error-ink)]">Failed</div>
|
|
2790
|
+
<ul class="flex max-h-36 list-none flex-col overflow-y-auto">
|
|
2791
|
+
{#each res.failed as fail (fail.key)}
|
|
2792
|
+
<li class="flex items-center gap-2.5 border-t border-[color-mix(in_oklab,var(--cairn-error-border)_70%,transparent)] px-3 py-2 first:border-t-0">
|
|
2793
|
+
<span class="min-w-0 flex-1 truncate font-[family-name:var(--font-editor)] text-[0.75rem]">{fail.key}</span>
|
|
2794
|
+
<span class="flex-none text-[0.6875rem] text-[var(--cairn-error-ink)]">{fail.error}</span>
|
|
2795
|
+
</li>
|
|
2796
|
+
{/each}
|
|
2797
|
+
</ul>
|
|
2798
|
+
</div>
|
|
2799
|
+
{/if}
|
|
2800
|
+
<div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2801
|
+
<button type="button" class="btn btn-sm btn-primary" onclick={() => void finishOrphanPurge()}>Done</button>
|
|
2802
|
+
</div>
|
|
2803
|
+
<div class="sr-only" role="status" aria-live="polite">Done. {res.purged.length} purged, {res.skippedClaimed.length} kept, {res.failed.length} failed.</div>
|
|
2804
|
+
{:else if orphanPhase === 'result' && orphanPurging}
|
|
2805
|
+
<!-- THE IRREVERSIBLE PURGE CONFIRM: the typed-count gate, reserved for THIS path only. The badge
|
|
2806
|
+
and the submit carry the SOLID danger fill (--color-error), the one fill the destructive
|
|
2807
|
+
register owns. The verb is Purge, never Delete, and the callout states that there is no git
|
|
2808
|
+
history for raw bytes. The submit is disabled until the typed value equals the selected
|
|
2809
|
+
count. role="alert" is reserved for a post-action failure below. -->
|
|
2810
|
+
<div class="mb-3 flex items-start gap-3">
|
|
2811
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--color-error)] text-[var(--color-error-content)]" aria-hidden="true">
|
|
2812
|
+
<TriangleAlertIcon class="h-5 w-5" />
|
|
2813
|
+
</span>
|
|
2814
|
+
<div class="flex-1">
|
|
2815
|
+
<h2 bind:this={orphanTitle} tabindex="-1" id="cairn-ml-orphan-title" class="text-lg font-bold tracking-tight outline-hidden font-[family-name:var(--font-display)]">Purge {orphanSelectedCount} orphaned {orphanSelectedCount === 1 ? 'file' : 'files'}?</h2>
|
|
2816
|
+
<p id="cairn-ml-orphan-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">This removes the stored bytes for good. It is not a library delete, and it cannot be undone.</p>
|
|
2817
|
+
</div>
|
|
2818
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={cancelOrphanPurge}>
|
|
2819
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
2820
|
+
</button>
|
|
2821
|
+
</div>
|
|
2822
|
+
<div class="flex flex-col gap-3">
|
|
2823
|
+
<!-- The dry-run: the keys to remove, each with a checkerboard mat (record-not-picture). -->
|
|
2824
|
+
<ul class="flex max-h-40 list-none flex-col gap-1 overflow-y-auto rounded-box border border-[var(--cairn-card-border)] p-2">
|
|
2825
|
+
{#each orphanBytes.filter((b) => orphanKeys.has(b.key)) as byte (byte.key)}
|
|
2826
|
+
<li class="flex items-center gap-2.5 rounded px-1.5 py-1">
|
|
2827
|
+
<span class="h-6 w-8 flex-none rounded border border-[var(--cairn-card-border)] bg-base-200 [background-image:linear-gradient(45deg,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_25%,transparent_25%,transparent_75%,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_75%),linear-gradient(45deg,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_25%,transparent_25%,transparent_75%,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_75%)] [background-position:0_0,4px_4px] [background-size:8px_8px]" aria-hidden="true"></span>
|
|
2828
|
+
<span class="min-w-0 flex-1 truncate font-[family-name:var(--font-editor)] text-[0.75rem]">{byte.key}</span>
|
|
2829
|
+
</li>
|
|
2830
|
+
{/each}
|
|
2831
|
+
</ul>
|
|
2832
|
+
<!-- The IRREVERSIBLE callout, distinct from the bulk delete's git-revert reassurance. -->
|
|
2833
|
+
<div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3 text-[0.8125rem] leading-relaxed text-[var(--cairn-error-ink)]">
|
|
2834
|
+
<TriangleAlertIcon class="mt-0.5 h-4 w-4 flex-none" aria-hidden="true" />
|
|
2835
|
+
<span><b class="font-semibold">This cannot be undone.</b> A library delete lives in git history and a developer can bring it back. There is no git history for raw bytes, so once these are purged they are gone.</span>
|
|
2836
|
+
</div>
|
|
2837
|
+
<!-- The typed-count gate, reserved for the irreversible path. -->
|
|
2838
|
+
<div class="flex flex-col gap-1.5">
|
|
2839
|
+
<label class="text-[0.8125rem]" for="cairn-ml-purge-confirm">Type <code class="rounded bg-[var(--cairn-code-chip)] px-1 py-0.5 font-[family-name:var(--font-editor)] text-[0.75rem]">{orphanSelectedCount}</code> to purge these files for good.</label>
|
|
2840
|
+
<input
|
|
2841
|
+
id="cairn-ml-purge-confirm"
|
|
2842
|
+
class="input input-sm"
|
|
2843
|
+
type="text"
|
|
2844
|
+
autocomplete="off"
|
|
2845
|
+
placeholder="Type the number of files"
|
|
2846
|
+
aria-label="Type the file count to confirm the purge"
|
|
2847
|
+
bind:value={orphanConfirmInput}
|
|
2848
|
+
/>
|
|
2849
|
+
</div>
|
|
2850
|
+
{#if orphanPurgeError}
|
|
2851
|
+
<div role="alert" class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3 text-[0.8125rem] leading-relaxed text-[var(--cairn-error-ink)]">
|
|
2852
|
+
<TriangleAlertIcon class="mt-0.5 h-4 w-4 flex-none" aria-hidden="true" />
|
|
2853
|
+
<span>{orphanPurgeError}</span>
|
|
2854
|
+
</div>
|
|
2855
|
+
{/if}
|
|
2856
|
+
<div class="flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2857
|
+
<button type="button" class="btn btn-sm" onclick={cancelOrphanPurge}>Cancel</button>
|
|
2858
|
+
<button
|
|
2859
|
+
type="button"
|
|
2860
|
+
class="btn btn-sm border-0 bg-[var(--color-error)] text-[var(--color-error-content)] hover:bg-[var(--color-error)]/90"
|
|
2861
|
+
disabled={!orphanConfirmMatches || orphanPurgeBusy}
|
|
2862
|
+
onclick={() => void applyOrphanPurge()}
|
|
2863
|
+
>
|
|
2864
|
+
<Trash2Icon class="h-3.5 w-3.5" aria-hidden="true" /> Purge {orphanSelectedCount} {orphanSelectedCount === 1 ? 'file' : 'files'}
|
|
2865
|
+
</button>
|
|
2866
|
+
</div>
|
|
2867
|
+
</div>
|
|
2868
|
+
<div class="sr-only" aria-live="polite">Purge {orphanSelectedCount} orphaned {orphanSelectedCount === 1 ? 'file' : 'files'}. This cannot be undone.</div>
|
|
2869
|
+
{:else if orphanPhase === 'result' && orphanScan}
|
|
2870
|
+
<!-- THE TWO-SECTION RESULT: an "Orphaned files" purge surface and a read-only "Broken references"
|
|
2871
|
+
data-integrity readout. -->
|
|
2872
|
+
<div class="mb-4 flex items-start gap-3">
|
|
2873
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-base-200 text-[var(--color-muted)]" aria-hidden="true">
|
|
2874
|
+
<DatabaseIcon class="h-5 w-5" />
|
|
2875
|
+
</span>
|
|
2876
|
+
<div class="flex-1">
|
|
2877
|
+
<h2 bind:this={orphanTitle} tabindex="-1" id="cairn-ml-orphan-title" class="text-lg font-bold tracking-tight outline-hidden font-[family-name:var(--font-display)]">Orphaned files and broken references</h2>
|
|
2878
|
+
<p id="cairn-ml-orphan-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
2879
|
+
A scan of stored files against the library across every tracked branch. It found {orphanBytes.length} stored {orphanBytes.length === 1 ? 'file' : 'files'} with no record, and {orphanBroken.length} {orphanBroken.length === 1 ? 'record whose file is' : 'records whose files are'} gone.
|
|
2880
|
+
</p>
|
|
2881
|
+
</div>
|
|
2882
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close" onclick={closeOrphanScan}>
|
|
2883
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
2884
|
+
</button>
|
|
2885
|
+
</div>
|
|
2886
|
+
|
|
2887
|
+
<div class="flex flex-col gap-5">
|
|
2888
|
+
<!-- SECTION 1: orphaned BYTES, the irreversible purge surface. -->
|
|
2889
|
+
<section>
|
|
2890
|
+
<div class="mb-2 flex items-baseline justify-between gap-2">
|
|
2891
|
+
<span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold">Orphaned files <span class="rounded-full bg-base-content/[0.07] px-1.5 py-0.5 text-[0.6875rem] tabular-nums">{orphanBytes.length}</span></span>
|
|
2892
|
+
</div>
|
|
2893
|
+
<p class="mb-2 text-[0.75rem] leading-relaxed text-[var(--color-muted)]">Stored files with no record in the library. No <code class="rounded bg-[var(--cairn-code-chip)] px-1 py-0.5 font-[family-name:var(--font-editor)] text-[0.6875rem]">media:</code> reference can point to these, so nothing on the site uses them through cairn.</p>
|
|
2894
|
+
{#if orphanBytes.length === 0}
|
|
2895
|
+
<!-- The calm empty state: a clean scan, no purge control. -->
|
|
2896
|
+
<div class="flex items-center gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] text-[var(--color-muted)]">
|
|
2897
|
+
<CheckIcon class="h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" /> No orphaned files found. Every stored file has a record.
|
|
2898
|
+
</div>
|
|
2899
|
+
{:else}
|
|
2900
|
+
<!-- The residual-risk note, named at the point of action. -->
|
|
2901
|
+
<div class="mb-2 flex items-start gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3 text-[0.8125rem] leading-relaxed text-[var(--cairn-error-ink)]">
|
|
2902
|
+
<TriangleAlertIcon class="mt-0.5 h-4 w-4 flex-none" aria-hidden="true" />
|
|
2903
|
+
<span><b class="font-semibold">Purging a file removes the bytes for good.</b> There is no git history for raw storage, so this cannot be undone. The one thing cairn cannot check: a page that hardcodes a file's web address in raw HTML would still load these.</span>
|
|
2904
|
+
</div>
|
|
2905
|
+
<div class="overflow-hidden rounded-box border border-[var(--cairn-card-border)]">
|
|
2906
|
+
<div class="flex items-center gap-2.5 border-b border-[var(--cairn-card-border)] bg-base-200/60 px-3 py-2">
|
|
2907
|
+
<input
|
|
2908
|
+
bind:this={orphanSelectAll}
|
|
2909
|
+
type="checkbox"
|
|
2910
|
+
class="checkbox checkbox-sm border-[var(--cairn-error-border)]"
|
|
2911
|
+
aria-label="Select all orphaned files"
|
|
2912
|
+
onchange={toggleOrphanAll}
|
|
2913
|
+
/>
|
|
2914
|
+
<span class="text-[0.75rem] font-semibold text-[var(--color-muted)]">{orphanBytes.length} {orphanBytes.length === 1 ? 'file' : 'files'} in storage with no record</span>
|
|
2915
|
+
</div>
|
|
2916
|
+
<!-- A plain list of labelled native checkboxes, NOT a listbox. The rows carry no roving
|
|
2917
|
+
tabindex or key handler, so the listbox role would have been decorative and would
|
|
2918
|
+
have fought the Tab-to-checkbox model. Each checkbox is the selection signal; the
|
|
2919
|
+
header select-all conveys group state. -->
|
|
2920
|
+
<ul aria-label="Orphaned files" class="flex max-h-52 list-none flex-col overflow-y-auto p-0">
|
|
2921
|
+
{#each orphanBytes as byte (byte.key)}
|
|
2922
|
+
{@const picked = orphanKeys.has(byte.key)}
|
|
2923
|
+
<li class="flex items-center gap-2.5 border-t border-[color-mix(in_oklab,var(--cairn-card-border)_70%,transparent)] px-3 py-2 first:border-t-0">
|
|
2924
|
+
<input
|
|
2925
|
+
type="checkbox"
|
|
2926
|
+
class="checkbox checkbox-sm border-[var(--cairn-error-border)]"
|
|
2927
|
+
checked={picked}
|
|
2928
|
+
aria-label={`Select ${byte.key}`}
|
|
2929
|
+
onchange={() => toggleOrphanKey(byte.key)}
|
|
2930
|
+
/>
|
|
2931
|
+
<span class="h-6 w-8 flex-none rounded border border-[var(--cairn-card-border)] bg-base-200 [background-image:linear-gradient(45deg,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_25%,transparent_25%,transparent_75%,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_75%),linear-gradient(45deg,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_25%,transparent_25%,transparent_75%,color-mix(in_oklab,var(--color-base-content)_7%,transparent)_75%)] [background-position:0_0,4px_4px] [background-size:8px_8px]" aria-hidden="true"></span>
|
|
2932
|
+
<div class="min-w-0 flex-1">
|
|
2933
|
+
<div class="truncate font-[family-name:var(--font-editor)] text-[0.75rem]">{byte.key}</div>
|
|
2934
|
+
<div class="text-[0.6875rem] text-[var(--color-muted)]">No library record</div>
|
|
2935
|
+
</div>
|
|
2936
|
+
</li>
|
|
2937
|
+
{/each}
|
|
2938
|
+
</ul>
|
|
2939
|
+
</div>
|
|
2940
|
+
<!-- The per-section action: a selection note plus the SOLID-danger Purge (never a warning fill). -->
|
|
2941
|
+
<div class="mt-3 flex items-center gap-2.5">
|
|
2942
|
+
<span class="inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
2943
|
+
{orphanSelectedCount} of {orphanBytes.length} selected
|
|
2944
|
+
{#if orphanSelectedCount > 0}<button type="button" class="link text-[var(--color-muted)]" onclick={clearOrphanSelection}>Clear</button>{/if}
|
|
2945
|
+
</span>
|
|
2946
|
+
<span class="flex-1"></span>
|
|
2947
|
+
<button
|
|
2948
|
+
type="button"
|
|
2949
|
+
class="btn btn-sm border-0 bg-[var(--color-error)] text-[var(--color-error-content)] hover:bg-[var(--color-error)]/90"
|
|
2950
|
+
aria-haspopup="dialog"
|
|
2951
|
+
disabled={orphanSelectedCount === 0}
|
|
2952
|
+
onclick={openOrphanPurge}
|
|
2953
|
+
>
|
|
2954
|
+
<Trash2Icon class="h-3.5 w-3.5" aria-hidden="true" /> Purge {orphanSelectedCount} {orphanSelectedCount === 1 ? 'file' : 'files'}
|
|
2955
|
+
</button>
|
|
2956
|
+
</div>
|
|
2957
|
+
{/if}
|
|
2958
|
+
</section>
|
|
2959
|
+
|
|
2960
|
+
<!-- SECTION 2: BROKEN references, a READ-ONLY data-integrity readout. No checkbox, no action. -->
|
|
2961
|
+
{#if orphanBroken.length > 0}
|
|
2962
|
+
<section data-testid="cairn-broken-refs">
|
|
2963
|
+
<div class="mb-2 flex items-baseline justify-between gap-2">
|
|
2964
|
+
<span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold">Broken references <span class="rounded-full bg-base-content/[0.07] px-1.5 py-0.5 text-[0.6875rem] tabular-nums">{orphanBroken.length}</span></span>
|
|
2965
|
+
</div>
|
|
2966
|
+
<p class="mb-2 text-[0.75rem] leading-relaxed text-[var(--color-muted)]">A record points at a file that is no longer in storage. This is not something to delete here. Re-upload or remove the reference from the entries below.</p>
|
|
2967
|
+
<ul class="flex list-none flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] p-0">
|
|
2968
|
+
{#each orphanBroken as ref (ref.hash)}
|
|
2969
|
+
<li class="flex items-center gap-2.5 border-t border-[color-mix(in_oklab,var(--cairn-card-border)_70%,transparent)] px-3 py-2 first:border-t-0">
|
|
2970
|
+
<span class="flex h-7 w-9 flex-none items-center justify-center rounded border border-[var(--cairn-card-border)] bg-base-200 text-[var(--color-muted)]" aria-hidden="true">
|
|
2971
|
+
<ImageOffIcon class="h-3.5 w-3.5" />
|
|
2972
|
+
</span>
|
|
2973
|
+
<div class="min-w-0 flex-1">
|
|
2974
|
+
<div class="truncate text-[0.8125rem] font-semibold">{ref.slug || ref.hash}</div>
|
|
2975
|
+
<div class="truncate font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--color-muted)]">file missing in storage</div>
|
|
2976
|
+
</div>
|
|
2977
|
+
<span class="flex-none text-[0.6875rem] font-semibold text-[var(--color-muted)]">{brokenWhereUsed(ref.usage.length)}</span>
|
|
2978
|
+
</li>
|
|
2979
|
+
{/each}
|
|
2980
|
+
</ul>
|
|
2981
|
+
</section>
|
|
2982
|
+
{/if}
|
|
2983
|
+
</div>
|
|
2984
|
+
|
|
2985
|
+
<div class="mt-5 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
2986
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
2987
|
+
<GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> Scanned across the site and every open edit
|
|
2988
|
+
</span>
|
|
2989
|
+
<button type="button" class="btn btn-sm" onclick={closeOrphanScan}>Close</button>
|
|
2990
|
+
</div>
|
|
2991
|
+
{/if}
|
|
2992
|
+
</div>
|
|
2993
|
+
</dialog>
|