@glw907/cairn-cms 0.57.0 → 0.58.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 +67 -0
- package/dist/components/CairnMediaLibrary.svelte +997 -7
- package/dist/components/EditPage.svelte +1 -0
- package/dist/components/MediaHeroField.svelte +17 -8
- package/dist/components/MediaHeroField.svelte.d.ts +13 -5
- package/dist/components/admin-icons.d.ts +4 -0
- package/dist/components/admin-icons.js +4 -0
- package/dist/components/cairn-admin.css +255 -3
- package/dist/content/frontmatter.js +5 -0
- package/dist/content/media-rewrite.d.ts +65 -0
- package/dist/content/media-rewrite.js +442 -0
- package/dist/content/types.d.ts +2 -0
- package/dist/content/validate.js +4 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/rewrite-plan.d.ts +65 -0
- package/dist/media/rewrite-plan.js +61 -0
- package/dist/render/registry.js +1 -1
- package/dist/sveltekit/cairn-admin.d.ts +5 -0
- package/dist/sveltekit/cairn-admin.js +9 -0
- package/dist/sveltekit/content-routes.d.ts +92 -2
- package/dist/sveltekit/content-routes.js +338 -4
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/CairnMediaLibrary.svelte +997 -7
- package/src/lib/components/EditPage.svelte +1 -0
- package/src/lib/components/MediaHeroField.svelte +17 -8
- package/src/lib/components/admin-icons.ts +4 -0
- package/src/lib/content/frontmatter.ts +4 -0
- package/src/lib/content/media-rewrite.ts +555 -0
- package/src/lib/content/types.ts +2 -0
- package/src/lib/content/validate.ts +3 -0
- package/src/lib/log/events.ts +4 -1
- package/src/lib/media/rewrite-plan.ts +122 -0
- package/src/lib/render/registry.ts +1 -1
- package/src/lib/sveltekit/cairn-admin.ts +9 -0
- package/src/lib/sveltekit/content-routes.ts +451 -6
- package/src/lib/sveltekit/index.ts +2 -0
|
@@ -24,12 +24,33 @@ It is node-safe by construction: it types assets with MediaLibraryEntry from the
|
|
|
24
24
|
projection and pulls in no editor module (the editor-boundary test bars a @codemirror leak).
|
|
25
25
|
-->
|
|
26
26
|
<script lang="ts">
|
|
27
|
-
import { flushSync, tick } from 'svelte';
|
|
27
|
+
import { flushSync, getContext, tick } from 'svelte';
|
|
28
|
+
import { deserialize } from '$app/forms';
|
|
28
29
|
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
29
|
-
import type {
|
|
30
|
+
import type {
|
|
31
|
+
MediaLibraryData,
|
|
32
|
+
ContentFormFailure,
|
|
33
|
+
MediaReplacePreviewPlan,
|
|
34
|
+
MediaReplaceFailure,
|
|
35
|
+
MediaReplacePreviewEntry,
|
|
36
|
+
MediaAltPreviewPlan,
|
|
37
|
+
MediaAltPropagateFailure,
|
|
38
|
+
} from '../sveltekit/content-routes.js';
|
|
39
|
+
import type { AltPlacement } from '../content/media-rewrite.js';
|
|
30
40
|
import type { UsageEntry } from '../media/usage.js';
|
|
41
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
31
42
|
import { publicPath } from '../media/naming.js';
|
|
32
43
|
import { mediaToken } from '../media/reference.js';
|
|
44
|
+
import { CSRF_CONTEXT_KEY } from './csrf-context.js';
|
|
45
|
+
import {
|
|
46
|
+
ingestFile,
|
|
47
|
+
buildUploadRequest,
|
|
48
|
+
sendUpload,
|
|
49
|
+
ingestFailureKind,
|
|
50
|
+
failureCard,
|
|
51
|
+
type IngestFailureCard,
|
|
52
|
+
} from './client-ingest.js';
|
|
53
|
+
import { uploadOutcome, type UploadEnvelope } from './media-upload-outcome.js';
|
|
33
54
|
import CsrfField from './CsrfField.svelte';
|
|
34
55
|
import CairnLogo from './CairnLogo.svelte';
|
|
35
56
|
import {
|
|
@@ -48,6 +69,10 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
48
69
|
FileTextIcon,
|
|
49
70
|
ClockIcon,
|
|
50
71
|
Link2OffIcon,
|
|
72
|
+
RefreshCwIcon,
|
|
73
|
+
GitBranchIcon,
|
|
74
|
+
ArrowRightIcon,
|
|
75
|
+
MegaphoneIcon,
|
|
51
76
|
} from './admin-icons.js';
|
|
52
77
|
|
|
53
78
|
interface Props {
|
|
@@ -62,6 +87,16 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
62
87
|
|
|
63
88
|
let { data, form }: Props = $props();
|
|
64
89
|
|
|
90
|
+
// The success flash a redirected action carried back: a safe-delete or a metadata edit. The
|
|
91
|
+
// conflict error (data.flashError) renders in the inline error treatment below instead.
|
|
92
|
+
const FLASH_MESSAGE = {
|
|
93
|
+
deleted: 'Asset deleted.',
|
|
94
|
+
updated: 'Changes saved.',
|
|
95
|
+
replaced: 'Asset replaced.',
|
|
96
|
+
altPropagated: 'Alt text applied.',
|
|
97
|
+
} as const;
|
|
98
|
+
const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
|
|
99
|
+
|
|
65
100
|
// --- the per-hash usage facts the screen joins onto each asset ---
|
|
66
101
|
/** The distinct-entry usage count for an asset; zero when the asset has no usage key. */
|
|
67
102
|
function usageCount(hash: string): number {
|
|
@@ -189,6 +224,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
189
224
|
// The element that opened the slide-over (a tile or a row trigger), so focus returns to it on
|
|
190
225
|
// close (the non-modal region recipe: focus moves in on open, back to the origin on close).
|
|
191
226
|
let panelOrigin: HTMLElement | null = null;
|
|
227
|
+
let panelEl = $state<HTMLElement | null>(null);
|
|
192
228
|
let closeButton = $state<HTMLButtonElement | null>(null);
|
|
193
229
|
let deleteDialog = $state<HTMLDialogElement | null>(null);
|
|
194
230
|
|
|
@@ -210,8 +246,18 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
210
246
|
// Escape closes the slide-over (the non-modal region recipe). A window listener carries it, the
|
|
211
247
|
// way EditPage's details panel does, so the non-interactive region needs no keyboard handler. The
|
|
212
248
|
// dialog (when open) claims Escape natively, so the panel handles it only when no dialog is up.
|
|
249
|
+
// Escape is also the native clear gesture for the toolbar's type="search" input, so the close
|
|
250
|
+
// fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
|
|
251
|
+
// panel exactly as the user left it, while an Escape with focus in the panel still closes it.
|
|
213
252
|
function onWindowKeydown(e: KeyboardEvent) {
|
|
214
|
-
if (
|
|
253
|
+
if (
|
|
254
|
+
e.key === 'Escape' &&
|
|
255
|
+
selected &&
|
|
256
|
+
!deleteDialog?.open &&
|
|
257
|
+
!replaceDialog?.open &&
|
|
258
|
+
!altDialog?.open &&
|
|
259
|
+
panelEl?.contains(document.activeElement)
|
|
260
|
+
) {
|
|
215
261
|
e.preventDefault();
|
|
216
262
|
closePanel();
|
|
217
263
|
}
|
|
@@ -239,6 +285,387 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
239
285
|
}
|
|
240
286
|
}
|
|
241
287
|
|
|
288
|
+
// --- the Replace flow: a two-step alertdialog (upload, then impact review) over the selected asset ---
|
|
289
|
+
// Replace uploads a new file for the selected asset; cairn is content-addressed, so the new file has a
|
|
290
|
+
// new hash and every published reference is repointed to it in one commit to main. The dialog opens on
|
|
291
|
+
// the quiet upload step, holds the server-owned record on a successful upload, fetches the preview
|
|
292
|
+
// (fail-closed), and renders the impact review behind a typed-slug gate. The CSRF token getter comes
|
|
293
|
+
// from the admin context, the same seam the insert popover reads.
|
|
294
|
+
const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
|
|
295
|
+
|
|
296
|
+
type ReplaceStep = 'upload' | 'review' | 'blocked';
|
|
297
|
+
// The transient upload status under the upload step: idle, an in-flight ingest/upload, or a typed
|
|
298
|
+
// ingest failure card with a retry. Mirrors the insert popover's failed-card grammar.
|
|
299
|
+
type ReplaceUpload =
|
|
300
|
+
| { kind: 'idle' }
|
|
301
|
+
| { kind: 'working' }
|
|
302
|
+
| { kind: 'failed'; card: IngestFailureCard | { status: 'failed'; message: string }; retry: () => void };
|
|
303
|
+
|
|
304
|
+
let replaceDialog = $state<HTMLDialogElement | null>(null);
|
|
305
|
+
// The entry-point button that opened the dialog, so focus restores to it on close (the alertdialog
|
|
306
|
+
// recipe, like the delete dialog's slide-over Delete button).
|
|
307
|
+
let replaceOrigin: HTMLElement | null = null;
|
|
308
|
+
// The Cancel control, the destructive-confirm initial focus.
|
|
309
|
+
let replaceCancelButton = $state<HTMLButtonElement | null>(null);
|
|
310
|
+
let replaceFileInput = $state<HTMLInputElement | null>(null);
|
|
311
|
+
let replaceStep = $state<ReplaceStep>('upload');
|
|
312
|
+
let replaceUpload = $state<ReplaceUpload>({ kind: 'idle' });
|
|
313
|
+
// The server-owned record the upload returned (the new asset), held for the preview and the apply.
|
|
314
|
+
let replaceRecord = $state<MediaEntry | null>(null);
|
|
315
|
+
// The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
|
|
316
|
+
let replacePlan = $state<MediaReplacePreviewPlan | null>(null);
|
|
317
|
+
let replaceFailure = $state<MediaReplaceFailure | null>(null);
|
|
318
|
+
// The typed-slug confirm gate, echoing the delete dialog's type-to-confirm.
|
|
319
|
+
let replaceConfirmInput = $state('');
|
|
320
|
+
// The asset the Replace dialog acts on, pinned at open so a background re-render never swaps it.
|
|
321
|
+
let replaceAsset = $state<MediaLibraryEntry | null>(null);
|
|
322
|
+
const replaceConfirmMatches = $derived(replaceAsset !== null && replaceConfirmInput === replaceAsset.slug);
|
|
323
|
+
|
|
324
|
+
function openReplaceDialog(origin?: HTMLElement | null) {
|
|
325
|
+
if (!selected) return;
|
|
326
|
+
// The entry-point button passed from the click (focus restores here on close), falling back to the
|
|
327
|
+
// active element. A programmatic .click() does not focus its target, so the explicit origin is the
|
|
328
|
+
// reliable restore point.
|
|
329
|
+
replaceOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
|
|
330
|
+
replaceAsset = selected;
|
|
331
|
+
replaceStep = 'upload';
|
|
332
|
+
replaceUpload = { kind: 'idle' };
|
|
333
|
+
replaceRecord = null;
|
|
334
|
+
replacePlan = null;
|
|
335
|
+
replaceFailure = null;
|
|
336
|
+
replaceConfirmInput = '';
|
|
337
|
+
// Show the dialog after the step state flushes, then move focus to Cancel.
|
|
338
|
+
void tick().then(() => {
|
|
339
|
+
replaceDialog?.showModal();
|
|
340
|
+
replaceCancelButton?.focus();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function closeReplaceDialog() {
|
|
344
|
+
replaceDialog?.close();
|
|
345
|
+
replaceAsset = null;
|
|
346
|
+
replaceRecord = null;
|
|
347
|
+
replacePlan = null;
|
|
348
|
+
replaceFailure = null;
|
|
349
|
+
replaceConfirmInput = '';
|
|
350
|
+
replaceUpload = { kind: 'idle' };
|
|
351
|
+
// Restore focus to the entry-point button (the alertdialog focus-restore recipe).
|
|
352
|
+
replaceOrigin?.focus();
|
|
353
|
+
replaceOrigin = null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The chosen-file handler: route the file through the ingest-and-upload loop, exactly as the insert
|
|
357
|
+
// popover does, then fetch the preview. A file is the only path (Pass B is upload-new-only).
|
|
358
|
+
function onReplaceFileChosen(e: Event) {
|
|
359
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
360
|
+
const file = input.files?.[0];
|
|
361
|
+
if (file) void runReplaceUpload(file);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The upload loop for the new file. It ingests (decode/transcode), uploads through the shared
|
|
365
|
+
// transport, and on the success envelope holds the new record and runs the preview. A typed ingest or
|
|
366
|
+
// upload failure surfaces a retry card on the upload step; an expired session reads as a generic card.
|
|
367
|
+
// The upload posts to the media-scoped ?/mediaUpload action: the Library is not entry-scoped, so it
|
|
368
|
+
// overrides buildUploadRequest's entry URL while reusing its header-and-body transport verbatim.
|
|
369
|
+
async function runReplaceUpload(file: File) {
|
|
370
|
+
if (!replaceAsset) return;
|
|
371
|
+
replaceUpload = { kind: 'working' };
|
|
372
|
+
const genericFail = () =>
|
|
373
|
+
(replaceUpload = {
|
|
374
|
+
kind: 'failed',
|
|
375
|
+
card: { status: 'failed', message: GENERIC_UPLOAD_MESSAGE },
|
|
376
|
+
retry: () => void runReplaceUpload(file),
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
let ingested: Awaited<ReturnType<typeof ingestFile>>;
|
|
380
|
+
try {
|
|
381
|
+
ingested = await ingestFile(file);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const built = buildUploadRequest({
|
|
388
|
+
conceptId: '',
|
|
389
|
+
id: '',
|
|
390
|
+
bytes: ingested.blob,
|
|
391
|
+
contentType: ingested.contentType,
|
|
392
|
+
csrf: csrf?.() ?? '',
|
|
393
|
+
filename: file.name,
|
|
394
|
+
width: ingested.width,
|
|
395
|
+
height: ingested.height,
|
|
396
|
+
});
|
|
397
|
+
let res: Response;
|
|
398
|
+
try {
|
|
399
|
+
res = await sendUpload(REPLACE_UPLOAD_URL, built.init);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// The guard's expired-session 303 under redirect:'manual' surfaces as an opaque, status-0 response.
|
|
405
|
+
if (res.type === 'opaqueredirect' || res.status === 0) {
|
|
406
|
+
genericFail();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
let outcome: ReturnType<typeof uploadOutcome>;
|
|
410
|
+
try {
|
|
411
|
+
outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
|
|
412
|
+
} catch {
|
|
413
|
+
genericFail();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (outcome.kind !== 'inserted') {
|
|
417
|
+
genericFail();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Hold the server-owned record, then fetch the impact preview for (oldHash -> newHash).
|
|
421
|
+
replaceRecord = outcome.record;
|
|
422
|
+
replaceUpload = { kind: 'idle' };
|
|
423
|
+
await runReplacePreview();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// A per-call request token guards the preview fetch against a stale response landing on a closed or
|
|
427
|
+
// reopened dialog. Svelte reactivity does not track reads below the first `await`, so each call pins
|
|
428
|
+
// its own sequence at entry and bails after the await if a newer call (a reopen, or a "Check usage
|
|
429
|
+
// again" double-click) has since superseded it.
|
|
430
|
+
let replacePreviewSeq = 0;
|
|
431
|
+
|
|
432
|
+
// The preview fetch: POST the (oldHash, newHash, slug) tuple in the 2a transport (a text/plain
|
|
433
|
+
// body, the CSRF token in the X-Cairn-CSRF header), parse the SvelteKit ActionResult envelope, and
|
|
434
|
+
// route to the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the
|
|
435
|
+
// blocked step's "Check usage again". The slug is the OLD asset's: a replace keeps the name and
|
|
436
|
+
// changes only the content hash, so the repointed token carries the existing slug, not the new file's.
|
|
437
|
+
async function runReplacePreview() {
|
|
438
|
+
if (!replaceAsset || !replaceRecord) return;
|
|
439
|
+
const hash = replaceAsset.hash;
|
|
440
|
+
const seq = ++replacePreviewSeq;
|
|
441
|
+
// The fail-closed landing: an unverifiable usage read, an unreachable preview, or an unparseable
|
|
442
|
+
// body all route to the blocked step. The passed failure carries the branch-naming error when the
|
|
443
|
+
// server returned one; a transport miss carries the empty error (the generic honest line stands in).
|
|
444
|
+
const blockClosed = (failure?: MediaReplaceFailure) => {
|
|
445
|
+
replaceFailure = failure ?? { error: '', hash, usage: [], foundIn: 0 };
|
|
446
|
+
replacePlan = null;
|
|
447
|
+
replaceStep = 'blocked';
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const body = JSON.stringify({ oldHash: hash, newHash: replaceRecord.hash, slug: replaceAsset.slug });
|
|
451
|
+
let result: { type: string; data?: unknown };
|
|
452
|
+
try {
|
|
453
|
+
const res = await fetch(REPLACE_PREVIEW_URL, {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
456
|
+
body,
|
|
457
|
+
});
|
|
458
|
+
result = deserialize(await res.text()) as { type: string; data?: unknown };
|
|
459
|
+
} catch {
|
|
460
|
+
// Drop a stale response that lost the race to a reopen or a re-run before surfacing the block.
|
|
461
|
+
if (seq !== replacePreviewSeq) return;
|
|
462
|
+
blockClosed();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// The dialog was closed or reopened (for another asset, or via a re-run) while this fetch was in
|
|
466
|
+
// flight, so this response is stale: ignore it rather than clobber the live state.
|
|
467
|
+
if (seq !== replacePreviewSeq) return;
|
|
468
|
+
if (result.type === 'success' && result.data) {
|
|
469
|
+
replacePlan = result.data as MediaReplacePreviewPlan;
|
|
470
|
+
replaceFailure = null;
|
|
471
|
+
replaceConfirmInput = '';
|
|
472
|
+
replaceStep = 'review';
|
|
473
|
+
} else {
|
|
474
|
+
blockClosed(result.data as MediaReplaceFailure | undefined);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const GENERIC_UPLOAD_MESSAGE = 'The upload could not be completed. Please try again.';
|
|
479
|
+
// The media-scoped upload and preview action URLs, relative to /admin/media. The upload reuses the
|
|
480
|
+
// shared ingest transport but the Library has no entry, so it targets ?/mediaUpload rather than the
|
|
481
|
+
// entry-scoped ?/upload. The apply form below posts ?/mediaReplace.
|
|
482
|
+
const REPLACE_UPLOAD_URL = '?/mediaUpload';
|
|
483
|
+
const REPLACE_PREVIEW_URL = '?/mediaReplacePreview';
|
|
484
|
+
|
|
485
|
+
// The affected-entry well caps past this many rows; "Show all N" reveals the rest into the same
|
|
486
|
+
// scroll container (the a11y contract: aria-expanded + aria-controls).
|
|
487
|
+
const REPLACE_ROW_CAP = 8;
|
|
488
|
+
let replaceShowAll = $state(false);
|
|
489
|
+
// The affected-entry list element, so "Show all" can move focus to the first newly revealed row (the
|
|
490
|
+
// one just past the cap) instead of dropping to <body> when the expander button unmounts.
|
|
491
|
+
let replaceEntriesList = $state<HTMLElement | null>(null);
|
|
492
|
+
$effect(() => {
|
|
493
|
+
// Reset the reveal whenever a fresh plan arrives, so a second preview never opens pre-expanded.
|
|
494
|
+
void replacePlan;
|
|
495
|
+
replaceShowAll = false;
|
|
496
|
+
});
|
|
497
|
+
// Reveal the capped rows, then move focus to the first newly revealed row (the rev.2 contract). The
|
|
498
|
+
// expander unmounts on the flag flip, so without this focus falls to <body>.
|
|
499
|
+
function showAllReplaceEntries() {
|
|
500
|
+
replaceShowAll = true;
|
|
501
|
+
void tick().then(() => (replaceEntriesList?.children[REPLACE_ROW_CAP] as HTMLElement | undefined)?.focus());
|
|
502
|
+
}
|
|
503
|
+
const replaceEntries = $derived(replacePlan?.entries ?? []);
|
|
504
|
+
const replaceVisibleEntries = $derived(
|
|
505
|
+
replaceShowAll ? replaceEntries : replaceEntries.slice(0, REPLACE_ROW_CAP),
|
|
506
|
+
);
|
|
507
|
+
const replaceHiddenCount = $derived(Math.max(0, replaceEntries.length - REPLACE_ROW_CAP));
|
|
508
|
+
// The server's distinct affected-entry count, read in several places across the review markup and
|
|
509
|
+
// the apply button. Coalesced once here so each read stays a plain number.
|
|
510
|
+
const replaceAffected = $derived(replacePlan?.affectedCount ?? 0);
|
|
511
|
+
|
|
512
|
+
// The where-used summary line for one affected entry, derived from its repointed placements: a hero
|
|
513
|
+
// count and a body count, folded into a plain phrase ("Hero and 2 in the body", "1 in the body").
|
|
514
|
+
function replaceWhereUsed(entry: MediaReplacePreviewEntry): string {
|
|
515
|
+
let hero = 0;
|
|
516
|
+
let body = 0;
|
|
517
|
+
for (const p of entry.placements) {
|
|
518
|
+
if (p.kind === 'hero') hero += 1;
|
|
519
|
+
else body += 1;
|
|
520
|
+
}
|
|
521
|
+
const parts: string[] = [];
|
|
522
|
+
if (hero > 0) parts.push(hero === 1 ? 'Hero' : `${hero} heroes`);
|
|
523
|
+
if (body > 0) parts.push(`${body} in the body`);
|
|
524
|
+
return parts.length > 0 ? parts.join(' and ') : 'Used in this entry';
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// The specific unreadable branch named by a fail-closed failure, or null for the generic honest line.
|
|
528
|
+
// The current MediaReplaceFailure carries only an error string, so a cairn/* branch name is pulled
|
|
529
|
+
// from the message when the strict read named one; otherwise the generic variant stands in.
|
|
530
|
+
const replaceBlockedBranch = $derived.by(() => {
|
|
531
|
+
const match = replaceFailure?.error.match(/cairn\/[^\s.]+/);
|
|
532
|
+
return match ? match[0] : null;
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// --- the Push-alt flow: a one-step review dialog (the everyday register) over the selected asset ---
|
|
536
|
+
// Alt propagation pushes the asset's default alt into published placements that lack it, with one
|
|
537
|
+
// bucket-level opt-in to also overwrite placements that carry a custom alt. It is reversible and
|
|
538
|
+
// frequent, so the dialog is role="dialog" (not alertdialog) with no typed-slug gate; apply is always
|
|
539
|
+
// enabled. The preview fetch reuses the 2a transport (a text/plain body, the CSRF token in the
|
|
540
|
+
// X-Cairn-CSRF header) and fails closed to a blocked surface when usage cannot be verified.
|
|
541
|
+
type AltStep = 'review' | 'blocked';
|
|
542
|
+
const ALT_PREVIEW_URL = '?/mediaAltPreview';
|
|
543
|
+
|
|
544
|
+
let altDialog = $state<HTMLDialogElement | null>(null);
|
|
545
|
+
// The entry-point button that opened the dialog, so focus restores to it on close.
|
|
546
|
+
let altOrigin: HTMLElement | null = null;
|
|
547
|
+
// The Cancel control, the initial focus on open.
|
|
548
|
+
let altCancelButton = $state<HTMLButtonElement | null>(null);
|
|
549
|
+
let altStep = $state<AltStep>('review');
|
|
550
|
+
// The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
|
|
551
|
+
let altPlan = $state<MediaAltPreviewPlan | null>(null);
|
|
552
|
+
let altFailure = $state<MediaAltPropagateFailure | null>(null);
|
|
553
|
+
// The bucket-level opt-in to also overwrite customized alts. Bound to the one native checkbox.
|
|
554
|
+
let altOverwrite = $state(false);
|
|
555
|
+
// The asset the dialog acts on, pinned at open so a background re-render never swaps it. The alt it
|
|
556
|
+
// pushes is this asset's default alt.
|
|
557
|
+
let altAsset = $state<MediaLibraryEntry | null>(null);
|
|
558
|
+
|
|
559
|
+
function openAltDialog(origin?: HTMLElement | null) {
|
|
560
|
+
if (!selected) return;
|
|
561
|
+
altOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
|
|
562
|
+
altAsset = selected;
|
|
563
|
+
altStep = 'review';
|
|
564
|
+
altPlan = null;
|
|
565
|
+
altFailure = null;
|
|
566
|
+
altOverwrite = false;
|
|
567
|
+
void tick().then(() => {
|
|
568
|
+
altDialog?.showModal();
|
|
569
|
+
altCancelButton?.focus();
|
|
570
|
+
});
|
|
571
|
+
void runAltPreview();
|
|
572
|
+
}
|
|
573
|
+
function closeAltDialog() {
|
|
574
|
+
altDialog?.close();
|
|
575
|
+
altAsset = null;
|
|
576
|
+
altPlan = null;
|
|
577
|
+
altFailure = null;
|
|
578
|
+
altOverwrite = false;
|
|
579
|
+
altOrigin?.focus();
|
|
580
|
+
altOrigin = null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// The per-call request token for the alt preview, mirroring the Replace guard: a stale response from
|
|
584
|
+
// a closed or reopened dialog (or a "Check usage again" double-click) is dropped after the await.
|
|
585
|
+
let altPreviewSeq = 0;
|
|
586
|
+
|
|
587
|
+
// The preview fetch: POST the hash in the 2a transport, parse the ActionResult envelope, and route to
|
|
588
|
+
// the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the blocked
|
|
589
|
+
// step's "Check usage again".
|
|
590
|
+
async function runAltPreview() {
|
|
591
|
+
if (!altAsset) return;
|
|
592
|
+
const hash = altAsset.hash;
|
|
593
|
+
const seq = ++altPreviewSeq;
|
|
594
|
+
const blockClosed = (failure?: MediaAltPropagateFailure) => {
|
|
595
|
+
altFailure = failure ?? { error: '' };
|
|
596
|
+
altPlan = null;
|
|
597
|
+
altStep = 'blocked';
|
|
598
|
+
};
|
|
599
|
+
let result: { type: string; data?: unknown };
|
|
600
|
+
try {
|
|
601
|
+
const res = await fetch(ALT_PREVIEW_URL, {
|
|
602
|
+
method: 'POST',
|
|
603
|
+
headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
604
|
+
body: JSON.stringify({ hash }),
|
|
605
|
+
});
|
|
606
|
+
result = deserialize(await res.text()) as { type: string; data?: unknown };
|
|
607
|
+
} catch {
|
|
608
|
+
if (seq !== altPreviewSeq) return;
|
|
609
|
+
blockClosed();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
// Stale-response guard: a reopen or a re-run superseded this fetch while it was in flight.
|
|
613
|
+
if (seq !== altPreviewSeq) return;
|
|
614
|
+
if (result.type === 'success' && result.data) {
|
|
615
|
+
altPlan = result.data as MediaAltPreviewPlan;
|
|
616
|
+
altFailure = null;
|
|
617
|
+
altStep = 'review';
|
|
618
|
+
} else {
|
|
619
|
+
blockClosed(result.data as MediaAltPropagateFailure | undefined);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// The default alt the dialog propagates: the selected asset's stored alt. Empty is guarded by the
|
|
624
|
+
// entry point (an asset with no default alt cannot push one), but the dialog reads it defensively.
|
|
625
|
+
const altPushed = $derived(altAsset?.alt.trim() ?? '');
|
|
626
|
+
|
|
627
|
+
// The three buckets, flattened from the plan's entries: each row carries its entry title, the
|
|
628
|
+
// placement kind (the pill), and the placement's before/after. Grouping by bucket keeps each well
|
|
629
|
+
// self-contained, the way the mockup lays them out.
|
|
630
|
+
type AltRow = { title: string; kind: AltPlacement['kind']; before: string; after: string; key: string };
|
|
631
|
+
function altRows(bucket: AltPlacement['bucket']): AltRow[] {
|
|
632
|
+
const rows: AltRow[] = [];
|
|
633
|
+
for (const entry of altPlan?.entries ?? []) {
|
|
634
|
+
entry.placements.forEach((p, i) => {
|
|
635
|
+
if (p.bucket !== bucket) return;
|
|
636
|
+
rows.push({ title: entry.title, kind: p.kind, before: p.before, after: p.after, key: `${entry.concept}/${entry.id}/${i}` });
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return rows;
|
|
640
|
+
}
|
|
641
|
+
const altFillRows = $derived(altRows('will-fill'));
|
|
642
|
+
const altCustomRows = $derived(altRows('customized'));
|
|
643
|
+
const altSkipRows = $derived(altRows('decorative-skipped'));
|
|
644
|
+
|
|
645
|
+
// The committed total: the will-fill placements always, plus the customized placements only on the
|
|
646
|
+
// opt-in. The footer button and the live region read this; the count moves when the opt-in toggles.
|
|
647
|
+
const altCounts = $derived(altPlan?.counts ?? { willFill: 0, customized: 0, decorativeSkipped: 0 });
|
|
648
|
+
const altTotal = $derived(altCounts.willFill + (altOverwrite ? altCounts.customized : 0));
|
|
649
|
+
|
|
650
|
+
// The will-fill bucket caps past this many rows; "Show all N" reveals the rest (aria-expanded +
|
|
651
|
+
// aria-controls). The customized bucket lists in full (it is the consequential one).
|
|
652
|
+
const ALT_ROW_CAP = 8;
|
|
653
|
+
let altShowAll = $state(false);
|
|
654
|
+
// The will-fill list element, so "Show all" can move focus to its first newly revealed row.
|
|
655
|
+
let altFillList = $state<HTMLElement | null>(null);
|
|
656
|
+
$effect(() => {
|
|
657
|
+
void altPlan;
|
|
658
|
+
altShowAll = false;
|
|
659
|
+
});
|
|
660
|
+
const altFillVisible = $derived(altShowAll ? altFillRows : altFillRows.slice(0, ALT_ROW_CAP));
|
|
661
|
+
const altFillHidden = $derived(Math.max(0, altFillRows.length - ALT_ROW_CAP));
|
|
662
|
+
// Reveal the capped will-fill rows, then move focus to the first newly revealed row (the rev.2
|
|
663
|
+
// contract: the expander unmounts on the flag flip, so focus would otherwise fall to <body>).
|
|
664
|
+
function showAllAltFill() {
|
|
665
|
+
altShowAll = true;
|
|
666
|
+
void tick().then(() => (altFillList?.children[ALT_ROW_CAP] as HTMLElement | undefined)?.focus());
|
|
667
|
+
}
|
|
668
|
+
|
|
242
669
|
// --- the where-used overlay the slide-over and the dialog read, grouped published-then-branch ---
|
|
243
670
|
function usageEntries(hash: string): UsageEntry[] {
|
|
244
671
|
return data.usage[hash]?.entries ?? [];
|
|
@@ -444,6 +871,16 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
444
871
|
</button>
|
|
445
872
|
</header>
|
|
446
873
|
|
|
874
|
+
<!-- The action feedback strip (the office flash grammar). A persistent polite live region carries
|
|
875
|
+
the success message, so an inserted-fresh element is announced reliably; the visible alert below
|
|
876
|
+
keeps its styling without a role. The strip never steals focus. -->
|
|
877
|
+
<div class="sr-only" aria-live="polite">{flashMessage}</div>
|
|
878
|
+
{#if flashMessage}
|
|
879
|
+
<div class="alert alert-success mb-4 text-sm">{flashMessage}</div>
|
|
880
|
+
{/if}
|
|
881
|
+
{#if data.flashError}
|
|
882
|
+
<div role="alert" class="alert alert-error mb-4 text-sm">{data.flashError}</div>
|
|
883
|
+
{/if}
|
|
447
884
|
{#if data.error}
|
|
448
885
|
<div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
|
|
449
886
|
{/if}
|
|
@@ -536,7 +973,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
536
973
|
aria-selected={selected?.hash === asset.hash}
|
|
537
974
|
tabindex={i === activeIndex ? 0 : -1}
|
|
538
975
|
aria-label="{asset.displayName}. {missing ? 'Needs alt text' : 'Described'}. {used > 0 ? `Found in ${used} ${used === 1 ? 'entry' : 'entries'}` : 'No references found'}."
|
|
539
|
-
class="group flex cursor-pointer flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 outline-
|
|
976
|
+
class="group 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 {selected?.hash === asset.hash ? 'ring-2 ring-primary/70' : ''}"
|
|
540
977
|
onclick={(e) => openAsset(asset, e.currentTarget)}
|
|
541
978
|
onkeydown={(e) => onGridKeydown(e, i)}
|
|
542
979
|
>
|
|
@@ -676,6 +1113,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
676
1113
|
and focus returns to the originating tile or row (the region-with-focus-management recipe).
|
|
677
1114
|
Below the narrow breakpoint the same panel reads as a bottom sheet (the responsive treatment). -->
|
|
678
1115
|
<aside
|
|
1116
|
+
bind:this={panelEl}
|
|
679
1117
|
role="region"
|
|
680
1118
|
aria-label="{asset.displayName} details"
|
|
681
1119
|
class="fixed inset-x-0 bottom-0 z-30 flex max-h-[85vh] flex-col rounded-t-2xl border-t border-[var(--cairn-card-border)] bg-base-100 shadow-[var(--cairn-shadow)] sm:inset-x-auto sm:bottom-0 sm:right-0 sm:top-16 sm:max-h-none sm:w-[22rem] sm:rounded-t-none sm:border-l sm:border-t-0"
|
|
@@ -830,9 +1268,33 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
830
1268
|
</dl>
|
|
831
1269
|
</div>
|
|
832
1270
|
|
|
833
|
-
<!-- The actions.
|
|
834
|
-
|
|
835
|
-
|
|
1271
|
+
<!-- The actions block (rev.2 decision 7): two quiet text-weight entry points (Replace, Push alt)
|
|
1272
|
+
above the existing danger-bordered Delete. The quiet controls are button:not(.btn) levelled
|
|
1273
|
+
rows, lighter than a bordered button; each carries aria-haspopup="dialog". Push alt's handler
|
|
1274
|
+
lands in Task 8; the button is placed now so the block matches the design. -->
|
|
1275
|
+
<div class="flex flex-col gap-1 border-t border-[var(--cairn-card-border)] pt-4">
|
|
1276
|
+
<span class="{headerLabel} mb-1">Actions</span>
|
|
1277
|
+
<button
|
|
1278
|
+
type="button"
|
|
1279
|
+
data-cairn-replace-open
|
|
1280
|
+
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]"
|
|
1281
|
+
aria-haspopup="dialog"
|
|
1282
|
+
onclick={(e) => openReplaceDialog(e.currentTarget)}
|
|
1283
|
+
>
|
|
1284
|
+
<RefreshCwIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1285
|
+
Replace image
|
|
1286
|
+
</button>
|
|
1287
|
+
<button
|
|
1288
|
+
type="button"
|
|
1289
|
+
data-cairn-pushalt-open
|
|
1290
|
+
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]"
|
|
1291
|
+
aria-haspopup="dialog"
|
|
1292
|
+
onclick={(e) => openAltDialog(e.currentTarget)}
|
|
1293
|
+
>
|
|
1294
|
+
<MegaphoneIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1295
|
+
Push alt to placements
|
|
1296
|
+
</button>
|
|
1297
|
+
<button type="button" class="btn btn-sm mt-1.5 border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={openDeleteDialog}>
|
|
836
1298
|
<Trash2Icon class="h-4 w-4" aria-hidden="true" /> Delete
|
|
837
1299
|
</button>
|
|
838
1300
|
</div>
|
|
@@ -927,3 +1389,531 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
927
1389
|
</div>
|
|
928
1390
|
{/if}
|
|
929
1391
|
</dialog>
|
|
1392
|
+
|
|
1393
|
+
<!-- The Replace alertdialog: a native modal <dialog> (native focus trap + Escape), NO light dismiss.
|
|
1394
|
+
A replace repoints a content hash and can break a draft, so it carries role="alertdialog", the
|
|
1395
|
+
danger register, and a typed-slug gate. Step one is the quiet upload; step two is the impact review
|
|
1396
|
+
gated behind the typed slug; the blocked step is the fail-closed surface (no apply button). -->
|
|
1397
|
+
<dialog
|
|
1398
|
+
bind:this={replaceDialog}
|
|
1399
|
+
data-testid="cairn-replace-dialog"
|
|
1400
|
+
class="modal"
|
|
1401
|
+
role="alertdialog"
|
|
1402
|
+
aria-modal="true"
|
|
1403
|
+
aria-labelledby="cairn-ml-replace-title"
|
|
1404
|
+
aria-describedby="cairn-ml-replace-sub"
|
|
1405
|
+
oncancel={closeReplaceDialog}
|
|
1406
|
+
>
|
|
1407
|
+
{#if replaceAsset}
|
|
1408
|
+
{@const asset = replaceAsset}
|
|
1409
|
+
<div class="modal-box max-w-xl">
|
|
1410
|
+
<div class="mb-3 flex items-start gap-3">
|
|
1411
|
+
<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">
|
|
1412
|
+
{#if replaceStep === 'blocked'}<TriangleAlertIcon class="h-5 w-5" />{:else}<RefreshCwIcon class="h-5 w-5" />{/if}
|
|
1413
|
+
</span>
|
|
1414
|
+
<div class="flex-1">
|
|
1415
|
+
<h2 id="cairn-ml-replace-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">
|
|
1416
|
+
{#if replaceStep === 'review'}
|
|
1417
|
+
Replace {asset.slug} in {replaceAffected} published {replaceAffected === 1 ? 'entry' : 'entries'}
|
|
1418
|
+
{:else if replaceStep === 'blocked'}
|
|
1419
|
+
Replace is on hold
|
|
1420
|
+
{:else}
|
|
1421
|
+
Replace {asset.displayName}
|
|
1422
|
+
{/if}
|
|
1423
|
+
</h2>
|
|
1424
|
+
<p id="cairn-ml-replace-sub" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
1425
|
+
{#if replaceStep === 'review'}
|
|
1426
|
+
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.
|
|
1427
|
+
{:else if replaceStep === 'blocked'}
|
|
1428
|
+
cairn could not read every place this image is used, so it will not repoint references it cannot see. No file was changed.
|
|
1429
|
+
{:else}
|
|
1430
|
+
Upload a new file. Every published entry that uses this image points to the new one, in one commit to main.
|
|
1431
|
+
{/if}
|
|
1432
|
+
</p>
|
|
1433
|
+
</div>
|
|
1434
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={closeReplaceDialog}>
|
|
1435
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
1436
|
+
</button>
|
|
1437
|
+
</div>
|
|
1438
|
+
|
|
1439
|
+
{#if replaceStep === 'upload'}
|
|
1440
|
+
<!-- Step one: upload a new file (upload-new-only). The asset being replaced stays named above
|
|
1441
|
+
the dropzone, so the author never loses it. Cancel is the initial focus; no apply yet. -->
|
|
1442
|
+
<div class="flex flex-col gap-3">
|
|
1443
|
+
<div class="flex items-center gap-3 rounded-box border border-[var(--cairn-card-border)] bg-base-200/60 p-3">
|
|
1444
|
+
<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">
|
|
1445
|
+
{#if brokenHashes.has(asset.hash)}
|
|
1446
|
+
<ImageOffIcon class="h-5 w-5 text-[var(--color-subtle)]" aria-hidden="true" />
|
|
1447
|
+
{:else}
|
|
1448
|
+
<img src={thumbSrc(asset)} alt="" aria-hidden="true" class="h-full w-full object-cover" onerror={() => markBroken(asset.hash)} />
|
|
1449
|
+
{/if}
|
|
1450
|
+
</span>
|
|
1451
|
+
<span class="flex min-w-0 flex-col gap-0.5">
|
|
1452
|
+
<span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">Replacing</span>
|
|
1453
|
+
<span class="text-sm font-semibold">{asset.displayName}</span>
|
|
1454
|
+
<span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-[var(--color-muted)] tabular-nums">
|
|
1455
|
+
{#if dimensions(asset)}{dimensions(asset)}<span class="px-1" aria-hidden="true">·</span>{/if}{formatBytes(asset.bytes)}
|
|
1456
|
+
</span>
|
|
1457
|
+
</span>
|
|
1458
|
+
</div>
|
|
1459
|
+
|
|
1460
|
+
{#if replaceUpload.kind === 'failed'}
|
|
1461
|
+
<!-- A typed ingest/upload failure: an assertive alert with the message and a Retry. -->
|
|
1462
|
+
<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">
|
|
1463
|
+
<TriangleAlertIcon class="h-6 w-6 text-[var(--cairn-error-ink)]" aria-hidden="true" />
|
|
1464
|
+
<span class="text-[0.8125rem] text-[var(--cairn-error-ink)]">{replaceUpload.card.message}</span>
|
|
1465
|
+
<button type="button" class="btn btn-sm" onclick={replaceUpload.retry}>Try another file</button>
|
|
1466
|
+
</div>
|
|
1467
|
+
{:else if replaceUpload.kind === 'working'}
|
|
1468
|
+
<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)]">
|
|
1469
|
+
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
|
1470
|
+
<span class="text-[0.8125rem]">Preparing the new file...</span>
|
|
1471
|
+
</div>
|
|
1472
|
+
{:else}
|
|
1473
|
+
<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)]">
|
|
1474
|
+
<UploadIcon class="h-6 w-6 text-primary" aria-hidden="true" />
|
|
1475
|
+
<span class="text-[0.875rem] font-medium text-base-content">Drop the new image, or upload</span>
|
|
1476
|
+
<span class="text-xs">PNG, JPEG, WebP, or HEIC. We convert HEIC for you.</span>
|
|
1477
|
+
<button type="button" class="btn btn-sm btn-primary mt-1.5" onclick={() => replaceFileInput?.click()}>Choose a file</button>
|
|
1478
|
+
<input
|
|
1479
|
+
bind:this={replaceFileInput}
|
|
1480
|
+
type="file"
|
|
1481
|
+
accept="image/*"
|
|
1482
|
+
class="sr-only"
|
|
1483
|
+
aria-label="Choose a new image to replace this asset"
|
|
1484
|
+
onchange={onReplaceFileChosen}
|
|
1485
|
+
/>
|
|
1486
|
+
</div>
|
|
1487
|
+
{/if}
|
|
1488
|
+
</div>
|
|
1489
|
+
<div class="mt-4 flex justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
1490
|
+
<button bind:this={replaceCancelButton} type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
|
|
1491
|
+
</div>
|
|
1492
|
+
{:else if replaceStep === 'review'}
|
|
1493
|
+
{@const newRec = replaceRecord}
|
|
1494
|
+
<!-- Step two: the impact review. The from/to strip carries the CORRECTED content-addressed copy
|
|
1495
|
+
(the name stays, only the hash changes); the affected-entry well is expanded by default and
|
|
1496
|
+
scroll-capped; the branch-delta is a calm report-only aside; the typed-slug gates apply. -->
|
|
1497
|
+
<div class="flex flex-col gap-4">
|
|
1498
|
+
{#if newRec}
|
|
1499
|
+
<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">
|
|
1500
|
+
<div class="flex min-w-0 flex-col gap-0.5">
|
|
1501
|
+
<span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">Current</span>
|
|
1502
|
+
<span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-[var(--color-muted)] tabular-nums line-through">.{asset.hash}</span>
|
|
1503
|
+
</div>
|
|
1504
|
+
<ArrowRightIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1505
|
+
<div class="flex min-w-0 flex-col gap-0.5">
|
|
1506
|
+
<span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">New file</span>
|
|
1507
|
+
<span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-primary tabular-nums">.{newRec.hash}</span>
|
|
1508
|
+
</div>
|
|
1509
|
+
<div class="col-span-3 flex items-start gap-2 border-t border-[var(--cairn-card-border)] pt-2.5">
|
|
1510
|
+
<CheckIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
1511
|
+
<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>
|
|
1512
|
+
</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
{/if}
|
|
1515
|
+
|
|
1516
|
+
<div>
|
|
1517
|
+
<div class="mb-2 flex items-baseline justify-between">
|
|
1518
|
+
<span class={headerLabel}>Published entries that will be repointed</span>
|
|
1519
|
+
<span class="text-xs tabular-nums text-[var(--color-muted)]">{replaceEntries.length}</span>
|
|
1520
|
+
</div>
|
|
1521
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100">
|
|
1522
|
+
<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">
|
|
1523
|
+
{#each replaceVisibleEntries as entry, i (entry.concept + '/' + entry.id)}
|
|
1524
|
+
<!-- The first row past the cap is a script-only focus target for "Show all" (tabindex
|
|
1525
|
+
-1 keeps it out of the tab order). svelte-ignore: the rule allows a literal -1 but
|
|
1526
|
+
does not see through the per-row conditional that selects which row carries it. -->
|
|
1527
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
1528
|
+
<li class="flex items-start gap-2.5 rounded px-1.5 py-1.5" tabindex={i === REPLACE_ROW_CAP ? -1 : undefined}>
|
|
1529
|
+
<FileTextIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1530
|
+
<span class="flex min-w-0 flex-col">
|
|
1531
|
+
<span class="truncate text-[0.8125rem] font-medium">{entry.title}</span>
|
|
1532
|
+
<span class="truncate text-[0.6875rem] text-[var(--color-muted)]">{replaceWhereUsed(entry)}</span>
|
|
1533
|
+
</span>
|
|
1534
|
+
</li>
|
|
1535
|
+
{/each}
|
|
1536
|
+
</ul>
|
|
1537
|
+
{#if replaceHiddenCount > 0 && !replaceShowAll}
|
|
1538
|
+
<div class="border-t border-[var(--cairn-card-border)] p-1.5">
|
|
1539
|
+
<button
|
|
1540
|
+
type="button"
|
|
1541
|
+
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]"
|
|
1542
|
+
aria-expanded={replaceShowAll}
|
|
1543
|
+
aria-controls="cairn-ml-replace-entries"
|
|
1544
|
+
onclick={showAllReplaceEntries}
|
|
1545
|
+
>
|
|
1546
|
+
Show the other {replaceHiddenCount} {replaceHiddenCount === 1 ? 'entry' : 'entries'}
|
|
1547
|
+
</button>
|
|
1548
|
+
</div>
|
|
1549
|
+
{/if}
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
|
|
1553
|
+
{#if (replacePlan?.branchDelta?.length ?? 0) > 0}
|
|
1554
|
+
<!-- The report-only branch delta: open cairn/* edits keep the old file until they publish.
|
|
1555
|
+
Calm dashed base-200, never the danger register. -->
|
|
1556
|
+
<div class="rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
|
|
1557
|
+
<div class="mb-1.5 flex items-center gap-2">
|
|
1558
|
+
<GitBranchIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1559
|
+
<span class="text-[0.8125rem] font-semibold">Open edits still on the old file</span>
|
|
1560
|
+
<span class="text-xs tabular-nums text-[var(--color-muted)]">{replacePlan?.branchDelta.length ?? 0}</span>
|
|
1561
|
+
</div>
|
|
1562
|
+
<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>
|
|
1563
|
+
<ul class="flex list-none flex-col gap-1 p-0">
|
|
1564
|
+
{#each replacePlan?.branchDelta ?? [] as delta (delta.branch)}
|
|
1565
|
+
<li class="font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--cairn-warning-ink)]">{delta.branch}</li>
|
|
1566
|
+
{/each}
|
|
1567
|
+
</ul>
|
|
1568
|
+
</div>
|
|
1569
|
+
{/if}
|
|
1570
|
+
|
|
1571
|
+
<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">
|
|
1572
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
1573
|
+
<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>
|
|
1574
|
+
</div>
|
|
1575
|
+
|
|
1576
|
+
<div class="flex flex-col gap-1.5">
|
|
1577
|
+
<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>
|
|
1578
|
+
<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} />
|
|
1579
|
+
</div>
|
|
1580
|
+
</div>
|
|
1581
|
+
|
|
1582
|
+
<!-- A polite live region mirrors the footer impact for a screen reader on the review step. The
|
|
1583
|
+
role="status" matches the Push-alt live region: the stronger, more portable form. -->
|
|
1584
|
+
<div class="sr-only" role="status" aria-live="polite">
|
|
1585
|
+
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.` : ''}
|
|
1586
|
+
</div>
|
|
1587
|
+
|
|
1588
|
+
<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">
|
|
1589
|
+
<CsrfField />
|
|
1590
|
+
<input type="hidden" name="oldHash" value={asset.hash} />
|
|
1591
|
+
<input type="hidden" name="newHash" value={replaceRecord?.hash ?? ''} />
|
|
1592
|
+
<input type="hidden" name="confirmSlug" value={replaceConfirmInput} />
|
|
1593
|
+
<input type="hidden" name="media" value={replaceRecord ? JSON.stringify([replaceRecord]) : '[]'} />
|
|
1594
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
1595
|
+
<GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
|
|
1596
|
+
</span>
|
|
1597
|
+
<button type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
|
|
1598
|
+
<button type="submit" class="btn btn-sm btn-error" disabled={!replaceConfirmMatches}>
|
|
1599
|
+
<RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Replace in {replaceAffected} {replaceAffected === 1 ? 'entry' : 'entries'}
|
|
1600
|
+
</button>
|
|
1601
|
+
</form>
|
|
1602
|
+
{:else}
|
|
1603
|
+
<!-- The fail-closed surface: usage could not be fully verified, so the replace refuses rather
|
|
1604
|
+
than guess. NO apply button (not even disabled), and no typed gate. A quiet "Check usage
|
|
1605
|
+
again" re-runs the scan; the held upload stays ready. -->
|
|
1606
|
+
<div class="flex flex-col gap-3">
|
|
1607
|
+
<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">
|
|
1608
|
+
<span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
|
|
1609
|
+
<TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Usage could not be fully verified
|
|
1610
|
+
</span>
|
|
1611
|
+
<p class="text-[0.8125rem] leading-relaxed">
|
|
1612
|
+
{#if replaceBlockedBranch}
|
|
1613
|
+
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.
|
|
1614
|
+
{:else}
|
|
1615
|
+
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.
|
|
1616
|
+
{/if}
|
|
1617
|
+
</p>
|
|
1618
|
+
{#if replaceBlockedBranch}
|
|
1619
|
+
<p class="inline-flex items-center gap-1.5 text-[0.8125rem]">
|
|
1620
|
+
<XIcon class="h-3.5 w-3.5 flex-none text-[var(--cairn-error-ink)]" aria-hidden="true" />
|
|
1621
|
+
Could not read <code class="font-[family-name:var(--font-editor)] text-[0.75rem]">{replaceBlockedBranch}</code>
|
|
1622
|
+
</p>
|
|
1623
|
+
{:else}
|
|
1624
|
+
<p class="inline-flex items-center gap-1.5 text-[0.8125rem]">
|
|
1625
|
+
<XIcon class="h-3.5 w-3.5 flex-none text-[var(--cairn-error-ink)]" aria-hidden="true" />
|
|
1626
|
+
An edit branch would not load.
|
|
1627
|
+
</p>
|
|
1628
|
+
{/if}
|
|
1629
|
+
<button type="button" class="btn btn-sm self-start border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={runReplacePreview}>
|
|
1630
|
+
<RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Check usage again
|
|
1631
|
+
</button>
|
|
1632
|
+
</div>
|
|
1633
|
+
<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">
|
|
1634
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
1635
|
+
<span>Your uploaded file is held and ready. Once the scan completes, the review opens with the full impact.</span>
|
|
1636
|
+
</div>
|
|
1637
|
+
</div>
|
|
1638
|
+
<div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
1639
|
+
<span class="mr-auto text-[0.75rem] text-[var(--color-muted)]">No file was changed.</span>
|
|
1640
|
+
<button type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
|
|
1641
|
+
</div>
|
|
1642
|
+
{/if}
|
|
1643
|
+
</div>
|
|
1644
|
+
{/if}
|
|
1645
|
+
</dialog>
|
|
1646
|
+
|
|
1647
|
+
<!-- The Push-alt review dialog: a native modal <dialog> (native focus trap + Escape), NO light dismiss.
|
|
1648
|
+
Alt fill is reversible and frequent, so it carries role="dialog" (the everyday register, never
|
|
1649
|
+
alertdialog) with NO typed-slug gate; apply is always enabled. The review step lists three buckets
|
|
1650
|
+
(will-fill always applied, customized behind one opt-in, decorative-skipped reported); the blocked
|
|
1651
|
+
step is the fail-closed surface (no apply form). -->
|
|
1652
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
1653
|
+
<!-- The explicit role="dialog" is the native <dialog> default, but it is stated to mark the everyday
|
|
1654
|
+
register against the Replace dialog's role="alertdialog" sibling, and the component test reads it. -->
|
|
1655
|
+
<dialog
|
|
1656
|
+
bind:this={altDialog}
|
|
1657
|
+
data-testid="cairn-alt-dialog"
|
|
1658
|
+
class="modal"
|
|
1659
|
+
role="dialog"
|
|
1660
|
+
aria-modal="true"
|
|
1661
|
+
aria-labelledby="cairn-ml-alt-title"
|
|
1662
|
+
aria-describedby="cairn-ml-alt-sub"
|
|
1663
|
+
oncancel={closeAltDialog}
|
|
1664
|
+
>
|
|
1665
|
+
{#if altAsset}
|
|
1666
|
+
{@const asset = altAsset}
|
|
1667
|
+
<div class="modal-box max-w-xl">
|
|
1668
|
+
<div class="mb-3 flex items-start gap-3">
|
|
1669
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-primary/10 text-primary" aria-hidden="true">
|
|
1670
|
+
<MegaphoneIcon class="h-5 w-5" />
|
|
1671
|
+
</span>
|
|
1672
|
+
<div class="flex-1">
|
|
1673
|
+
<h2 id="cairn-ml-alt-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">
|
|
1674
|
+
{#if altStep === 'blocked'}
|
|
1675
|
+
Push alt is on hold
|
|
1676
|
+
{:else}
|
|
1677
|
+
Fill alt on {altCounts.willFill} {altCounts.willFill === 1 ? 'placement' : 'placements'}
|
|
1678
|
+
{/if}
|
|
1679
|
+
</h2>
|
|
1680
|
+
<p id="cairn-ml-alt-sub" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
1681
|
+
{#if altStep === 'blocked'}
|
|
1682
|
+
cairn could not read every place this image is used, so it will not write alt where it cannot see. Nothing was changed.
|
|
1683
|
+
{:else}
|
|
1684
|
+
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.
|
|
1685
|
+
{/if}
|
|
1686
|
+
</p>
|
|
1687
|
+
</div>
|
|
1688
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={closeAltDialog}>
|
|
1689
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
1690
|
+
</button>
|
|
1691
|
+
</div>
|
|
1692
|
+
|
|
1693
|
+
{#if altStep === 'review'}
|
|
1694
|
+
<div class="flex flex-col gap-4">
|
|
1695
|
+
<!-- The alt being pushed, shown once so the author confirms the text before applying. -->
|
|
1696
|
+
<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">
|
|
1697
|
+
<MegaphoneIcon class="mt-0.5 h-4 w-4 flex-none text-primary" aria-hidden="true" />
|
|
1698
|
+
<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>
|
|
1699
|
+
</div>
|
|
1700
|
+
|
|
1701
|
+
<div class="flex flex-col gap-3">
|
|
1702
|
+
<!-- WILL FILL: every row's honest (no alt) -> default alt, always applied. -->
|
|
1703
|
+
{#if altFillRows.length > 0}
|
|
1704
|
+
<div class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
|
|
1705
|
+
<div class="flex items-center gap-2.5 p-3">
|
|
1706
|
+
<span class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-md bg-primary/10 text-primary" aria-hidden="true">
|
|
1707
|
+
<CheckIcon class="h-3.5 w-3.5" />
|
|
1708
|
+
</span>
|
|
1709
|
+
<div class="min-w-0 flex-1">
|
|
1710
|
+
<div class="text-[0.8125rem] font-semibold">Will fill the gap</div>
|
|
1711
|
+
<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>
|
|
1712
|
+
</div>
|
|
1713
|
+
<span class="flex-none text-[0.8125rem] font-bold tabular-nums text-primary">{altFillRows.length}</span>
|
|
1714
|
+
</div>
|
|
1715
|
+
<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">
|
|
1716
|
+
{#each altFillVisible as row, i (row.key)}
|
|
1717
|
+
<!-- The first row past the cap is the script-only focus target for "Show all"
|
|
1718
|
+
(tabindex -1). svelte-ignore: as above, the conditional hides the literal -1. -->
|
|
1719
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
1720
|
+
<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}>
|
|
1721
|
+
<FileTextIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1722
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
1723
|
+
<div class="flex items-center gap-1.5">
|
|
1724
|
+
<span class="truncate text-[0.8125rem] font-semibold">{row.title}</span>
|
|
1725
|
+
<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>
|
|
1726
|
+
</div>
|
|
1727
|
+
<div class="flex flex-wrap items-baseline gap-1.5 text-[0.75rem] leading-snug">
|
|
1728
|
+
<span class="italic text-[var(--color-muted)]">(no alt)</span>
|
|
1729
|
+
<ArrowRightIcon class="h-3 w-3 flex-none text-[var(--color-muted)] opacity-65" aria-hidden="true" />
|
|
1730
|
+
<span class="font-medium text-primary">{row.after}</span>
|
|
1731
|
+
</div>
|
|
1732
|
+
</div>
|
|
1733
|
+
</li>
|
|
1734
|
+
{/each}
|
|
1735
|
+
</ul>
|
|
1736
|
+
{#if altFillHidden > 0 && !altShowAll}
|
|
1737
|
+
<div class="border-t border-[var(--cairn-card-border)] p-1.5">
|
|
1738
|
+
<button
|
|
1739
|
+
type="button"
|
|
1740
|
+
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]"
|
|
1741
|
+
aria-expanded={altShowAll}
|
|
1742
|
+
aria-controls="cairn-ml-alt-fill"
|
|
1743
|
+
onclick={showAllAltFill}
|
|
1744
|
+
>
|
|
1745
|
+
Show the other {altFillHidden} {altFillHidden === 1 ? 'placement' : 'placements'}, all gaining the same alt
|
|
1746
|
+
</button>
|
|
1747
|
+
</div>
|
|
1748
|
+
{/if}
|
|
1749
|
+
</div>
|
|
1750
|
+
|
|
1751
|
+
<!-- The body-vs-hero caveat, anchored beside will-fill where the surprised author looks. -->
|
|
1752
|
+
<div class="flex items-start gap-2 px-0.5 text-[0.75rem] leading-relaxed">
|
|
1753
|
+
<TriangleAlertIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--cairn-warning-ink)]" aria-hidden="true" />
|
|
1754
|
+
<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>
|
|
1755
|
+
</div>
|
|
1756
|
+
{/if}
|
|
1757
|
+
|
|
1758
|
+
<!-- HAS CUSTOM ALT: one bucket-level opt-in (a real native checkbox). Before it is checked,
|
|
1759
|
+
each row shows its existing alt plain and "kept"; checking flips to was -> default. -->
|
|
1760
|
+
{#if altCustomRows.length > 0}
|
|
1761
|
+
<div data-cairn-alt-custom class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
|
|
1762
|
+
<div class="flex items-center gap-2.5 p-3">
|
|
1763
|
+
<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">
|
|
1764
|
+
<MegaphoneIcon class="h-3.5 w-3.5" />
|
|
1765
|
+
</span>
|
|
1766
|
+
<div class="min-w-0 flex-1">
|
|
1767
|
+
<div class="text-[0.8125rem] font-semibold">Already has custom alt</div>
|
|
1768
|
+
<div class="mt-px text-[0.6875rem] leading-snug text-[var(--color-muted)]">
|
|
1769
|
+
{altOverwrite ? 'You chose to overwrite these.' : 'Left alone by default. You can overwrite these too.'}
|
|
1770
|
+
</div>
|
|
1771
|
+
</div>
|
|
1772
|
+
<span class="flex-none text-[0.8125rem] font-bold tabular-nums text-[var(--cairn-warning-ink)]">{altCustomRows.length}</span>
|
|
1773
|
+
</div>
|
|
1774
|
+
<!-- The opt-in band, styled in the danger family: overwriting an editor's words is the
|
|
1775
|
+
destructive choice. The checkbox is a REAL native input in the a11y tree. -->
|
|
1776
|
+
<div class="border-t border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3">
|
|
1777
|
+
<label class="flex cursor-pointer items-start gap-2.5">
|
|
1778
|
+
<input
|
|
1779
|
+
type="checkbox"
|
|
1780
|
+
data-cairn-alt-optin
|
|
1781
|
+
class="checkbox checkbox-sm mt-px border-[var(--cairn-error-border)] checked:border-[var(--cairn-error-ink)] checked:bg-[var(--cairn-error-ink)]"
|
|
1782
|
+
aria-describedby="cairn-ml-alt-optin-hint"
|
|
1783
|
+
bind:checked={altOverwrite}
|
|
1784
|
+
/>
|
|
1785
|
+
<span class="text-[0.8125rem] leading-snug text-[var(--cairn-error-ink)]">
|
|
1786
|
+
<span class="font-semibold">Also overwrite {altCustomRows.length === 1 ? 'this 1 placement' : `these ${altCustomRows.length} placements`} with the default alt.</span>
|
|
1787
|
+
<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>
|
|
1788
|
+
</span>
|
|
1789
|
+
</label>
|
|
1790
|
+
</div>
|
|
1791
|
+
<ul class="flex max-h-44 list-none flex-col overflow-y-auto p-0">
|
|
1792
|
+
{#each altCustomRows as row (row.key)}
|
|
1793
|
+
<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">
|
|
1794
|
+
<FileTextIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1795
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
1796
|
+
<div class="flex items-center gap-1.5">
|
|
1797
|
+
<span class="truncate text-[0.8125rem] font-semibold">{row.title}</span>
|
|
1798
|
+
<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>
|
|
1799
|
+
</div>
|
|
1800
|
+
<div class="flex flex-wrap items-baseline gap-1.5 text-[0.75rem] leading-snug">
|
|
1801
|
+
{#if altOverwrite}
|
|
1802
|
+
<span data-cairn-alt-was class="text-base-content line-through decoration-[var(--color-muted)]/55">{`“${row.before}”`}</span>
|
|
1803
|
+
<ArrowRightIcon class="h-3 w-3 flex-none text-[var(--color-muted)] opacity-65" aria-hidden="true" />
|
|
1804
|
+
<span class="font-medium text-primary">{altPushed}</span>
|
|
1805
|
+
{:else}
|
|
1806
|
+
<span class="text-base-content">{`“${row.before}”`}</span>
|
|
1807
|
+
<span class="text-[var(--color-muted)] opacity-65" aria-hidden="true">·</span>
|
|
1808
|
+
<span class="text-[var(--color-muted)]">kept</span>
|
|
1809
|
+
{/if}
|
|
1810
|
+
</div>
|
|
1811
|
+
</div>
|
|
1812
|
+
</li>
|
|
1813
|
+
{/each}
|
|
1814
|
+
</ul>
|
|
1815
|
+
</div>
|
|
1816
|
+
{/if}
|
|
1817
|
+
|
|
1818
|
+
<!-- DECORATIVE HERO, SKIPPED: listed, muted, never an input. -->
|
|
1819
|
+
{#if altSkipRows.length > 0}
|
|
1820
|
+
<div data-cairn-alt-skip class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 opacity-90">
|
|
1821
|
+
<div class="flex items-center gap-2.5 p-3">
|
|
1822
|
+
<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">
|
|
1823
|
+
<ImageOffIcon class="h-3.5 w-3.5" />
|
|
1824
|
+
</span>
|
|
1825
|
+
<div class="min-w-0 flex-1">
|
|
1826
|
+
<div class="text-[0.8125rem] font-semibold">Marked decorative, skipped</div>
|
|
1827
|
+
<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>
|
|
1828
|
+
</div>
|
|
1829
|
+
<span class="flex-none text-[0.8125rem] font-bold tabular-nums text-[var(--color-muted)]">{altSkipRows.length}</span>
|
|
1830
|
+
</div>
|
|
1831
|
+
<ul class="flex list-none flex-col border-t border-[var(--cairn-card-border)] p-0">
|
|
1832
|
+
{#each altSkipRows as row (row.key)}
|
|
1833
|
+
<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">
|
|
1834
|
+
<span class="truncate">{row.title}</span>
|
|
1835
|
+
<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>
|
|
1836
|
+
</li>
|
|
1837
|
+
{/each}
|
|
1838
|
+
</ul>
|
|
1839
|
+
</div>
|
|
1840
|
+
{/if}
|
|
1841
|
+
</div>
|
|
1842
|
+
|
|
1843
|
+
{#if (altPlan?.branchDelta?.length ?? 0) > 0}
|
|
1844
|
+
<!-- The report-only branch delta: open cairn/* edits keep their own alt until they publish. -->
|
|
1845
|
+
<div class="rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
|
|
1846
|
+
<div class="mb-1.5 flex items-center gap-2">
|
|
1847
|
+
<GitBranchIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
1848
|
+
<span class="text-[0.8125rem] font-semibold">Open edits not touched</span>
|
|
1849
|
+
<span class="text-xs tabular-nums text-[var(--color-muted)]">{altPlan?.branchDelta.length ?? 0}</span>
|
|
1850
|
+
</div>
|
|
1851
|
+
<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>
|
|
1852
|
+
<ul class="flex list-none flex-col gap-1 p-0">
|
|
1853
|
+
{#each altPlan?.branchDelta ?? [] as delta (delta.branch)}
|
|
1854
|
+
<li class="font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--cairn-warning-ink)]">{delta.branch}</li>
|
|
1855
|
+
{/each}
|
|
1856
|
+
</ul>
|
|
1857
|
+
</div>
|
|
1858
|
+
{/if}
|
|
1859
|
+
|
|
1860
|
+
<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">
|
|
1861
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
1862
|
+
<span>Every version stays in git history, so any overwrite can be undone.</span>
|
|
1863
|
+
</div>
|
|
1864
|
+
</div>
|
|
1865
|
+
|
|
1866
|
+
<!-- The polite live region announces the moving committed total when the opt-in toggles. -->
|
|
1867
|
+
<div class="sr-only" role="status" aria-live="polite">
|
|
1868
|
+
Now writing alt to {altTotal} {altTotal === 1 ? 'placement' : 'placements'}.{altOverwrite && altCounts.customized > 0 ? ` ${altCounts.willFill} filled, ${altCounts.customized} overwritten.` : ''}
|
|
1869
|
+
</div>
|
|
1870
|
+
|
|
1871
|
+
<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">
|
|
1872
|
+
<CsrfField />
|
|
1873
|
+
<input type="hidden" name="hash" value={asset.hash} />
|
|
1874
|
+
<!-- The opt-in checkbox lives beside the customized rows (outside the form), so its bound
|
|
1875
|
+
state is mirrored here as the posted flag. The server reads form.get('overwrite') === 'on'. -->
|
|
1876
|
+
<input type="hidden" name="overwrite" value={altOverwrite ? 'on' : ''} />
|
|
1877
|
+
<span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
|
|
1878
|
+
<GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
|
|
1879
|
+
</span>
|
|
1880
|
+
<button type="button" class="btn btn-sm" onclick={closeAltDialog}>Cancel</button>
|
|
1881
|
+
<button type="submit" class="btn btn-sm btn-primary">
|
|
1882
|
+
<CheckIcon class="h-4 w-4" aria-hidden="true" />
|
|
1883
|
+
{#if altOverwrite && altCounts.customized > 0}
|
|
1884
|
+
Update {altTotal} {altTotal === 1 ? 'placement' : 'placements'}
|
|
1885
|
+
{:else}
|
|
1886
|
+
Fill {altTotal} {altTotal === 1 ? 'placement' : 'placements'}
|
|
1887
|
+
{/if}
|
|
1888
|
+
</button>
|
|
1889
|
+
</form>
|
|
1890
|
+
{:else}
|
|
1891
|
+
<!-- The fail-closed surface: usage could not be fully verified, so the push refuses rather than
|
|
1892
|
+
guess. NO apply form. A quiet "Check usage again" re-runs the scan. The banner on open is
|
|
1893
|
+
role="status" (not alert): no action was attempted yet. MediaAltPropagateFailure carries
|
|
1894
|
+
only `error`, so the generic honest line stands in. -->
|
|
1895
|
+
<div class="flex flex-col gap-3">
|
|
1896
|
+
<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">
|
|
1897
|
+
<span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
|
|
1898
|
+
<TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Usage could not be fully verified
|
|
1899
|
+
</span>
|
|
1900
|
+
<p class="text-[0.8125rem] leading-relaxed">
|
|
1901
|
+
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.
|
|
1902
|
+
</p>
|
|
1903
|
+
<button type="button" class="btn btn-sm self-start border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={runAltPreview}>
|
|
1904
|
+
<RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Check usage again
|
|
1905
|
+
</button>
|
|
1906
|
+
</div>
|
|
1907
|
+
<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">
|
|
1908
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
1909
|
+
<span>Nothing was changed. Once the scan completes, the review opens with every placement.</span>
|
|
1910
|
+
</div>
|
|
1911
|
+
</div>
|
|
1912
|
+
<div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
1913
|
+
<span class="mr-auto text-[0.75rem] text-[var(--color-muted)]">No alt was changed.</span>
|
|
1914
|
+
<button type="button" class="btn btn-sm" onclick={closeAltDialog}>Cancel</button>
|
|
1915
|
+
</div>
|
|
1916
|
+
{/if}
|
|
1917
|
+
</div>
|
|
1918
|
+
{/if}
|
|
1919
|
+
</dialog>
|