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>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-18 07:43:05 +02:00
1 parent 6632836580
commit 106f84dfd5
19 files changed
+300 -69

No files matched your search

+18 -2
View File
@@ -751,9 +751,25 @@ details.card > summary:hover { background: #fafbf8 }
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
.favicon-field{display:flex;align-items:center;gap:12px}
.favicon-field select{flex:1}
.favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
.favicon-editor[hidden]{display:none}
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
.favicon-stage img{display:block;max-width:100%;max-height:360px;width:auto;height:auto}
.favicon-frame{position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid rgba(0,0,0,.6);box-shadow:0 0 0 9999px rgba(20,30,25,.55);cursor:move}
.favicon-stage{overflow:hidden}
.favicon-frame:focus-visible{outline:3px solid var(--accent);outline-offset:0}
.favicon-handle{position:absolute;right:-7px;bottom:-7px;width:14px;height:14px;background:#fff;border:1px solid rgba(0,0,0,.6);border-radius:3px;cursor:nwse-resize}
.favicon-side input[type=range]{width:100%}
.favicon-samples{display:flex;align-items:flex-end;gap:16px;margin:12px 0}
.favicon-samples figure{margin:0;text-align:center;font-size:11px;color:var(--muted)}
.favicon-samples canvas{display:block;margin:0 auto 6px;border-radius:8px;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/12px 12px;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.favicon-sample-large{width:90px;height:90px}
.favicon-sample-small{width:32px;height:32px;border-radius:4px!important}
@media(max-width:700px){.favicon-editor{grid-template-columns:1fr}}
.color-field { display: flex; gap: 8px; align-items: center }
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
-25
View File
@@ -11,31 +11,6 @@ dialog::backdrop{background:#172e2966}
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
.media-launch{margin-top:14px;white-space:normal}
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
.media-heading h2{margin:0}
.media-heading .eyebrow{margin-bottom:8px}
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.media-tools label{margin-top:10px}
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
.media-card strong{font-size:12px;overflow-wrap:anywhere}
.media-card span{font-size:11px;color:var(--muted)}
.media-details{padding-left:22px;border-left:1px solid var(--line)}
.media-details button,.media-details a{margin-top:12px;display:block}
.media-details input{font-size:13px}
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
.media-footer label{margin:0;font-size:12px}
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
#media-status{font-size:12px;margin-block:15px}
.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}}
/* ---- shell ------------------------------------------------------------------------------- */
html:has(>.editor-body){overflow:hidden;height:100%}
+137
View File
@@ -0,0 +1,137 @@
import { openMedia } from './media.js';
/*
* The site icon in the settings: an image from the media library (uploads happen right there in
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
* left and top of the width and height, the edge of the shorter side - so it holds for every
* variant of the image, and the server cuts the PNG files from it when the form is saved.
*/
const field = document.querySelector('[data-favicon-field]');
if (field) setup(field);
function setup(field) {
const idInput = field.querySelector('[name="faviconMediaId"]');
const cropInput = field.querySelector('[name="faviconCrop"]');
const choose = field.querySelector('[data-favicon-choose]');
const remove = field.querySelector('[data-favicon-remove]');
const current = field.querySelector('.favicon-current');
const base = document.querySelector('meta[name="admin-base"]').content;
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
<div class="favicon-side">
<label for="favicon-size">Größe des Ausschnitts</label>
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
<div class="favicon-samples" aria-label="Vorschau">
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
</div>
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
</div>`;
field.after(editor);
const stage = editor.querySelector('.favicon-stage');
const image = stage.querySelector('img');
const frame = stage.querySelector('.favicon-frame');
const size = editor.querySelector('#favicon-size');
const large = editor.querySelector('.favicon-sample-large');
const small = editor.querySelector('.favicon-sample-small');
let crop = { x: 0, y: 0, s: 1 };
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
/** Keep the square inside the picture, then show it and write it into the form. */
const apply = () => {
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
crop.s = clamp(crop.s, 0.02, 1);
const e = edge();
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
size.value = Math.round(crop.s * 100);
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
for (const canvas of [large, small]) {
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingQuality = 'high';
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
}
};
const center = () => {
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
};
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
const resize = s => {
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
crop.s = clamp(s, 0.02, 1); const after = edge();
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
};
const load = (src, initial) => new Promise((resolve, reject) => {
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
image.src = src;
});
const largest = item => item.variants.at(-1).src;
const showCurrent = label => {
current.replaceChildren(); current.append(document.createTextNode(label));
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
remove.hidden = !idInput.value;
};
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
if (!items.length) return;
idInput.value = items[0].id; cropInput.value = '';
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
catch (error) { showCurrent(error.message); }
} }));
remove.addEventListener('click', () => {
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
});
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
size.addEventListener('input', () => resize(size.value / 100));
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
let drag = null;
frame.addEventListener('pointerdown', event => {
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
});
frame.addEventListener('pointermove', event => {
if (!drag) return;
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
else {
// The corner follows the larger of both movements, measured in image pixels.
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
crop.x = drag.crop.x; crop.y = drag.crop.y;
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
}
apply();
});
const stop = () => { drag = null; };
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
frame.addEventListener('keydown', event => {
const step = event.shiftKey ? 0.1 : 0.01;
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
});
// An icon that is already set opens with its picture and its cut-out.
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
.then(({ item }) => {
const parts = cropInput.value.split(',').map(Number);
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
})
.catch(() => {});
}
}
+30
View File
@@ -0,0 +1,30 @@
/* The media library dialog. Loaded by the editor and by every screen that picks an image from
the library (the site icon in the settings), so it does not rely on editor.css. */
.media-dialog{padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
.media-dialog::backdrop{background:#172e2966}
.media-dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.media-dialog .panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
.media-launch{margin-top:14px;white-space:normal}
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
.media-heading h2{margin:0}
.media-heading .eyebrow{margin-bottom:8px}
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.media-tools label{margin-top:10px}
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
.media-card strong{font-size:12px;overflow-wrap:anywhere}
.media-card span{font-size:11px;color:var(--muted)}
.media-details{padding-left:22px;border-left:1px solid var(--line)}
.media-details button,.media-details a{margin-top:12px;display:block}
.media-details input{font-size:13px}
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
.media-footer label{margin:0;font-size:12px}
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
#media-status{font-size:12px;margin-block:15px}
.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}}
+4 -3
View File
@@ -40,7 +40,8 @@ export function createMediaPreview(initial = {}) {
return { element: wrap, update };
}
export function openMedia({ multiple = false, onChoose } = {}) {
/** 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');
@@ -128,11 +129,11 @@ export function openMedia({ multiple = false, onChoose } = {}) {
}
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
});
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
$('#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 (!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; }
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);
+2 -1
View File
@@ -11,7 +11,8 @@ export function setupPicker(picker, onSelect = () => {}) {
input.removeAttribute('aria-activedescendant'); current = -1;
};
const select = item => {
value.value = item?.id ?? '';
// Most fields store the page id; a setting that keeps an address asks for the path instead.
value.value = item?.[picker.dataset.value || 'id'] ?? '';
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);