diff --git a/.claude/launch.json b/.claude/launch.json
index a97d3cf..f7ab68c 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -25,7 +25,7 @@
"runtimeArgs": [
"-NoProfile",
"-Command",
- "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
+ "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-3fc9a10171d3/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
],
"port": 8091
},
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 9caf612..12c02da 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,5 +1,14 @@
# Projektstatus
+## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026)
+
+**Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“.
+
+- **Symbol der Website** hat einen eigenen Abschnitt in den Einstellungen statt einer Auswahlliste am Ende der Unternehmensdaten. Der Knopf öffnet die Mediathek (dort auch Upload), danach folgt ein Zuschnitt: Quadrat ziehen, Größe an der Ecke, per Regler oder mit +/−, Pfeiltasten zum Verschieben, Vorschau in 180 und 32 px. Gespeichert wird `faviconCrop` als `links,oben,kante` in Anteilen (links/oben von Breite/Höhe, Kante von der kürzeren Seite), `renderFavicon()` schneidet daraus die PNGs. `faviconVersion` hängt am Verweis; ohne Zuschnitt bleibt es bei den ersten acht Zeichen der Bildkennung, bestehende Verweise ändern sich also nicht.
+- `openMedia()` kennt `needsAlt: false`: Für das Symbol wird kein Alternativtext verlangt. Die Stile des Mediathek-Dialogs stehen jetzt in `media-dialog.css` statt in `editor.css`, damit der Dialog auch außerhalb des Editors funktioniert.
+- **Datenschutz und Impressum** werden über die Inhaltssuche gewählt wie die 404-Seite. Die Suche kann jetzt statt der Kennung den Pfad speichern (`valueKey: 'path'` bzw. `data-value="path"`), gespeichert wird also weiter die Adresse.
+- **Seiten- und Beitragsliste** der Verwaltung sortieren nach Anlagedatum absteigend. Die Einrückung der Unterseiten ist dort entfallen – ohne Baumreihenfolge hätte sie eine falsche Elternseite angedeutet.
+
## Sechste Installation: der Redesign-Entwurf „EB Solar“ (16.09.2026)
**Anlass:** Umut: „bitte redesign aufsetzen ebsolar.de achte auf die richtlinien wie immer machs wie die anderen tagespflege, elektro-baernhard co“ – dazu das Paket `ebsolar-redesign.zip` (statische Website: Startseite, Projektübersicht, acht Projektseiten, eigene SVG-Symbole, Kontaktformular per `mailto`).
diff --git a/public/assets/admin.css b/public/assets/admin.css
index a1e63a9..b8d03cc 100644
--- a/public/assets/admin.css
+++ b/public/assets/admin.css
@@ -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 }
diff --git a/public/assets/editor.css b/public/assets/editor.css
index 3787808..a43dc0e 100644
--- a/public/assets/editor.css
+++ b/public/assets/editor.css
@@ -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%}
diff --git a/public/assets/favicon-field.js b/public/assets/favicon-field.js
new file mode 100644
index 0000000..661b234
--- /dev/null
+++ b/public/assets/favicon-field.js
@@ -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 = `
+
+
+
+
Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.
+
+ Startbildschirm
+ Browser-Reiter
+
+
+
`;
+ 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(() => {});
+ }
+}
diff --git a/public/assets/media-dialog.css b/public/assets/media-dialog.css
new file mode 100644
index 0000000..d166fc9
--- /dev/null
+++ b/public/assets/media-dialog.css
@@ -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}}
diff --git a/public/assets/media.js b/public/assets/media.js
index 79f0a61..430592b 100644
--- a/public/assets/media.js
+++ b/public/assets/media.js
@@ -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);
diff --git a/public/assets/page-picker.js b/public/assets/page-picker.js
index 90aa5eb..5f93b92 100644
--- a/public/assets/page-picker.js
+++ b/public/assets/page-picker.js
@@ -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);
diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs
index e5d1584..e87ea28 100644
--- a/scripts/editor-check.mjs
+++ b/scripts/editor-check.mjs
@@ -1014,7 +1014,10 @@ try {
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
await page.goto(origin + '/admin/settings');
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
- await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
+ // The privacy page is chosen by search, the way the 404 page is: type, then take the hit.
+ await page.getByLabel('Datenschutzseite', { exact: true }).fill('grundlagen');
+ await page.locator('#privacyPath-results [role=option]').filter({ hasText: '/grundlagen' }).first().click();
+ assert.equal(await page.locator('.content-picker:has(#privacyPath) .picker-value').inputValue(), 'grundlagen');
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
await page.locator('.notice.ok').waitFor();
diff --git a/src/Content/MediaLibrary.php b/src/Content/MediaLibrary.php
index 863783e..592c9b7 100644
--- a/src/Content/MediaLibrary.php
+++ b/src/Content/MediaLibrary.php
@@ -222,11 +222,16 @@ final class MediaLibrary
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
*
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
- * als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
+ * als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein quadratischer
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
* Einstellung, nicht bei jedem Aufruf.
+ *
+ * $crop ist der im Zuschnitt gewählte Ausschnitt als [links, oben, Kante]: links und oben als
+ * Anteil von Breite und Höhe, die Kante als Anteil der kürzeren Seite. Ohne Angabe die Mitte.
+ *
+ * @param array{0: float, 1: float, 2: float}|null $crop
*/
- public function renderFavicon(string $mediaId): void
+ public function renderFavicon(string $mediaId, ?array $crop = null): void
{
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
@@ -244,10 +249,18 @@ final class MediaLibrary
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
}
try {
- // Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
- $kante = min(imagesx($source), imagesy($source));
- $links = (int) ((imagesx($source) - $kante) / 2);
- $oben = (int) ((imagesy($source) - $kante) / 2);
+ // Quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke. Die Anteile
+ // gelten für jede Variante gleich; gerechnet wird in der größten, die es gibt.
+ $breite = imagesx($source);
+ $hoehe = imagesy($source);
+ $kante = min($breite, $hoehe);
+ $links = (int) (($breite - $kante) / 2);
+ $oben = (int) (($hoehe - $kante) / 2);
+ if (null !== $crop) {
+ $kante = max(1, min($kante, (int) round($crop[2] * min($breite, $hoehe))));
+ $links = max(0, min($breite - $kante, (int) round($crop[0] * $breite)));
+ $oben = max(0, min($hoehe - $kante, (int) round($crop[1] * $hoehe)));
+ }
foreach (self::FAVICON_SIZES as $size) {
$target = imagecreatetruecolor($size, $size);
try {
diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php
index b5ef591..af06093 100644
--- a/src/Content/PageRepository.php
+++ b/src/Content/PageRepository.php
@@ -459,8 +459,9 @@ final class PageRepository
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
$pages = max(1, (int) ceil($total / $limit));
$page = max(1, min($pages, $page));
- $items = $qb->select('p.*', 'r.document')->orderBy('post' === $type ? 'p.created_at' : 'p.path', 'post' === $type ? 'DESC' : 'ASC')
- ->addOrderBy('p.id')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
+ $items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC')
+ // Neueste zuerst, bei Seiten wie bei Beiträgen (Umut: „absteigend also neueste zuerst“).
+ ->addOrderBy('p.id', 'DESC')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
}
diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php
index 18458d9..3d77dc0 100644
--- a/src/Content/SiteSettings.php
+++ b/src/Content/SiteSettings.php
@@ -50,6 +50,10 @@ final class SiteSettings
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
'faviconMediaId' => '',
+ // Der Zuschnitt dazu als "links,oben,kante" in Anteilen, leer heißt: die Mitte. Die Version
+ // hängt am Verweis, damit ein neuer Ausschnitt im Browser ankommt.
+ 'faviconCrop' => '',
+ 'faviconVersion' => '',
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
@@ -229,6 +233,14 @@ final class SiteSettings
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
+ $crop = '' === $favicon ? '' : trim((string) ($input['faviconCrop'] ?? ''));
+ $anteile = null;
+ if ('' !== $crop) {
+ $anteile = array_map('floatval', explode(',', $crop));
+ if (1 !== preg_match('/^[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?$/D', $crop) || max($anteile) > 1 || $anteile[2] < 0.02) {
+ throw new InvalidPage('Der Ausschnitt für das Symbol ist ungültig. Bitte das Bild neu zuschneiden.');
+ }
+ }
if ('' !== $favicon) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
@@ -237,12 +249,15 @@ final class SiteSettings
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
// wäre schlimmer als das alte Symbol.
try {
- $this->media->renderFavicon($favicon);
+ $this->media->renderFavicon($favicon, $anteile);
} catch (\RuntimeException $error) {
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
}
}
$values['faviconMediaId'] = $favicon;
+ $values['faviconCrop'] = $crop;
+ // Ohne Zuschnitt die Kennung wie bisher, damit bestehende Verweise gleich bleiben.
+ $values['faviconVersion'] = '' === $favicon ? '' : ('' === $crop ? substr($favicon, 0, 8) : substr(md5($favicon.'|'.$crop), 0, 8));
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
$wert = trim((string) ($input[$key] ?? ''));
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php
index 38580f2..16ed82c 100644
--- a/src/Controller/SettingsController.php
+++ b/src/Controller/SettingsController.php
@@ -4,7 +4,6 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\DemoContent;
-use App\Content\MediaLibrary;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\RedirectRepository;
@@ -20,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
- public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
+ public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage)
{
}
@@ -31,8 +30,22 @@ final class SettingsController extends AbstractController
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
+ $settings = $this->settings->load();
$choosable = [];
+ // Datenschutz und Impressum werden per Suche gewählt; das Feld zeigt Titel und Adresse der
+ // gespeicherten Seite. Eine Adresse ohne Seite bleibt sichtbar, statt still zu verschwinden.
+ $legal = ['privacyPath' => null, 'imprintPath' => null];
+ foreach (array_keys($legal) as $key) {
+ if ('' !== $settings[$key]) {
+ $legal[$key] = ['path' => $settings[$key], 'title' => $settings[$key]];
+ }
+ }
foreach ($this->pages->listForEditor() as $page) {
+ foreach ($legal as $key => $selection) {
+ if (null !== $selection && $selection['path'] === $page['path']) {
+ $legal[$key]['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['path'];
+ }
+ }
// Only a published page can serve as the root; a draft would answer every visit with 404.
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
@@ -41,15 +54,14 @@ final class SettingsController extends AbstractController
}
return $this->render('admin/settings.html.twig', [
- 'settings' => $this->settings->load(),
+ 'settings' => $settings,
+ 'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
'choosableHome' => $choosable,
'consentGroups' => Consent::GROUPS,
'missingLegal' => $this->settings->missingLegalPages(),
'consentGroupHelp' => Consent::GROUP_HELP,
- // Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
- 'images' => $this->media->page(1, false, 96)['items'],
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'],
'socialNetworks' => SocialProfiles::NETWORKS,
diff --git a/templates/admin/content-picker.html.twig b/templates/admin/content-picker.html.twig
index 06baf24..0625f8d 100644
--- a/templates/admin/content-picker.html.twig
+++ b/templates/admin/content-picker.html.twig
@@ -1,11 +1,11 @@
{% set selected = selected|default(null) %}
-
{% for page in pages %}
- {# Indentation by class, not by style attribute: the administration forbids inline styles, so
- an attribute here would simply be dropped and the tree would look flat. #}
-
+ {# Newest first, not as a tree: indenting subpages here would suggest a parent that is not
+ the row above. The address in the row shows where a page sits. #}
+
{{ page.type == 'post' ? '✎' : '▤' }}
diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig
index 2902f4f..72e9e68 100644
--- a/templates/admin/editor.html.twig
+++ b/templates/admin/editor.html.twig
@@ -4,7 +4,7 @@
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %}
-{% block styles %}{% endblock %}
+{% block styles %}{% endblock %}
{% block scripts %}{% endblock %}
{% block body %}
{# The API base decides what is edited. An area is shared, so it has no draft and no
diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig
index 8b73540..0c08b33 100644
--- a/templates/admin/settings.html.twig
+++ b/templates/admin/settings.html.twig
@@ -3,7 +3,8 @@
{% block title %}Einstellungen{% endblock %}
{% block heading %}Unternehmen und Adressen{% endblock %}
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
-{% block scripts %}{% endblock %}
+{% block styles %}{% endblock %}
+{% block scripts %}{% endblock %}
{% block content %}
{% if missingLegal is not empty %}
@@ -129,15 +130,19 @@
Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.
Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.
-
-
- {% if settings.faviconMediaId %}{% endif %}
-
+
Symbol der Website
Favicon im Browser-Reiter und auf dem Startbildschirm
Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.
+
Das Bild kommt aus der Mediathek, dort kann es auch gleich hochgeladen werden. Danach das Quadrat auf den gewünschten Ausschnitt ziehen und die Größe einstellen. Beim Speichern entstehen daraus PNG-Dateien in 32 und 180 Pixeln.
Knopf „nach oben"
Erscheint beim Scrollen, in der Ecke
Springt an den Seitenanfang. Er braucht kein JavaScript: In Browsern mit Scroll-Zeitachse blendet er sich beim Scrollen ein, in den übrigen steht er von Anfang an da.
@@ -261,13 +266,11 @@
-
-
+ {% include 'admin/content-picker.html.twig' with {id: 'privacyPath', name: 'privacyPath', type: 'page', valueKey: 'path', label: 'Datenschutzseite', selected: legalSelection.privacyPath} only %}
Pflicht, sobald der Hinweis an ist, und die Seite muss existieren.
-
-
+ {% include 'admin/content-picker.html.twig' with {id: 'imprintPath', name: 'imprintPath', type: 'page', valueKey: 'path', label: 'Impressum', selected: legalSelection.imprintPath} only %}
Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter /einwilligung kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.
diff --git a/templates/favicon.html.twig b/templates/favicon.html.twig
index 546b01d..b54ac1e 100644
--- a/templates/favicon.html.twig
+++ b/templates/favicon.html.twig
@@ -1,9 +1,11 @@
{# Das Symbol der Website. Eigenes Bild aus den Einstellungen, sonst das mitgelieferte Zeichen.
Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt. #}
-{% set favicon = siteSettings.load().faviconMediaId|default('') %}
+{% set site = siteSettings.load() %}
+{% set favicon = site.faviconMediaId|default('') %}
+{% set version = site.faviconVersion|default('') ?: favicon|slice(0, 8) %}
{% if favicon %}
-
-
+
+
{% else %}
{% endif %}
diff --git a/tests/CmsTest.php b/tests/CmsTest.php
index 6bd2a64..ab9cd1e 100644
--- a/tests/CmsTest.php
+++ b/tests/CmsTest.php
@@ -1592,6 +1592,19 @@ final class CmsTest extends TestCase
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
+ // Der Zuschnitt aus dem Werkzeug: links oben, ein Viertel der kurzen Seite. Der Verweis
+ // bekommt eine neue Version, sonst hielte der Browser am alten Ausschnitt fest.
+ $this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => '0,0,0.25']);
+ self::assertSame('0,0,0.25', $this->site->load()['faviconCrop']);
+ self::assertStringContainsString('/favicon.png?v='.substr(md5($item['id'].'|0,0,0.25'), 0, 8), $this->request('/')->getContent());
+ foreach (['2,0,0.5', '0,0,0.01', 'mitte'] as $falsch) {
+ try {
+ $this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => $falsch]);
+ self::fail('Ein ungültiger Ausschnitt muss abgelehnt werden: '.$falsch);
+ } catch (InvalidPage) {
+ }
+ }
+
// Ein winziges quadratisches Zeichen hat keine einzige der vier Regelbreiten - genau so
// sieht ein echtes Favicon aus, und genau daran ist die erste Fassung gescheitert.
$klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-');