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
103 lines
4.7 KiB
HTML
103 lines
4.7 KiB
HTML
{% extends "basis.html" %}
|
||
{% block titel %}{{ item.anzeige_titel }}{% endblock %}
|
||
{% block inhalt %}
|
||
|
||
<a href="/" class="text-sm text-marke-700 font-semibold">← Übersicht</a>
|
||
|
||
<div class="mt-3 grid md:grid-cols-2 gap-5">
|
||
<div class="karte">
|
||
{% if item.bilder %}
|
||
<img src="/uploads/{{ item.hauptbild.image_url }}"
|
||
alt="{{ item.anzeige_titel }}" class="w-full object-cover">
|
||
{% if item.bilder|length > 1 %}
|
||
<div class="grid grid-cols-4 gap-1 p-1">
|
||
{% for b in item.bilder %}
|
||
<img src="/uploads/{{ b.image_url }}" alt="" loading="lazy"
|
||
class="aspect-square object-cover rounded-lg">
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="aspect-[3/4] grid place-items-center text-slate-300">
|
||
kein Bild
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div>
|
||
<h1 class="text-2xl font-bold">{{ item.anzeige_titel }}</h1>
|
||
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||
<div><dt class="text-slate-500">Grösse</dt>
|
||
<dd class="font-semibold">{{ item.size or '–' }}</dd></div>
|
||
<div><dt class="text-slate-500">Kategorie</dt>
|
||
<dd class="font-semibold">{{ item.kategorie.name if item.kategorie else '–' }}</dd></div>
|
||
<div><dt class="text-slate-500">Für</dt>
|
||
<dd class="font-semibold">{{ geschlecht_text(item.gender) }}</dd></div>
|
||
<div><dt class="text-slate-500">Zustand</dt>
|
||
<dd class="font-semibold">{{ zustand_text(item.condition) }}</dd></div>
|
||
<div><dt class="text-slate-500">Saison</dt>
|
||
<dd class="font-semibold">{{ saison_text(item.season) }}</dd></div>
|
||
</dl>
|
||
|
||
{% if item.description %}
|
||
<p class="mt-4 text-slate-700 whitespace-pre-line">{{ item.description }}</p>
|
||
{% endif %}
|
||
|
||
<div class="mt-6">
|
||
{% if item.status == 'available' %}
|
||
<form method="post" action="/kleid/{{ item.id }}/reservieren"
|
||
class="karte p-4 space-y-3">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<label class="etikett" for="wer">Dein Name</label>
|
||
<input class="feld" id="wer" name="reserved_by" required
|
||
maxlength="100" autocomplete="name"
|
||
placeholder="z.B. Familie Meier">
|
||
<button class="knopf-haupt w-full">Reservieren</button>
|
||
<p class="text-xs text-slate-500">
|
||
Du bekommst danach einen Link, mit dem du die Reservierung
|
||
selbst wieder aufheben kannst.
|
||
</p>
|
||
</form>
|
||
|
||
{% elif item.status == 'reserved' %}
|
||
<p class="rounded-xl bg-amber-100 text-amber-900 px-4 py-3">
|
||
Ist bereits reserviert.
|
||
</p>
|
||
{% if betreiber %}
|
||
<p class="mt-2 text-sm text-slate-600">
|
||
Reserviert von: <strong>{{ item.reserved_by }}</strong>
|
||
</p>
|
||
<form method="post" action="/kleid/{{ item.id }}/freigeben" class="mt-2">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<button class="knopf-still">Reservierung aufheben</button>
|
||
</form>
|
||
{% endif %}
|
||
|
||
{% elif item.status == 'given_away' %}
|
||
<p class="rounded-xl bg-slate-200 text-slate-700 px-4 py-3">
|
||
Schon abgegeben.
|
||
</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if betreiber %}
|
||
<div class="mt-6 border-t border-slate-200 pt-4 flex flex-wrap gap-2">
|
||
<a href="/kleid/{{ item.id }}/bearbeiten" class="knopf-still">Bearbeiten</a>
|
||
{% if item.status == 'reserved' %}
|
||
<form method="post" action="/kleid/{{ item.id }}/abgegeben">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<button class="knopf-still">Als abgegeben markieren</button>
|
||
</form>
|
||
{% endif %}
|
||
<form method="post" action="/kleid/{{ item.id }}/loeschen"
|
||
onsubmit="return confirm('Wirklich löschen? Auch die Fotos werden entfernt.')">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<button class="knopf-still text-rose-700">Löschen</button>
|
||
</form>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|