Files
KantSites/public/assets/media.js
T
kantarciandClaude Opus 5.5 76613b9f8e Add videos: library, block and section background
Videos join the media library as their own table (schema v28), with a
video block and a looping background on sections. video.js starts loops
only while visible, offers a pause button and leaves the still image for
reduced motion and data saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWYm8kjAWmZaB2QB2xuBhU
2026-11-05 05:23:17 +00:00

275 lines
21 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 };
}
/** 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 = `<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 || !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.'); });
}
/**
* Ein PDF aus der Mediathek wählen - oder eines hochladen und gleich wählen.
*
* Ein eigener, schmaler Dialog statt einer Betriebsart des Bilderdialogs: ein PDF hat keine
* Vorschau, keinen Alternativtext und keine Mehrfachauswahl. Gewählt wird die Adresse, die dann
* im Linkziel steht.
*/
export function openDocuments({ onChoose } = {}) {
const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog document-dialog');
dialog.setAttribute('aria-labelledby', 'document-title');
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">DOKUMENTE</p><h2 id="document-title">PDF wählen</h2></div><button class="secondary" id="document-close">Schließen</button></div>
<p class="panel-hint">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.</p>
<div class="media-tools"><div><label for="document-search">PDFs suchen</label><input id="document-search" type="search" placeholder="Titel"></div><div><label for="document-upload">PDF hochladen</label><input id="document-upload" type="file" accept="application/pdf,.pdf" disabled></div></div>
<p id="document-status" role="status" aria-live="polite">Wird geladen …</p><p id="document-error" class="media-error" role="alert" hidden></p>
<div id="document-list" class="document-list" role="group" aria-label="PDF auswählen"></div>
<div class="media-footer"><span></span><button id="document-insert" class="primary" disabled>PDF übernehmen</button></div>`;
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 = `<div class="media-heading"><div><p class="eyebrow">VIDEOS</p><h2 id="video-dialog-title">Video wählen</h2></div><button class="secondary" id="video-close">Schließen</button></div>
<p class="panel-hint">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.</p>
<div class="media-tools"><div><label for="video-search">Videos suchen</label><input id="video-search" type="search" placeholder="Titel"></div><div><label for="video-upload">Video hochladen</label><input id="video-upload" type="file" accept="video/mp4,video/webm,.mp4,.webm" disabled></div></div>
<p id="video-status" role="status" aria-live="polite">Wird geladen …</p><p id="video-error" class="media-error" role="alert" hidden></p>
<div id="video-list" class="document-list" role="group" aria-label="Video auswählen"></div>
<div class="media-footer"><span></span><button id="video-insert" class="primary" disabled>Video übernehmen</button></div>`;
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());