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

+1 -1
View File
@@ -25,7 +25,7 @@
"runtimeArgs": [ "runtimeArgs": [
"-NoProfile", "-NoProfile",
"-Command", "-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 "port": 8091
}, },
+9
View File
@@ -1,5 +1,14 @@
# Projektstatus # 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) ## 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`). **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`).
+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 > 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 } .install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
.favicon-field{display:flex;align-items:center;gap:12px} .favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
.favicon-field select{flex:1} .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-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 { display: flex; gap: 8px; align-items: center }
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 } .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)} 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} .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 ------------------------------------------------------------------------------- */ /* ---- shell ------------------------------------------------------------------------------- */
html:has(>.editor-body){overflow:hidden;height:100%} 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 }; 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 opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog'; const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
dialog.setAttribute('aria-labelledby', 'media-title'); 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-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', () => { $('#media-insert').addEventListener('click', () => {
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived); const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
const decorative = $('#media-decorative').checked; 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(); onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
}); });
dialog.showModal(); lock(true); dialog.showModal(); lock(true);
+2 -1
View File
@@ -11,7 +11,8 @@ export function setupPicker(picker, onSelect = () => {}) {
input.removeAttribute('aria-activedescendant'); current = -1; input.removeAttribute('aria-activedescendant'); current = -1;
}; };
const select = item => { 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.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : ''; input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item); close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
+4 -1
View File
@@ -1014,7 +1014,10 @@ try {
// looked at from a visitor's browser: the banner has to appear, and the embed must not load. // 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.goto(origin + '/admin/settings');
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check(); 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.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
await page.locator('.notice.ok').waitFor(); await page.locator('.notice.ok').waitFor();
+19 -6
View File
@@ -222,11 +222,16 @@ final class MediaLibrary
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website. * 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 * 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 * Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
* Einstellung, nicht bei jedem Aufruf. * 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: // 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 // 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.'); throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
} }
try { try {
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke. // Quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke. Die Anteile
$kante = min(imagesx($source), imagesy($source)); // gelten für jede Variante gleich; gerechnet wird in der größten, die es gibt.
$links = (int) ((imagesx($source) - $kante) / 2); $breite = imagesx($source);
$oben = (int) ((imagesy($source) - $kante) / 2); $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) { foreach (self::FAVICON_SIZES as $size) {
$target = imagecreatetruecolor($size, $size); $target = imagecreatetruecolor($size, $size);
try { try {
+3 -2
View File
@@ -459,8 +459,9 @@ final class PageRepository
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne(); $total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
$pages = max(1, (int) ceil($total / $limit)); $pages = max(1, (int) ceil($total / $limit));
$page = max(1, min($pages, $page)); $page = max(1, min($pages, $page));
$items = $qb->select('p.*', 'r.document')->orderBy('post' === $type ? 'p.created_at' : 'p.path', 'post' === $type ? 'DESC' : 'ASC') $items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC')
->addOrderBy('p.id')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative(); // 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]; return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
} }
+16 -1
View File
@@ -50,6 +50,10 @@ final class SiteSettings
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim // 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. // Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
'faviconMediaId' => '', '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 // 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 // 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. // 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['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false); $values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
$favicon = trim((string) ($input['faviconMediaId'] ?? '')); $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 ('' !== $favicon) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($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).'); 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 // die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
// wäre schlimmer als das alte Symbol. // wäre schlimmer als das alte Symbol.
try { try {
$this->media->renderFavicon($favicon); $this->media->renderFavicon($favicon, $anteile);
} catch (\RuntimeException $error) { } catch (\RuntimeException $error) {
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage()); throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
} }
} }
$values['faviconMediaId'] = $favicon; $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) { foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
$wert = trim((string) ($input[$key] ?? '')); $wert = trim((string) ($input[$key] ?? ''));
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) { if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
+17 -5
View File
@@ -4,7 +4,6 @@ namespace App\Controller;
use App\Content\Consent; use App\Content\Consent;
use App\Content\DemoContent; use App\Content\DemoContent;
use App\Content\MediaLibrary;
use App\Content\NotFoundPage; use App\Content\NotFoundPage;
use App\Content\PageRepository; use App\Content\PageRepository;
use App\Content\RedirectRepository; use App\Content\RedirectRepository;
@@ -20,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController 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 // Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf. // Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
$this->denyAccessUnlessGranted('ROLE_PUBLISHER'); $this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$settings = $this->settings->load();
$choosable = []; $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 ($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. // 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']) { if ('page' === $page['type'] && null !== $page['published_revision_id']) {
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); $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', [ return $this->render('admin/settings.html.twig', [
'settings' => $this->settings->load(), 'settings' => $settings,
'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(), 'notFoundSelection' => $this->notFoundPage->selected(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)), 'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
'choosableHome' => $choosable, 'choosableHome' => $choosable,
'consentGroups' => Consent::GROUPS, 'consentGroups' => Consent::GROUPS,
'missingLegal' => $this->settings->missingLegalPages(), 'missingLegal' => $this->settings->missingLegalPages(),
'consentGroupHelp' => Consent::GROUP_HELP, '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. // Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'], 'palette' => $this->theme->load()['colors'],
'socialNetworks' => SocialProfiles::NETWORKS, 'socialNetworks' => SocialProfiles::NETWORKS,
+2 -2
View File
@@ -1,11 +1,11 @@
{% set selected = selected|default(null) %} {% set selected = selected|default(null) %}
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"> <div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}>
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label> <label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
<div class="picker-input"> <div class="picker-input">
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}"> <input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
<button type="button" class="subtle picker-clear" aria-label="{{ label|default('Seite') }}: Auswahl entfernen">✕</button> <button type="button" class="subtle picker-clear" aria-label="{{ label|default('Seite') }}: Auswahl entfernen">✕</button>
</div> </div>
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? selected.id : '' }}"> <input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? attribute(selected, valueKey|default('id')) : '' }}">
<div id="{{ id }}-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div> <div id="{{ id }}-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
<p class="picker-status field-help" role="status"></p> <p class="picker-status field-help" role="status"></p>
</div> </div>
+3 -3
View File
@@ -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'} %} {% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
<div class="rows"> <div class="rows">
{% for page in pages %} {% for page in pages %}
{# Indentation by class, not by style attribute: the administration forbids inline styles, so {# Newest first, not as a tree: indenting subpages here would suggest a parent that is not
an attribute here would simply be dropped and the tree would look flat. #} the row above. The address in the row shows where a page sits. #}
<article class="row row-stack depth-{{ min(page.depth, 3) }}"> <article class="row row-stack">
<div class="row-line"> <div class="row-line">
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span> <span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
<div class="row-main"> <div class="row-main">
+1 -1
View File
@@ -4,7 +4,7 @@
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %} {% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
{% block title %}{{ subject.title }} bearbeiten{% endblock %} {% block title %}{{ subject.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %} {% block body_class %}editor-body{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %} {% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %} {% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block body %} {% block body %}
{# The API base decides what is edited. An area is shared, so it has no draft and no {# The API base decides what is edited. An area is shared, so it has no draft and no
+16 -13
View File
@@ -3,7 +3,8 @@
{% block title %}Einstellungen{% endblock %} {% block title %}Einstellungen{% endblock %}
{% block heading %}Unternehmen und Adressen{% 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 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 %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %} {% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %} {% block content %}
{% if missingLegal is not empty %} {% if missingLegal is not empty %}
@@ -129,15 +130,19 @@
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p> <p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label> <label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
<p class="field-help">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.</p> <p class="field-help">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.</p>
<label for="faviconMediaId">Symbol der Website</label> <div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
<div class="favicon-field"> <div class="favicon-field" data-favicon-field aria-labelledby="favicon-title" role="group">
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32">{% endif %} <input type="hidden" name="faviconMediaId" value="{{ settings.faviconMediaId }}">
<select id="faviconMediaId" name="faviconMediaId"> <input type="hidden" name="faviconCrop" value="{{ settings.faviconCrop }}">
<option value="">Mitgeliefertes Zeichen</option> <div class="favicon-current">
{% for image in images %}<option value="{{ image.id }}"{{ settings.faviconMediaId == image.id ? ' selected' : '' }}>{{ image.title }}</option>{% endfor %} {% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconVersion ?: settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32"> <span>Eigenes Symbol</span>{% else %}<img class="favicon-preview" src="/favicon.svg" alt="" width="32" height="32"> <span>Mitgeliefertes Zeichen</span>{% endif %}
</select> </div>
<div class="favicon-actions">
<button type="button" class="secondary" data-favicon-choose>{{ settings.faviconMediaId ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
<button type="button" class="text-button danger-text" data-favicon-remove{{ settings.faviconMediaId ? '' : ' hidden' }}>Mitgeliefertes Zeichen verwenden</button>
</div>
</div> </div>
<p class="field-help">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.</p> <p class="field-help">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.</p>
<div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div> <div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
<label class="check-label" for="scrollTop"><input id="scrollTop" name="scrollTop" type="checkbox" value="1"{{ settings.scrollTop ? ' checked' : '' }}> Knopf „nach oben" anzeigen</label> <label class="check-label" for="scrollTop"><input id="scrollTop" name="scrollTop" type="checkbox" value="1"{{ settings.scrollTop ? ' checked' : '' }}> Knopf „nach oben" anzeigen</label>
<p class="field-help">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.</p> <p class="field-help">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.</p>
@@ -261,13 +266,11 @@
</fieldset> </fieldset>
<div class="fields"> <div class="fields">
<div> <div>
<label for="privacyPath">Adresse der Datenschutzseite</label> {% include 'admin/content-picker.html.twig' with {id: 'privacyPath', name: 'privacyPath', type: 'page', valueKey: 'path', label: 'Datenschutzseite', selected: legalSelection.privacyPath} only %}
<input id="privacyPath" name="privacyPath" maxlength="400" value="{{ settings.privacyPath }}" placeholder="datenschutz">
<p class="field-help">Pflicht, sobald der Hinweis an ist, und die Seite muss existieren.</p> <p class="field-help">Pflicht, sobald der Hinweis an ist, und die Seite muss existieren.</p>
</div> </div>
<div> <div>
<label for="imprintPath">Adresse des Impressums</label> {% include 'admin/content-picker.html.twig' with {id: 'imprintPath', name: 'imprintPath', type: 'page', valueKey: 'path', label: 'Impressum', selected: legalSelection.imprintPath} only %}
<input id="imprintPath" name="imprintPath" maxlength="400" value="{{ settings.imprintPath }}" placeholder="impressum">
</div> </div>
</div> </div>
<p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p> <p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p>
+5 -3
View File
@@ -1,9 +1,11 @@
{# Das Symbol der Website. Eigenes Bild aus den Einstellungen, sonst das mitgelieferte Zeichen. {# 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. #} 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 %} {% if favicon %}
<link rel="icon" href="/favicon.png?v={{ favicon|slice(0, 8) }}" type="image/png" sizes="32x32"> <link rel="icon" href="/favicon.png?v={{ version }}" type="image/png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ favicon|slice(0, 8) }}"> <link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ version }}">
{% else %} {% else %}
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
{% endif %} {% endif %}
+13
View File
@@ -1592,6 +1592,19 @@ final class CmsTest extends TestCase
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.'); self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode()); 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 // 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. // sieht ein echtes Favicon aus, und genau daran ist die erste Fassung gescheitert.
$klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-'); $klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-');