@glw907/cairn-cms 0.58.0 → 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 +37 -0
- package/dist/components/CairnMediaLibrary.svelte +1101 -27
- package/dist/components/CairnMediaLibrary.svelte.d.ts +10 -2
- package/dist/components/admin-icons.d.ts +1 -0
- package/dist/components/admin-icons.js +1 -0
- package/dist/components/cairn-admin.css +147 -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/sveltekit/cairn-admin.d.ts +3 -0
- package/dist/sveltekit/cairn-admin.js +6 -0
- package/dist/sveltekit/content-routes.d.ts +37 -4
- package/dist/sveltekit/content-routes.js +247 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/lib/components/CairnMediaLibrary.svelte +1101 -27
- package/src/lib/components/admin-icons.ts +1 -0
- package/src/lib/log/events.ts +2 -0
- 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/sveltekit/cairn-admin.ts +6 -0
- package/src/lib/sveltekit/content-routes.ts +293 -5
- package/src/lib/sveltekit/index.ts +1 -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
|
|
@@ -26,6 +34,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
26
34
|
<script lang="ts">
|
|
27
35
|
import { flushSync, getContext, tick } from 'svelte';
|
|
28
36
|
import { deserialize } from '$app/forms';
|
|
37
|
+
import { invalidateAll } from '$app/navigation';
|
|
29
38
|
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
30
39
|
import type {
|
|
31
40
|
MediaLibraryData,
|
|
@@ -35,7 +44,12 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
35
44
|
MediaReplacePreviewEntry,
|
|
36
45
|
MediaAltPreviewPlan,
|
|
37
46
|
MediaAltPropagateFailure,
|
|
47
|
+
MediaBulkDeleteResult,
|
|
48
|
+
MediaOrphanPurgeResult,
|
|
49
|
+
MediaBulkFailure,
|
|
38
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';
|
|
39
53
|
import type { AltPlacement } from '../content/media-rewrite.js';
|
|
40
54
|
import type { UsageEntry } from '../media/usage.js';
|
|
41
55
|
import type { MediaEntry } from '../media/manifest.js';
|
|
@@ -73,6 +87,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
73
87
|
GitBranchIcon,
|
|
74
88
|
ArrowRightIcon,
|
|
75
89
|
MegaphoneIcon,
|
|
90
|
+
DatabaseIcon,
|
|
76
91
|
} from './admin-icons.js';
|
|
77
92
|
|
|
78
93
|
interface Props {
|
|
@@ -94,6 +109,8 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
94
109
|
updated: 'Changes saved.',
|
|
95
110
|
replaced: 'Asset replaced.',
|
|
96
111
|
altPropagated: 'Alt text applied.',
|
|
112
|
+
bulkDeleted: 'Assets deleted.',
|
|
113
|
+
orphansPurged: 'Orphans purged.',
|
|
97
114
|
} as const;
|
|
98
115
|
const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
|
|
99
116
|
|
|
@@ -114,7 +131,9 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
114
131
|
const triageCounts = $derived({
|
|
115
132
|
all: data.assets.length,
|
|
116
133
|
needsAlt: data.assets.filter((a) => needsAlt(a)).length,
|
|
117
|
-
//
|
|
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).
|
|
118
137
|
unused: data.assets.filter((a) => usageCount(a.hash) === 0).length,
|
|
119
138
|
});
|
|
120
139
|
|
|
@@ -139,7 +158,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
139
158
|
const segments: { value: Triage; label: string; count: () => number }[] = [
|
|
140
159
|
{ value: 'all', label: 'All', count: () => triageCounts.all },
|
|
141
160
|
{ value: 'needs-alt', label: 'Needs alt', count: () => triageCounts.needsAlt },
|
|
142
|
-
{ value: 'unused', label: '
|
|
161
|
+
{ value: 'unused', label: 'No references found', count: () => triageCounts.unused },
|
|
143
162
|
];
|
|
144
163
|
|
|
145
164
|
// The triage radiogroup's roving tabindex and ARIA radio keyboard pattern: the selected radio is
|
|
@@ -249,17 +268,24 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
249
268
|
// Escape is also the native clear gesture for the toolbar's type="search" input, so the close
|
|
250
269
|
// fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
|
|
251
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.
|
|
252
275
|
function onWindowKeydown(e: KeyboardEvent) {
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
!deleteDialog?.open &&
|
|
257
|
-
!replaceDialog?.open &&
|
|
258
|
-
!altDialog?.open &&
|
|
259
|
-
panelEl?.contains(document.activeElement)
|
|
260
|
-
) {
|
|
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)) {
|
|
261
279
|
e.preventDefault();
|
|
262
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();
|
|
263
289
|
}
|
|
264
290
|
}
|
|
265
291
|
|
|
@@ -786,20 +812,435 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
786
812
|
activeIndex = i;
|
|
787
813
|
tileEls[i]?.focus();
|
|
788
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
|
+
|
|
789
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
|
+
}
|
|
790
1222
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
791
1223
|
e.preventDefault();
|
|
792
|
-
|
|
1224
|
+
const to = Math.min(i + 1, visible.length - 1);
|
|
1225
|
+
if (e.shiftKey) selectRange(to);
|
|
1226
|
+
focusTile(to);
|
|
793
1227
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
794
1228
|
e.preventDefault();
|
|
795
|
-
|
|
1229
|
+
const to = Math.max(i - 1, 0);
|
|
1230
|
+
if (e.shiftKey) selectRange(to);
|
|
1231
|
+
focusTile(to);
|
|
796
1232
|
} else if (e.key === 'Home') {
|
|
797
1233
|
e.preventDefault();
|
|
798
1234
|
focusTile(0);
|
|
799
1235
|
} else if (e.key === 'End') {
|
|
800
1236
|
e.preventDefault();
|
|
801
1237
|
focusTile(visible.length - 1);
|
|
802
|
-
} 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).
|
|
803
1244
|
e.preventDefault();
|
|
804
1245
|
openAsset(visible[i], tileEls[i]);
|
|
805
1246
|
}
|
|
@@ -942,6 +1383,19 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
942
1383
|
|
|
943
1384
|
<span class="flex-1"></span>
|
|
944
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
|
+
|
|
945
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">
|
|
946
1400
|
<button type="button" aria-label="Grid view" aria-pressed={density === 'grid'} class={densityButtonClass(density === 'grid')} onclick={() => (density = 'grid')}>
|
|
947
1401
|
<LayoutGridIcon class="h-4 w-4" />
|
|
@@ -952,6 +1406,21 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
952
1406
|
</div>
|
|
953
1407
|
</div>
|
|
954
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
|
+
|
|
955
1424
|
{#if sorted.length === 0}
|
|
956
1425
|
<!-- A filter or search narrowed the set to zero; the assets exist, none match. -->
|
|
957
1426
|
<div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
|
|
@@ -959,30 +1428,46 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
959
1428
|
<p class="text-sm text-[var(--color-muted)]">No media match this filter.</p>
|
|
960
1429
|
</div>
|
|
961
1430
|
{:else if density === 'grid'}
|
|
962
|
-
<!-- The grid: a roving-tabindex listbox of tiles. One tabstop; arrows move the
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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">
|
|
966
1436
|
{#each visible as asset, i (asset.hash)}
|
|
967
1437
|
{@const used = usageCount(asset.hash)}
|
|
968
1438
|
{@const missing = needsAlt(asset)}
|
|
1439
|
+
{@const picked = selectedHashes.has(asset.hash)}
|
|
969
1440
|
<li role="presentation" class="contents">
|
|
970
1441
|
<div
|
|
971
1442
|
bind:this={tileEls[i]}
|
|
972
1443
|
role="option"
|
|
973
|
-
aria-selected={
|
|
1444
|
+
aria-selected={picked}
|
|
974
1445
|
tabindex={i === activeIndex ? 0 : -1}
|
|
975
1446
|
aria-label="{asset.displayName}. {missing ? 'Needs alt text' : 'Described'}. {used > 0 ? `Found in ${used} ${used === 1 ? 'entry' : 'entries'}` : 'No references found'}."
|
|
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/
|
|
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' : ''}"
|
|
977
1448
|
onclick={(e) => openAsset(asset, e.currentTarget)}
|
|
978
1449
|
onkeydown={(e) => onGridKeydown(e, i)}
|
|
979
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>
|
|
980
1463
|
<div class="relative flex aspect-[4/3] items-center justify-center bg-base-200/60">
|
|
981
|
-
<!-- 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. -->
|
|
982
1467
|
{#if used > 0}
|
|
983
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>
|
|
984
1469
|
{:else}
|
|
985
|
-
<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>
|
|
986
1471
|
{/if}
|
|
987
1472
|
{#if brokenHashes.has(asset.hash)}
|
|
988
1473
|
<span data-cairn-broken class="flex flex-col items-center gap-1 text-[var(--color-subtle)]">
|
|
@@ -1017,13 +1502,17 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
1017
1502
|
{/each}
|
|
1018
1503
|
</ul>
|
|
1019
1504
|
{:else}
|
|
1020
|
-
<!-- The list density: a
|
|
1021
|
-
column sorts through a real
|
|
1022
|
-
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. -->
|
|
1023
1511
|
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-x-auto shadow-[var(--cairn-shadow)]">
|
|
1024
1512
|
<table class="table">
|
|
1025
1513
|
<thead>
|
|
1026
1514
|
<tr class="border-base-300">
|
|
1515
|
+
<th class="w-10"><span class="sr-only">Select</span></th>
|
|
1027
1516
|
<th class={headerLabel}>Asset</th>
|
|
1028
1517
|
<th class="{headerLabel} w-32">Alt status</th>
|
|
1029
1518
|
<th class="{headerLabel} w-40">Used</th>
|
|
@@ -1040,7 +1529,17 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
1040
1529
|
{#each visible as asset (asset.hash)}
|
|
1041
1530
|
{@const used = usageCount(asset.hash)}
|
|
1042
1531
|
{@const missing = needsAlt(asset)}
|
|
1043
|
-
|
|
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>
|
|
1044
1543
|
<td class="max-w-0">
|
|
1045
1544
|
<button type="button" class="flex w-full items-center gap-3 text-left" onclick={(e) => openAsset(asset, e.currentTarget)}>
|
|
1046
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">
|
|
@@ -1089,6 +1588,45 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
1089
1588
|
</div>
|
|
1090
1589
|
{/if}
|
|
1091
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
|
+
|
|
1092
1630
|
{#if sorted.length > 0}
|
|
1093
1631
|
<!-- The announced count plus the managed Load more (never infinite scroll). One persistent
|
|
1094
1632
|
polite region carries "Showing N of M". -->
|
|
@@ -1310,6 +1848,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
1310
1848
|
bind:this={deleteDialog}
|
|
1311
1849
|
class="modal"
|
|
1312
1850
|
role="alertdialog"
|
|
1851
|
+
aria-modal="true"
|
|
1313
1852
|
aria-labelledby="cairn-ml-delete-title"
|
|
1314
1853
|
aria-describedby="cairn-ml-delete-desc"
|
|
1315
1854
|
oncancel={closeDeleteDialog}
|
|
@@ -1917,3 +2456,538 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
|
|
|
1917
2456
|
</div>
|
|
1918
2457
|
{/if}
|
|
1919
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>
|