Files
KantSites/public/assets/media.js
T
kantarci 6d21c452bd
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
feat: add new-tab links and removable media previews (1.7.3)
2026-09-14 11:48:44 +02:00

143 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
const previews = new Map();
async function previewItem(id) {
if (!previews.has(id)) previews.set(id, fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media/' + id, { credentials: 'same-origin' }).then(async response => {
if (!response.ok || response.redirected) throw new Error('Bild nicht erreichbar');
return (await response.json()).item;
}).catch(error => { previews.delete(id); throw error; }));
return previews.get(id);
}
/** A preview of the current selection, including small originals without a 480 px variant. */
export function createMediaPreview(initial = {}) {
const wrap = element('figure', '', 'selected-media-preview');
const image = element('img'); image.alt = ''; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; image.hidden = true;
const caption = element('figcaption'); caption.setAttribute('aria-live', 'polite');
wrap.append(image, caption); let sequence = 0;
const update = async ({ src = '', mediaId = '', label = '' } = {}) => {
const request = ++sequence; image.hidden = true; image.removeAttribute('src');
caption.textContent = src || mediaId ? 'Bildvorschau wird geladen …' : 'Kein Bild ausgewählt';
if (!src && !mediaId) return;
try {
if (!src && /^[a-f0-9]{32}$/.test(mediaId)) {
const item = await previewItem(mediaId);
if (request !== sequence) return;
src = item.variants[0]?.src || ''; label ||= item.title;
}
const url = new URL(src, location.origin);
if (!(src.startsWith('/') && !src.startsWith('//') && url.origin === location.origin) && url.protocol !== 'https:') throw new Error();
if (!src) throw new Error();
const description = label || decodeURIComponent(url.pathname.split('/').pop()) || 'Ausgewähltes Bild';
image.onload = () => { if (request === sequence) { image.hidden = false; caption.textContent = description; } };
image.onerror = () => { if (request === sequence) { image.hidden = true; caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.'; } };
image.src = url.href;
} catch {
if (request === sequence) caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.';
}
};
update(initial);
return { element: wrap, update };
}
export function openMedia({ multiple = false, onChoose } = {}) {
const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
dialog.setAttribute('aria-labelledby', 'media-title');
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">BILDER FÜR DEINE WEBSITE</p><h2 id="media-title">Medienverwaltung</h2></div><button class="secondary" id="media-close">Schließen</button></div>
<p class="panel-hint">JPEG, PNG oder WebP · bis 8 MB und 12 Megapixel. Bilder werden optimiert und sind über ihre Bildadresse öffentlich erreichbar. Archivieren erhält bestehende Verwendungen.</p>
<div class="media-tools"><div><label for="media-search">Bilder suchen</label><input id="media-search" type="search" placeholder="Titel oder Alternativtext"></div><div><label for="media-upload">Bilder hochladen</label><input id="media-upload" type="file" accept="image/jpeg,image/png,image/webp" multiple></div></div>
<label class="check-label"><input id="media-archived" type="checkbox"> Archivierte Bilder anzeigen</label>
<p id="media-status" role="status" aria-live="polite">Wird geladen …</p><p id="media-error" role="alert" hidden></p>
<div class="media-layout"><div id="media-grid" class="media-grid" role="group" aria-label="Bilder auswählen"></div><div id="media-details" class="media-details"><p>Ein Bild auswählen, um seine Angaben zu bearbeiten.</p></div></div>
<div class="media-footer"><label class="check-label" id="decorative-choice"><input id="media-decorative" type="checkbox"> Auswahl in dieser Seite dekorativ verwenden</label><button id="media-insert" class="primary" disabled>Auswahl übernehmen</button></div>`;
document.body.append(dialog);
const $ = selector => dialog.querySelector(selector);
let items = [], selected = new Set(), active = null, token, working = false;
const error = message => { $('#media-error').textContent = message; $('#media-error').hidden = !message; };
const lock = state => {
working = state; dialog.querySelectorAll('button,input').forEach(node => node.disabled = state);
$('#media-insert').disabled = state || !selected.size;
};
async function api(path = '', body) {
const response = await fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
for (const item of data.items || (data.item ? [data.item] : [])) previews.set(item.id, Promise.resolve(item));
return data;
}
function render() {
const focusedMedia = dialog.contains(document.activeElement) ? document.activeElement.dataset.mediaId : null;
const query = $('#media-search').value.toLocaleLowerCase('de');
const visible = items.filter(item => ($('#media-archived').checked || !item.archived) && `${item.title} ${item.alt}`.toLocaleLowerCase('de').includes(query));
$('#media-grid').replaceChildren();
for (const item of visible) {
const card = element('button', '', 'media-card'); card.type = 'button'; card.dataset.mediaId = item.id;
card.setAttribute('aria-label', `${item.title}${item.archived ? ' · Archiviert' : ''}`);
card.setAttribute('aria-pressed', String(selected.has(item.id))); card.disabled = working;
const image = element('img'); image.src = item.variants[0].src; image.alt = ''; image.loading = 'lazy'; image.width = 180; image.height = 130;
card.append(image, element('strong', item.title), element('span', item.archived ? 'Archiviert' : `${item.variants.at(-1).width} × ${item.variants.at(-1).height} px`));
card.addEventListener('click', () => {
active = item;
if (!multiple) selected.clear();
if (selected.has(item.id)) selected.delete(item.id); else if (!item.archived) selected.add(item.id);
render(); details();
}); $('#media-grid').append(card);
}
if (!visible.length) $('#media-grid').append(element('p', 'Keine Bilder gefunden. Lade ein Bild hoch oder ändere den Suchbegriff.', 'muted'));
$('#media-status').textContent = `${items.filter(item => !item.archived).length} Bilder · ${selected.size} ausgewählt`;
$('#media-insert').textContent = `Auswahl übernehmen (${selected.size})`; $('#media-insert').disabled = working || !selected.size;
if (focusedMedia) dialog.querySelector(`[data-media-id="${focusedMedia}"]`)?.focus({ preventScroll: true });
}
function details() {
const panel = $('#media-details'); panel.replaceChildren(); if (!active) return;
const current = active;
panel.append(element('h3', 'Bildangaben'));
for (const [key, label] of [['title', 'Bildtitel'], ['alt', 'Alternativtext des Mediums']]) {
const caption = element('label', label); caption.htmlFor = key === 'title' ? 'media-title-field' : `media-${key}`;
const input = element('input'); input.id = caption.htmlFor; input.value = current[key]; input.maxLength = key === 'title' ? 160 : 300;
panel.append(caption, input);
}
panel.append(element('p', 'Alt-Text beschreibt den Bildinhalt. Änderungen hier gelten für zukünftiges Einfügen; vorhandene Seiten behalten ihre eigenen Texte.', 'field-help'));
const save = element('button', 'Bildangaben speichern', 'secondary'); save.id = 'media-save';
const archive = element('button', current.archived ? 'Wiederherstellen' : 'Archivieren', 'secondary'); archive.id = 'media-archive';
const update = async archived => {
const data = { title: $('#media-title-field').value, alt: $('#media-alt').value, archived, version: current.version };
lock(true); error('');
try { const result = await api('/' + current.id, data); Object.assign(current, result.item); if (archived) selected.delete(current.id); render(); details(); }
catch (err) { error(err.message || 'Verbindung unterbrochen. Bitte erneut versuchen.'); }
finally { lock(false); $('#media-save')?.focus({ preventScroll: true }); }
};
save.addEventListener('click', () => update(current.archived)); archive.addEventListener('click', () => update(!current.archived));
panel.append(save, archive);
const link = element('a', 'Bilddatei öffnen ↗'); link.href = current.variants.at(-1).src; link.target = '_blank'; link.rel = 'noopener'; panel.append(link);
}
$('#media-search').addEventListener('input', render); $('#media-archived').addEventListener('change', render);
$('#media-close').addEventListener('click', () => dialog.close());
dialog.addEventListener('cancel', event => { if (working) event.preventDefault(); });
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); else document.querySelector('#properties button')?.focus(); });
$('#media-upload').addEventListener('change', async () => {
const files = [...$('#media-upload').files]; if (!files.length) return;
lock(true); error(''); const failures = [];
for (let index = 0; index < files.length; index++) {
const file = files[index]; $('#media-status').textContent = `Upload ${index + 1} von ${files.length}: ${file.name}`;
if (file.size > 8 * 1024 * 1024) { failures.push(`${file.name}: größer als 8 MB`); continue; }
try { const form = new FormData(); form.append('file', file); const data = await api('/upload', form); items.unshift(data.item); active = data.item; }
catch (err) { failures.push(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
}
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
});
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
$('#media-insert').addEventListener('click', () => {
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
const decorative = $('#media-decorative').checked;
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
});
dialog.showModal(); lock(true);
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
}
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());