"Wieviel wann wo wie die Seite benutzt" - naheliegend wäre ein Analysewerkzeug gewesen, das eine Einwilligung braucht und damit genau die Besucher verliert, die ablehnen. Also andersherum: eine Messung, die niemanden wiedererkennt. Gespeichert wird ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain), kein Datensatz je Aufruf. Keine IP-Adresse, keine Kennung, kein Cookie - auch nicht gehasht, denn der Hash einer IP-Adresse bleibt ein Personenbezug. Ein Test liest die Spaltenliste der Tabelle und vergleicht sie mit dieser Zusage, damit sie beim nächsten Ausbau nicht unbemerkt fällt. Vom Verweis bleibt nur der Host: die volle Adresse kann einen Suchbegriff oder einen Namen tragen. Gezählt wird auf kernel.terminate, nach der Antwort - die Messung darf keine Anfrage verlangsamen, und ein Fehler beim Zählen kostet einen Zähler, nicht eine Seite. Nur GET auf HTML mit 200 oder 404; Verwaltung, Assets, Feed, Sitemap und Einwilligungsseite sind ausgenommen, erkennbare Maschinen über ihre Kennung. Das ist eine Heuristik, deshalb heißt die Zahl auf dem Bildschirm nicht "Besucher". Erfundene Crawler-Adressen laufen gesammelt unter (sonstige), sonst wächst die Tabelle mit jedem geprobten Pfad. Die Diagramme zeichnet der Server als SVG mit Klassen statt Stilattributen: kein Diagrammwerkzeug, kein Skript, und die Richtlinie bleibt streng. Die Zahlen stehen zusätzlich in einer Tabelle, weil ein Balken für einen Screenreader nichts ist. Was die Zahlen nicht sagen, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist die Folge der Entscheidung, nicht eine Lücke. Aufbewahrung: nach 60 Tagen werden die Stunden eines Tages verschmolzen, nach 425 Tagen gelöscht - gut ein Jahr, damit der Vorjahresmonat vergleichbar bleibt. cms:stats:prune macht beides und gehört in einen Cronjob. 279 PHP-Tests / 1119 Assertions unter PHP 8.2.12 und 8.4.22, axe auf zwölf Verwaltungsbildschirmen ohne Verstöße. Dabei kam eine PHP-8.4-Verwarnung zu fputcsv() heraus, jetzt mit ausdrücklichem Escape-Parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
56 lines
3.6 KiB
Twig
56 lines
3.6 KiB
Twig
{# The frame around every management screen. One sidebar, always the same, so each area is one
|
|
click away and the current one is visible. Screens fill in heading, lead, actions and content. #}
|
|
{% extends 'admin/base.html.twig' %}
|
|
{% block body_class %}admin-shell{% endblock %}
|
|
{% block body %}
|
|
<header class="shell-nav">
|
|
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>sites</span></a>
|
|
<nav aria-label="Verwaltung">
|
|
<p class="nav-label" id="nav-content">INHALT</p>
|
|
<ul class="nav-list" aria-labelledby="nav-content">
|
|
{% set entries = [
|
|
{id: 'pages', route: 'admin_pages', icon: '▤', label: 'Seiten'},
|
|
{id: 'partials', route: 'admin_partials', icon: '◫', label: 'Bereiche'},
|
|
{id: 'menus', route: 'admin_menus', icon: '☰', label: 'Menüs'},
|
|
{id: 'media', route: 'admin_media', icon: '▩', label: 'Mediathek'},
|
|
{id: 'forms', route: 'admin_forms', icon: '✉', label: 'Formulare'},
|
|
{id: 'statistics', route: 'admin_statistics', icon: '▁', label: 'Statistik'}
|
|
] %}
|
|
{% for entry in entries %}
|
|
<li><a class="nav-item" href="{{ path(entry.route) }}"{{ nav|default('') == entry.id ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">{{ entry.icon }}</span>{{ entry.label }}</a></li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p class="nav-label" id="nav-site">WEBSITE</p>
|
|
<ul class="nav-list" aria-labelledby="nav-site">
|
|
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">◐</span>Design</a></li>
|
|
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⚙</span>Einstellungen</a></li>
|
|
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">☺</span>Benutzer</a></li>{% endif %}
|
|
<li><a class="nav-item nav-out" href="/">Website ansehen</a></li>
|
|
<li><a class="nav-item nav-out" href="/beitraege">Beitragsübersicht</a></li>
|
|
</ul>
|
|
</nav>
|
|
<div class="shell-foot">
|
|
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
|
<form action="{{ path('admin_logout') }}" method="post">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
|
<button class="subtle" type="submit">Abmelden</button>
|
|
</form>
|
|
</div>
|
|
</header>
|
|
<main id="admin-main" class="shell-main" tabindex="-1">
|
|
<div class="shell-top">
|
|
<div class="shell-heading">
|
|
<h1>{% block heading %}Verwaltung{% endblock %}</h1>
|
|
<p class="muted">{% block lead %}{% endblock %}</p>
|
|
</div>
|
|
<div class="shell-actions">{% block actions %}{% endblock %}</div>
|
|
</div>
|
|
<div class="shell-body stack">
|
|
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
|
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
|
{% endfor %}{% endfor %}
|
|
{% block content %}{% endblock %}
|
|
</div>
|
|
</main>
|
|
{% endblock %}
|