@glw907/cairn-cms 0.56.2 → 0.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +949 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +348 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +578 -0
- package/dist/components/MediaHeroField.svelte.d.ts +75 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +901 -9
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +12 -0
- package/dist/components/markdown-directives.js +42 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +22 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +64 -11
- package/dist/content/validate.js +31 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +77 -2
- package/dist/sveltekit/content-routes.js +470 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +949 -0
- package/src/lib/components/EditPage.svelte +348 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +578 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +46 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +20 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +80 -13
- package/src/lib/content/validate.ts +29 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +589 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The admin Media Library screen, a peer of Posts and Pages. It browses every committed media asset,
|
|
4
|
+
shows where each one is used, edits its name and default alt, and deletes it safely. The resting
|
|
5
|
+
surface is a visual contact-sheet grid (a roving-tabindex listbox of tiles), with a list-density
|
|
6
|
+
toggle that flips to an enriched sortable table. One toolbar row carries search, a pick-one triage
|
|
7
|
+
radiogroup (All, Needs alt, Unused), and the density toggle. Filtering, sorting, and a growing
|
|
8
|
+
client window all run over the full loaded set in component state.
|
|
9
|
+
|
|
10
|
+
Activating a tile or row opens a NON-MODAL detail slide-over from the right (the established
|
|
11
|
+
details-slide-over recipe): no scrim, the library stays live and in the a11y tree behind it, Escape
|
|
12
|
+
closes it, focus moves in on open and returns to the originating tile or row on close. It is a
|
|
13
|
+
labelled region, not a dialog, so it never traps focus or inerts the list. It holds the large
|
|
14
|
+
preview, the name and the `media:` reference with a copy button, the alt editor (a describe or
|
|
15
|
+
decorative radiogroup plus the alt field, posting to `?/mediaUpdate` together with the display name
|
|
16
|
+
and slug), the where-used list grouped published-then-branch, the metadata grid, and the actions.
|
|
17
|
+
|
|
18
|
+
Delete opens a two-faced safe-delete alertdialog: a native modal `<dialog>` with no light dismiss.
|
|
19
|
+
The in-use face names the breaking entries and gates Delete behind a typed-slug confirmation; the
|
|
20
|
+
orphan face is a calm confirm. Both post to `?/mediaDelete`. A `form` carrying a fresh
|
|
21
|
+
`MediaDeleteRefusal` re-opens the in-use face on its fresh breaking list.
|
|
22
|
+
|
|
23
|
+
It is node-safe by construction: it types assets with MediaLibraryEntry from the shared node-safe
|
|
24
|
+
projection and pulls in no editor module (the editor-boundary test bars a @codemirror leak).
|
|
25
|
+
-->
|
|
26
|
+
<script lang="ts">
|
|
27
|
+
import { flushSync, tick } from 'svelte';
|
|
28
|
+
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
29
|
+
import type { MediaLibraryData, ContentFormFailure } from '../sveltekit/content-routes.js';
|
|
30
|
+
import type { UsageEntry } from '../media/usage.js';
|
|
31
|
+
import { publicPath } from '../media/naming.js';
|
|
32
|
+
import { mediaToken } from '../media/reference.js';
|
|
33
|
+
import CsrfField from './CsrfField.svelte';
|
|
34
|
+
import CairnLogo from './CairnLogo.svelte';
|
|
35
|
+
import {
|
|
36
|
+
SearchIcon,
|
|
37
|
+
UploadIcon,
|
|
38
|
+
LayoutGridIcon,
|
|
39
|
+
ListIcon,
|
|
40
|
+
CheckIcon,
|
|
41
|
+
TriangleAlertIcon,
|
|
42
|
+
ImageOffIcon,
|
|
43
|
+
Trash2Icon,
|
|
44
|
+
ChevronDownIcon,
|
|
45
|
+
ChevronRightIcon,
|
|
46
|
+
XIcon,
|
|
47
|
+
CopyIcon,
|
|
48
|
+
FileTextIcon,
|
|
49
|
+
ClockIcon,
|
|
50
|
+
Link2OffIcon,
|
|
51
|
+
} from './admin-icons.js';
|
|
52
|
+
|
|
53
|
+
interface Props {
|
|
54
|
+
/** The media library load's data: the unioned assets, the per-hash usage overlay, and a
|
|
55
|
+
* degraded-load error. */
|
|
56
|
+
data: MediaLibraryData;
|
|
57
|
+
/** The last media action's result. A `?/mediaDelete` refusal carries the fresh breaking list
|
|
58
|
+
* the in-use face re-opens on; a `?/mediaUpdate` failure carries the error the slide-over
|
|
59
|
+
* surfaces. The route exports one `form`, so this is the merged `ContentFormFailure`. */
|
|
60
|
+
form?: ContentFormFailure | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let { data, form }: Props = $props();
|
|
64
|
+
|
|
65
|
+
// The success flash a redirected action carried back: a safe-delete or a metadata edit. The
|
|
66
|
+
// conflict error (data.flashError) renders in the inline error treatment below instead.
|
|
67
|
+
const FLASH_MESSAGE = { deleted: 'Asset deleted.', updated: 'Changes saved.' } as const;
|
|
68
|
+
const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
|
|
69
|
+
|
|
70
|
+
// --- the per-hash usage facts the screen joins onto each asset ---
|
|
71
|
+
/** The distinct-entry usage count for an asset; zero when the asset has no usage key. */
|
|
72
|
+
function usageCount(hash: string): number {
|
|
73
|
+
return data.usage[hash]?.count ?? 0;
|
|
74
|
+
}
|
|
75
|
+
/** Empty alt is the needs-alt signal (the asset carries no caption field, so this is the only
|
|
76
|
+
* per-asset alt fact). A non-image asset would read Not applicable, but the delivery route is
|
|
77
|
+
* image-only today, so every committed asset here is an image. */
|
|
78
|
+
function needsAlt(asset: MediaLibraryEntry): boolean {
|
|
79
|
+
return asset.alt.trim() === '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- the live count line and the triage counts, over the FULL loaded set ---
|
|
83
|
+
const usedCount = $derived(data.assets.filter((a) => usageCount(a.hash) > 0).length);
|
|
84
|
+
const triageCounts = $derived({
|
|
85
|
+
all: data.assets.length,
|
|
86
|
+
needsAlt: data.assets.filter((a) => needsAlt(a)).length,
|
|
87
|
+
// Unused: no usage entry, or a count of zero.
|
|
88
|
+
unused: data.assets.filter((a) => usageCount(a.hash) === 0).length,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// The type facet (Images, Documents) is a designed-in seam: it stays hidden until the library
|
|
92
|
+
// holds more than one top-level content type. The delivery route is image-only today, so this is
|
|
93
|
+
// present without dead UI. (No selection state yet; the seam is the visibility computation.)
|
|
94
|
+
const distinctTypes = $derived.by(() => {
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
for (const a of data.assets) seen.add(a.contentType.split('/')[0] ?? '');
|
|
97
|
+
return seen;
|
|
98
|
+
});
|
|
99
|
+
const showFacet = $derived(distinctTypes.size > 1);
|
|
100
|
+
|
|
101
|
+
// --- the toolbar state ---
|
|
102
|
+
type Triage = 'all' | 'needs-alt' | 'unused';
|
|
103
|
+
type Density = 'grid' | 'list';
|
|
104
|
+
let query = $state('');
|
|
105
|
+
let triage = $state<Triage>('all');
|
|
106
|
+
let density = $state<Density>('grid');
|
|
107
|
+
|
|
108
|
+
// The triage segments, in display order, each naming its value, label, and live count.
|
|
109
|
+
const segments: { value: Triage; label: string; count: () => number }[] = [
|
|
110
|
+
{ value: 'all', label: 'All', count: () => triageCounts.all },
|
|
111
|
+
{ value: 'needs-alt', label: 'Needs alt', count: () => triageCounts.needsAlt },
|
|
112
|
+
{ value: 'unused', label: 'Unused', count: () => triageCounts.unused },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// The triage radiogroup's roving tabindex and ARIA radio keyboard pattern: the selected radio is
|
|
116
|
+
// the only tab stop, and Arrow/Home/End move the selection and the focus, mirroring the grid's
|
|
117
|
+
// roving listbox. A declared radiogroup owes this keyboard model.
|
|
118
|
+
let segEls = $state<HTMLButtonElement[]>([]);
|
|
119
|
+
function selectTriage(value: Triage) {
|
|
120
|
+
triage = value;
|
|
121
|
+
}
|
|
122
|
+
function onTriageKeydown(e: KeyboardEvent, i: number) {
|
|
123
|
+
let next = i;
|
|
124
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % segments.length;
|
|
125
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + segments.length) % segments.length;
|
|
126
|
+
else if (e.key === 'Home') next = 0;
|
|
127
|
+
else if (e.key === 'End') next = segments.length - 1;
|
|
128
|
+
else return;
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
selectTriage(segments[next].value);
|
|
131
|
+
segEls[next]?.focus();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function matchesTriage(asset: MediaLibraryEntry): boolean {
|
|
135
|
+
switch (triage) {
|
|
136
|
+
case 'needs-alt':
|
|
137
|
+
return needsAlt(asset);
|
|
138
|
+
case 'unused':
|
|
139
|
+
return usageCount(asset.hash) === 0;
|
|
140
|
+
default:
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Search spans the display name and the alt over the FULL set. MediaLibraryEntry carries no
|
|
146
|
+
// caption field, so there is nothing further to search; the toolbar copy says "name or alt".
|
|
147
|
+
const filtered = $derived.by(() => {
|
|
148
|
+
const q = query.trim().toLowerCase();
|
|
149
|
+
return data.assets.filter((a) => {
|
|
150
|
+
if (!matchesTriage(a)) return false;
|
|
151
|
+
if (!q) return true;
|
|
152
|
+
return a.displayName.toLowerCase().includes(q) || a.alt.toLowerCase().includes(q);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// --- sorting (the list density's Added column) ---
|
|
157
|
+
let sortAsc = $state(false); // newest-first by default, the usual CMS convention
|
|
158
|
+
const sorted = $derived.by(() => {
|
|
159
|
+
// Lexical compare on the ISO createdAt is chronological; copy first so the source order holds.
|
|
160
|
+
return [...filtered].sort((a, b) => {
|
|
161
|
+
const cmp = a.createdAt.localeCompare(b.createdAt);
|
|
162
|
+
return sortAsc ? cmp : -cmp;
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
function toggleSort() {
|
|
166
|
+
sortAsc = !sortAsc;
|
|
167
|
+
}
|
|
168
|
+
const addedSort = $derived(sortAsc ? 'ascending' : 'descending');
|
|
169
|
+
|
|
170
|
+
// --- the client pagination window (a growing visible count, never infinite scroll) ---
|
|
171
|
+
const PAGE = 24;
|
|
172
|
+
let shown = $state(PAGE);
|
|
173
|
+
// Reset the window whenever the filtered set changes so a narrowing filter never strands the
|
|
174
|
+
// window past the result count. (Reading `sorted.length` ties this to filter/sort/search.)
|
|
175
|
+
$effect(() => {
|
|
176
|
+
void sorted.length;
|
|
177
|
+
shown = PAGE;
|
|
178
|
+
});
|
|
179
|
+
const visible = $derived(sorted.slice(0, shown));
|
|
180
|
+
const hasMore = $derived(shown < sorted.length);
|
|
181
|
+
function loadMore() {
|
|
182
|
+
shown = Math.min(shown + PAGE, sorted.length);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- selection, the slide-over, and the safe-delete dialog ---
|
|
186
|
+
// `selected` is the asset the slide-over (and the alertdialog) render off. The table's per-row
|
|
187
|
+
// trash opens the alertdialog straight to the right face for that asset (requestDelete) without
|
|
188
|
+
// opening the slide-over; a tile or row activation opens the slide-over (openAsset).
|
|
189
|
+
let selected = $state<MediaLibraryEntry | null>(null);
|
|
190
|
+
// True while the dialog was opened straight from a row trash without the slide-over, so the
|
|
191
|
+
// {#if selected} slide-over stays closed for a delete-only intent.
|
|
192
|
+
let deleteOnly = $state(false);
|
|
193
|
+
|
|
194
|
+
// The element that opened the slide-over (a tile or a row trigger), so focus returns to it on
|
|
195
|
+
// close (the non-modal region recipe: focus moves in on open, back to the origin on close).
|
|
196
|
+
let panelOrigin: HTMLElement | null = null;
|
|
197
|
+
let panelEl = $state<HTMLElement | null>(null);
|
|
198
|
+
let closeButton = $state<HTMLButtonElement | null>(null);
|
|
199
|
+
let deleteDialog = $state<HTMLDialogElement | null>(null);
|
|
200
|
+
|
|
201
|
+
function openAsset(asset: MediaLibraryEntry, origin?: HTMLElement | null) {
|
|
202
|
+
panelOrigin = origin ?? (document.activeElement as HTMLElement | null);
|
|
203
|
+
deleteOnly = false;
|
|
204
|
+
selected = asset;
|
|
205
|
+
// flushSync mounts the panel synchronously so its close button exists before we move focus in.
|
|
206
|
+
flushSync();
|
|
207
|
+
closeButton?.focus();
|
|
208
|
+
}
|
|
209
|
+
/** Close the slide-over and return focus to the tile or row that opened it. */
|
|
210
|
+
function closePanel() {
|
|
211
|
+
selected = null;
|
|
212
|
+
deleteOnly = false;
|
|
213
|
+
panelOrigin?.focus();
|
|
214
|
+
panelOrigin = null;
|
|
215
|
+
}
|
|
216
|
+
// Escape closes the slide-over (the non-modal region recipe). A window listener carries it, the
|
|
217
|
+
// way EditPage's details panel does, so the non-interactive region needs no keyboard handler. The
|
|
218
|
+
// dialog (when open) claims Escape natively, so the panel handles it only when no dialog is up.
|
|
219
|
+
// Escape is also the native clear gesture for the toolbar's type="search" input, so the close
|
|
220
|
+
// fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
|
|
221
|
+
// panel exactly as the user left it, while an Escape with focus in the panel still closes it.
|
|
222
|
+
function onWindowKeydown(e: KeyboardEvent) {
|
|
223
|
+
if (e.key === 'Escape' && selected && !deleteDialog?.open && panelEl?.contains(document.activeElement)) {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
closePanel();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The per-row delete intent opens the alertdialog directly on the right face for that asset.
|
|
230
|
+
function requestDelete(asset: MediaLibraryEntry) {
|
|
231
|
+
deleteOnly = true;
|
|
232
|
+
selected = asset;
|
|
233
|
+
openDeleteDialog();
|
|
234
|
+
}
|
|
235
|
+
// The slide-over's Delete button opens the same dialog for the already-selected asset.
|
|
236
|
+
function openDeleteDialog() {
|
|
237
|
+
confirmSlugInput = '';
|
|
238
|
+
flushSync();
|
|
239
|
+
deleteDialog?.showModal();
|
|
240
|
+
}
|
|
241
|
+
function closeDeleteDialog() {
|
|
242
|
+
deleteDialog?.close();
|
|
243
|
+
confirmSlugInput = '';
|
|
244
|
+
// A row-only delete leaves no slide-over to return to, so clear the selection on cancel.
|
|
245
|
+
if (deleteOnly) {
|
|
246
|
+
deleteOnly = false;
|
|
247
|
+
selected = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- the where-used overlay the slide-over and the dialog read, grouped published-then-branch ---
|
|
252
|
+
function usageEntries(hash: string): UsageEntry[] {
|
|
253
|
+
return data.usage[hash]?.entries ?? [];
|
|
254
|
+
}
|
|
255
|
+
/** Published rows first, then the edit-branch rows. */
|
|
256
|
+
function publishedRows(hash: string): UsageEntry[] {
|
|
257
|
+
return usageEntries(hash).filter((e) => e.origin.kind === 'published');
|
|
258
|
+
}
|
|
259
|
+
function branchRows(hash: string): UsageEntry[] {
|
|
260
|
+
return usageEntries(hash).filter((e) => e.origin.kind === 'branch');
|
|
261
|
+
}
|
|
262
|
+
const branchNameOf = (e: UsageEntry): string => (e.origin.kind === 'branch' ? e.origin.branch : '');
|
|
263
|
+
|
|
264
|
+
// --- the safe-delete dialog's face and its type-to-confirm gate ---
|
|
265
|
+
// The breaking list the dialog shows: the FRESH list from a refusal when one is present for this
|
|
266
|
+
// asset, else the load-time overlay. The fresh server list supersedes a stale load-time count.
|
|
267
|
+
const refusalForSelected = $derived(
|
|
268
|
+
form && form.hash && selected && form.hash === selected.hash ? form : null,
|
|
269
|
+
);
|
|
270
|
+
// The slide-over's error alert covers two failures that leave no in-use dialog to re-open: a pure
|
|
271
|
+
// ?/mediaUpdate failure (only `error`, no `hash`) and a hash-bearing delete refusal that is NOT an
|
|
272
|
+
// in-use block (a 404 "not committed", with `hash` but no `usage`). An in-use refusal (usage rows)
|
|
273
|
+
// re-opens the dialog instead, so it is excluded here.
|
|
274
|
+
const hasUsage = $derived((form?.usage?.length ?? 0) > 0);
|
|
275
|
+
const updateError = $derived(form?.error && !hasUsage ? form.error : null);
|
|
276
|
+
const breakingRows = $derived.by((): UsageEntry[] => {
|
|
277
|
+
if (refusalForSelected?.usage) return refusalForSelected.usage;
|
|
278
|
+
return selected ? usageEntries(selected.hash) : [];
|
|
279
|
+
});
|
|
280
|
+
// The face is chosen by whether the asset is in use at open: in-use names what breaks and gates
|
|
281
|
+
// Delete on a typed slug; orphan is a calm confirm. A refusal's fresh list also forces in-use.
|
|
282
|
+
const deleteInUse = $derived(breakingRows.length > 0);
|
|
283
|
+
const deleteBreakingPublished = $derived(breakingRows.filter((e) => e.origin.kind === 'published'));
|
|
284
|
+
const deleteBreakingBranch = $derived(breakingRows.filter((e) => e.origin.kind === 'branch'));
|
|
285
|
+
|
|
286
|
+
// The type-to-confirm input. The Delete submit is gated until it equals the asset slug (the one
|
|
287
|
+
// legitimate disable: a visible, typed destructive confirmation, not a hidden requirement).
|
|
288
|
+
let confirmSlugInput = $state('');
|
|
289
|
+
const confirmMatches = $derived(selected !== null && confirmSlugInput === selected.slug);
|
|
290
|
+
|
|
291
|
+
// Forms post full-page (no use:enhance), so on a failure the screen remounts with no selection and
|
|
292
|
+
// the error would render nowhere. This effect re-surfaces the failure from the `form` prop. An
|
|
293
|
+
// in-use delete refusal (usage rows) re-opens the dialog on its fresh breaking list; any other
|
|
294
|
+
// hash-bearing failure (a 404 "not committed", an invalid-slug ?/mediaUpdate) re-selects the asset
|
|
295
|
+
// and opens the slide-over so its error alert renders. The action redirects on success, so a
|
|
296
|
+
// present `form` is always a failure to re-surface.
|
|
297
|
+
//
|
|
298
|
+
// The dialog is always mounted and its body reads breakingRows/deleteInUse reactively, so set the
|
|
299
|
+
// state then call showModal() directly. tick() (NOT flushSync, which Svelte's flush_sync_in_effect
|
|
300
|
+
// guard rejects inside an effect on a newer 5.x) flushes the new `selected` before showModal so the
|
|
301
|
+
// dialog body renders the fresh asset.
|
|
302
|
+
$effect(() => {
|
|
303
|
+
if (!form || !form.hash) return;
|
|
304
|
+
const target = data.assets.find((a) => a.hash === form!.hash);
|
|
305
|
+
if (!target) return;
|
|
306
|
+
if (form.usage && form.usage.length > 0) {
|
|
307
|
+
// The in-use face, re-opened on the server's fresh breaking list.
|
|
308
|
+
if (deleteDialog && !deleteDialog.open) {
|
|
309
|
+
deleteOnly = true;
|
|
310
|
+
selected = target;
|
|
311
|
+
confirmSlugInput = '';
|
|
312
|
+
void tick().then(() => deleteDialog?.showModal());
|
|
313
|
+
}
|
|
314
|
+
} else if (!selected) {
|
|
315
|
+
// A hash-bearing failure that is not an in-use block: re-select the asset and open the
|
|
316
|
+
// slide-over so updateError renders. Guarded on `!selected` so it runs once, not on every edit.
|
|
317
|
+
deleteOnly = false;
|
|
318
|
+
selected = target;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// --- the copy-reference affordance, announced politely ---
|
|
323
|
+
let copyNotice = $state('');
|
|
324
|
+
function copyReference(token: string) {
|
|
325
|
+
void navigator.clipboard?.writeText(token).then(
|
|
326
|
+
() => {
|
|
327
|
+
copyNotice = 'Reference copied to the clipboard.';
|
|
328
|
+
},
|
|
329
|
+
() => {
|
|
330
|
+
copyNotice = 'Could not copy the reference.';
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- the alt editor's describe/decorative model (the 2b capture-card model) ---
|
|
336
|
+
// Seeded from the selected asset each time the slide-over opens: a non-empty alt is "describe", an
|
|
337
|
+
// empty alt is "decorative" only when the author last chose it, else unset. The Library has no
|
|
338
|
+
// stored decorative flag, so an empty alt reads as unset (needs-alt), matching MediaCaptureCard.
|
|
339
|
+
let altMode = $state<'describe' | 'decorative' | null>(null);
|
|
340
|
+
let altText = $state('');
|
|
341
|
+
let nameInput = $state('');
|
|
342
|
+
let slugInput = $state('');
|
|
343
|
+
// Reseed the editable fields whenever the selected asset changes.
|
|
344
|
+
$effect(() => {
|
|
345
|
+
const a = selected;
|
|
346
|
+
if (!a) return;
|
|
347
|
+
altText = a.alt;
|
|
348
|
+
altMode = a.alt.trim() !== '' ? 'describe' : null;
|
|
349
|
+
nameInput = a.displayName;
|
|
350
|
+
slugInput = a.slug;
|
|
351
|
+
});
|
|
352
|
+
// The submitted alt: a described image carries its text, a decorative or left-blank submits empty
|
|
353
|
+
// (matching MediaCaptureCard's needs-alt-debt model).
|
|
354
|
+
const submittedAlt = $derived(altMode === 'describe' ? altText : '');
|
|
355
|
+
|
|
356
|
+
// --- the roving tabindex over the grid's visible tiles ---
|
|
357
|
+
// One tabstop for the listbox: the active index is the only option with tabindex 0; arrows,
|
|
358
|
+
// Home, and End move it; Enter/Space activate. The active index is clamped as filtering changes
|
|
359
|
+
// the visible set, so a focused option that filters out moves to a valid neighbor.
|
|
360
|
+
let activeIndex = $state(0);
|
|
361
|
+
$effect(() => {
|
|
362
|
+
const max = Math.max(0, visible.length - 1);
|
|
363
|
+
if (activeIndex > max) activeIndex = max;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
let tileEls = $state<HTMLElement[]>([]);
|
|
367
|
+
function focusTile(i: number) {
|
|
368
|
+
activeIndex = i;
|
|
369
|
+
tileEls[i]?.focus();
|
|
370
|
+
}
|
|
371
|
+
function onGridKeydown(e: KeyboardEvent, i: number) {
|
|
372
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
focusTile(Math.min(i + 1, visible.length - 1));
|
|
375
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
focusTile(Math.max(i - 1, 0));
|
|
378
|
+
} else if (e.key === 'Home') {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
focusTile(0);
|
|
381
|
+
} else if (e.key === 'End') {
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
focusTile(visible.length - 1);
|
|
384
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
385
|
+
e.preventDefault();
|
|
386
|
+
openAsset(visible[i], tileEls[i]);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// --- the broken-thumbnail affordance: a tile/row whose R2 object 404s still lists ---
|
|
391
|
+
// The set of hashes whose thumbnail failed to load, so the dead asset can be cleared.
|
|
392
|
+
let brokenHashes = $state(new Set<string>());
|
|
393
|
+
function markBroken(hash: string) {
|
|
394
|
+
if (brokenHashes.has(hash)) return;
|
|
395
|
+
const next = new Set(brokenHashes);
|
|
396
|
+
next.add(hash);
|
|
397
|
+
brokenHashes = next;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- display helpers ---
|
|
401
|
+
const dateFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
|
|
402
|
+
function formatAdded(iso: string): string {
|
|
403
|
+
const parsed = new Date(iso);
|
|
404
|
+
return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
|
|
405
|
+
}
|
|
406
|
+
function formatBytes(bytes: number): string {
|
|
407
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
408
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
409
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
410
|
+
}
|
|
411
|
+
/** The total stored bytes, for the count line. */
|
|
412
|
+
const totalBytes = $derived(data.assets.reduce((sum, a) => sum + a.bytes, 0));
|
|
413
|
+
/** Dimensions plus type for the list row metadata line. */
|
|
414
|
+
function dimensions(asset: MediaLibraryEntry): string {
|
|
415
|
+
return asset.width && asset.height ? `${asset.width}×${asset.height}` : '';
|
|
416
|
+
}
|
|
417
|
+
function typeLabel(asset: MediaLibraryEntry): string {
|
|
418
|
+
return asset.ext.toUpperCase();
|
|
419
|
+
}
|
|
420
|
+
function thumbSrc(asset: MediaLibraryEntry): string {
|
|
421
|
+
return publicPath(asset.slug, asset.hash, asset.ext, 'slug');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The selected-cue check glyph for the triage radiogroup (WCAG 1.4.1): hue never carries the
|
|
425
|
+
// chosen state alone, the same non-color cue the ConceptList triage uses.
|
|
426
|
+
function segButtonClass(on: boolean): string {
|
|
427
|
+
return `inline-flex items-center gap-1.5 px-3 py-1 text-[0.8125rem] font-normal ${on ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
|
|
428
|
+
}
|
|
429
|
+
function densityButtonClass(on: boolean): string {
|
|
430
|
+
return `inline-flex items-center justify-center rounded-md p-1.5 ${on ? 'bg-primary/10 text-primary' : 'text-[var(--color-muted)] hover:bg-base-content/[0.06]'}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
|
|
434
|
+
</script>
|
|
435
|
+
|
|
436
|
+
<svelte:window onkeydown={onWindowKeydown} />
|
|
437
|
+
|
|
438
|
+
<!-- The office header recipe: the Media eyebrow, the display-face heading, a live count line, and
|
|
439
|
+
the Upload primary action top-right. -->
|
|
440
|
+
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
441
|
+
<div class="flex flex-col gap-0.5">
|
|
442
|
+
<span class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Media</span>
|
|
443
|
+
<h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">Media library</h1>
|
|
444
|
+
<p class="text-sm text-[var(--color-muted)]">
|
|
445
|
+
{triageCounts.all} {triageCounts.all === 1 ? 'image' : 'images'}, {usedCount} used on the site<span class="px-1.5" aria-hidden="true">·</span>{formatBytes(totalBytes)} stored
|
|
446
|
+
</p>
|
|
447
|
+
</div>
|
|
448
|
+
<!-- TODO(Task 7+): wire a real Library upload (no media-only upload action exists in 3c; the 2b
|
|
449
|
+
upload commits to an entry's branch at save). This is a working, focusable button shell, never
|
|
450
|
+
a faked upload. -->
|
|
451
|
+
<button type="button" class="btn btn-primary btn-sm shrink-0">
|
|
452
|
+
<UploadIcon class="h-4 w-4" /> Upload
|
|
453
|
+
</button>
|
|
454
|
+
</header>
|
|
455
|
+
|
|
456
|
+
<!-- The action feedback strip (the office flash grammar). A persistent polite live region carries
|
|
457
|
+
the success message, so an inserted-fresh element is announced reliably; the visible alert below
|
|
458
|
+
keeps its styling without a role. The strip never steals focus. -->
|
|
459
|
+
<div class="sr-only" aria-live="polite">{flashMessage}</div>
|
|
460
|
+
{#if flashMessage}
|
|
461
|
+
<div class="alert alert-success mb-4 text-sm">{flashMessage}</div>
|
|
462
|
+
{/if}
|
|
463
|
+
{#if data.flashError}
|
|
464
|
+
<div role="alert" class="alert alert-error mb-4 text-sm">{data.flashError}</div>
|
|
465
|
+
{/if}
|
|
466
|
+
{#if data.error}
|
|
467
|
+
<div role="alert" class="alert alert-warning mb-4 text-sm">{data.error}</div>
|
|
468
|
+
{/if}
|
|
469
|
+
|
|
470
|
+
{#if data.assets.length === 0}
|
|
471
|
+
<!-- The empty state owns the content area (the office recipe): the mark, the copy, and an Upload
|
|
472
|
+
CTA over a dropzone line. Triage and search stay hidden until there is content. -->
|
|
473
|
+
<div class="flex min-h-[52vh] flex-col items-center justify-center gap-4 px-6 py-14 text-center">
|
|
474
|
+
<CairnLogo class="h-12 w-12 text-primary opacity-30" />
|
|
475
|
+
<div class="space-y-1">
|
|
476
|
+
<p class="font-semibold text-base-content font-[family-name:var(--font-display)] text-xl">No media yet</p>
|
|
477
|
+
<p class="mx-auto max-w-[40ch] text-sm text-[var(--color-muted)]">
|
|
478
|
+
Upload an image and it shows up here, ready to drop into a post or set as a hero.
|
|
479
|
+
</p>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="mt-1 flex flex-col items-center gap-2 rounded-box border border-dashed border-[var(--cairn-card-border)] px-7 py-5 text-[var(--color-muted)]">
|
|
482
|
+
<button type="button" class="btn btn-primary btn-sm">
|
|
483
|
+
<UploadIcon class="h-4 w-4" /> Upload an image
|
|
484
|
+
</button>
|
|
485
|
+
<span class="text-xs">or drop a file anywhere on this page</span>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
{:else}
|
|
489
|
+
<!-- One toolbar row: search (left, flexes), the triage radiogroup, the type facet (seam), and the
|
|
490
|
+
grid/list density toggle (right). -->
|
|
491
|
+
<div class="mb-4 flex flex-wrap items-center gap-3">
|
|
492
|
+
<label class="input input-sm min-w-0 flex-1 sm:max-w-xs">
|
|
493
|
+
<SearchIcon class="h-4 w-4 opacity-60" aria-hidden="true" />
|
|
494
|
+
<input type="search" aria-label="Search the media library" bind:value={query} placeholder="Search name or alt" />
|
|
495
|
+
</label>
|
|
496
|
+
|
|
497
|
+
<!-- The triage is a pick-one radiogroup: aria-checked, never aria-pressed. -->
|
|
498
|
+
<div role="radiogroup" aria-label="Filter assets" class="bg-base-100 inline-flex items-center overflow-hidden rounded-lg border border-[var(--cairn-card-border)]">
|
|
499
|
+
{#each segments as seg, i (seg.value)}
|
|
500
|
+
<button
|
|
501
|
+
bind:this={segEls[i]}
|
|
502
|
+
type="button"
|
|
503
|
+
role="radio"
|
|
504
|
+
aria-checked={triage === seg.value}
|
|
505
|
+
tabindex={triage === seg.value ? 0 : -1}
|
|
506
|
+
class="{segButtonClass(triage === seg.value)} {i > 0 ? 'border-l border-[var(--cairn-card-border)]' : ''}"
|
|
507
|
+
onclick={() => selectTriage(seg.value)}
|
|
508
|
+
onkeydown={(e) => onTriageKeydown(e, i)}
|
|
509
|
+
>
|
|
510
|
+
{#if triage === seg.value}<CheckIcon class="h-3 w-3" aria-hidden="true" />{/if}
|
|
511
|
+
{seg.label}<span class="tabular-nums">{seg.count()}</span>
|
|
512
|
+
</button>
|
|
513
|
+
{/each}
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
{#if showFacet}
|
|
517
|
+
<!-- The type facet seam, shown only past one distinct stored type. It is presentational in
|
|
518
|
+
this slice (images-only delivery), so it carries no live filter selection yet. -->
|
|
519
|
+
<div role="radiogroup" aria-label="Filter by type" class="bg-base-100 inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[0.8125rem] text-[var(--color-muted)]">
|
|
520
|
+
<span class="text-xs">Type</span>
|
|
521
|
+
<button type="button" role="radio" aria-checked="true" class="font-medium text-primary">All</button>
|
|
522
|
+
</div>
|
|
523
|
+
{/if}
|
|
524
|
+
|
|
525
|
+
<span class="flex-1"></span>
|
|
526
|
+
|
|
527
|
+
<div role="group" aria-label="Layout density" class="bg-base-100 inline-flex items-center gap-1 rounded-lg border border-[var(--cairn-card-border)] p-0.5">
|
|
528
|
+
<button type="button" aria-label="Grid view" aria-pressed={density === 'grid'} class={densityButtonClass(density === 'grid')} onclick={() => (density = 'grid')}>
|
|
529
|
+
<LayoutGridIcon class="h-4 w-4" />
|
|
530
|
+
</button>
|
|
531
|
+
<button type="button" aria-label="List view" aria-pressed={density === 'list'} class={densityButtonClass(density === 'list')} onclick={() => (density = 'list')}>
|
|
532
|
+
<ListIcon class="h-4 w-4" />
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
|
|
537
|
+
{#if sorted.length === 0}
|
|
538
|
+
<!-- A filter or search narrowed the set to zero; the assets exist, none match. -->
|
|
539
|
+
<div role="status" class="flex flex-col items-center gap-3 px-6 py-14 text-center">
|
|
540
|
+
<SearchIcon class="h-8 w-8 text-[var(--color-subtle)] opacity-40" aria-hidden="true" />
|
|
541
|
+
<p class="text-sm text-[var(--color-muted)]">No media match this filter.</p>
|
|
542
|
+
</div>
|
|
543
|
+
{:else if density === 'grid'}
|
|
544
|
+
<!-- The grid: a roving-tabindex listbox of tiles. One tabstop; arrows move the roving index;
|
|
545
|
+
Enter/Space open. Each tile names the asset, its alt status (a glyph plus a label, never hue
|
|
546
|
+
alone), and a compact usage marker. -->
|
|
547
|
+
<ul role="listbox" aria-label="Media library" class="grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3 lg:grid-cols-4">
|
|
548
|
+
{#each visible as asset, i (asset.hash)}
|
|
549
|
+
{@const used = usageCount(asset.hash)}
|
|
550
|
+
{@const missing = needsAlt(asset)}
|
|
551
|
+
<li role="presentation" class="contents">
|
|
552
|
+
<div
|
|
553
|
+
bind:this={tileEls[i]}
|
|
554
|
+
role="option"
|
|
555
|
+
aria-selected={selected?.hash === asset.hash}
|
|
556
|
+
tabindex={i === activeIndex ? 0 : -1}
|
|
557
|
+
aria-label="{asset.displayName}. {missing ? 'Needs alt text' : 'Described'}. {used > 0 ? `Found in ${used} ${used === 1 ? 'entry' : 'entries'}` : 'No references found'}."
|
|
558
|
+
class="group flex cursor-pointer flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-primary/70 {selected?.hash === asset.hash ? 'ring-2 ring-primary/70' : ''}"
|
|
559
|
+
onclick={(e) => openAsset(asset, e.currentTarget)}
|
|
560
|
+
onkeydown={(e) => onGridKeydown(e, i)}
|
|
561
|
+
>
|
|
562
|
+
<div class="relative flex aspect-[4/3] items-center justify-center bg-base-200/60">
|
|
563
|
+
<!-- The usage marker, top-right: a used count, or the warning-ink Unused chip. -->
|
|
564
|
+
{#if used > 0}
|
|
565
|
+
<span class="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full border border-[var(--cairn-card-border)] bg-base-100/90 px-2 py-0.5 text-[0.625rem] font-semibold text-[var(--color-muted)]">used {used}</span>
|
|
566
|
+
{:else}
|
|
567
|
+
<span class="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full border border-[var(--cairn-card-border)] bg-base-100/90 px-2 py-0.5 text-[0.625rem] font-semibold text-[var(--cairn-warning-ink)]">Unused</span>
|
|
568
|
+
{/if}
|
|
569
|
+
{#if brokenHashes.has(asset.hash)}
|
|
570
|
+
<span data-cairn-broken class="flex flex-col items-center gap-1 text-[var(--color-subtle)]">
|
|
571
|
+
<ImageOffIcon class="h-7 w-7" aria-hidden="true" />
|
|
572
|
+
<span class="text-[0.625rem]">Image missing</span>
|
|
573
|
+
</span>
|
|
574
|
+
{:else}
|
|
575
|
+
<img
|
|
576
|
+
src={thumbSrc(asset)}
|
|
577
|
+
alt=""
|
|
578
|
+
aria-hidden="true"
|
|
579
|
+
class="max-h-full max-w-full object-contain"
|
|
580
|
+
onerror={() => markBroken(asset.hash)}
|
|
581
|
+
/>
|
|
582
|
+
{/if}
|
|
583
|
+
</div>
|
|
584
|
+
<div class="flex items-center justify-between gap-2 border-t border-[var(--cairn-card-border)] px-2.5 py-2">
|
|
585
|
+
<span class="cairn-ml-name min-w-0 flex-1 truncate text-[0.8125rem] font-medium">{asset.displayName}</span>
|
|
586
|
+
{#if missing}
|
|
587
|
+
<span class="inline-flex items-center gap-1 text-[var(--cairn-warning-ink)]" role="img" aria-label="Needs alt text">
|
|
588
|
+
<TriangleAlertIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
589
|
+
<span class="text-[0.625rem] font-medium">Needs alt</span>
|
|
590
|
+
</span>
|
|
591
|
+
{:else}
|
|
592
|
+
<span class="inline-flex items-center gap-1 text-[var(--color-positive-ink)]" role="img" aria-label="Described">
|
|
593
|
+
<CheckIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
594
|
+
</span>
|
|
595
|
+
{/if}
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
</li>
|
|
599
|
+
{/each}
|
|
600
|
+
</ul>
|
|
601
|
+
{:else}
|
|
602
|
+
<!-- The list density: a real table. Each row opens the detail (sets `selected`); the Added
|
|
603
|
+
column sorts through a real <th><button> with aria-sort; the per-row delete is always
|
|
604
|
+
visible and sets the pending-delete intent Task 7 reads. -->
|
|
605
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-x-auto shadow-[var(--cairn-shadow)]">
|
|
606
|
+
<table class="table">
|
|
607
|
+
<thead>
|
|
608
|
+
<tr class="border-base-300">
|
|
609
|
+
<th class={headerLabel}>Asset</th>
|
|
610
|
+
<th class="{headerLabel} w-32">Alt status</th>
|
|
611
|
+
<th class="{headerLabel} w-40">Used</th>
|
|
612
|
+
<th class="w-24 text-right" aria-sort={addedSort}>
|
|
613
|
+
<button type="button" class="ml-auto inline-flex items-center gap-1 {headerLabel} hover:text-base-content" aria-label="Sort by date added" onclick={toggleSort}>
|
|
614
|
+
Added
|
|
615
|
+
<ChevronDownIcon class="h-3 w-3 {sortAsc ? 'rotate-180' : ''}" aria-hidden="true" />
|
|
616
|
+
</button>
|
|
617
|
+
</th>
|
|
618
|
+
<th class="w-12 text-right"><span class="sr-only">Actions</span></th>
|
|
619
|
+
</tr>
|
|
620
|
+
</thead>
|
|
621
|
+
<tbody>
|
|
622
|
+
{#each visible as asset (asset.hash)}
|
|
623
|
+
{@const used = usageCount(asset.hash)}
|
|
624
|
+
{@const missing = needsAlt(asset)}
|
|
625
|
+
<tr class="transition-colors hover:bg-base-200/60 {selected?.hash === asset.hash ? 'bg-primary/[0.06]' : ''}">
|
|
626
|
+
<td class="max-w-0">
|
|
627
|
+
<button type="button" class="flex w-full items-center gap-3 text-left" onclick={(e) => openAsset(asset, e.currentTarget)}>
|
|
628
|
+
<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">
|
|
629
|
+
{#if brokenHashes.has(asset.hash)}
|
|
630
|
+
<ImageOffIcon data-cairn-broken class="h-4 w-4 text-[var(--color-subtle)]" aria-hidden="true" />
|
|
631
|
+
{:else}
|
|
632
|
+
<img src={thumbSrc(asset)} alt="" aria-hidden="true" class="h-full w-full object-cover" onerror={() => markBroken(asset.hash)} />
|
|
633
|
+
{/if}
|
|
634
|
+
</span>
|
|
635
|
+
<span class="flex min-w-0 flex-col">
|
|
636
|
+
<span class="cairn-ml-name truncate text-sm font-semibold">{asset.displayName}</span>
|
|
637
|
+
<span class="truncate text-[0.75rem] text-[var(--color-muted)] tabular-nums">
|
|
638
|
+
{#if dimensions(asset)}{dimensions(asset)}<span class="px-1" aria-hidden="true">·</span>{/if}{formatBytes(asset.bytes)}<span class="px-1" aria-hidden="true">·</span>{typeLabel(asset)}
|
|
639
|
+
</span>
|
|
640
|
+
</span>
|
|
641
|
+
</button>
|
|
642
|
+
</td>
|
|
643
|
+
<td class="w-32">
|
|
644
|
+
{#if missing}
|
|
645
|
+
<span class="inline-flex items-center gap-1 text-[0.75rem] font-medium text-[var(--cairn-warning-ink)]">
|
|
646
|
+
<TriangleAlertIcon class="h-3.5 w-3.5" aria-hidden="true" /> Needs alt
|
|
647
|
+
</span>
|
|
648
|
+
{:else}
|
|
649
|
+
<span class="inline-flex items-center gap-1 text-[0.75rem] font-medium text-[var(--color-positive-ink)]">
|
|
650
|
+
<CheckIcon class="h-3.5 w-3.5" aria-hidden="true" /> Described
|
|
651
|
+
</span>
|
|
652
|
+
{/if}
|
|
653
|
+
</td>
|
|
654
|
+
<td class="w-40 text-[0.8125rem]">
|
|
655
|
+
{#if used > 0}
|
|
656
|
+
<span class="text-base-content">found in {used}</span>
|
|
657
|
+
{:else}
|
|
658
|
+
<span class="text-[var(--color-muted)]">no references found</span>
|
|
659
|
+
{/if}
|
|
660
|
+
</td>
|
|
661
|
+
<td class="w-24 text-right text-sm tabular-nums text-[var(--color-muted)]">{formatAdded(asset.createdAt)}</td>
|
|
662
|
+
<td class="w-12 text-right">
|
|
663
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Delete {asset.displayName}" onclick={() => requestDelete(asset)}>
|
|
664
|
+
<Trash2Icon class="h-4 w-4 text-error" />
|
|
665
|
+
</button>
|
|
666
|
+
</td>
|
|
667
|
+
</tr>
|
|
668
|
+
{/each}
|
|
669
|
+
</tbody>
|
|
670
|
+
</table>
|
|
671
|
+
</div>
|
|
672
|
+
{/if}
|
|
673
|
+
|
|
674
|
+
{#if sorted.length > 0}
|
|
675
|
+
<!-- The announced count plus the managed Load more (never infinite scroll). One persistent
|
|
676
|
+
polite region carries "Showing N of M". -->
|
|
677
|
+
<div class="sr-only" role="status" aria-live="polite">Showing {visible.length} of {sorted.length} {sorted.length === 1 ? 'image' : 'images'}.</div>
|
|
678
|
+
<div class="mt-4 flex flex-col items-center gap-2">
|
|
679
|
+
<span class="text-sm text-[var(--color-muted)]">Showing {visible.length} of {sorted.length}</span>
|
|
680
|
+
{#if hasMore}
|
|
681
|
+
<button type="button" class="btn btn-sm" onclick={loadMore}>Load more</button>
|
|
682
|
+
{/if}
|
|
683
|
+
</div>
|
|
684
|
+
{/if}
|
|
685
|
+
{/if}
|
|
686
|
+
|
|
687
|
+
<!-- A persistent polite region announces a copy-reference result. -->
|
|
688
|
+
<div class="sr-only" role="status" aria-live="polite">{copyNotice}</div>
|
|
689
|
+
|
|
690
|
+
{#if selected && !deleteOnly}
|
|
691
|
+
{@const asset = selected}
|
|
692
|
+
{@const reference = mediaToken({ slug: asset.slug, hash: asset.hash })}
|
|
693
|
+
<!-- The NON-MODAL detail slide-over: no scrim, the library stays live behind it. It is a labelled
|
|
694
|
+
region, not a dialog, so the list stays in the a11y tree and the tab order. Escape closes it
|
|
695
|
+
and focus returns to the originating tile or row (the region-with-focus-management recipe).
|
|
696
|
+
Below the narrow breakpoint the same panel reads as a bottom sheet (the responsive treatment). -->
|
|
697
|
+
<aside
|
|
698
|
+
bind:this={panelEl}
|
|
699
|
+
role="region"
|
|
700
|
+
aria-label="{asset.displayName} details"
|
|
701
|
+
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"
|
|
702
|
+
>
|
|
703
|
+
<div class="flex items-center justify-between border-b border-[var(--cairn-card-border)] px-4 py-3.5">
|
|
704
|
+
<h2 class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Asset</h2>
|
|
705
|
+
<button bind:this={closeButton} type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close details" onclick={closePanel}>
|
|
706
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
707
|
+
</button>
|
|
708
|
+
</div>
|
|
709
|
+
|
|
710
|
+
<div class="flex flex-col gap-5 overflow-y-auto p-4">
|
|
711
|
+
<!-- The large preview, object-fit contain on the quiet mat, with the broken-image affordance. -->
|
|
712
|
+
<div class="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-200/60">
|
|
713
|
+
{#if brokenHashes.has(asset.hash)}
|
|
714
|
+
<span data-cairn-broken class="flex flex-col items-center gap-1 text-[var(--color-subtle)]">
|
|
715
|
+
<ImageOffIcon class="h-8 w-8" aria-hidden="true" />
|
|
716
|
+
<span class="text-xs">Image missing</span>
|
|
717
|
+
</span>
|
|
718
|
+
{:else}
|
|
719
|
+
<img src={thumbSrc(asset)} alt="" aria-hidden="true" class="max-h-full max-w-full object-contain" onerror={() => markBroken(asset.hash)} />
|
|
720
|
+
{/if}
|
|
721
|
+
</div>
|
|
722
|
+
|
|
723
|
+
<!-- The name and the media: reference with a copy button. -->
|
|
724
|
+
<div class="flex flex-col gap-1.5">
|
|
725
|
+
<span class="text-[1.0625rem] font-semibold leading-tight break-words">{asset.displayName}</span>
|
|
726
|
+
<span class="flex items-center gap-1.5">
|
|
727
|
+
<code class="min-w-0 break-all font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--color-muted)]">{reference}</code>
|
|
728
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Copy reference" onclick={() => copyReference(reference)}>
|
|
729
|
+
<CopyIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
|
730
|
+
</button>
|
|
731
|
+
</span>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
<!-- The metadata edit form: the display name, the slug, and the default alt, posting one Save
|
|
735
|
+
to ?/mediaUpdate. The alt is the asset DEFAULT for new placements, never a rewrite of
|
|
736
|
+
the alt already committed in existing placements (decision 6). -->
|
|
737
|
+
<form method="POST" action="?/mediaUpdate" class="flex flex-col gap-4">
|
|
738
|
+
<CsrfField />
|
|
739
|
+
<input type="hidden" name="hash" value={asset.hash} />
|
|
740
|
+
|
|
741
|
+
<label class="flex flex-col gap-1">
|
|
742
|
+
<span class="text-[0.8125rem] font-medium">Name</span>
|
|
743
|
+
<input class="input input-sm" name="displayName" bind:value={nameInput} autocomplete="off" />
|
|
744
|
+
</label>
|
|
745
|
+
<label class="flex flex-col gap-1">
|
|
746
|
+
<span class="text-[0.8125rem] font-medium">URL slug</span>
|
|
747
|
+
<input class="input input-sm font-[family-name:var(--font-editor)]" name="slug" bind:value={slugInput} autocomplete="off" />
|
|
748
|
+
</label>
|
|
749
|
+
|
|
750
|
+
<!-- The alt editor: the describe/decorative radiogroup (the 2b model) plus the alt field.
|
|
751
|
+
Alt is debt: Save is never gated on it, and a left-blank or a decorative both submit an
|
|
752
|
+
empty alt. The submitted value rides a hidden input so the disabled-or-absent textarea
|
|
753
|
+
never strands the field. -->
|
|
754
|
+
<fieldset class="flex flex-col gap-2" aria-describedby="cairn-ml-alt-note">
|
|
755
|
+
<legend class="text-[0.8125rem] font-medium">Default alt text</legend>
|
|
756
|
+
<p id="cairn-ml-alt-note" class="text-xs text-[var(--color-muted)]">
|
|
757
|
+
The default for the next time this image is placed. It does not change the alt on pages that already use it. You can save without it and add it later.
|
|
758
|
+
</p>
|
|
759
|
+
<input type="hidden" name="alt" value={submittedAlt} />
|
|
760
|
+
<label class="flex cursor-pointer items-center gap-2">
|
|
761
|
+
<input type="radio" class="radio radio-sm" name="cairn-ml-alt-mode" value="describe" bind:group={altMode} />
|
|
762
|
+
<span class="text-sm">Describe it</span>
|
|
763
|
+
</label>
|
|
764
|
+
{#if altMode === 'describe'}
|
|
765
|
+
<textarea class="textarea textarea-sm ml-6 w-[calc(100%-1.5rem)]" aria-label="Alt text description" rows="2" bind:value={altText}></textarea>
|
|
766
|
+
{/if}
|
|
767
|
+
<label class="flex cursor-pointer items-center gap-2">
|
|
768
|
+
<input type="radio" class="radio radio-sm" name="cairn-ml-alt-mode" value="decorative" bind:group={altMode} />
|
|
769
|
+
<span class="text-sm">Decorative</span>
|
|
770
|
+
</label>
|
|
771
|
+
</fieldset>
|
|
772
|
+
|
|
773
|
+
{#if updateError}
|
|
774
|
+
<p role="alert" class="text-xs text-[var(--cairn-error-ink)]">{updateError}</p>
|
|
775
|
+
{/if}
|
|
776
|
+
|
|
777
|
+
<div class="flex justify-end">
|
|
778
|
+
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
|
779
|
+
</div>
|
|
780
|
+
</form>
|
|
781
|
+
|
|
782
|
+
<!-- Where used, grouped published-then-branch. Each entry links to its editor; a branch entry
|
|
783
|
+
names its branch. No entries shows the no-references treatment (never a bare "unused"). -->
|
|
784
|
+
<div class="flex flex-col gap-3">
|
|
785
|
+
<div class="flex items-baseline justify-between">
|
|
786
|
+
<span class={headerLabel}>Where used</span>
|
|
787
|
+
{#if usageEntries(asset.hash).length > 0}
|
|
788
|
+
<span class="text-xs text-[var(--color-muted)]">{usageEntries(asset.hash).length} {usageEntries(asset.hash).length === 1 ? 'entry' : 'entries'}</span>
|
|
789
|
+
{/if}
|
|
790
|
+
</div>
|
|
791
|
+
|
|
792
|
+
{#if usageEntries(asset.hash).length === 0}
|
|
793
|
+
<div class="flex items-start gap-2.5 rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
|
|
794
|
+
<Link2OffIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
795
|
+
<span class="text-[0.8125rem] leading-relaxed">No references found. Deleting this changes nothing readers see.</span>
|
|
796
|
+
</div>
|
|
797
|
+
{:else}
|
|
798
|
+
{#if publishedRows(asset.hash).length > 0}
|
|
799
|
+
<div class="flex flex-col gap-1.5">
|
|
800
|
+
<span class="text-[0.6875rem] font-semibold text-[var(--color-muted)]">Published on the site</span>
|
|
801
|
+
<ul class="flex list-none flex-col gap-1 p-0">
|
|
802
|
+
{#each publishedRows(asset.hash) as entry (entry.concept + '/' + entry.id)}
|
|
803
|
+
<li>
|
|
804
|
+
<a href="/admin/{entry.concept}/{entry.id}" class="flex items-center gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-100 px-2.5 py-2 no-underline hover:border-primary/40">
|
|
805
|
+
<FileTextIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
806
|
+
<span class="min-w-0 flex-1 truncate text-[0.8125rem] font-medium">{entry.title}</span>
|
|
807
|
+
<ChevronRightIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)] opacity-60" aria-hidden="true" />
|
|
808
|
+
</a>
|
|
809
|
+
</li>
|
|
810
|
+
{/each}
|
|
811
|
+
</ul>
|
|
812
|
+
</div>
|
|
813
|
+
{/if}
|
|
814
|
+
{#if branchRows(asset.hash).length > 0}
|
|
815
|
+
<div class="flex flex-col gap-1.5">
|
|
816
|
+
<span class="text-[0.6875rem] font-semibold text-[var(--color-muted)]">In an unpublished edit</span>
|
|
817
|
+
<ul class="flex list-none flex-col gap-1 p-0">
|
|
818
|
+
{#each branchRows(asset.hash) as entry (entry.concept + '/' + entry.id + branchNameOf(entry))}
|
|
819
|
+
<li>
|
|
820
|
+
<a href="/admin/{entry.concept}/{entry.id}" class="flex items-center gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-100 px-2.5 py-2 no-underline hover:border-primary/40">
|
|
821
|
+
<FileTextIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
|
|
822
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
823
|
+
<span class="truncate text-[0.8125rem] font-medium">{entry.title}</span>
|
|
824
|
+
<span class="truncate font-[family-name:var(--font-editor)] text-[0.625rem] text-[var(--cairn-warning-ink)]">{branchNameOf(entry)}</span>
|
|
825
|
+
</span>
|
|
826
|
+
<ChevronRightIcon class="h-3.5 w-3.5 flex-none text-[var(--color-muted)] opacity-60" aria-hidden="true" />
|
|
827
|
+
</a>
|
|
828
|
+
</li>
|
|
829
|
+
{/each}
|
|
830
|
+
</ul>
|
|
831
|
+
</div>
|
|
832
|
+
{/if}
|
|
833
|
+
{/if}
|
|
834
|
+
</div>
|
|
835
|
+
|
|
836
|
+
<!-- The metadata grid. -->
|
|
837
|
+
<div>
|
|
838
|
+
<span class={headerLabel}>Details</span>
|
|
839
|
+
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3.5 gap-y-1.5 text-[0.8125rem]">
|
|
840
|
+
{#if dimensions(asset)}
|
|
841
|
+
<dt class="text-[var(--color-muted)]">Dimensions</dt>
|
|
842
|
+
<dd class="m-0 text-right tabular-nums">{dimensions(asset)}</dd>
|
|
843
|
+
{/if}
|
|
844
|
+
<dt class="text-[var(--color-muted)]">Size</dt>
|
|
845
|
+
<dd class="m-0 text-right tabular-nums">{formatBytes(asset.bytes)}</dd>
|
|
846
|
+
<dt class="text-[var(--color-muted)]">Type</dt>
|
|
847
|
+
<dd class="m-0 text-right">{typeLabel(asset)}</dd>
|
|
848
|
+
<dt class="text-[var(--color-muted)]">Added</dt>
|
|
849
|
+
<dd class="m-0 text-right tabular-nums">{formatAdded(asset.createdAt)}</dd>
|
|
850
|
+
</dl>
|
|
851
|
+
</div>
|
|
852
|
+
|
|
853
|
+
<!-- The actions. Replace is deferred (no Replace control in this slice). -->
|
|
854
|
+
<div class="flex gap-2.5 border-t border-[var(--cairn-card-border)] pt-4">
|
|
855
|
+
<button type="button" class="btn btn-sm flex-1 border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={openDeleteDialog}>
|
|
856
|
+
<Trash2Icon class="h-4 w-4" aria-hidden="true" /> Delete
|
|
857
|
+
</button>
|
|
858
|
+
</div>
|
|
859
|
+
</div>
|
|
860
|
+
</aside>
|
|
861
|
+
{/if}
|
|
862
|
+
|
|
863
|
+
<!-- The two-faced safe-delete alertdialog: a native modal <dialog> (the focus trap is native), with
|
|
864
|
+
NO light dismiss (no method="dialog" backdrop). The in-use face names the breaking entries and
|
|
865
|
+
gates Delete behind the typed-slug confirmation; the orphan face is a calm confirm. Both post
|
|
866
|
+
hash to ?/mediaDelete; the in-use face also posts confirmSlug. -->
|
|
867
|
+
<dialog
|
|
868
|
+
bind:this={deleteDialog}
|
|
869
|
+
class="modal"
|
|
870
|
+
role="alertdialog"
|
|
871
|
+
aria-labelledby="cairn-ml-delete-title"
|
|
872
|
+
aria-describedby="cairn-ml-delete-desc"
|
|
873
|
+
oncancel={closeDeleteDialog}
|
|
874
|
+
>
|
|
875
|
+
{#if selected}
|
|
876
|
+
{@const asset = selected}
|
|
877
|
+
<div class="modal-box max-w-lg">
|
|
878
|
+
<div class="mb-3 flex items-start gap-3">
|
|
879
|
+
<span class="flex h-9 w-9 flex-none items-center justify-center rounded-box {deleteInUse ? 'bg-[var(--cairn-error-tint)] text-[var(--cairn-error-ink)]' : 'bg-base-content/[0.07] text-[var(--color-muted)]'}" aria-hidden="true">
|
|
880
|
+
{#if deleteInUse}<TriangleAlertIcon class="h-5 w-5" />{:else}<Trash2Icon class="h-5 w-5" />{/if}
|
|
881
|
+
</span>
|
|
882
|
+
<div class="flex-1">
|
|
883
|
+
<h2 id="cairn-ml-delete-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">Delete {asset.displayName}?</h2>
|
|
884
|
+
<p id="cairn-ml-delete-desc" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
|
|
885
|
+
{#if deleteInUse}
|
|
886
|
+
Deleting this breaks the image in {breakingRows.length} {breakingRows.length === 1 ? 'entry' : 'entries'}. Type the name to delete it anyway.
|
|
887
|
+
{:else}
|
|
888
|
+
No references found. Deleting this changes nothing readers see.
|
|
889
|
+
{/if}
|
|
890
|
+
</p>
|
|
891
|
+
</div>
|
|
892
|
+
</div>
|
|
893
|
+
|
|
894
|
+
<div class="flex flex-col gap-3">
|
|
895
|
+
{#if deleteInUse}
|
|
896
|
+
<div>
|
|
897
|
+
<span class="mb-2 inline-flex items-center gap-1.5 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
|
|
898
|
+
<XIcon class="h-3.5 w-3.5" aria-hidden="true" /> These would break
|
|
899
|
+
</span>
|
|
900
|
+
<ul class="flex max-h-44 list-none flex-col gap-1 overflow-y-auto rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-2">
|
|
901
|
+
{#if deleteBreakingPublished.length > 0}
|
|
902
|
+
<li class="px-1.5 pb-0.5 pt-1 text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">Published on the site</li>
|
|
903
|
+
{#each deleteBreakingPublished as entry (entry.concept + '/' + entry.id)}
|
|
904
|
+
<li><a href="/admin/{entry.concept}/{entry.id}" class="flex items-center gap-2 rounded px-1.5 py-1 text-[0.8125rem] font-medium no-underline hover:bg-[var(--cairn-error-ink)]/10">{entry.title}</a></li>
|
|
905
|
+
{/each}
|
|
906
|
+
{/if}
|
|
907
|
+
{#if deleteBreakingBranch.length > 0}
|
|
908
|
+
<li class="px-1.5 pb-0.5 pt-1 text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">In an unpublished edit</li>
|
|
909
|
+
{#each deleteBreakingBranch as entry (entry.concept + '/' + entry.id + branchNameOf(entry))}
|
|
910
|
+
<li>
|
|
911
|
+
<a href="/admin/{entry.concept}/{entry.id}" class="flex flex-col rounded px-1.5 py-1 no-underline hover:bg-[var(--cairn-error-ink)]/10">
|
|
912
|
+
<span class="text-[0.8125rem] font-medium">{entry.title}</span>
|
|
913
|
+
<span class="font-[family-name:var(--font-editor)] text-[0.6rem] text-[var(--cairn-warning-ink)]">{branchNameOf(entry)}</span>
|
|
914
|
+
</a>
|
|
915
|
+
</li>
|
|
916
|
+
{/each}
|
|
917
|
+
{/if}
|
|
918
|
+
</ul>
|
|
919
|
+
</div>
|
|
920
|
+
{/if}
|
|
921
|
+
|
|
922
|
+
<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">
|
|
923
|
+
<ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
|
|
924
|
+
<span>Every version stays in git history, so a developer can bring this back later.</span>
|
|
925
|
+
</div>
|
|
926
|
+
|
|
927
|
+
<form method="POST" action="?/mediaDelete" class="flex flex-col gap-3">
|
|
928
|
+
<CsrfField />
|
|
929
|
+
<input type="hidden" name="hash" value={asset.hash} />
|
|
930
|
+
{#if deleteInUse}
|
|
931
|
+
<input type="hidden" name="confirmSlug" value={confirmSlugInput} />
|
|
932
|
+
<div class="flex flex-col gap-1.5">
|
|
933
|
+
<label class="text-[0.875rem]" for="cairn-ml-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 delete it anyway.</label>
|
|
934
|
+
<input id="cairn-ml-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={confirmSlugInput} />
|
|
935
|
+
</div>
|
|
936
|
+
{/if}
|
|
937
|
+
<div class="flex justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
|
|
938
|
+
<button type="button" class="btn btn-sm" onclick={closeDeleteDialog}>Cancel</button>
|
|
939
|
+
{#if deleteInUse}
|
|
940
|
+
<button type="submit" class="btn btn-sm btn-error" disabled={!confirmMatches}>Delete anyway</button>
|
|
941
|
+
{:else}
|
|
942
|
+
<button type="submit" class="btn btn-sm btn-error">Delete it</button>
|
|
943
|
+
{/if}
|
|
944
|
+
</div>
|
|
945
|
+
</form>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
{/if}
|
|
949
|
+
</dialog>
|