Rework forms, statistics, design and the sidebar foot in 1.15.0
Forms are an editor now: one card per field with add and remove, the technical name derived from the label, options only for a choice, and a new "Aussehen" section with a button label and four colours (field border and background, button and button text) that travel to the page as one rule per form inside the hashed style block. A live preview draws the form with the site's default button and names the text contrast. Statistics open with six figures (views against the same span before, average per day, busiest day, pages, referred views, missing addresses), a chart with grid lines and a title per bar, a weekday chart and ranked lists with share bars. The design screen shows the palette as colour cards with seven ready-made palettes, picks the base colours from the palette, previews every font in itself and the typography as a sample. The sidebar ends in an account card with role and a logout icon. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a4ee04533c
commit
160c394c3f
25 files changed
+1293
-278
No files matched your search
+158
-59
@@ -1,77 +1,176 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'forms' %}
|
||||
{# Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website - dafuer braucht die
|
||||
Seite dessen Stylesheet, genau wie die Designseite. #}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Formulare{% endblock %}
|
||||
{% block heading %}Formulare{% endblock %}
|
||||
{% block lead %}Felder, Empfänger und Einwilligungstext werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
|
||||
{% block lead %}Felder, Empfänger, Einwilligungstext und Aussehen werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
|
||||
{% block actions %}<a class="primary" href="#neues-formular">Neues Formular</a>{% endblock %}
|
||||
|
||||
{# Eine Feldkarte. Der Index steht in den Feldnamen; die Vorlage fuer neue Felder traegt an seiner
|
||||
Stelle den Platzhalter __n__, den das Skript beim Einfuegen ersetzt. #}
|
||||
{% macro field(f, n, row, types) %}
|
||||
{% set id = 'f' ~ f ~ '-' ~ n %}
|
||||
<li class="field-card{{ row.label == '' ? ' field-card-new' : '' }}" data-field>
|
||||
<div class="field-card-head">
|
||||
<span class="field-card-index" aria-hidden="true" data-field-index></span>
|
||||
<div class="field-card-title">
|
||||
<label for="{{ id }}-label">Beschriftung</label>
|
||||
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : '' }}" data-field-label>
|
||||
</div>
|
||||
<div class="field-card-type">
|
||||
<label for="{{ id }}-type">Typ</label>
|
||||
<select id="{{ id }}-type" name="fields[{{ n }}][type]" data-field-type>
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="field-remove" data-remove-field hidden aria-label="Feld entfernen" title="Feld entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</div>
|
||||
<div class="field-card-body">
|
||||
<div>
|
||||
<label for="{{ id }}-name">Technischer Name</label>
|
||||
<input id="{{ id }}-name" name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" placeholder="vorname" data-field-name>
|
||||
<p class="field-help">Kleinbuchstaben, Ziffern, Unterstrich. Steht in der E-Mail vor der Antwort.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ id }}-help">Hilfetext</label>
|
||||
<input id="{{ id }}-help" name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" placeholder="Steht unter dem Feld" data-field-help>
|
||||
</div>
|
||||
<div data-field-options>
|
||||
<label for="{{ id }}-options">Optionen der Auswahl</label>
|
||||
<input id="{{ id }}-options" name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" placeholder="Beratung, Angebot, Sonstiges" data-field-options-input>
|
||||
<p class="field-help">Durch Komma trennen, mindestens zwei.</p>
|
||||
</div>
|
||||
<div class="field-card-flags">
|
||||
<label class="check-label" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflichtfeld</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}">
|
||||
{% for form in forms|merge([null]) %}
|
||||
{% set new = form is null %}
|
||||
{% set f = loop.index %}
|
||||
{% set rows = new ? [] : form.fields %}
|
||||
<section class="stack-section">
|
||||
<div class="section-head">
|
||||
<h2>{{ new ? 'Neues Formular' : form.name }}{% if not new %} <span class="count">{{ form.fields|length }} Felder</span>{% endif %}</h2>
|
||||
{% if not new %}<span class="muted">Block-Adresse <code>{{ form.slug }}</code></span>{% endif %}
|
||||
</div>
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def card card-body">
|
||||
{% set settings = new ? {} : form.settings %}
|
||||
{% if new %}
|
||||
<details class="card form-new" id="neues-formular"{{ forms is empty ? ' open' : '' }}>
|
||||
<summary>Neues Formular anlegen</summary>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
{% endif %}
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def" data-form-editor>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forms') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ new ? 0 : form.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="name-{{ loop.index }}">Name</label>
|
||||
<input id="name-{{ loop.index }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}">
|
||||
</div>
|
||||
{% if new %}
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="recipient-{{ loop.index }}">Empfänger</label>
|
||||
<input id="recipient-{{ loop.index }}" name="recipient" type="email" required value="{{ new ? '' : form.settings.recipient }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ loop.index }}">Betreff</label>
|
||||
<input id="subject-{{ loop.index }}" name="subject" maxlength="160" value="{{ new ? '' : form.settings.subject }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="reply-{{ loop.index }}">Antwortadresse aus Feld</label>
|
||||
<input id="reply-{{ loop.index }}" name="replyToField" maxlength="32" value="{{ new ? '' : form.settings.replyToField }}" placeholder="email">
|
||||
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label for="success-{{ loop.index }}">Meldung nach dem Absenden</label>
|
||||
<textarea id="success-{{ loop.index }}" name="success" maxlength="300" rows="3" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ new ? '' : form.settings.success }}</textarea>
|
||||
</div>
|
||||
{% if new %}
|
||||
{# Die Zusammenfassung des Details ist keine Ueberschrift; ohne diese springt die
|
||||
Gliederung von der Seitenueberschrift direkt zu den Abschnittstiteln. #}
|
||||
<h2 class="visually-hidden">Neues Formular</h2>
|
||||
{% else %}
|
||||
<div class="card-head">
|
||||
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
|
||||
<span class="muted">Im Editor als Formularblock mit der Adresse <code>{{ form.slug }}</code></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body form-editor">
|
||||
<div class="form-editor-main">
|
||||
<h3 class="form-part">Zustellung</h3>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="name-{{ f }}">Name</label>
|
||||
<input id="name-{{ f }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}" placeholder="Kontakt">
|
||||
</div>
|
||||
{% if new %}
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen. Kleinbuchstaben, Ziffern, Bindestrich.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="recipient-{{ f }}">Empfänger</label>
|
||||
<input id="recipient-{{ f }}" name="recipient" type="email" required value="{{ settings.recipient|default('') }}" placeholder="info@beispiel.de">
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ f }}">Betreff der E-Mail</label>
|
||||
<input id="subject-{{ f }}" name="subject" maxlength="160" value="{{ settings.subject|default('') }}" placeholder="Neue Formularnachricht">
|
||||
</div>
|
||||
<div>
|
||||
<label for="reply-{{ f }}">Antwortadresse aus Feld</label>
|
||||
<input id="reply-{{ f }}" name="replyToField" maxlength="32" value="{{ settings.replyToField|default('') }}" placeholder="email">
|
||||
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label for="success-{{ f }}">Meldung nach dem Absenden</label>
|
||||
<textarea id="success-{{ f }}" name="success" maxlength="300" rows="2" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ settings.success|default('') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table field-table">
|
||||
<caption class="visually-hidden">Felder des Formulars. Eine Beschriftung zu leeren entfernt das Feld.</caption>
|
||||
<thead><tr><th scope="col">Technischer Name</th><th scope="col">Typ</th><th scope="col">Beschriftung</th><th scope="col">Pflicht</th><th scope="col">Hilfetext</th><th scope="col">Optionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows|merge([{name: '', type: 'text', label: '', required: false, help: '', options: []}, {name: '', type: 'text', label: '', required: false, help: '', options: []}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.label == '' %} class="table-new"{% endif %}>
|
||||
<td data-label="Technischer Name"><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
|
||||
<td data-label="Typ">
|
||||
<select name="fields[{{ n }}][type]" aria-label="Typ von Feld {{ n + 1 }}">
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td data-label="Beschriftung"><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
|
||||
<td class="check-cell" data-label="Pflichtfeld"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
|
||||
<td data-label="Hilfetext"><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
|
||||
<td data-label="Optionen"><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Optionen einer Auswahl durch Komma trennen. Die letzten beiden Zeilen sind für neue Felder.</p>
|
||||
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
|
||||
<div class="form-fields-head">
|
||||
<h3 class="form-part">Felder</h3>
|
||||
<button type="button" class="secondary" data-add-field hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Feld hinzufügen</button>
|
||||
</div>
|
||||
<ol class="field-list" data-field-list>
|
||||
{% for row in rows %}{{ karte.field(f, loop.index0, row, types) }}{% endfor %}
|
||||
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
|
||||
</ol>
|
||||
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}</template>
|
||||
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert.</p>
|
||||
|
||||
<h3 class="form-part">Aussehen</h3>
|
||||
<div class="fields form-look">
|
||||
<div>
|
||||
<label for="buttonLabel-{{ f }}">Beschriftung des Buttons</label>
|
||||
<input id="buttonLabel-{{ f }}" name="buttonLabel" maxlength="40" value="{{ settings.buttonLabel|default('') }}" placeholder="Absenden" data-form-button-label>
|
||||
</div>
|
||||
{% for key, label in {fieldBorderColor: 'Rahmen der Felder', fieldBackgroundColor: 'Hintergrund der Felder', buttonColor: 'Button', buttonInk: 'Buttonschrift'} %}
|
||||
<div>
|
||||
<label for="{{ key }}-{{ f }}">{{ label }}</label>
|
||||
<input id="{{ key }}-{{ f }}" name="{{ key }}" value="{{ settings[key]|default('') }}" placeholder="Wie die Website" data-form-color="{{ key }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field-help">Leer heißt: wie die Website. Sonst eine Farbe aus der Palette oder ein Wert wie <code>#1a2b3c</code>. Form und Größe des Buttons kommen aus dem Design.</p>
|
||||
</div>
|
||||
|
||||
{# Die Vorschau: dieselben Felder, dieselben Farben, der Standardbutton der Website.
|
||||
Ohne Skript steht sie mit den gespeicherten Feldern da; mit Skript folgt sie jeder
|
||||
Eingabe. #}
|
||||
<aside class="form-editor-preview" aria-label="Vorschau des Formulars">
|
||||
<h3 class="form-part">Vorschau</h3>
|
||||
<div class="form-preview" data-form-preview>
|
||||
{% for row in rows %}
|
||||
<div class="form-preview-field">
|
||||
{% if row.type == 'consent' %}
|
||||
<span class="form-preview-check"><span class="form-preview-box" aria-hidden="true"></span>{{ row.label }} *</span>
|
||||
{% else %}
|
||||
<span class="form-preview-label">{{ row.label }}{% if row.required %} *{% endif %}</span>
|
||||
<span class="form-preview-input{{ row.type == 'textarea' ? ' form-preview-area' : '' }}{{ row.type == 'select' ? ' form-preview-select' : '' }}" aria-hidden="true">{{ row.type == 'select' ? '— bitte wählen —' : '' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<span class="cms-button b-desktop-shape-{{ schema.buttons.shape }} b-desktop-appearance-{{ schema.buttons.appearance }} b-desktop-size-{{ schema.buttons.size }}" data-preview-button>{{ settings.buttonLabel|default('Absenden') }}</span>
|
||||
</div>
|
||||
<p class="field-help">Schrift, Abstände und Buttonform kommen aus dem Design der Website.</p>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="card-foot form-editor-foot">
|
||||
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
|
||||
{% if not new %}<span class="muted">Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.</span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if new %}
|
||||
</details>
|
||||
{% else %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
|
||||
{% if submissions.items is empty %}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
|
||||
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
|
||||
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5'
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
logout: 'M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5m5-12 4 4-4 4m4-4H9',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
remove: 'M5 7h14M9 7V4h6v3m-7 0v13h8V7'
|
||||
} %}
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name]|default(paths.pages) }}"/></svg>
|
||||
@@ -53,13 +53,25 @@
|
||||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
{# Der Fuss der Leiste: eine Kontokarte mit Namen und Rolle, daneben der Abmeldeknopf als
|
||||
Symbol mit Beschriftung fuer Vorlesesoftware, darunter die Fassung. Vorher standen drei
|
||||
Zeilen lose untereinander - Fassung, Name, ein grauer Knopf; jetzt ist es eine Einheit. #}
|
||||
<div class="shell-foot">
|
||||
<p class="shell-version">{% if is_granted('ROLE_ADMIN') %}<a href="{{ path('admin_system') }}">Fassung {{ kant_version() }}</a>{% set updateVersion = kant_update_version() %}{% if updateVersion %}<a class="version-update" href="{{ path('admin_system', {pruefen: 1}) }}" aria-label="Update {{ updateVersion }} verfügbar. Aktualisierung öffnen.">Update {{ updateVersion }} verfügbar</a>{% endif %}{% else %}Fassung {{ kant_version() }}{% endif %}</p>
|
||||
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ 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 class="shell-account">
|
||||
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}>
|
||||
<span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span>
|
||||
<span class="shell-user-text"><strong>{{ app.user.userIdentifier }}</strong><span>{{ is_granted('ROLE_ADMIN') ? 'Administrator' : (is_granted('ROLE_PUBLISHER') ? 'Veröffentlicher' : 'Redakteur') }}</span></span>
|
||||
</a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
<button class="shell-logout" type="submit" title="Abmelden">{% include 'admin/icon.html.twig' with {name: 'logout'} only %}<span class="visually-hidden">Abmelden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
{% set updateVersion = is_granted('ROLE_ADMIN') ? kant_update_version() : null %}
|
||||
<p class="shell-version">
|
||||
{% if is_granted('ROLE_ADMIN') %}<a href="{{ path('admin_system') }}">KantSites {{ kant_version() }}</a>{% else %}<span>KantSites {{ kant_version() }}</span>{% endif %}
|
||||
{% if updateVersion %}<a class="version-update" href="{{ path('admin_system', {pruefen: 1}) }}" aria-label="Update {{ updateVersion }} verfügbar. Aktualisierung öffnen.">Update {{ updateVersion }} verfügbar</a>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<main id="admin-main" class="shell-main" tabindex="-1">
|
||||
|
||||
@@ -4,20 +4,61 @@
|
||||
{% block heading %}Statistik{% endblock %}
|
||||
{% block lead %}Gezählt wird auf dem Server, ohne Cookie und ohne IP-Adresse – deshalb braucht diese Messung keine Einwilligung. Zeiten in UTC.{% endblock %}
|
||||
{% block actions %}
|
||||
<nav class="range-switch" aria-label="Zeitraum">
|
||||
{% for days, label in ranges %}
|
||||
<a href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<a class="secondary" href="{{ path('admin_statistics_csv', {tage: range}) }}">Als CSV</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% set perDay = stats.total / stats.range %}
|
||||
{% set delta = stats.previousTotal > 0 ? ((stats.total - stats.previousTotal) / stats.previousTotal * 100)|round : null %}
|
||||
{% set weekdayNames = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] %}
|
||||
{% set weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
|
||||
|
||||
<nav class="range-switch" aria-label="Zeitraum">
|
||||
{% for days, label in ranges %}
|
||||
<a class="{{ days == range ? 'primary' : 'secondary' }}" href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{# Die Kennzahlen zuerst: was der Zeitraum gebracht hat, und woran man es misst. #}
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile stat-tile-main">
|
||||
<span class="stat-label">Seitenaufrufe</span>
|
||||
<strong class="stat-value">{{ stats.total|number_format(0, ',', '.') }}</strong>
|
||||
{% if delta is null %}
|
||||
<span class="stat-note">{{ stats.total > 0 ? 'im Zeitraum davor keine Aufrufe' : 'noch nichts gezählt' }}</span>
|
||||
{% else %}
|
||||
<span class="stat-note stat-delta {{ delta > 0 ? 'stat-up' : (delta < 0 ? 'stat-down' : '') }}">{{ delta > 0 ? '+' : '' }}{{ delta|number_format(0, ',', '.') }} % gegenüber {{ ranges[range]|lower }} davor ({{ stats.previousTotal|number_format(0, ',', '.') }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Im Schnitt je Tag</span>
|
||||
<strong class="stat-value">{{ perDay|number_format(perDay < 10 ? 1 : 0, ',', '.') }}</strong>
|
||||
<span class="stat-note">seit {{ stats.from|date('d.m.Y') }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Stärkster Tag</span>
|
||||
<strong class="stat-value">{{ stats.busiestDay ? stats.busiest|number_format(0, ',', '.') : '–' }}</strong>
|
||||
<span class="stat-note">{{ stats.busiestDay ? stats.busiestDay|date('d.m.Y') ~ ', ' ~ weekdayNames[stats.busiestDay|date('N') - 1] : 'kein Aufruf im Zeitraum' }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Aufgerufene Seiten</span>
|
||||
<strong class="stat-value">{{ stats.pageCount|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">unterschiedliche Adressen</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Über Verweise</span>
|
||||
<strong class="stat-value">{{ stats.referred|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">{{ stats.total > 0 ? (stats.referred / stats.total * 100)|round ~ ' % kamen von einer anderen Website' : 'von einer anderen Website' }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Nicht gefunden</span>
|
||||
<strong class="stat-value">{{ stats.missingTotal|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">Aufrufe einer Adresse ohne Seite</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Seitenaufrufe <span class="count">{{ stats.total }}</span></h2>
|
||||
<span class="muted">ab {{ stats.from|date('d.m.Y') }}{% if stats.total > 0 %} · im Schnitt {{ (stats.total / stats.range)|number_format(stats.total / stats.range < 10 ? 1 : 0, ',', '.') }} am Tag{% endif %}</span>
|
||||
<h2>Verlauf <span class="count">{{ stats.total }}</span></h2>
|
||||
<span class="muted">Aufrufe je Tag, {{ stats.from|date('d.m.') }} bis heute</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.total == 0 %}
|
||||
@@ -25,26 +66,30 @@
|
||||
{% else %}
|
||||
{# Drawn by the server as an SVG: no chart library, no script, and it prints. The bars are
|
||||
coloured by class, not by a style attribute, because inline styles are forbidden here.
|
||||
The numbers themselves are in the table below, which is what a screen reader reads. #}
|
||||
<svg class="chart" viewBox="0 0 {{ stats.days|length * 12 }} 116" role="img"
|
||||
The numbers themselves are in the table below, which is what a screen reader reads.
|
||||
Jeder Balken traegt seinen Tag und seine Zahl als Titel - der Browser zeigt sie beim
|
||||
Ueberfahren, ohne dass ein Skript etwas nachrechnen muss. #}
|
||||
{% set width = stats.days|length * 12 %}
|
||||
<svg class="chart chart-days" viewBox="0 0 {{ width }} 116" role="img"
|
||||
aria-label="{{ stats.total }} Aufrufe zwischen {{ stats.from|date('d.m.Y') }} und heute, höchster Tageswert {{ stats.busiest }}" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="{{ width }}" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.days %}
|
||||
{% set height = entry.count > 0 ? (entry.count / stats.busiest * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
<rect class="chart-bar{{ entry.day == stats.busiestDay ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.day|date('d.m.Y') }}: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ stats.days|length * 12 }}" y2="100.5"></line>
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ width }}" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>höchster Tag: {{ stats.busiest }}</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>Skala bis {{ stats.busiest }} · stärkster Tag hervorgehoben</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||||
|
||||
<details class="chart-table">
|
||||
<summary>Zahlen je Tag</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<caption class="visually-hidden">Aufrufe je Tag im gewählten Zeitraum</caption>
|
||||
<thead><tr><th scope="col">Tag</th><th scope="col">Aufrufe</th></tr></thead>
|
||||
<thead><tr><th scope="col">Tag</th><th scope="col">Wochentag</th><th scope="col">Aufrufe</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in stats.days|reverse %}
|
||||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ entry.count }}</td></tr>
|
||||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ weekdayShort[entry.day|date('N') - 1] }}</td><td>{{ entry.count }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -55,19 +100,38 @@
|
||||
</div>
|
||||
|
||||
{% if stats.total > 0 %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde, nur die letzten {{ compactAfter }} Tage sind stundengenau</span></div>
|
||||
<div class="card-body">
|
||||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||||
<div class="stats-columns stats-columns-2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde</span></div>
|
||||
<div class="card-body">
|
||||
{% set busiestHour = max(stats.hours|map(entry => entry.count)|filter(count => count > 0)|default([1])) %}
|
||||
{% for entry in stats.hours %}
|
||||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>0 Uhr</span><span>12 Uhr</span><span>23 Uhr</span></p>
|
||||
<p class="field-help">Ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="288" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.hours %}
|
||||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar{{ entry.count == busiestHour and entry.count > 0 ? ' chart-bar-peak' : '' }}" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.hour }} bis {{ entry.hour + 1 }} Uhr: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>0 Uhr</span><span>6</span><span>12 Uhr</span><span>18</span><span>23 Uhr</span></p>
|
||||
<p class="field-help">Nur die letzten {{ compactAfter }} Tage sind stundengenau; ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Wochentag</h2><span class="muted">Im Schnitt je Tag</span></div>
|
||||
<div class="card-body">
|
||||
{% set busiestWeekday = max(stats.weekdays|map(entry => entry.average)|filter(average => average > 0)|default([1])) %}
|
||||
<svg class="chart chart-weekdays" viewBox="0 0 84 116" role="img" aria-label="Aufrufe je Wochentag im Schnitt" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="84" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.weekdays %}
|
||||
{% set height = entry.average > 0 ? (entry.average / busiestWeekday * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar{{ entry.average == busiestWeekday and entry.average > 0 ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ weekdayNames[entry.weekday - 1] }}: im Schnitt {{ entry.average|number_format(1, ',', '.') }} Aufrufe ({{ entry.count }} gesamt)</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="84" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend chart-legend-even">{% for name in weekdayShort %}<span>{{ name }}</span>{% endfor %}</p>
|
||||
<p class="field-help">Der Schnitt gleicht aus, dass ein Zeitraum nicht jeden Wochentag gleich oft enthält.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -75,7 +139,7 @@
|
||||
<div class="stats-columns">
|
||||
{% for group in [
|
||||
{title: 'Seiten', hint: 'Meistaufgerufen', rows: stats.pages, key: 'path', link: true},
|
||||
{title: 'Verweise von außen', hint: 'Domain, die verlinkt hat', rows: stats.referrers, key: 'referrer_host', link: false},
|
||||
{title: 'Verweise von außen', hint: 'Websites, die verlinkt haben', rows: stats.referrers, key: 'referrer_host', link: false},
|
||||
{title: 'Nicht gefundene Adressen', hint: 'Kandidaten für eine Weiterleitung', rows: stats.missing, key: 'path', link: false}
|
||||
] %}
|
||||
<div class="card">
|
||||
@@ -84,11 +148,14 @@
|
||||
{% if group.rows is empty %}
|
||||
<p class="muted">Keine Daten im Zeitraum.</p>
|
||||
{% else %}
|
||||
{% set top = (group.rows|first).count %}
|
||||
<ol class="stats-list">
|
||||
{% for row in group.rows %}
|
||||
<li>
|
||||
<span class="stats-rank" aria-hidden="true">{{ loop.index }}</span>
|
||||
{% if group.link %}<a href="{{ row[group.key] }}">{{ row[group.key] }}</a>{% else %}<span>{{ row[group.key] }}</span>{% endif %}
|
||||
<strong>{{ row.count }}</strong>
|
||||
<svg class="stats-share" viewBox="0 0 100 6" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="stats-share-bar" x="0" y="0" width="{{ (row.count / top * 100)|round(1) }}" height="6" rx="3"></rect></svg>
|
||||
<strong>{{ row.count|number_format(0, ',', '.') }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -98,14 +165,14 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Was diese Zahlen nicht sagen</h2><span class="muted">Ehrlich statt vollständig</span></div>
|
||||
<details class="card">
|
||||
<summary>Was diese Zahlen nicht sagen</summary>
|
||||
<div class="card-body">
|
||||
<p class="muted">Gezählt werden <strong>Aufrufe</strong>, nicht Besucher: ohne Wiedererkennung lässt sich nicht sagen, ob zehn Aufrufe von zehn Personen oder von einer kommen. Es gibt deshalb auch keine Verweildauer, keine Absprungrate, keinen Klickweg durch die Website und kein Land. All das braucht ein Merkmal, das eine Person wiedererkennbar macht – und damit eine Einwilligung.</p>
|
||||
<p class="field-help">Erkennbare Suchmaschinen und Überwachungsdienste werden anhand ihrer Kennung ausgeschlossen. Das ist eine Heuristik: wer sich als Browser ausgibt, wird mitgezählt. Die Zahl ist also „Aufrufe, die sich nicht als Maschine gemeldet haben“, nicht „Menschen“. Erfundene Adressen von Crawlern laufen gesammelt unter <code>(sonstige)</code>, damit die Tabelle nicht mit tausenden Zeilen wächst.</p>
|
||||
<p class="field-help">Aufbewahrt wird {{ keepDays }} Tage, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. Danach wird gelöscht.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
+198
-103
@@ -1,122 +1,214 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'theme' %}
|
||||
{% block styles %}<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/theme-buttons.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{# Die lokalen Schriftdateien, sofern es sie gibt: dann zeigt die Vorschau jede Google-Schrift so,
|
||||
wie die Website sie ausliefert. Von Google selbst wird hier nichts geladen. #}
|
||||
{% block styles %}<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() }}">{% if fontsStylesheet %}<link rel="stylesheet" href="/assets/fonts/fonts.css?v={{ kant_version() }}">{% endif %}{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/theme-panel.js?v={{ kant_version() }}"></script><script type="module" src="/assets/theme-buttons.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Design{% endblock %}
|
||||
{% block heading %}Farben, Schriften, Grundtypografie{% endblock %}
|
||||
{% block lead %}Das ist die Grundlage für die ganze Website. Jeder Block erbt von hier und kann jeden Wert einzeln überschreiben – je Gerät und beim Überfahren.{% endblock %}
|
||||
{% block content %}
|
||||
<form action="{{ path('admin_theme_save') }}" method="post" class="card card-body theme-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
|
||||
<div class="section-head"><h2>Farbpalette <span class="count">{{ theme.colors|length }}</span></h2><span class="muted">Beliebig viele eigene Farben</span></div>
|
||||
<table class="table theme-table">
|
||||
<caption class="visually-hidden">Farben der Website. Einen Schlüssel zu leeren entfernt die Farbe.</caption>
|
||||
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Farbe</th><th scope="col">Wert</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in theme.colors|merge([{key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.key == '' %} class="table-new"{% endif %}>
|
||||
<td><input name="colors[{{ n }}][key]" maxlength="32" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Farbe {{ n + 1 }}" placeholder="akzent"></td>
|
||||
<td><input name="colors[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Farbe {{ n + 1 }}" placeholder="Akzent"></td>
|
||||
<td><input type="color" name="colors[{{ n }}][value]" value="{{ row.value }}" aria-label="Farbwähler für Farbe {{ n + 1 }}" class="theme-swatch"></td>
|
||||
<td><code>{{ row.value }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Der Schlüssel wird im Editor als <code>@schlüssel</code> verwendet und bleibt bei Farbänderungen gleich – eine Farbe ändern wirkt überall, wo sie benutzt wird.</p>
|
||||
|
||||
<div class="section-head"><h2>Schriften <span class="count">{{ theme.fonts|length }}</span></h2><span class="muted">Systemschrift oder Google Fonts</span></div>
|
||||
<table class="table theme-table">
|
||||
<caption class="visually-hidden">Schriften der Website. Einen Schlüssel zu leeren entfernt die Schrift.</caption>
|
||||
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Quelle</th><th scope="col">Schriftname</th><th scope="col">Ersatz</th><th scope="col">Schnitte</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in theme.fonts|merge([{key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.key == '' %} class="table-new"{% endif %}>
|
||||
<td><input name="fonts[{{ n }}][key]" maxlength="40" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Schrift {{ n + 1 }}" placeholder="heading"></td>
|
||||
<td><input name="fonts[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Schrift {{ n + 1 }}" placeholder="Überschriften"></td>
|
||||
<td>
|
||||
<select name="fonts[{{ n }}][source]" aria-label="Quelle von Schrift {{ n + 1 }}">
|
||||
<option value="system"{{ row.source == 'system' ? ' selected' : '' }}>Systemschrift</option>
|
||||
<option value="google"{{ row.source == 'google' ? ' selected' : '' }}>Google Fonts</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="fonts[{{ n }}][family]" maxlength="48" value="{{ row.family }}" aria-label="Schriftname von Schrift {{ n + 1 }}" placeholder="Inter"></td>
|
||||
<td>
|
||||
<select name="fonts[{{ n }}][fallback]" aria-label="Ersatzschrift von Schrift {{ n + 1 }}">
|
||||
{% for value, label in fallbacks %}<option value="{{ value }}"{{ row.fallback == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="weights-cell">
|
||||
{% for weight in weights %}
|
||||
<label><input type="checkbox" name="fonts[{{ n }}][weights][]" value="{{ weight }}"{{ weight in row.weights ? ' checked' : '' }}> {{ weight }}</label>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Bei Google Fonts den Namen genau wie dort schreiben, zum Beispiel <code>Inter</code> oder <code>Playfair Display</code>. Je Schnitt wird eine Datei geladen – nur ankreuzen, was wirklich gebraucht wird. <strong>Google Fonts bedeutet eine Anfrage an Google bei jedem Seitenaufruf.</strong></p>
|
||||
|
||||
<div class="section-head"><h2>Grundtypografie</h2><span class="muted">Gilt überall, solange ein Block nichts anderes sagt</span></div>
|
||||
<div class="fields">
|
||||
{# Eine Farbe der Palette als Karte: der Farbwähler ist die Fläche selbst. #}
|
||||
{% macro color(n, row) %}
|
||||
<li class="swatch-card{{ row.key == '' ? ' swatch-card-new' : '' }}" data-color-card>
|
||||
<label class="swatch-well" for="color-{{ n }}-value">
|
||||
<input type="color" id="color-{{ n }}-value" name="colors[{{ n }}][value]" value="{{ row.value }}" class="swatch-input" aria-label="Farbwert von Farbe {{ n }}" data-color-value>
|
||||
<code class="swatch-hex" data-color-hex>{{ row.value }}</code>
|
||||
</label>
|
||||
<div class="swatch-fields">
|
||||
<div>
|
||||
<label for="base-font">Schrift für Fließtext</label>
|
||||
<select id="base-font" name="base[fontFamily]">
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<label for="color-{{ n }}-name">Name</label>
|
||||
<input id="color-{{ n }}-name" name="colors[{{ n }}][name]" maxlength="60" value="{{ row.name }}" placeholder="Akzent" data-color-name>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-heading-font">Schrift für Überschriften</label>
|
||||
<select id="base-heading-font" name="base[headingFontFamily]">
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.headingFontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-size">Grundgröße</label>
|
||||
<input id="base-size" name="base[fontSize]" value="{{ theme.base.fontSize }}" placeholder="18px">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-line">Zeilenhöhe</label>
|
||||
<input id="base-line" name="base[lineHeight]" type="number" step="0.05" min="0.8" max="3" value="{{ theme.base.lineHeight }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-scale">Überschriftenskala</label>
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }}> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
<p class="field-help">Die Breite des Inhalts auf allen Seiten. Abschnitte mit der Inhaltsbreite „Breit“ oder „Randlos“ halten sich nicht daran – das ist der Zweck dieser beiden.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-color">Textfarbe</label>
|
||||
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-bg">Hintergrundfarbe</label>
|
||||
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-link">Linkfarbe</label>
|
||||
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
<label for="color-{{ n }}-key">Schlüssel</label>
|
||||
<input id="color-{{ n }}-key" name="colors[{{ n }}][key]" maxlength="32" value="{{ row.key }}" pattern="[a-z][a-z0-9\-]*" placeholder="akzent" data-color-key>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
|
||||
<button type="button" class="field-remove" data-remove-color hidden aria-label="Farbe entfernen" title="Farbe entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{# Eine Schrift als Karte: oben die Probe in der Schrift selbst, darunter die Einstellungen. #}
|
||||
{% macro font(n, row, fallbacks, weights) %}
|
||||
<li class="font-card{{ row.key == '' ? ' font-card-new' : '' }}" data-font-card>
|
||||
<div class="font-sample" data-font-sample>
|
||||
<span class="font-sample-big" aria-hidden="true">Aa</span>
|
||||
<span class="font-sample-line">
|
||||
<span class="font-sample-text">Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich.</span>
|
||||
<span class="font-sample-weights" data-font-weights>{% for weight in weights %}<span class="font-weight-{{ weight }}"{{ weight in row.weights ? '' : ' hidden' }}>{{ weight }}</span>{% endfor %}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-fields">
|
||||
<div>
|
||||
<label for="font-{{ n }}-name">Name</label>
|
||||
<input id="font-{{ n }}-name" name="fonts[{{ n }}][name]" maxlength="60" value="{{ row.name }}" placeholder="Überschriften" data-font-name>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-key">Schlüssel</label>
|
||||
<input id="font-{{ n }}-key" name="fonts[{{ n }}][key]" maxlength="40" value="{{ row.key }}" pattern="[a-z][a-z0-9\-]*" placeholder="heading" data-font-key>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-source">Quelle</label>
|
||||
<select id="font-{{ n }}-source" name="fonts[{{ n }}][source]" data-font-source>
|
||||
<option value="system"{{ row.source == 'system' ? ' selected' : '' }}>Systemschrift</option>
|
||||
<option value="google"{{ row.source == 'google' ? ' selected' : '' }}>Google Fonts</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-family">Schriftname</label>
|
||||
<input id="font-{{ n }}-family" name="fonts[{{ n }}][family]" maxlength="48" value="{{ row.family }}" placeholder="Inter" data-font-family>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-fallback">Ersatzschrift</label>
|
||||
<select id="font-{{ n }}-fallback" name="fonts[{{ n }}][fallback]" data-font-fallback>
|
||||
{% for value, label in fallbacks %}<option value="{{ value }}"{{ row.fallback == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<fieldset class="font-weights">
|
||||
<legend>Schnitte</legend>
|
||||
{% for weight in weights %}
|
||||
<label class="chip"><input type="checkbox" name="fonts[{{ n }}][weights][]" value="{{ weight }}"{{ weight in row.weights ? ' checked' : '' }} data-font-weight> {{ weight }}</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<button type="button" class="field-remove" data-remove-font hidden aria-label="Schrift entfernen" title="Schrift entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
{% set emptyColor = {key: '', name: '', value: '#4a7c59'} %}
|
||||
{% set emptyFont = {key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]} %}
|
||||
<form action="{{ path('admin_theme_save') }}" method="post" class="theme-form stack" data-theme-editor data-palettes="{{ palettes|json_encode|e('html_attr') }}" data-color-names="{{ colorNames|json_encode|e('html_attr') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Farbpalette <span class="count" data-color-count>{{ theme.colors|length }}</span></h2>
|
||||
<span class="muted">Jede Farbe hat einen Schlüssel – im Editor als <code>@schlüssel</code>, und eine Änderung hier wirkt überall</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="palette-presets" role="group" aria-label="Fertige Paletten" hidden data-palette-presets>
|
||||
<span class="palette-presets-label">Fertige Palette übernehmen</span>
|
||||
<div class="palette-preset-row">
|
||||
{% for preset in palettes %}
|
||||
<button type="button" class="palette-preset" data-palette-preset="{{ loop.index0 }}" aria-label="Palette {{ preset.name }} übernehmen">
|
||||
<svg viewBox="0 0 50 14" aria-hidden="true" focusable="false">{% for key, value in preset.colors %}<rect x="{{ loop.index0 * 10 }}" y="0" width="10" height="14" fill="{{ value }}"></rect>{% endfor %}</svg>
|
||||
<span>{{ preset.name }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field-help" data-palette-status role="status">Ersetzt die fünf Grundfarben; eigene Farben bleiben. Gilt erst nach dem Speichern.</p>
|
||||
</div>
|
||||
<ul class="swatch-list" data-color-list>
|
||||
{% for row in theme.colors|merge([emptyColor, emptyColor]) %}{{ karte.color(loop.index0, row) }}{% endfor %}
|
||||
</ul>
|
||||
<template data-color-template>{{ karte.color('__n__', emptyColor) }}</template>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="secondary" data-add-color hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Farbe hinzufügen</button>
|
||||
<p class="field-help">Eine Farbe ohne Schlüssel wird nicht gespeichert – so lässt sich eine Farbe auch ohne Skript entfernen. Die beiden leeren Karten sind für neue Farben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Schriften <span class="count" data-font-count>{{ theme.fonts|length }}</span></h2>
|
||||
<span class="muted">Systemschrift oder Google Fonts – ausgeliefert vom eigenen Server</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="font-list" data-font-list>
|
||||
{% for row in theme.fonts|merge([emptyFont]) %}{{ karte.font(loop.index0, row, fallbacks, weights) }}{% endfor %}
|
||||
</ul>
|
||||
<template data-font-template>{{ karte.font('__n__', emptyFont, fallbacks, weights) }}</template>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="secondary" data-add-font hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Schrift hinzufügen</button>
|
||||
<p class="field-help">Bei Google Fonts den Namen genau wie dort schreiben, zum Beispiel <code>Inter</code> oder <code>Playfair Display</code>. Je Schnitt wird eine Datei geladen – nur ankreuzen, was wirklich gebraucht wird. {% if fontsStylesheet %}Die Probe nutzt die Schriftdateien auf diesem Server; eine neue Schrift erscheint darin erst, wenn sie unten geholt wurde.{% else %}Die Probe zeigt eine Google-Schrift erst, wenn sie unten auf diesen Server geholt wurde.{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Grundtypografie</h2>
|
||||
<span class="muted">Gilt überall, solange ein Block nichts anderes sagt</span>
|
||||
</div>
|
||||
<div class="card-body type-layout">
|
||||
<div class="type-fields">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="base-font">Schrift für Fließtext</label>
|
||||
<select id="base-font" name="base[fontFamily]" data-font-select>
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-heading-font">Schrift für Überschriften</label>
|
||||
<select id="base-heading-font" name="base[headingFontFamily]" data-font-select>
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.headingFontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-size">Grundgröße</label>
|
||||
<input id="base-size" name="base[fontSize]" value="{{ theme.base.fontSize }}" placeholder="18px">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-line">Zeilenhöhe</label>
|
||||
<input id="base-line" name="base[lineHeight]" type="number" step="0.05" min="0.8" max="3" value="{{ theme.base.lineHeight }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-scale">Überschriftenskala</label>
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
<p class="field-help">Abschnitte mit der Inhaltsbreite „Breit“ oder „Randlos“ halten sich nicht daran – das ist der Zweck dieser beiden.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fields type-colors">
|
||||
<div>
|
||||
<label for="base-color">Textfarbe</label>
|
||||
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-base-color>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-bg">Seitenfarbe</label>
|
||||
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-base-color>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-link">Linkfarbe</label>
|
||||
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-base-color>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Aus der Palette wählen oder eine eigene Farbe eintragen. Eine Palettenfarbe folgt späteren Änderungen an der Palette, eine eigene bleibt, wie sie ist.</p>
|
||||
<div class="field-wide">
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }} data-base-underline> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
</div>
|
||||
{# Die Probe: so liest sich die Website mit diesen Werten. Ohne Skript in der Schrift
|
||||
der Verwaltung, mit Skript in Schrift, Größe, Zeilenhöhe und Farben von hier. #}
|
||||
<aside class="type-preview-column" aria-label="Vorschau der Grundtypografie">
|
||||
<h3 class="form-part">Vorschau</h3>
|
||||
<div class="type-preview" data-type-preview>
|
||||
<p class="type-preview-h1" data-type-h1>Überschrift der Seite</p>
|
||||
<p class="type-preview-h2" data-type-h2>Ein Abschnitt darunter</p>
|
||||
<p class="type-preview-p" data-type-p>Fließtext in der Grundgröße, mit <span class="type-preview-link" data-type-link>einem Link im Text</span> und genug Zeilen, um die Zeilenhöhe zu sehen. Gute Typografie fällt nicht auf – sie liest sich einfach leicht.</p>
|
||||
</div>
|
||||
<p class="field-help">Überschriften folgen der Skala: die erste Ebene ist vier Stufen über der Grundgröße, die zweite drei.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<fieldset class="button-defaults" id="button-defaults" data-schema="{{ {colors: theme.colors, fonts: theme.fonts, buttonPresets: buttonPresets}|json_encode|e('html_attr') }}">
|
||||
<legend>Standardbutton</legend>
|
||||
<p class="field-help">Die Grundlage für alle Button-Elemente. Eigene Varianten und Gestaltung im Editor haben Vorrang.</p>
|
||||
<div class="button-defaults-layout">
|
||||
<div class="fields">
|
||||
{% for key, options in buttonOptions %}<div><label for="buttons-{{ key }}">{{ {shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe'}[key] }}</label><select id="buttons-{{ key }}" name="buttons[{{ key }}]">{% for value, label in options %}<option value="{{ value }}"{{ theme.buttons[key] == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>{% endfor %}
|
||||
<div><label for="buttons-fontFamily">Button-Schrift</label><select id="buttons-fontFamily" name="buttons[fontFamily]"><option value="inherit">Schrift der Umgebung übernehmen</option>{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.buttons.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-fontFamily">Button-Schrift</label><select id="buttons-fontFamily" name="buttons[fontFamily]" data-font-select><option value="inherit">Schrift der Umgebung übernehmen</option>{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.buttons.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-fontSize">Button-Schriftgröße (px)</label><input id="buttons-fontSize" type="number" min="10" max="72" step="0.5" name="buttons[fontSize]" value="{{ theme.buttons.fontSize }}"></div>
|
||||
<div><label for="buttons-fontWeight">Button-Schriftstärke</label><select id="buttons-fontWeight" name="buttons[fontWeight]">{% for weight in weights %}<option value="{{ weight }}"{{ theme.buttons.fontWeight == weight ? ' selected' : '' }}>{{ weight }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-letterSpacing">Buchstabenabstand (px)</label><input id="buttons-letterSpacing" type="number" min="-1" max="8" step="0.1" name="buttons[letterSpacing]" value="{{ theme.buttons.letterSpacing }}"></div>
|
||||
@@ -126,7 +218,10 @@
|
||||
<div class="button-preview-column"><h3>Live-Vorschau</h3><div class="button-state-previews" aria-label="Standardbutton-Vorschau"></div><div class="button-preset-grid" role="group" aria-label="Standardbutton-Vorlagen">{% for preset in buttonPresets %}<button type="button" class="button-preset" data-button-preset aria-label="Standardbutton {{ preset.name }}"><span class="button-preset-label">{{ preset.name }}</span></button>{% endfor %}</div></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
<div class="theme-save">
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
<span class="muted">Gilt sofort auf allen Seiten.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% elseif sent %}
|
||||
<p class="form-success" role="status">{{ form.settings.success }}</p>
|
||||
{% else %}
|
||||
<form class="block-form" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
<form class="block-form" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
<input type="hidden" name="page" value="{{ page.path }}">
|
||||
<input type="hidden" name="block" value="{{ blockId }}">
|
||||
<input type="hidden" name="issued" value="{{ issued }}">
|
||||
@@ -58,6 +58,6 @@
|
||||
<noscript><p class="field-error">Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.</p></noscript>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<button class="button cms-button" type="submit">Absenden</button>
|
||||
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
Reference in new issue
Block a user