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 }; } /** needsAlt: false for images that never appear as content, such as the site icon. */ export function openMedia({ multiple = false, onChoose, needsAlt = true } = {}) { const opener = document.activeElement; const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog'; dialog.setAttribute('aria-labelledby', 'media-title'); dialog.innerHTML = `

BILDER FÜR DEINE WEBSITE

Medienverwaltung

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.

Wird geladen …

Ein Bild auswählen, um seine Angaben zu bearbeiten.

`; 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 || !needsAlt; $('#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 (needsAlt && !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());