Files
KantSites/public/assets/media.js
T
kantarciandClaude Opus 5 106f84dfd5 Pick the site icon from the library and crop it, find legal pages by search, list newest first
- The site icon is no longer a select at the end of the company data. It has
  its own section: a button opens the media library (upload included), then
  a square is dragged, resized by its corner, a slider or +/-, and previewed
  at 180 and 32 px. The crop is stored as fractions (faviconCrop) and the
  PNGs are cut from it; faviconVersion changes the link so browsers pick up
  a new cut. Icons without a crop keep their old link.
- openMedia() takes needsAlt: false, since the icon never appears as
  content. The dialog styles moved from editor.css to media-dialog.css so
  the dialog works outside the editor.
- Privacy page and imprint are chosen with the content search, like the 404
  page. The picker can store the path instead of the id (valueKey/data-value).
- The page and post lists in the administration sort newest first; the
  subpage indentation is gone there, as it would point at the wrong parent.

Tests: 581 PHPUnit, 35 node, test:editor green (the consent step now picks
the privacy page by search). test:admin --admin-only fails only at the known
nested-interactive menu summaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:43:05 +02:00

144 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.'); });
}
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());