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:
1 parent
6632836580
commit
106f84dfd5
19 files changed
+300
-69
No files matched your search
+1
-1
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
|
|||||||
@@ -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%}
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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-');
|
||||||
|
|||||||
Reference in new issue
Block a user