Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting. Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen, getrennten Entwürfen/Veröffentlichungen und optimistischer Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau, responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek, Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript. Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
8.7 KiB
JavaScript
102 lines
8.7 KiB
JavaScript
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
|
||
|
||
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('/admin/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}).`);
|
||
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());
|