Getrennter Titel fuer Suchergebnisse je Seite und Beitrag (Migration 20260913000100): auf der Seite steht "Leistungen", im Suchergebnis darf der lange Titel stehen. Leer heisst weiterhin: der Titel der Seite. Schriften kommen jetzt vom eigenen Server. Ein Knopf im Designbereich oder cms:fonts:fetch holt die gewaehlten Google-Schnitte als WOFF2 nach public/assets/fonts. Das spart zwei fremde Verbindungen nacheinander (gemessen 820 ms) und die IP-Weitergabe an Google. Ohne Dateien bleibt es beim bisherigen Verweis - eine Seite ohne Schrift waere schlimmer. Ein Stylesheet statt vier, mit Pruefsumme im Namen und ohne Kommentare, dazu eine .htaccess mit Kompression, Cache und Schutz interner Dateien. Der Entwicklungsserver sendet dieselben Kopfzeilen, sonst misst man lokal etwas, das es im Betrieb nicht gibt. Bilder in der Groesse, in der sie erscheinen: ein nur fuer Desktop gesetztes Hintergrundbild gilt auf dem Telefon in Telefongroesse (189 KB -> 40 KB), img nennt seine feste Breite oder sizes="auto", und fetchpriority liegt auf dem ersten Bild des Inhalts statt auf dem Logo. Neu: npm run test:links prueft jeden internen Verweis auf seine Antwort. Lighthouse gegen den Nachbau: Barrierefreiheit, Best Practices und SEO 100 auf allen vier geprueften Adressen, Performance 100 ausser auf der bilderreichen Startseite (98 mobil, 96 Desktop). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
6.1 KiB
Twig
96 lines
6.1 KiB
Twig
{% extends 'admin/shell.html.twig' %}
|
||
{% set nav = 'media' %}
|
||
{% block title %}Mediathek{% endblock %}
|
||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% endblock %}
|
||
{% block heading %}Mediathek{% endblock %}
|
||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||
{% block content %}
|
||
|
||
{% if is_granted('ROLE_EDITOR') %}
|
||
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||
{# A drop area rather than the browser's file widget. The input stays a real file input and
|
||
stays focusable, so the form works by keyboard and without JavaScript; the script only adds
|
||
dragging and the list of chosen files. No "required": the input is visually hidden, and a
|
||
browser cannot show its validation bubble on something invisible. The server refuses an
|
||
empty upload with a readable message instead. #}
|
||
<div class="dropzone" data-dropzone>
|
||
<label class="dropzone-label" for="files">
|
||
<span class="dropzone-icon" aria-hidden="true">▩</span>
|
||
<strong>Bilder hierher ziehen</strong>
|
||
<span class="dropzone-hint">oder klicken, um Dateien zu wählen</span>
|
||
</label>
|
||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp" multiple class="dropzone-input">
|
||
</div>
|
||
<p class="dropzone-chosen" data-chosen role="status">Noch keine Datei gewählt.</p>
|
||
<p class="field-help">JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.</p>
|
||
<button class="primary" type="submit">Hochladen</button>
|
||
</form>
|
||
{% endif %}
|
||
|
||
<div class="section-head">
|
||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||
<span class="muted">
|
||
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
|
||
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
|
||
</span>
|
||
</div>
|
||
|
||
{% if library.items is empty %}
|
||
<div class="empty">
|
||
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
|
||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
|
||
</div>
|
||
{% else %}
|
||
<ul class="library-grid">
|
||
{% for item in library.items %}
|
||
<li class="library-tile">
|
||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||
</a>
|
||
<div class="library-body">
|
||
<strong>{{ item.title }}</strong>
|
||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||
<code>{{ item.id }}</code>
|
||
</div>
|
||
{% if is_granted('ROLE_EDITOR') %}
|
||
<details class="sheet">
|
||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||
<div class="sheet-body">
|
||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||
<input type="hidden" name="version" value="{{ item.version }}">
|
||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||
<label for="title-{{ item.id }}">Bildtitel</label>
|
||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||
<button class="primary" type="submit">Angaben speichern</button>
|
||
</form>
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
|
||
{% if library.pages > 1 %}
|
||
<nav class="pager" aria-label="Seiten der Mediathek">
|
||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||
</nav>
|
||
{% endif %}
|
||
{% endif %}
|
||
|
||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||
{% endblock %}
|