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
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-11-05 05:23:17 +00:00
1 parent 592e3d5342
commit 76613b9f8e
25 files changed
+674 -20

No files matched your search

+65
View File
@@ -206,4 +206,69 @@ export function openDocuments({ onChoose } = {}) {
}).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());