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
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.
DOKUMENTE
PDF bis 20 MB. Es wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar – nichts Vertrauliches hochladen. Ohne „In neuem Tab öffnen“ zeigt die Website es in einem Betrachter über der Seite.
Wird geladen …
`; document.body.append(dialog); const $ = selector => dialog.querySelector(selector); let items = [], chosen = null, token; const error = message => { $('#document-error').textContent = message; $('#document-error').hidden = !message; }; const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`; const base = document.querySelector('meta[name="admin-base"]').content + '/api/dokumente'; function render() { const query = $('#document-search').value.toLocaleLowerCase('de'); const visible = items.filter(item => !item.archived && item.title.toLocaleLowerCase('de').includes(query)); $('#document-list').replaceChildren(); for (const item of visible) { const card = element('button', '', 'document-card'); card.type = 'button'; card.setAttribute('aria-pressed', String(chosen?.id === item.id)); card.append(element('span', 'PDF', 'document-badge'), element('strong', item.title), element('span', size(item.bytes))); card.addEventListener('click', () => { chosen = item; render(); card.focus(); }); card.addEventListener('dblclick', () => { chosen = item; $('#document-insert').click(); }); $('#document-list').append(card); } if (!visible.length) $('#document-list').append(element('p', items.length ? 'Kein PDF mit diesem Titel.' : 'Noch keine PDFs. Lade oben eines hoch.', 'muted')); $('#document-status').textContent = `${items.filter(item => !item.archived).length} PDFs${chosen ? ` · gewählt: ${chosen.title}` : ''}`; $('#document-insert').disabled = !chosen; } $('#document-search').addEventListener('input', render); $('#document-close').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); }); $('#document-upload').addEventListener('change', async () => { const file = $('#document-upload').files[0]; if (!file) return; error(''); if (file.size > 20 * 1024 * 1024) { error(`${file.name}: größer als 20 MB`); $('#document-upload').value = ''; return; } $('#document-status').textContent = `Wird hochgeladen: ${file.name}`; try { const form = new FormData(); form.append('file', file); const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form }); 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 || `Upload fehlgeschlagen (${response.status}).`); items.unshift(data.item); chosen = data.item; } catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); } $('#document-upload').value = ''; render(); }); $('#document-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } }); dialog.showModal(); fetch(base, { credentials: 'same-origin' }).then(async response => { if (!response.ok || response.redirected) throw new Error('PDFs konnten nicht geladen werden. Bitte neu anmelden.'); // Hochladen erst mit Token - sonst weist der Server eine zu früh gewählte Datei ab. const data = await response.json(); items = data.items; token = data.csrfToken; $('#document-upload').disabled = false; render(); }).catch(err => { $('#document-status').textContent = ''; error(err.message); }); } /** * Ein Video aus der Mediathek wählen - oder eines hochladen und gleich wählen. * * Wie bei den PDFs ein eigener, schmaler Dialog. Mit only: 'video/webm' bietet er nur dieses Format * an, für das Zweitformat neben dem MP4. */ export function openVideos({ onChoose, only = '' } = {}) { const opener = document.activeElement; const dialog = element('dialog', '', 'media-dialog document-dialog'); dialog.setAttribute('aria-labelledby', 'video-dialog-title'); dialog.innerHTML = `VIDEOS
MP4 (H.264) oder WebM bis 64 MB. Das Video wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar. Kleine Dateien laden schneller – ein Hintergrund sollte nur wenige MB haben.
Wird geladen …
`; document.body.append(dialog); const $ = selector => dialog.querySelector(selector); let items = [], chosen = null, token; const error = message => { $('#video-error').textContent = message; $('#video-error').hidden = !message; }; const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`; const base = document.querySelector('meta[name="admin-base"]').content + '/api/videos'; const fits = item => !only || item.mime === only; function render() { const query = $('#video-search').value.toLocaleLowerCase('de'); const visible = items.filter(item => fits(item) && item.title.toLocaleLowerCase('de').includes(query)); $('#video-list').replaceChildren(); for (const item of visible) { const card = element('button', '', 'document-card'); card.type = 'button'; card.setAttribute('aria-pressed', String(chosen?.id === item.id)); card.append(element('span', item.mime === 'video/webm' ? 'WEBM' : 'MP4', 'document-badge'), element('strong', item.title), element('span', size(item.bytes))); card.addEventListener('click', () => { chosen = item; render(); card.focus(); }); card.addEventListener('dblclick', () => { chosen = item; $('#video-insert').click(); }); $('#video-list').append(card); } if (!visible.length) $('#video-list').append(element('p', items.some(fits) ? 'Kein Video mit diesem Titel.' : 'Noch keine passenden Videos. Lade oben eines hoch.', 'muted')); $('#video-status').textContent = `${items.filter(fits).length} Videos${chosen ? ` · gewählt: ${chosen.title}` : ''}`; $('#video-insert').disabled = !chosen; } $('#video-search').addEventListener('input', render); $('#video-close').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); }); $('#video-upload').addEventListener('change', async () => { const file = $('#video-upload').files[0]; if (!file) return; error(''); if (file.size > 64 * 1024 * 1024) { error(`${file.name}: größer als 64 MB`); $('#video-upload').value = ''; return; } $('#video-status').textContent = `Wird hochgeladen: ${file.name}`; try { const form = new FormData(); form.append('file', file); const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form }); 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 || `Upload fehlgeschlagen (${response.status}).`); items.unshift(data.item); chosen = fits(data.item) ? data.item : null; } catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); } $('#video-upload').value = ''; render(); }); $('#video-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } }); dialog.showModal(); fetch(base, { credentials: 'same-origin' }).then(async response => { if (!response.ok || response.redirected) throw new Error('Videos konnten nicht geladen werden. Bitte neu anmelden.'); const data = await response.json(); items = data.items; token = data.csrfToken; $('#video-upload').disabled = false; render(); }).catch(err => { $('#video-status').textContent = ''; error(err.message); }); } document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());