Das Auswahlfeld des Browsers sah aus wie das Auswahlfeld des Browsers. Hochgeladen wird jetzt über eine Ablagefläche: Bilder hineinziehen oder klicken, darunter steht, welche Dateien gewählt sind und welche übersprungen wurden. Darunter liegt weiter ein echtes input[type=file] - unsichtbar, aber fokussierbar, mit der Fläche als label und dem Fokusring über :focus-within. Ohne JavaScript bleibt genau das übrig: ein Dateifeld mit Beschriftung. Das Skript ergänzt Ziehen und benennt die Auswahl; abgeschickt wird nie von selbst. Kein "required", weil der Browser seine Meldung nicht an etwas Unsichtbares hängen kann - der Server weist einen leeren Upload mit einem lesbaren Satz ab. docs/UPDATE.md plant das Update über einen Git-Tag: Vorprüfung, Sicherung, Wartungszustand mit 503 für die Website und offenem /admin, Checkout, composer, Migration, Cache, Rauchtest, Protokoll - und den Rückweg in derselben Reihenfolge rückwärts. Migrationen bleiben nicht umkehrbar, der Rückweg ist die Sicherung. Kein Updater, der eine Adresse aus dem Formular zieht, und kein automatisches Update im Hintergrund. Zwei Entscheidungen hängen am Hosting: Composer auf dem Server oder vendor/ im Tag, und ob es Shell-Zugriff gibt. Gebaut ist davon nichts. 219 PHP-Tests / 881 Assertions unter PHP 8.4.22, darunter Upload über das Formular mit zwei Dateien, CSRF-Pflicht, Versionskonflikt, Archivfilter und Blättern mit 26 Bildern. Playwright prüft die Ablagefläche am echten Dateifeld; axe bleibt auf elf Bildschirmen ohne Verstöße. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
6.0 KiB
Twig
96 lines
6.0 KiB
Twig
{% extends 'admin/shell.html.twig' %}
|
||
{% set nav = 'media' %}
|
||
{% block title %}Mediathek{% endblock %}
|
||
{% block scripts %}<script type="module" src="/assets/media-drop.js"></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">Kant CMS · Arbeitsstand</p>
|
||
{% endblock %}
|