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

+2
View File
@@ -975,3 +975,5 @@ details.card > summary:hover { background: #fafbf8 }
.theme-save .muted { font-size: 12px }
.theme-form .button-defaults { margin: 0; background: var(--paper) }
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
+14 -7
View File
@@ -1,5 +1,5 @@
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
import { openMedia, createMediaPreview } from './media.js';
import { openMedia, openDocuments, createMediaPreview } from './media.js';
import { createDesignPanel } from './design-panel.js';
import { renderButtonControls, setButtonDesign } from './button-design.js';
import { createRichText } from './rich-text.js';
@@ -330,7 +330,14 @@ function renderContent(block, panel) {
input.addEventListener('blur', () => { history.mergeKey = null; });
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
if (key === 'href') {
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
// Tab zeigt die Website es im Betrachter über der Seite.
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
pdf.addEventListener('click', () => openDocuments({ onChoose: item => { input.value = item.src; applyValue(); history.mergeKey = null; } }));
container.append(pdf);
field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
}
};
if (!block) {
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
@@ -373,7 +380,7 @@ function renderContent(block, panel) {
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
field('Titel', 'title', block.props.title, { maxLength: 120 });
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
}
if (block.type === 'stat') {
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
@@ -389,7 +396,7 @@ function renderContent(block, panel) {
field('Titel', 'title', block.props.title, { maxLength: 160 });
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
}
if (block.type === 'contact') {
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
@@ -410,7 +417,7 @@ function renderContent(block, panel) {
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
}
if (block.type === 'social') {
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
@@ -527,7 +534,7 @@ function renderContent(block, panel) {
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
}
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
if (block.type === 'image') {
renderImageWidth(block, panel);
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
@@ -536,7 +543,7 @@ function renderContent(block, panel) {
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
showLinkOptions(clickAction); panel.append(linkOptions);
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
+83
View File
@@ -99,3 +99,86 @@
}
});
})();
/*
* Der PDF-Betrachter - in derselben Datei wie der Bildbetrachter, damit eine Seite mit beidem
* nicht zwei Skripte laedt.
*
* Ohne dieses Skript ist ein Link auf ein PDF ein gewöhnlicher Link: der Browser öffnet die
* Datei. Das Skript zeigt sie stattdessen in einem Rahmen über der Seite, mit dem Betrachter,
* den der Browser ohnehin mitbringt - kein eigener Nachbau, der ein Megabyte wiegt.
*
* Wo der Browser keinen solchen Betrachter hat oder ihn im Rahmen nur halb zeigt, bleibt es beim
* neuen Tab: Chrome auf Android hat keinen, und Safari auf dem iPhone zeigt im Rahmen nur die
* erste Seite. Ein Link mit „In neuem Tab öffnen“ bleibt ohnehin, was er ist.
*/
(() => {
const PDF = /^\/media\/[a-f0-9]{32}\.pdf$/;
const links = [...document.querySelectorAll('a[href]')].filter(link => {
const url = new URL(link.href, location.href);
return url.origin === location.origin && PDF.test(url.pathname) && '_blank' !== link.target;
});
if (0 === links.length) {
return;
}
const eingebettet = false !== navigator.pdfViewerEnabled && !matchMedia('(pointer: coarse)').matches;
let dialog = null;
let rahmen = null;
let titel = null;
let laden = null;
const baue = () => {
dialog = document.createElement('dialog');
dialog.className = 'pdf-dialog';
dialog.setAttribute('aria-labelledby', 'pdf-dialog-title');
dialog.innerHTML = `
<div class="pdf-bar">
<p class="pdf-title" id="pdf-dialog-title"></p>
<a class="pdf-action pdf-download" download>Herunterladen</a>
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
</div>
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
`;
document.body.append(dialog);
rahmen = dialog.querySelector('.pdf-frame');
titel = dialog.querySelector('.pdf-title');
laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')];
dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close());
dialog.addEventListener('click', event => {
if (event.target === dialog) {
dialog.close();
}
});
// Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter
// und das nächste Öffnen zeigt kurz das vorige Dokument.
dialog.addEventListener('close', () => rahmen.removeAttribute('src'));
};
for (const link of links) {
link.addEventListener('click', event => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
return;
}
event.preventDefault();
if (!eingebettet) {
window.open(link.href, '_blank', 'noopener');
return;
}
if (null === dialog) {
baue();
}
const name = (link.getAttribute('aria-label') || link.textContent || '').trim();
titel.textContent = name || 'Dokument';
rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument';
for (const aktion of laden) {
aktion.href = link.href;
}
rahmen.src = link.href;
dialog.showModal();
});
}
})();
+10
View File
@@ -28,3 +28,13 @@
.media-dialog> .panel-hint{margin-top:20px}
.media-details>p{font-size:12px}
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */
.document-dialog{max-width:720px}
.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px}
.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left}
.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
.document-card strong{font-size:13px;overflow-wrap:anywhere}
.document-card span{font-size:11px;color:var(--muted)}
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
.href-document{margin:6px 0 4px}
+4 -4
View File
@@ -1,5 +1,5 @@
/**
* Dragging images onto the upload field, and saying which files are about to go up.
* Dragging images and PDFs onto the upload field, and saying which files are about to go up.
*
* Everything here is an addition. Without this script the field is still a file input with a
* label: clicking it opens the file picker and the form submits the same way. The script never
@@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]');
const input = zone?.querySelector('input[type="file"]');
const chosen = document.querySelector('[data-chosen]');
if (zone && input && chosen) {
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const describe = (files, ignored = 0) => {
if (!files.length) {
chosen.textContent = ignored
? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.`
: 'Noch keine Datei gewählt.';
return;
}
const names = [...files].map(file => file.name).join(', ');
const size = [...files].reduce((total, file) => total + file.size, 0);
chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : '');
};
input.addEventListener('change', () => describe(input.files));
+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());
+13
View File
@@ -557,6 +557,19 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
.zoom-prev{left:8px}.zoom-next{right:8px}
}
/* Der PDF-Betrachter: fast bildschirmfüllend, mit einer schmalen Leiste darüber. Den Rest macht
der Betrachter des Browsers - Blättern, Zoom, Drucken. */
.pdf-dialog{border:0;padding:0;width:min(96vw,1200px);height:94vh;max-width:none;max-height:none;background:#1b212c;color:#fff;border-radius:12px;overflow:hidden}
.pdf-dialog[open]{display:flex;flex-direction:column}
.pdf-dialog::backdrop{background:rgba(8,12,20,.88)}
.pdf-bar{display:flex;align-items:center;gap:10px;padding:10px 10px 10px 18px}
.pdf-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#fff}
.pdf-action{color:#fff;font-size:13px;text-decoration:none;padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.12);white-space:nowrap}
.pdf-action:hover{background:rgba(255,255,255,.24)}
.pdf-close{display:grid;place-items:center;width:40px;height:40px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:24px;line-height:1;cursor:pointer}
.pdf-close:hover{background:rgba(255,255,255,.26)}
.pdf-action:focus-visible,.pdf-close:focus-visible{outline:3px solid #fff;outline-offset:2px}
.pdf-frame{flex:1;width:100%;border:0;background:#525659}
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}