PDFs go through the same upload field as images, are checked by their content and kept unchanged in their own table. Every link field can pick one, and a page that links a PDF opens it in a dialog with the browser's own viewer; touch devices and browsers without a viewer get a new tab instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
16 KiB
JavaScript
210 lines
16 KiB
JavaScript
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); });
|
||
}
|
||
|
||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|