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) %} -
+
- +

diff --git a/templates/admin/content-results.html.twig b/templates/admin/content-results.html.twig index 8ffd873..b5636eb 100644 --- a/templates/admin/content-results.html.twig +++ b/templates/admin/content-results.html.twig @@ -3,9 +3,9 @@ {% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
{% 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. #} +
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
+
+ + +
+ {% if settings.faviconMediaId %} Eigenes Symbol{% else %} Mitgeliefertes Zeichen{% endif %} +
+
+ + +
-

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-');