Phase 4: Oberfläche mit Galerie, Erfassung und Reservierung

Jinja2 mit HTMX, Tailwind über die Standalone-Binärdatei im Docker-Build -
kein Node.js, kein npm. Mobile-First, Tippflächen mindestens 44 px hoch.

Seiten: Galerie mit Filterleiste, Detailseite, Erfassungsformular,
Stapel-Import mit Entwurfsliste, Anmeldung. HTMX tauscht beim Filtern nur
die Liste aus statt die ganze Seite neu zu laden.

Aus den Entscheidungen im Plan folgt konkret:

- Der Stapel-Import steht ZUERST im Formular: wer zwanzig Teile hat, will
  nicht zwanzigmal ein Formular ausfüllen. Bewusst ohne capture-Attribut,
  damit sich wahlweise fotografieren oder aus der Mediathek wählen lässt -
  mit capture ginge nur je ein Foto pro Vorgang.
- Grösse, Kategorie, Geschlecht, Saison und Zustand werden vom zuletzt
  gespeicherten Teil übernommen. Beim Abarbeiten einer Kiste sind das fünf
  Felder weniger pro Stück.
- Nach dem Nachtragen eines Entwurfs geht es direkt zum nächsten, nicht auf
  die Detailseite - das ist der eigentliche Arbeitsablauf.
- Titel bleibt leer erlaubt; angezeigt wird dann Kategorie und Grösse.
- reserved_by erscheint öffentlich nur als "reserviert". Den Namen sieht
  ausschliesslich der angemeldete Betreiber.
- Nach dem Reservieren wird der Freigabe-Link angezeigt, als vollständige
  Adresse zum Aufbewahren.
- Post/Redirect/Get überall, damit ein Neuladen nicht erneut abschickt.
- CSRF-Token in jedem Formular, zusätzlich zu SameSite=Lax.

HTMX liegt lokal im Repository, nicht vom CDN: die CSP erlaubt nur
script-src 'self', und ein CDN-Aufruf gäbe die IP jedes Besuchers weiter -
dieselbe Überlegung wie bei Swagger und bei den Schriften der Kantone-App.
Ein Test prüft für alle Seiten, dass nichts von aussen geladen wird.

Nebenbei TemplateResponse auf die aktuelle Signatur umgestellt (64
Deprecation-Warnungen weniger).

77 Tests, alle grün. Zusätzlich der ganze Weg gegen den Container:
anmelden, drei Fotos als Stapel, Entwurf nachtragen, Werte werden für das
nächste Teil übernommen, als Gast reservieren, Name bleibt verborgen,
Selbst-Freigabe über den Link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
2026-08-29 23:29:56 +02:00
co-authored by Claude Opus 5
parent 01cd0a73e8
commit 75b95b38e6
18 changed files with 1241 additions and 28 deletions
+49
View File
@@ -0,0 +1,49 @@
{% extends "basis.html" %}
{% block inhalt %}
<form class="mb-5 grid grid-cols-2 sm:grid-cols-4 gap-2"
hx-get="/teil/liste" hx-target="#liste" hx-swap="outerHTML"
hx-trigger="change">
<div>
<label class="etikett" for="f-size">Grösse</label>
<select class="feld" id="f-size" name="size">
<option value="">alle</option>
{% for g in groessen %}
<option value="{{ g.label }}" {{ 'selected' if g.label == filter.size }}>
{{ g.label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="etikett" for="f-kat">Kategorie</label>
<select class="feld" id="f-kat" name="category_id">
<option value="">alle</option>
{% for k in kategorien %}
<option value="{{ k.id }}"
{{ 'selected' if k.id|string == filter.category_id }}>
{{ k.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="etikett" for="f-gender">Für</label>
<select class="feld" id="f-gender" name="gender">
<option value="">alle</option>
<option value="girl" {{ 'selected' if filter.gender == 'girl' }}>Mädchen</option>
<option value="boy" {{ 'selected' if filter.gender == 'boy' }}>Buben</option>
<option value="unisex" {{ 'selected' if filter.gender == 'unisex' }}>egal</option>
</select>
</div>
<div>
<label class="etikett" for="f-status">Zustand</label>
<select class="feld" id="f-status" name="status">
<option value="available" {{ 'selected' if filter.status == 'available' }}>
nur verfügbare</option>
<option value="" {{ 'selected' if not filter.status }}>alle</option>
</select>
</div>
</form>
{% include "_liste.html" %}
{% endblock %}