Upload PDFs to the library, link them from any link target, show them in a viewer

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>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-22 07:56:29 +02:00
1 parent 04927b9563
commit e8a9dcdf5c
20 files changed
+667 -30

No files matched your search

+66
View File
@@ -140,4 +140,70 @@ export function openMedia({ multiple = false, onChoose, needsAlt = 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());