Compare commits

..
16 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 87b71ec71f fix(community): Label-ID im Veröffentlichen-Dialog, Status nie in „lädt“, Nachprüfung robust
Die Überschrift des Dialogs hatte dieselbe ID wie das Titelfeld (publish-title), das Label des
Pflichtfelds zeigte damit auf die Überschrift; jetzt publish-heading. Schließt der Browser den Dialog
mitten im Senden, wird er wieder geöffnet statt als abgebrochen aufgelöst. Die Status-Abfrage der Seite
wird bei einer lokalen Änderung während der Anfrage wiederholt (nie in „lädt“ hängen), die Nachprüfung nach
offenem Ausgang ist eine eigene Abfrage. Der Löschen-Dialog nennt bei unbekanntem Status einen allgemeinen
Hinweis und sagt, dass sich eine Veröffentlichung nur auf der Community-Seite zurückziehen lässt.
publishCard heißt lokal publishWithCheck.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:56:21 +00:00
ClaudeandClaude Sonnet 5.5 f6b9ee61f4 feat(community): Veröffentlichen aus Meine Karten
Neuer Dialog (publish-dialog.ts) laut Spec § 6: Titel (Pflicht, höchstens 60), Herausgeber/Bank (40),
Tags (bis 5, je 20, Komma-getrennt), Anzeigename (30, leer = Anonym, nie vorbelegt) und die
Pflicht-Checkbox im Wortlaut der Spec. Prüfung vor dem Senden mit deutschen Texten, während des Sendens
lässt sich der Dialog nicht schließen; Fehler stehen im Dialog („Veröffentlichen fehlgeschlagen – …“).

Meine Karten zeigt je Kachel den Status aus myPublications (einmal je Seitenaufbau, danach lokal
nachgeführt): Veröffentlicht + Ansehen + Zurückziehen, Ausgeblendet (gemeldet) + Zurückziehen, sonst
Veröffentlichen. Bei 409 wird der Status neu geholt; bleibt der Ausgang offen (503/5xx/429/408), wird vor
der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. Zurückziehen fragt nach, 404 gilt
als erledigt. Nicht angemeldet läuft über withLogin. Das Löschen einer veröffentlichten Karte weist darauf
hin, dass die Veröffentlichung bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:47:56 +00:00
ClaudeandClaude Sonnet 5.5 be440bc8e9 fix(community): gemeinsames formatDate, Rechte beim Ausführen, Melden-Dialog abgesichert
formatDate liegt jetzt in pages/format.ts und wird von Meine Karten und der
Detailseite genutzt; LOGIN_NEEDED kommt aus account/with-login.ts. Ausblenden,
Löschen und Zurückziehen prüfen Admin- bzw. Autor-Recht beim Ausführen (auch nach
der Rückfrage). Ein werfendes showModal im Melden-Dialog zeigt einen Hinweis statt
eines unbehandelten Fehlers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:35:44 +00:00
ClaudeandClaude Sonnet 5.5 3cec7151a6 feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage
Detailseite /community/<id>: große Karte, Titel, Herausgeber, „von …“, Datum,
Tags, Herunterladen (aircard-<slug>.png), „Als Vorlage gestalten“ und Melden-Dialog
(fünf Gründe, Dank-, Doppelt- und Drosselungs-Text). Autor: Zurückziehen; Admin:
Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe. Fehlende oder
ausgeblendete Karten zeigen Besuchern „nicht (mehr) verfügbar“, Autor und Admin
sehen sie mit dem Hinweis „Ausgeblendet“.

Editor: /gestalten?vorlage=<id> übernimmt das Galerie-Vollbild als „Nur Bild“,
setzt saved, savedConfig und cardName zurück, ersetzt die Adresse durch /gestalten
und fragt vorher bei ungespeicherter Arbeit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:28:00 +00:00
ClaudeandClaude Sonnet 5.5 c4a66f674f feat(home): kompakter Hero und Community-Galerie
Startseite "/" mit kompaktem Hero (grosse schwebende Karte rechts: neueste
sichtbare Community-Karte, sonst das eigene Beispielmotiv hero-beispiel.png)
und direkt darunter der Galerie #community, damit Ueberschrift und Oberkante der
ersten Kartenreihe bei 1440x900 und 390x844 im ersten Bildschirm stehen.

- mountHomePage(root, deps): Suche ?q= und Tag ?tag= aus der Adresse (aktiver
  Begriff mit "x"), Suchfeld der Kopfzeile per .value gefuellt, Mehr laden per
  Cursor, Leer-/Kein-Treffer-/Fehlertexte, Admin-Filter Alle/Gemeldet/
  Ausgeblendet (reagiert auf Anmelden/Abmelden), Anker nach dem Laden erneut
  angesprungen (router.scrollToHash exportiert), Object-URLs beim Verlassen frei.
- mountGalleryGrid: Kacheln mit Vorschau (Object-URL, max. 6 parallel), Titel,
  Herausgeber, bis 3 Tag-Knoepfe (Tag-Klick -> /?tag=<tag>#community), Link auf
  /community/<id>; Moderations-Marken nur in den Admin-Ansichten.
- Hero hoechstens 60vh auf breiten, hohen Fenstern; Schwebe-Animation nur ohne
  prefers-reduced-motion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:07:40 +00:00
ClaudeandClaude Sonnet 5.5 ae73dc4a0b fix(community): nur bei eindeutigem Ausgang aufräumen, Tag- und Kurzsuche, kleine Härtungen
- publish.ts: bei Fehlern der API werden die hochgeladenen Dateien nur bei eindeutiger Ablehnung
  (4xx außer 408/429, JWT-Fehler vor der Anfrage) gelöscht; bei offenem Ausgang (Netzfehler,
  Zeitlimit, 5xx, 429, Antwort ohne id) bleiben sie liegen, damit keine öffentliche Zeile auf
  gelöschte Bilder zeigt
- api.ts: ApiError.definitive und isDefinitiveFailure, Zeitlimit 15 s, gleichzeitige createJWT
  werden geteilt
- gallery.ts: Tag-Filter (contains auf tags), Kurzsuche unter 3 Zeichen per contains,
  loadGalleryImage prüft die Datei-ID
- geometry.ts: PREVIEW_WIDTH/PREVIEW_QUALITY an einer Stelle (Editor und Veröffentlichen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:48:06 +00:00
ClaudeandClaude Sonnet 5.5 89c86fd578 feat(community): Datenschicht für Galerie, API und Veröffentlichen
- gallery.ts: Galerie aus Appwrite lesen (Abfragen für Liste, Suche, Cursor, Admin-Filter),
  getGalleryCard (404 und ungültige ID ergeben null), eigene Veröffentlichungen, Meldungen, Bilder
- api.ts: Aufrufe von aircard-api mit JWT (Cache je Nutzer, 10 Minuten, geleert bei Auth-Änderung,
  genau eine Wiederholung bei 401), ApiError mit code, Netz-/Proxy-Fehler als 503
- publish.ts: rendern, zwei Uploads in gallery-images, API; bei Fehler genau die zwei Dateien
  dieses Versuchs löschen; normalizeTags
- cards.ts: eigenständiges fetchFile(bucketId, fileId); CardsApi.fetchFile bleibt Hülle
- appwrite.ts: IDS für gallery, reports, gallery-images

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:39:30 +00:00
ClaudeandClaude Sonnet 5.5 7bbe0a9fe6 fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden
Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:27:29 +00:00
ClaudeandClaude Sonnet 5.5 cd55d4c891 fix(api): Log-Flut begrenzen und Appwrite-Fehler strenger behandeln
- Kein Zugriffsprotokoll je Anfrage mehr (abgewiesene Anfragen an das
  unauthentifizierte /api/melden loggten sonst unbegrenzt); nur noch
  Ereignis- und 5xx-Zeilen, höchstens 120 pro Minute mit Sammelzeile
- Compose: logging json-file, max-size 5m, max-file 2
- appwrite.js: 404 nur mit Typ row_not_found/storage_file_not_found als
  "fehlt", listRows ohne rows-Array wirft, redirect: error
- Appwrite-Fehler: 502 bei 0/429/5xx, sonst 500 "Interner Fehler."
- Veröffentlichen legt beide Dateien auf die sichtbaren Rechte fest
  (erst nach dem Anlegen der Zeile, Rollback bei Fehler)
- setFilePermissions mit Promise.allSettled, gemeinsamer safeToken-Helfer
- Tests: 85 (Log-Flut, Log-Budget, 404-Typen, Auth-Header, Konto-Antworten)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:24:05 +00:00
ClaudeandClaude Sonnet 5.5 dd1597862a feat(api): aircard-api für Veröffentlichen, Melden und Admin
Neuer abhängigkeitsfreier Node-22-Dienst in deploy/api/ (appwrite.js, app.js,
server.js), der alle Galerie-Schreibzugriffe mit dem Appwrite-Server-Key
ausführt: POST /api/veroeffentlichen, /zurueckziehen, /melden (Rate-Limit
10/h je Melder, IPv6 auf /64, Auto-Ausblenden ab 3), /admin/sichtbarkeit und
GET /api/gesundheit.

- JWT-Prüfung über GET /account (Header X-Aircard-Jwt), Admin = Label admin
- IDs per Muster geprüft, Pfadsegmente kodiert, "." und ".." abgelehnt
- deterministische Zeilen-IDs (Galerie, Meldung): Appwrite-409 ersetzt jede
  Doppelt-Prüfung; Sperre je Karte gegen verlorene Zählerstände
- Dateiprüfung: Bucket, delete("user:<ich>"), PNG/WebP, höchstens 5 MiB
- Appwrite-Aufrufe mit 10-s-Zeitlimit, Logs ohne Key, Salz, JWT und IPs
- Tests gegen einen Fake-Appwrite im Speicher (70 Tests), Compose-Service
  aircard-api (Router aircard-gallery-api) und README ergänzt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:09:30 +00:00
ClaudeandClaude Sonnet 5.5 9e4f4ffacc feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder
Setup-Skript um Tabellen gallery und reports (Spalten boolean/integer/enum,
Volltext- und Key-Indizes) sowie den Bucket gallery-images erweitert.
DESIRED auf tables/buckets umgebaut, Vergleich je Spaltentyp und Index,
Indizes erst nach available-Spalten. Console-Anleitung um Abschnitt
Community (JWT an, Label admin, Key aircard-api) ergänzt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:50:54 +00:00
ClaudeandClaude Sonnet 5.5 25ff496548 feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner
- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung
  /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor);
  Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel
  bauen die Seite nicht neu auf.
- Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf,
  Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue.
- Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle
  Tokens, danger-/warn-/ok-text-Tokens statt fester Farben.
- Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg.
- User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:39:09 +00:00
ClaudeandClaude Opus 5.5 1a64e12bc5 docs: Plan Community – Ergänzungen aus Plan-Review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:23:02 +00:00
ClaudeandClaude Opus 5.5 21e1db1858 docs: Plan Community – Präzisierungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:08:34 +00:00
ClaudeandClaude Opus 5.5 23463f7d29 docs: Plan Startseite & Community-Galerie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:08:21 +00:00
ClaudeandClaude Opus 5.5 970e68e7fc docs: Spec Startseite & Community-Galerie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:05:59 +00:00
70 changed files with 13621 additions and 373 deletions

No files matched your search

+3 -1
View File
@@ -59,9 +59,11 @@ docker exec webklar-aircard-build sh /deploy.sh
Starten nur mit:
```sh
docker compose up -d aircard-build aircard
docker compose up -d aircard-build aircard aircard-api
```
`aircard-api` ist der kleine API-Dienst der Community-Galerie (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`).
Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404.
## Appwrite
+29 -9
View File
@@ -10,7 +10,8 @@ Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebau
| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` an Appwrite |
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` |
| `deploy/api/appwrite.js`, `app.js`, `server.js` | `aircard-ios.org/api/` (nur diese drei Dateien) | API-Dienst `aircard-api` der Community-Galerie, siehe [api/README.md](api/README.md) |
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build`, `aircard` und `aircard-api` |
Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
@@ -20,10 +21,11 @@ Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repo
APP=/home/webklar/apps
SRC=$APP/aircard-ios.org/dev/deploy
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx $APP/aircard-ios.org/api
cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
cp $SRC/api/appwrite.js $SRC/api/app.js $SRC/api/server.js $APP/aircard-ios.org/api/
# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
@@ -33,17 +35,33 @@ cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: W
cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml
```
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits.
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten, für den API-Dienst außerdem `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY` und `AIRCARD_API_SALT`. Diese Werte gehören nie in Logs, Chats oder Commits. Das Salz entsteht, ohne dass der Wert auf dem Bildschirm erscheint:
```sh
printf 'AIRCARD_API_SALT=%s\n' "$(openssl rand -hex 32)" >> /home/webklar/apps/.env
```
Den Key `AIRCARD_API_KEY` legt Kenso nach [docs/appwrite-console.md](../docs/appwrite-console.md) (Abschnitt 7.3) an und trägt ihn mit dem verdeckten `read -s`-Befehl ein.
## Starten
```sh
cd /home/webklar/apps && docker compose up -d aircard-build aircard
cd /home/webklar/apps && docker compose up -d aircard-build aircard aircard-api
```
Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
Nur diese drei Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen.
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen. Ebenso beendet sich `aircard-api` mit Exit-Code 1, wenn `AIRCARD_API_KEY`, `AIRCARD_API_SALT` oder `AIRCARD_APPWRITE_PROJECT_ID` fehlen (die Meldung nennt nur den Namen der Variable).
## API-Dienst `aircard-api`
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie mit dem Appwrite-Server-Key aus (Veröffentlichen, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern, Admin-Sichtbarkeit). Endpunkte, Statuscodes und Betrieb stehen in [api/README.md](api/README.md).
- **Route**: Traefik-Label am Dienst, Router `aircard-gallery-api` (``Host(`aircard-ios.org`) && PathPrefix(`/api`)``, Priorität 200, Port 8088). Der Name ist absichtlich nicht `aircard-api`: so heißt schon die Appwrite-Route (`/v1`) in `traefik/dynamic/aircard.yml`.
- **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
- **Log-Grenze**: Der Docker-Daemon des Servers hat keine eigenen Log-Optionen, deshalb steht im Service `logging: json-file` mit `max-size: 5m` und `max-file: 2` (höchstens 10 MB). Der Dienst selbst schreibt kein Zugriffsprotokoll und höchstens 120 Zeilen pro Minute.
- **Kein Port nach außen**: der Dienst vertraut dem Header `X-Real-Ip` (setzt Traefik) für Rate-Limit und Melder-Hash, deshalb nie `ports:` ergänzen.
## Manuell deployen
@@ -79,12 +97,13 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt.
- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`.
- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); `AIRCARD_APPWRITE_PROJECT_ID` wird aus `/home/webklar/apps/.env` gelesen. Vite liest sie direkt.
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein.
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein; für `aircard-api` zusätzlich `AIRCARD_API_KEY` (Key `aircard-api`) und `AIRCARD_API_SALT`.
## Änderungen später
- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
- `deploy/api/*.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
@@ -95,7 +114,8 @@ Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur di
## Tests
```sh
node --test deploy/webhook-server.test.js
node --test deploy/webhook-server.test.js deploy/deploy.test.js
node --test "deploy/api/*.test.js"
```
Die Tests brauchen nur Node und starten die Server auf Port 0.
Die Tests brauchen nur Node und starten die Server auf Port 0. Die API-Tests laufen gegen einen Fake-Appwrite im Speicher (`deploy/api/fake-appwrite.js`); das echte Appwrite wird nie angesprochen. Das Glob muss in Anführungszeichen stehen.
+62
View File
@@ -0,0 +1,62 @@
# aircard-api
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit. Die Website liest die Galerie direkt aus Appwrite; nur das Schreiben läuft hierüber.
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11. Deployment: [../README.md](../README.md).
## Dateien
| Datei | Aufgabe |
|---|---|
| `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden |
| `app.js` | Routen, Validierung, Rechte-Strings, Rate-Limit, Logik (`createApp`) |
| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`) |
| `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) |
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js`, `app.js` und `appwrite.js`.
## Umgebung
| Variable | Standard | Bedeutung |
|---|---|---|
| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) |
| `AIRCARD_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex |
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz |
| `AIRCARD_API_PORT` | `8088` | Port des Dienstes |
Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess endet mit Exit-Code 1. Wer das Salz ändert, macht alle bisherigen Melder-Hashes ungültig: dieselben Personen können dieselbe Karte dann nochmal melden (die Karten selbst bleiben unberührt).
## Endpunkte
Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>"}`. Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`).
| Endpunkt | Auth | Antworten |
|---|---|---|
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falsches Format oder > 5 MiB · `403` Datei nicht meine / falscher Bucket · `409` „Diese Karte ist schon veröffentlicht.“ |
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `GET /api/gesundheit` | keine | `200 {ok:true}` |
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“. Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“.
### Regeln im Einzelnen
- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab.
- **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt, keine Steuerzeichen). `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Bild und Vorschau müssen verschiedene Dateien sein.
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403`. **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index, auf die Datei-ID-Spalten nicht) seitenweise à 100 mit Cursor und läuft zusammen mit dem Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")`, `delete("user:<ich>")` (kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen. Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
- **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
- **Melden**: Rate-Limit 10 pro Stunde und Melder (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge). Der Melder-Hash ist `HMAC-SHA256(salt, ip)`; IPv6-Adressen werden vorher auf ihr /64-Netz gekürzt, IPv4-gemappte IPv6 auf IPv4. Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst darf deshalb nur über Traefik erreichbar sein (kein `ports:` in Compose).
- **Rechte** (genau diese Strings): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei sichtbar `read("any")`, `delete("user:<owner>")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`).
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
## Tests
```sh
node --test "deploy/api/*.test.js"
```
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen) und greifen nie auf das echte Appwrite zu.
+736
View File
@@ -0,0 +1,736 @@
'use strict';
/**
* Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
*
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket /
* Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet
* 409 schon veröffentlicht · 201 {id}
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/melden ohne Anmeldung {id, reason}
* 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
* GET /api/gesundheit {ok:true}
* alles andere 404; Body höchstens 8 KB (413), nur application/json (415)
*
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>}.
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle).
* Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen
* (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute.
*/
const crypto = require('node:crypto');
const net = require('node:net');
const { AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
const TABLE_GALLERY = 'gallery';
const TABLE_REPORTS = 'reports';
const BUCKET = 'gallery-images';
const MAX_BODY_BYTES = 8 * 1024;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
const HIDE_AT_REPORTS = 3;
const REPORT_LIST_LIMIT = 100;
const OWNER_ROWS_PAGE = 100;
const OWNER_ROWS_MAX_PAGES = 100;
const REPORT_DELETE_ROUNDS = 50;
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 60 * 1000;
const RATE_SWEEP_MS = 60 * 1000;
const RATE_MAX_KEYS = 50_000;
const LOG_BUDGET_PER_MINUTE = 120;
const LOG_WINDOW_MS = 60 * 1000;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
const MSG = {
unauthorized: 'Bitte melde dich an.',
notFound: 'Nicht gefunden.',
cardGone: 'Diese Karte ist nicht (mehr) verfügbar.',
forbidden: 'Dazu hast du keine Berechtigung.',
adminOnly: 'Nur für Admins.',
foreignFile: 'Diese Datei gehört dir nicht.',
fileMissing: 'Bild nicht gefunden.',
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.',
badReason: 'Bitte wähle einen gültigen Grund.',
badVisibility: 'Ungültige Angabe zur Sichtbarkeit.',
tooLarge: 'Die Anfrage ist zu groß.',
jsonOnly: 'Nur application/json wird unterstützt.',
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
internal: 'Interner Fehler.',
};
// ---- Reine Helfer ----------------------------------------------------------------------------------
function isId(value) {
return typeof value === 'string' && ID_PATTERN.test(value);
}
function assertOwner(owner) {
if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID');
return owner;
}
/** Sichtbare Galerie-Zeile: lesen darf jeder. */
function visibleRowPermissions() {
return ['read("any")'];
}
/** Ausgeblendete Zeile: nur Autor und Admins. */
function hiddenRowPermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function visibleFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`];
}
function hash32(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
}
/** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */
function galleryRowId(ownerId, sourceCardId) {
return hash32(`${ownerId}|${sourceCardId}`);
}
/** Feste Zeilen-ID je Karte und Melder: eine Meldung pro Melder und Karte. */
function reportRowId(galleryId, hash) {
return hash32(`${galleryId}|${hash}`);
}
function expandIpv6(address) {
let text = address;
const lastColon = text.lastIndexOf(':');
const tail = text.slice(lastColon + 1);
if (tail.includes('.')) {
const o = tail.split('.').map(Number);
text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const [head, rest] = text.split('::');
const headGroups = head === '' ? [] : head.split(':');
const restGroups = rest === undefined || rest === '' ? [] : rest.split(':');
const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups];
return groups.map((group) => Number.parseInt(group, 16));
}
/**
* Vereinheitlicht eine Client-Adresse für Hash und Rate-Limit: IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
*/
function normalizeIp(ip) {
if (typeof ip !== 'string') return '';
let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
if (zone !== -1) text = text.slice(0, zone);
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1];
if (net.isIPv4(text)) return text;
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
}
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`;
}
return text.toLowerCase().slice(0, 64);
}
function firstHeaderValue(req, name) {
const value = req.headers && req.headers[name];
const text = Array.isArray(value) ? value[0] : value;
if (typeof text !== 'string') return '';
return text.split(',')[0].trim();
}
/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */
function clientIp(req) {
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
}
/** HMAC-SHA256 (Hex) der normalisierten IP mit dem Server-Salz. */
function reporterHash(salt, ip) {
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex');
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */
function safeToken(value, fallback = 'unknown') {
const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60);
return text === '' ? fallback : text;
}
const TEXT_FIELDS = [
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
{ key: 'displayName', article: 'Der', name: 'Anzeigename', max: 30, required: false },
];
function readText(raw, field) {
if (raw === undefined || raw === null) {
return field.required ? { message: 'Bitte gib einen Titel an.' } : { value: '' };
}
if (typeof raw !== 'string') return { message: `${field.article} ${field.name} ist ungültig.` };
const value = raw.trim();
if (value === '' && field.required) return { message: 'Bitte gib einen Titel an.' };
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.article} ${field.name} enthält ungültige Zeichen.` };
if (value.length > field.max) return { message: `${field.article} ${field.name} ist zu lang (höchstens ${field.max} Zeichen).` };
return { value };
}
/** Tags: string[], je getrimmt und klein, Leere raus, Doppelte raus, höchstens 5 mit je höchstens 20 Zeichen. */
function readTags(raw) {
if (raw === undefined || raw === null) return { tags: [] };
if (!Array.isArray(raw)) return { message: 'Tags müssen als Liste gesendet werden.' };
const tags = [];
for (const entry of raw) {
if (typeof entry !== 'string') return { message: 'Ungültige Tags.' };
const tag = entry.trim().toLowerCase();
if (tag === '') continue;
if (tag.includes(',')) return { message: 'Tags dürfen kein Komma enthalten.' };
if (CONTROL_CHARACTERS.test(tag)) return { message: 'Tags enthalten ungültige Zeichen.' };
if (tag.length > 20) return { message: 'Ein Tag darf höchstens 20 Zeichen lang sein.' };
if (!tags.includes(tag)) tags.push(tag);
}
if (tags.length > 5) return { message: 'Bitte gib höchstens 5 Tags an.' };
return { tags };
}
/**
* Prüft den Veröffentlichen-Body (Längen aus Spec §6, IDs nach ID_PATTERN).
* @returns {{ok: true, data: object} | {ok: false, message: string}}
*/
function validatePublish(body) {
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
const data = {};
for (const field of TEXT_FIELDS) {
const result = readText(body[field.key], field);
if (result.message) return { ok: false, message: result.message };
data[field.key] = result.value;
}
const tags = readTags(body.tags);
if (tags.message) return { ok: false, message: tags.message };
data.tags = tags.tags;
for (const [key, message] of [
['imageFileId', 'Ungültige Bild-ID.'],
['previewFileId', 'Ungültige Vorschau-ID.'],
['sourceCardId', MSG.badCardId],
]) {
if (!isId(body[key])) return { ok: false, message };
data[key] = body[key];
}
if (data.imageFileId === data.previewFileId) {
return { ok: false, message: 'Bild und Vorschau müssen verschiedene Dateien sein.' };
}
return { ok: true, data };
}
/** `title issuer tags` klein mit Leerzeichen, höchstens 300 Zeichen (Volltext-Index der Galerie). */
function buildSearchText({ title = '', issuer = '', tags = [] }) {
const tagList = Array.isArray(tags) ? tags : String(tags).split(',');
return [title, issuer, ...tagList]
.map((part) => String(part).trim())
.filter((part) => part !== '')
.join(' ')
.toLowerCase()
.slice(0, 300);
}
// ---- HTTP-Hilfen ------------------------------------------------------------------------------------
class HttpError extends Error {
constructor(status, message, headers = {}) {
super(message);
this.status = status;
this.headers = headers;
}
}
function sendJson(res, status, payload, headers = {}) {
const body = JSON.stringify(payload);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
...headers,
});
res.end(body);
}
const JSON_CONTENT_TYPE = /^application\/json\s*(;|$)/i;
/** Liest den Rohbody mit Obergrenze; bei Überschreitung wird der Rest verworfen und die Verbindung geschlossen. */
function readRawBody(req) {
return new Promise((resolve, reject) => {
const tooLarge = () => new HttpError(413, MSG.tooLarge, { Connection: 'close' });
const declared = Number(req.headers['content-length']);
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
req.resume();
reject(tooLarge());
return;
}
const chunks = [];
let size = 0;
let settled = false;
const settle = (fn, value) => {
if (settled) return;
settled = true;
chunks.length = 0;
fn(value);
};
req.on('data', (chunk) => {
if (settled) return; // Rest verwerfen, Verbindung bleibt sauber
size += chunk.length;
if (size > MAX_BODY_BYTES) settle(reject, tooLarge());
else chunks.push(chunk);
});
req.on('end', () => {
if (settled) return;
const raw = Buffer.concat(chunks).toString('utf8');
settle(resolve, raw);
});
req.on('error', () => settle(reject, new HttpError(400, MSG.badRequest)));
req.on('close', () => settle(reject, new HttpError(400, MSG.badRequest)));
});
}
function parseJsonObject(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new HttpError(400, MSG.badRequest);
}
if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest);
return parsed;
}
/** Je Schlüssel nacheinander abarbeiten (z. B. je Karten-ID), damit Zähler und Rechte nie durcheinanderlaufen. */
function createKeyedLock() {
const tails = new Map();
return async function withLock(key, fn) {
const previous = tails.get(key) || Promise.resolve();
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const tail = previous.then(() => gate);
tails.set(key, tail);
await previous;
try {
return await fn();
} finally {
release();
if (tails.get(key) === tail) tails.delete(key);
}
};
}
function isDuplicate(err) {
return err instanceof AppwriteError && err.status === 409;
}
// ---- Die App ---------------------------------------------------------------------------------------
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @param {string} options.salt Salz für die Melder-Hashes
* @param {() => number} [options.now] Uhr in ms (für das Rate-Limit)
* @param {(line: string) => void} [options.log] bekommt fertige Zeilen mit Präfix [aircard-api]
* @returns {(req, res) => Promise<void>} Handler für http.createServer, mit `rateLimitEntries()` für Tests
*/
function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
if (!appwrite || typeof appwrite !== 'object') throw new TypeError('appwrite fehlt');
if (typeof salt !== 'string' || salt === '') throw new TypeError('salt fehlt');
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein
// Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen.
const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`);
let logWindowStart = 0;
let logCount = 0;
let logSuppressed = 0;
function say(message) {
const t = now();
if (t - logWindowStart >= LOG_WINDOW_MS) {
if (logSuppressed > 0) emit(`${logSuppressed} log lines suppressed (limit ${LOG_BUDGET_PER_MINUTE} per minute)`);
logWindowStart = t;
logCount = 0;
logSuppressed = 0;
}
if (logCount >= LOG_BUDGET_PER_MINUTE) {
logSuppressed += 1;
return;
}
logCount += 1;
emit(message);
}
const withLock = createKeyedLock();
// ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`)
const hits = new Map();
let lastSweep = 0;
function sweepRateLimit(t) {
if (t - lastSweep < RATE_SWEEP_MS) return;
lastSweep = t;
for (const [key, list] of hits) {
if (t - list[list.length - 1] >= RATE_WINDOW_MS) hits.delete(key);
}
}
/** @returns {{allowed: boolean, retryAfter?: number}} zählt die Meldung mit, wenn erlaubt */
function rateLimit(key) {
const t = now();
sweepRateLimit(t);
const list = (hits.get(key) || []).filter((stamp) => t - stamp < RATE_WINDOW_MS);
if (list.length >= RATE_LIMIT) {
hits.set(key, list);
return { allowed: false, retryAfter: Math.max(1, Math.ceil((list[0] + RATE_WINDOW_MS - t) / 1000)) };
}
list.push(t);
hits.delete(key); // neu einfügen: Reihenfolge = zuletzt benutzt
hits.set(key, list);
while (hits.size > RATE_MAX_KEYS) hits.delete(hits.keys().next().value);
return { allowed: true };
}
// ---- Anmeldung
async function authenticate(req) {
const jwt = req.headers['x-aircard-jwt'];
if (typeof jwt !== 'string' || jwt.length > 4096 || !JWT_CHARACTERS.test(jwt)) return null;
const account = await appwrite.accountFromJwt(jwt);
if (!account || !isId(account.$id)) return null;
return { id: account.$id, isAdmin: Array.isArray(account.labels) && account.labels.includes('admin') };
}
async function requireUser(req) {
const user = await authenticate(req);
if (!user) throw new HttpError(401, MSG.unauthorized);
return user;
}
// ---- Dateien und Zeilen
function checkFile(file, userId) {
if (!file) throw new HttpError(400, MSG.fileMissing);
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
throw new HttpError(403, MSG.foreignFile);
}
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize);
}
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
/**
* Datei-IDs, die schon eine Galerie-Zeile dieses Autors verwendet (Bild oder Vorschau, auch ausgeblendete Karten).
* Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
* Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit
* Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab).
*/
async function fileIdsInUse(ownerId) {
const used = new Set();
let cursor = null;
for (let page = 0; page < OWNER_ROWS_MAX_PAGES; page += 1) {
const queries = [queryEqual('ownerId', [ownerId]), queryLimit(OWNER_ROWS_PAGE)];
if (cursor !== null) queries.push(queryCursorAfter(cursor));
const rows = await appwrite.listRows(TABLE_GALLERY, queries);
for (const row of rows) {
used.add(row.imageFileId);
used.add(row.previewFileId);
}
if (rows.length < OWNER_ROWS_PAGE) return used;
cursor = rows[rows.length - 1].$id;
}
return null;
}
/**
* Setzt die Rechte beider Dateien. Wartet auf beide Aufrufe, auch wenn einer scheitert (die Sperre der Karte darf
* nicht freigegeben werden, solange noch ein PUT unterwegs ist), und wirft danach den ersten Fehler.
* @returns {Promise<Array>} je Datei das Ergebnis; null heißt: Datei gibt es nicht (mehr)
*/
async function setFilePermissions(row, permissions) {
const results = await Promise.allSettled(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions)));
const failed = results.find((result) => result.status === 'rejected');
if (failed) throw failed.reason;
return results.map((result) => result.value);
}
/** Dateien zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
async function hideCard(id, row, extra = {}) {
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
}
async function deleteReports(galleryId) {
for (let round = 0; round < REPORT_DELETE_ROUNDS; round += 1) {
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [galleryId]), queryLimit(REPORT_LIST_LIMIT)]);
if (reports.length === 0) return;
for (const report of reports) await appwrite.deleteRow(TABLE_REPORTS, report.$id);
}
}
/** reportCount = Anzahl Meldungszeilen der Karte; ab 3 wird ausgeblendet. */
async function syncReports(id, row) {
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [id]), queryLimit(REPORT_LIST_LIMIT)]);
const count = reports.length;
if (count >= HIDE_AT_REPORTS) {
await hideCard(id, row, { reportCount: count });
say(`card ${id} hidden automatically (${count} reports)`);
} else if (row.reportCount !== count) {
await appwrite.updateRow(TABLE_GALLERY, id, { reportCount: count });
}
}
// ---- Endpunkte
async function publish({ req, raw }) {
const user = await requireUser(req);
const validation = validatePublish(parseJsonObject(raw));
if (!validation.ok) throw new HttpError(400, validation.message);
const data = validation.data;
const [image, preview] = await Promise.all([
appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId),
]);
checkFile(image, user.id);
checkFile(preview, user.id);
// Je Autor nacheinander: Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`owner:${user.id}`, async () => {
const used = await fileIdsInUse(user.id);
if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) {
throw new HttpError(403, MSG.filesTaken);
}
const id = galleryRowId(user.id, data.sourceCardId);
try {
await appwrite.createRow(
TABLE_GALLERY,
id,
{
title: data.title,
issuer: data.issuer,
tags: data.tags.join(','),
displayName: data.displayName,
imageFileId: data.imageFileId,
previewFileId: data.previewFileId,
sourceCardId: data.sourceCardId,
ownerId: user.id,
hidden: false,
reportCount: 0,
searchText: buildSearchText(data),
},
visibleRowPermissions(),
);
} catch (err) {
if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished);
throw err;
}
// Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen).
// Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an.
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
try {
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
} catch (err) {
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {});
throw err;
}
say(`card ${id} published`);
return { status: 201, body: { id } };
});
}
async function withdraw({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
const id = body.id;
return withLock(id, async () => {
const row = await appwrite.getRow(TABLE_GALLERY, id);
if (!row) throw new HttpError(404, MSG.cardGone);
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
// Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
for (const fileId of fileIdsOf(row)) await appwrite.deleteFile(BUCKET, fileId);
await deleteReports(id);
await appwrite.deleteRow(TABLE_GALLERY, id);
say(`card ${id} withdrawn${row.ownerId === user.id ? '' : ' by admin'}`);
return { status: 200, body: { ok: true } };
});
}
async function reportCard({ req, raw }) {
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
const reporter = reporterHash(salt, clientIp(req));
const limit = rateLimit(reporter);
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLock(id, async () => {
const row = await appwrite.getRow(TABLE_GALLERY, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.cardGone);
let isNew = true;
try {
await appwrite.createRow(TABLE_REPORTS, reportRowId(id, reporter), { galleryId: id, reason: body.reason, reporterHash: reporter }, []);
} catch (err) {
if (!isDuplicate(err)) throw err;
isNew = false;
}
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
await syncReports(id, row);
if (isNew) say(`card ${id} reported (${body.reason})`);
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
});
}
async function setVisibility({ req, raw }) {
const user = await requireUser(req);
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
const id = body.id;
return withLock(id, async () => {
const row = await appwrite.getRow(TABLE_GALLERY, id);
if (!row) throw new HttpError(404, MSG.cardGone);
if (body.sichtbar) {
// Dateien, dann Meldungen, Zeile zuletzt: sichtbar wird die Karte erst, wenn alles andere steht.
await setFilePermissions(row, visibleFilePermissions(row.ownerId));
await deleteReports(id);
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
say(`card ${id} made visible by admin`);
} else {
await hideCard(id, row);
say(`card ${id} hidden by admin`);
}
return { status: 200, body: { ok: true } };
});
}
const ROUTES = new Map([
['GET /api/gesundheit', { name: 'gesundheit', run: async () => ({ status: 200, body: { ok: true } }) }],
['POST /api/veroeffentlichen', { name: 'veroeffentlichen', json: true, run: publish }],
['POST /api/zurueckziehen', { name: 'zurueckziehen', json: true, run: withdraw }],
['POST /api/melden', { name: 'melden', json: true, run: reportCard }],
['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }],
]);
async function handle(req, res) {
const pathname = (req.url || '').split('?')[0];
const route = ROUTES.get(`${req.method} ${pathname}`);
let status = 500;
try {
if (!route) throw new HttpError(404, MSG.notFound);
let raw = '';
if (route.json) {
if (!JSON_CONTENT_TYPE.test(req.headers['content-type'] || '')) {
req.resume();
throw new HttpError(415, MSG.jsonOnly);
}
raw = await readRawBody(req);
}
const result = await route.run({ req, raw });
status = result.status;
sendJson(res, result.status, result.body);
} catch (err) {
let headers = {};
let message = MSG.internal;
if (err instanceof HttpError) {
status = err.status;
message = err.message;
headers = err.headers;
} else if (err instanceof AppwriteError) {
// Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere
// (falscher Key, fehlende Tabelle, ungültige Struktur) ist ein Fehler hier (500).
const unavailable = err.status === 0 || err.status === 429 || err.status >= 500;
status = unavailable ? 502 : 500;
message = unavailable ? MSG.unavailable : MSG.internal;
say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`);
} else {
status = 500;
say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`);
}
if (!res.headersSent) sendJson(res, status, { error: message }, headers);
}
}
function handler(req, res) {
return handle(req, res).catch((err) => {
// handle() fängt alles selbst ab; das hier sichert nur gegen Fehler beim Senden.
say(`unexpected error ${safeToken(err && err.name)}`);
try {
res.destroy();
} catch {
// schon geschlossen
}
});
}
handler.rateLimitEntries = () => hits.size;
return handler;
}
module.exports = {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
clientIp,
normalizeIp,
validatePublish,
buildSearchText,
galleryRowId,
reportRowId,
};
File diff suppressed because it is too large. Load diff
+173
View File
@@ -0,0 +1,173 @@
'use strict';
/**
* Schmaler REST-Client für Appwrite 1.8 (Node 22, nur Bordmittel) mit dem Server-Key des API-Dienstes.
*
* createAppwrite({ endpoint, project, key, fetchImpl?, database?, timeoutMs? })
* getRow(table, id) Zeile oder null (404 row_not_found)
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter);
* eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles [])
* createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
* deleteFile(bucket, id) true, false bei 404 storage_file_not_found
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
*
* Als „fehlt“ gilt nur ein 404 mit dem erwarteten Typ (row_not_found, storage_file_not_found). Jedes andere 404
* (table_not_found, database_not_found, storage_bucket_not_found, unbekannte Route, 404 ohne JSON) ist ein Fehler:
* so verschwindet ein Konfigurationsfehler nicht als „Karte nicht gefunden“.
* Redirects werden nie verfolgt (`redirect: 'error'`), damit der Server-Key nicht an eine andere Adresse geht.
* Jeder Aufruf bricht nach `timeoutMs` (10 s) ab. Jedes Pfadsegment wird mit encodeURIComponent kodiert;
* "." und ".." lehnt der Client ab, weil encodeURIComponent sie nicht verändert und die URL-Normalisierung
* sie sonst als Verzeichniswechsel auslegen würde.
*
* Fehler: AppwriteError mit `status` (0 = keine Antwort), `type` (Appwrite-Typ, "timeout" oder "unreachable").
* Der Text enthält nie Key oder JWT.
*/
const DEFAULT_TIMEOUT_MS = 10_000;
const ROW_NOT_FOUND = 'row_not_found';
const FILE_NOT_FOUND = 'storage_file_not_found';
class AppwriteError extends Error {
constructor(status, type, message) {
super(message);
this.name = 'AppwriteError';
this.status = status;
this.type = type;
}
}
/** Appwrite-Abfrage als JSON-String. */
function queryEqual(attribute, values) {
return JSON.stringify({ method: 'equal', attribute, values });
}
function queryLimit(count) {
return JSON.stringify({ method: 'limit', values: [count] });
}
/** Nächste Seite nach der Zeile mit dieser ID (Reihenfolge: Anlegereihenfolge). */
function queryCursorAfter(id) {
return JSON.stringify({ method: 'cursorAfter', values: [id] });
}
/** Ein Pfadsegment: nur nichtleere Zeichenketten, nie "." oder "..", immer kodiert. */
function segment(value) {
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') {
throw new TypeError('Ungültiges Pfadsegment');
}
return encodeURIComponent(value);
}
function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, database = 'aircard', timeoutMs = DEFAULT_TIMEOUT_MS }) {
if (typeof endpoint !== 'string' || endpoint === '') throw new TypeError('endpoint fehlt');
if (typeof project !== 'string' || project === '') throw new TypeError('project fehlt');
if (typeof key !== 'string' || key === '') throw new TypeError('key fehlt');
if (typeof fetchImpl !== 'function') throw new TypeError('fetch fehlt');
const base = endpoint.replace(/\/+$/, '');
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
/** Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er. */
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) {
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
if (body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
let response;
let text;
try {
response = await fetchImpl(`${base}${path}`, init);
text = await response.text();
} catch (err) {
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw timedOut
? new AppwriteError(0, 'timeout', 'Appwrite antwortet nicht rechtzeitig')
: new AppwriteError(0, 'unreachable', 'Appwrite nicht erreichbar');
}
let parsed = null;
if (text !== '') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
}
if (response.status >= 200 && response.status < 300) return { status: response.status, body: parsed };
const type = parsed && typeof parsed.type === 'string' ? parsed.type : 'unknown';
if (response.status === 404 && missingTypes.includes(type)) return { status: 404, body: null };
const message = parsed && typeof parsed.message === 'string' ? parsed.message : `HTTP ${response.status}`;
throw new AppwriteError(response.status, type, message);
}
return {
async getRow(table, id) {
const result = await send('GET', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async listRows(table, queries = []) {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
const result = await send('GET', `${rowsPath(table)}${search === '' ? '' : `?${search}`}`);
if (!result.body || !Array.isArray(result.body.rows)) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return result.body.rows;
},
async createRow(table, rowId, data, permissions) {
const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } });
return result.body;
},
async updateRow(table, id, data, permissions) {
const body = { data };
if (Array.isArray(permissions)) body.permissions = permissions;
const result = await send('PATCH', `${rowsPath(table)}/${segment(id)}`, { body });
return result.body;
},
async deleteRow(table, id) {
const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status !== 404;
},
async getFile(bucket, id) {
const result = await send('GET', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async updateFilePermissions(bucket, id, permissions) {
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async deleteFile(bucket, id) {
const result = await send('DELETE', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status !== 404;
},
/** Prüft ein Nutzer-JWT: Konto aus GET /account (ohne Server-Key), null bei 401. */
async accountFromJwt(jwt) {
try {
const result = await send('GET', '/account', { headers: { 'X-Appwrite-JWT': jwt }, withKey: false });
return result.body;
} catch (err) {
if (err instanceof AppwriteError && err.status === 401) return null;
throw err;
}
},
};
}
module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter };
+346
View File
@@ -0,0 +1,346 @@
'use strict';
// Tests für deploy/api/appwrite.js gegen einen Fake-Appwrite im Speicher (fake-appwrite.js).
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const fakes = [];
afterEach(async () => {
for (const fake of fakes.splice(0)) await fake.close();
});
async function setup(options = {}) {
const fake = await startFakeAppwrite();
fakes.push(fake);
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, ...options });
return { fake, appwrite };
}
const lastCall = (fake) => fake.calls[fake.calls.length - 1];
test('Zeilen: anlegen, lesen, ändern, löschen mit Projekt- und Key-Header', async () => {
const { fake, appwrite } = await setup();
const created = await appwrite.createRow('gallery', 'row1', { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, ['read("any")']);
assert.equal(created.$id, 'row1');
assert.equal(lastCall(fake).method, 'POST');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows');
assert.deepEqual(lastCall(fake).body, {
rowId: 'row1',
data: { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' },
permissions: ['read("any")'],
});
assert.equal(lastCall(fake).headers['x-appwrite-project'], fake.project);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
assert.match(lastCall(fake).headers['content-type'], /^application\/json/);
assert.equal((await appwrite.getRow('gallery', 'row1')).title, 'T');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/row1');
await appwrite.updateRow('gallery', 'row1', { hidden: true });
assert.equal(lastCall(fake).method, 'PATCH');
assert.deepEqual(lastCall(fake).body, { data: { hidden: true } }, 'ohne permissions-Feld, wenn keine übergeben werden');
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("any")']);
await appwrite.updateRow('gallery', 'row1', { reportCount: 3 }, ['read("user:o")']);
assert.deepEqual(lastCall(fake).body, { data: { reportCount: 3 }, permissions: ['read("user:o")'] });
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("user:o")']);
assert.equal(await appwrite.deleteRow('gallery', 'row1'), true);
assert.equal(fake.row('gallery', 'row1'), null);
});
test('Fehlende Zeile/Datei: getRow und getFile liefern null, deleteRow/deleteFile/updateFilePermissions sind tolerant', async () => {
const { appwrite } = await setup();
assert.equal(await appwrite.getRow('gallery', 'nope'), null);
assert.equal(await appwrite.getFile('gallery-images', 'nope'), null);
assert.equal(await appwrite.deleteRow('gallery', 'nope'), false);
assert.equal(await appwrite.deleteFile('gallery-images', 'nope'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'nope', ['read("any")']), null);
});
test('Doppelte Zeilen-ID wirft AppwriteError mit Status 409 und Typ row_already_exists', async () => {
const { appwrite } = await setup();
const data = { galleryId: 'g', reason: 'spam', reporterHash: 'h' };
await appwrite.createRow('reports', 'r1', data, []);
await assert.rejects(
() => appwrite.createRow('reports', 'r1', data, []),
(err) => err instanceof AppwriteError && err.status === 409 && err.type === 'row_already_exists',
);
});
test('Andere Fehler (400, 401, 500) werden als AppwriteError geworfen', async () => {
const { fake, appwrite } = await setup();
await assert.rejects(
() => appwrite.createRow('gallery', 'x', { title: 'x'.repeat(61), imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, []),
(err) => err instanceof AppwriteError && err.status === 400 && err.type === 'document_invalid_structure',
);
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 500,
);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(
() => wrongKey.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 401,
);
});
test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeilen', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c']) {
await appwrite.createRow('reports', id, { galleryId: id === 'c' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
const rows = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(100)]);
assert.deepEqual(rows.map((row) => row.$id).sort(), ['a', 'b']);
const { search } = lastCall(fake);
const params = search.split('&').map((part) => decodeURIComponent(part));
assert.deepEqual(params, [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[100]}',
]);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
});
test('queryEqual, queryLimit und queryCursorAfter liefern Appwrite-JSON-Strings', () => {
assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}');
assert.equal(queryLimit(100), '{"method":"limit","values":[100]}');
assert.equal(queryCursorAfter('abc'), '{"method":"cursorAfter","values":["abc"]}');
});
test('listRows blättert mit cursorAfter', async () => {
const { appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd', 'e']) {
await appwrite.createRow('reports', id, { galleryId: 'g1', reason: 'spam', reporterHash: id }, []);
}
const first = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2)]);
assert.deepEqual(first.map((row) => row.$id), ['a', 'b']);
const second = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('b')]);
assert.deepEqual(second.map((row) => row.$id), ['c', 'd']);
const last = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('d')]);
assert.deepEqual(last.map((row) => row.$id), ['e']);
});
test('Dateien: lesen, Rechte setzen, löschen', async () => {
const { fake, appwrite } = await setup();
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
const file = await appwrite.getFile('gallery-images', 'f1');
assert.equal(file.mimeType, 'image/webp');
assert.equal(file.sizeOriginal, 1234);
assert.equal(file.bucketId, 'gallery-images');
assert.deepEqual(file.$permissions, ['read("any")', 'delete("user:u1")']);
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/f1');
await appwrite.updateFilePermissions('gallery-images', 'f1', ['read("user:u1")']);
assert.equal(lastCall(fake).method, 'PUT');
assert.deepEqual(lastCall(fake).body, { permissions: ['read("user:u1")'] });
assert.deepEqual(fake.file('f1').$permissions, ['read("user:u1")']);
assert.equal(await appwrite.deleteFile('gallery-images', 'f1'), true);
assert.equal(fake.file('f1'), null);
});
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
const { fake, appwrite } = await setup();
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
const user = await appwrite.accountFromJwt('aaa.bbb.ccc');
assert.equal(user.$id, 'u1');
assert.deepEqual(user.labels, ['admin']);
const call = lastCall(fake);
assert.equal(call.path, '/v1/account');
assert.equal(call.headers['x-appwrite-jwt'], 'aaa.bbb.ccc');
assert.equal(call.headers['x-appwrite-project'], fake.project);
assert.equal(call.headers['x-appwrite-key'], undefined, 'das Konto wird nie mit dem Server-Key abgefragt');
assert.equal(await appwrite.accountFromJwt('xxx.yyy.zzz'), null);
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
await assert.rejects(() => appwrite.accountFromJwt('aaa.bbb.ccc'), (err) => err instanceof AppwriteError && err.status === 500);
});
test('Pfadsegmente: jede ID wird kodiert, "." und ".." werden abgelehnt, bevor eine Anfrage rausgeht', async () => {
const { fake, appwrite } = await setup();
await appwrite.getRow('gallery', '../x');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/..%2Fx');
await appwrite.getFile('gallery-images', 'a b/c');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/a%20b%2Fc');
const before = fake.calls.length;
for (const bad of ['..', '.', '', 42, null, undefined]) {
await assert.rejects(() => appwrite.getRow('gallery', bad), /Pfadsegment/);
await assert.rejects(() => appwrite.deleteFile('gallery-images', bad), /Pfadsegment/);
}
await assert.rejects(() => appwrite.getRow('..', 'x'), /Pfadsegment/);
assert.equal(fake.calls.length, before, 'es ging keine Anfrage raus');
});
test('Zeitlimit: Appwrite-Aufrufe tragen standardmäßig AbortSignal.timeout(10000)', async () => {
let seenSignal = null;
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seenSignal = init.signal;
return new Response(JSON.stringify({ $id: 'x' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
},
});
await appwrite.getRow('gallery', 'x');
assert.ok(seenSignal instanceof AbortSignal);
assert.equal(seenSignal.aborted, false);
});
test('Zeitlimit: hängender Appwrite-Aufruf endet mit AppwriteError (timeout)', async () => {
const { fake, appwrite } = await setup({ timeoutMs: 80 });
fake.hangNext((call) => call.path.endsWith('/rows/slow'));
await assert.rejects(
() => appwrite.getRow('gallery', 'slow'),
(err) => err instanceof AppwriteError && err.type === 'timeout' && err.status === 0,
);
});
test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'GEHEIMER-KEY-123',
fetchImpl: async () => {
throw new TypeError('fetch failed GEHEIMER-KEY-123');
},
});
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'),
);
});
test('404 gilt nur mit dem erwarteten Typ als „fehlt“, jedes andere 404 ist ein Fehler', async () => {
const { fake, appwrite } = await setup();
const rejectsWith404 = (promise, type) =>
assert.rejects(promise, (err) => err instanceof AppwriteError && err.status === 404 && err.type === type);
// erwartete Typen: fehlt
assert.equal(await appwrite.getRow('gallery', 'x'), null);
assert.equal(await appwrite.deleteRow('gallery', 'x'), false);
assert.equal(await appwrite.getFile('gallery-images', 'x'), null);
assert.equal(await appwrite.deleteFile('gallery-images', 'x'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'x', []), null);
// unbekannte Tabelle / Datenbank / Bucket: Fehler
await rejectsWith404(appwrite.getRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.deleteRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.listRows('keine-tabelle', []), 'table_not_found');
await rejectsWith404(appwrite.getFile('kein-bucket', 'x'), 'storage_bucket_not_found');
await rejectsWith404(appwrite.updateFilePermissions('kein-bucket', 'x', []), 'storage_bucket_not_found');
await rejectsWith404(appwrite.deleteFile('kein-bucket', 'x'), 'storage_bucket_not_found');
const otherDatabase = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'andere-db' });
await rejectsWith404(otherDatabase.getRow('gallery', 'x'), 'database_not_found');
// Typ von der falschen Art (Zeile statt Datei und umgekehrt) und unbekannte Route
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'storage_file_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'storage_file_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'row_not_found' });
await rejectsWith404(appwrite.getFile('gallery-images', 'x'), 'row_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'general_route_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'general_route_not_found');
});
test('404 ohne JSON-Antwort (z. B. von einem Proxy) ist ein Fehler, kein „fehlt“', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response('404 page not found', { status: 404 }),
});
for (const call of [
() => appwrite.getRow('gallery', 'x'),
() => appwrite.getFile('gallery-images', 'x'),
() => appwrite.deleteRow('gallery', 'x'),
() => appwrite.deleteFile('gallery-images', 'x'),
() => appwrite.updateFilePermissions('gallery-images', 'x', []),
]) {
await assert.rejects(call, (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'unknown');
}
});
test('listRows: eine Antwort ohne rows-Array wirft einen AppwriteError statt still [] zu liefern', async () => {
for (const payload of [{ total: 0 }, { rows: 'x' }, { rows: null }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.listRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const empty = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }),
});
assert.deepEqual(await empty.listRows('reports', []), [], 'eine echte leere Liste bleibt eine leere Liste');
});
test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner Weiterleitung)', async () => {
const seen = [];
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seen.push(init.redirect);
return new Response(JSON.stringify({ rows: [], $id: 'x' }), { status: 200 });
},
});
await appwrite.getRow('gallery', 'x');
await appwrite.listRows('gallery', []);
await appwrite.createRow('gallery', 'x', {}, []);
await appwrite.updateRow('gallery', 'x', {});
await appwrite.deleteRow('gallery', 'x');
await appwrite.getFile('gallery-images', 'x');
await appwrite.updateFilePermissions('gallery-images', 'x', []);
await appwrite.deleteFile('gallery-images', 'x');
await appwrite.accountFromJwt('a.b.c');
assert.equal(seen.length, 9);
assert.ok(seen.every((value) => value === 'error'));
});
test('Eine Weiterleitung von Appwrite wird nicht verfolgt (echter Server antwortet 302)', async () => {
const target = http.createServer((req, res) => {
target.hits = (target.hits || 0) + 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve));
const redirector = http.createServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/gesammelt` });
res.end();
});
await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve));
try {
const appwrite = createAppwrite({ endpoint: `http://127.0.0.1:${redirector.address().port}/v1`, project: 'p', key: 'GEHEIMER-KEY' });
await assert.rejects(() => appwrite.getRow('gallery', 'x'), (err) => err instanceof AppwriteError && err.type === 'unreachable');
assert.equal(target.hits || 0, 0, 'das Ziel der Weiterleitung wurde nie angefragt');
} finally {
redirector.closeAllConnections();
target.closeAllConnections();
await new Promise((resolve) => redirector.close(resolve));
await new Promise((resolve) => target.close(resolve));
}
});
+306
View File
@@ -0,0 +1,306 @@
'use strict';
/**
* Nur für Tests: Appwrite im Speicher (Teilmenge der REST-API von 1.8.1) als echter HTTP-Server auf Port 0.
*
* GET /v1/account Header X-Appwrite-JWT (+ Projekt, ohne Key)
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
*
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der
* echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen.
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
*/
const http = require('node:http');
const BUCKETS = ['gallery-images', 'card-images'];
const COLUMNS = {
gallery: {
title: { size: 60, required: true },
issuer: { size: 40 },
tags: { size: 120 },
displayName: { size: 30 },
imageFileId: { size: 36, required: true },
previewFileId: { size: 36, required: true },
sourceCardId: { size: 36 },
ownerId: { size: 36, required: true },
hidden: { type: 'boolean' },
reportCount: { type: 'integer' },
searchText: { size: 300 },
},
reports: {
galleryId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
};
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
const tables = { gallery: new Map(), reports: new Map() };
const files = new Map();
const users = new Map();
const calls = [];
const injections = [];
let sequence = 0;
function addUser(jwt, { id, labels = [] }) {
users.set(jwt, { $id: id, email: `${id}@example.test`, labels });
}
/** Beliebige Konto-Antwort für `GET /account` (z. B. ohne labels oder mit ungültiger ID). */
function addRawUser(jwt, account) {
users.set(jwt, account);
}
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000 }) {
files.set(id, {
$id: id,
bucketId: bucket,
name: `${id}.png`,
$permissions: permissions || ['read("any")', `delete("user:${owner}")`],
mimeType,
sizeOriginal: size,
});
}
function addRow(table, id, data, permissions) {
const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data };
tables[table].set(id, row);
return row;
}
/** Nächster passender Aufruf (predicate(call) -> boolean) scheitert mit Status/Typ. */
function failNext(predicate, { status = 500, type = 'general_server_error' } = {}) {
injections.push({ predicate, status, type, hang: false });
}
/** Nächster passender Aufruf bekommt nie eine Antwort. */
function hangNext(predicate) {
injections.push({ predicate, hang: true });
}
/** Nächster passender Aufruf wird erst nach `delayMs` bearbeitet (Zustand ändert sich erst dann). */
function delayNext(predicate, delayMs) {
injections.push({ predicate, delayMs });
}
function validateData(table, data, { partial }) {
const columns = COLUMNS[table];
for (const [name, value] of Object.entries(data)) {
const column = columns[name];
if (!column) return `Unknown attribute: ${name}`;
if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`;
if (column.type === 'integer' && !Number.isInteger(value)) return `Invalid ${name}`;
if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`;
if (column.size !== undefined) {
if (typeof value !== 'string') return `Invalid ${name}`;
if (value.length > column.size) return `Invalid ${name}: too long`;
}
}
if (!partial) {
for (const [name, column] of Object.entries(columns)) {
if (column.required && data[name] === undefined) return `Missing required attribute: ${name}`;
}
}
return null;
}
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const raw = Buffer.concat(chunks).toString('utf8');
const [pathname, search = ''] = req.url.split('?');
const call = {
method: req.method,
path: pathname,
search,
headers: req.headers,
body: raw === '' ? null : safeParse(raw),
};
calls.push(call);
const reply = (status, payload) => {
if (status === 204) {
res.writeHead(204);
res.end();
return;
}
const text = JSON.stringify(payload);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
res.end(text);
};
const error = (status, type, message = type) => reply(status, { message, code: status, type });
const injectionIndex = injections.findIndex((entry) => entry.predicate(call));
if (injectionIndex !== -1) {
const [entry] = injections.splice(injectionIndex, 1);
if (entry.hang) return;
if (entry.delayMs) {
await new Promise((resolve) => setTimeout(resolve, entry.delayMs));
} else {
return error(entry.status, entry.type);
}
}
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments[0] !== 'v1' || req.headers['x-appwrite-project'] !== project) {
return error(401, 'general_unauthorized_scope', 'Missing or wrong project');
}
// GET /v1/account (JWT, ohne Key)
if (segments.length === 2 && segments[1] === 'account' && req.method === 'GET') {
const user = users.get(req.headers['x-appwrite-jwt']);
if (!user) return error(401, 'user_jwt_invalid', 'Invalid JWT');
return reply(200, user);
}
if (req.headers['x-appwrite-key'] !== key) return error(401, 'general_unauthorized_scope', 'Missing or wrong key');
// Zeilen
if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') {
const table = segments[4];
if (segments[2] !== database) return error(404, 'database_not_found');
if (!tables[table]) return error(404, 'table_not_found');
const store = tables[table];
if (segments.length === 6 && req.method === 'POST') {
const body = call.body || {};
if (typeof body.rowId !== 'string' || body.rowId === '') return error(400, 'general_argument_invalid', 'rowId is required');
if (store.has(body.rowId)) return error(409, 'row_already_exists');
const data = body.data || {};
const problem = validateData(table, data, { partial: false });
if (problem) return error(400, 'document_invalid_structure', problem);
const defaults = table === 'gallery' ? { hidden: false, reportCount: 0 } : {};
const row = addRow(table, body.rowId, { ...defaults, ...data }, body.permissions || []);
return reply(201, row);
}
if (segments.length === 6 && req.method === 'GET') {
const rows = runQueries([...store.values()], search);
if (rows === null) return error(400, 'general_query_invalid');
return reply(200, { total: rows.length, rows });
}
if (segments.length === 7) {
const row = store.get(segments[6]);
if (!row) return error(404, 'row_not_found');
if (req.method === 'GET') return reply(200, row);
if (req.method === 'DELETE') {
store.delete(segments[6]);
return reply(204);
}
if (req.method === 'PATCH') {
const body = call.body || {};
const data = body.data || {};
const problem = validateData(table, data, { partial: true });
if (problem) return error(400, 'document_invalid_structure', problem);
Object.assign(row, data);
if (Array.isArray(body.permissions)) row.$permissions = body.permissions;
return reply(200, row);
}
}
}
// Dateien
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
const file = files.get(segments[5]);
if (!file) return error(404, 'storage_file_not_found');
if (req.method === 'GET') return reply(200, file);
if (req.method === 'DELETE') {
files.delete(segments[5]);
return reply(204);
}
if (req.method === 'PUT') {
const body = call.body || {};
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
file.$permissions = body.permissions;
return reply(200, file);
}
}
return error(404, 'general_route_not_found');
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
return {
url: `http://127.0.0.1:${port}`,
endpoint: `http://127.0.0.1:${port}/v1`,
project,
key,
calls,
addUser,
addRawUser,
addFile,
addRow,
failNext,
hangNext,
delayNext,
row: (table, id) => tables[table].get(id) || null,
rows: (table) => [...tables[table].values()],
file: (id) => files.get(id) || null,
close: async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
},
};
}
function decodeSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function safeParse(text) {
try {
return JSON.parse(text);
} catch {
return { _unparsable: text };
}
}
/** Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. */
function runQueries(rows, search) {
let result = rows;
let limit = 25;
let cursor = null;
for (const part of search.split('&').filter(Boolean)) {
const eq = part.indexOf('=');
const name = decodeURIComponent(part.slice(0, eq));
if (name !== 'queries[]') return null;
let query;
try {
query = JSON.parse(decodeURIComponent(part.slice(eq + 1)));
} catch {
return null;
}
if (query.method === 'equal') {
result = result.filter((row) => query.values.includes(row[query.attribute]));
} else if (query.method === 'limit') {
limit = query.values[0];
} else if (query.method === 'cursorAfter') {
cursor = query.values[0];
} else {
return null;
}
}
if (cursor !== null) {
const index = result.findIndex((row) => row.$id === cursor);
if (index === -1) return null;
result = result.slice(index + 1);
}
return result.slice(0, limit);
}
module.exports = { startFakeAppwrite };
+99
View File
@@ -0,0 +1,99 @@
'use strict';
/**
* Start des API-Dienstes `aircard-api` (Node 22, nur Bordmittel): liest die Umgebung, baut createApp und
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
*
* Umgebung:
* AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (rows.read/write, files.read/write)
* AIRCARD_API_SALT Pflicht Salz für die Melder-Hashes (32 Byte hex)
* AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
* AIRCARD_API_PORT 8088
* Fehlt etwas Pflichtiges, nennt die Meldung nur den Namen der Variable (nie Werte) und der Prozess endet mit Exit-Code 1.
*/
const http = require('node:http');
const { createAppwrite } = require('./appwrite.js');
const { createApp } = require('./app.js');
const DEFAULT_PORT = 8088;
const DEFAULT_ENDPOINT = 'http://webklar-appwrite/v1';
/**
* Prüft die Umgebung, ohne etwas zu starten.
* @returns {{ok: true, config: object, warnings: string[]} | {ok: false, error: string}}
*/
function loadConfig(env) {
const read = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
const missing = ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID'].filter((name) => read(name) === '');
if (missing.length > 0) {
return { ok: false, error: `${missing.join(', ')} ${missing.length > 1 ? 'sind' : 'ist'} nicht gesetzt - Abbruch` };
}
const endpoint = read('AIRCARD_APPWRITE_ENDPOINT') || DEFAULT_ENDPOINT;
if (!/^https?:\/\/\S+$/.test(endpoint)) {
return { ok: false, error: 'AIRCARD_APPWRITE_ENDPOINT muss mit http:// oder https:// beginnen - Abbruch' };
}
const portText = read('AIRCARD_API_PORT');
const port = portText === '' ? DEFAULT_PORT : Number(portText);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
return { ok: false, error: 'AIRCARD_API_PORT ist keine gültige Portnummer - Abbruch' };
}
const warnings = [];
if (read('AIRCARD_API_SALT').length < 16) warnings.push('AIRCARD_API_SALT ist sehr kurz (empfohlen: 32 Byte hex)');
return {
ok: true,
warnings,
config: {
port,
endpoint,
project: read('AIRCARD_APPWRITE_PROJECT_ID'),
key: read('AIRCARD_API_KEY'),
salt: read('AIRCARD_API_SALT'),
},
};
}
function main() {
const say = (message) => console.log(`[aircard-api] ${new Date().toISOString()} ${message}`);
const loaded = loadConfig(process.env);
if (!loaded.ok) {
console.error(`[aircard-api] ${loaded.error}`);
process.exit(1);
}
for (const warning of loaded.warnings) say(`Warnung: ${warning}`);
const { port, endpoint, project, key, salt } = loaded.config;
const appwrite = createAppwrite({ endpoint, project, key });
const app = createApp({ appwrite, salt, log: console.log });
const server = http.createServer(app);
// Langsame oder hängende Clients nicht ewig halten; Keep-Alive länger als Traefiks Leerlauf (90 s),
// damit Traefik keine Verbindung wiederverwendet, die Node gerade schließt.
server.headersTimeout = 10_000;
server.requestTimeout = 15_000;
server.keepAliveTimeout = 95_000;
server.listen(port, () => say(`listening on :${server.address().port} (Appwrite: ${endpoint})`));
const shutdown = (signal) => {
say(`${signal} received, shutting down`);
server.close(() => process.exit(0));
server.closeIdleConnections();
setTimeout(() => process.exit(0), 5000).unref();
};
process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT', () => shutdown('SIGINT'));
}
module.exports = { loadConfig };
if (require.main === module) {
main();
}
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Tests für deploy/api/server.js: Umgebungsprüfung und ein Start als echter Prozess gegen einen Fake-Appwrite.
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { loadConfig } = require('./server.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const SERVER = path.join(__dirname, 'server.js');
const KEY = 'KEY-test-secret-value';
const SALT = 'salt-test-secret-0123456789abcdef0123456789';
const children = [];
const fakes = [];
afterEach(async () => {
for (const child of children.splice(0)) {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
await new Promise((resolve) => child.once('exit', resolve));
}
}
for (const fake of fakes.splice(0)) await fake.close();
});
function validEnv(extra = {}) {
return {
AIRCARD_API_KEY: KEY,
AIRCARD_API_SALT: SALT,
AIRCARD_APPWRITE_PROJECT_ID: 'proj-test',
...extra,
};
}
function startProcess(env) {
const child = spawn(process.execPath, [SERVER], { env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
children.push(child);
const output = { text: '' };
child.stdout.on('data', (chunk) => (output.text += chunk));
child.stderr.on('data', (chunk) => (output.text += chunk));
const exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));
return { child, output, exited };
}
async function waitFor(condition, what, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
function httpRequest(port, method, urlPath, { headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = http.request(
{
host: '127.0.0.1',
port,
method,
path: urlPath,
headers: payload === undefined ? headers : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers },
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
},
);
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
test('loadConfig: Standardwerte und gelesene Werte', () => {
const result = loadConfig(validEnv());
assert.equal(result.ok, true);
assert.deepEqual(result.config, {
port: 8088,
endpoint: 'http://webklar-appwrite/v1',
project: 'proj-test',
key: KEY,
salt: SALT,
});
assert.deepEqual(result.warnings, []);
const custom = loadConfig(validEnv({ AIRCARD_API_PORT: '9100', AIRCARD_APPWRITE_ENDPOINT: 'https://appwrite.example/v1' }));
assert.equal(custom.config.port, 9100);
assert.equal(custom.config.endpoint, 'https://appwrite.example/v1');
});
test('loadConfig: fehlender Key, Salz oder Projekt wird mit dem Namen der Variable gemeldet, nie mit Werten', () => {
for (const name of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID']) {
for (const value of [undefined, '', ' ']) {
const env = validEnv();
if (value === undefined) delete env[name];
else env[name] = value;
const result = loadConfig(env);
assert.equal(result.ok, false, `${name}=${JSON.stringify(value)}`);
assert.ok(result.error.includes(name));
assert.ok(!result.error.includes(KEY) && !result.error.includes(SALT));
}
}
const none = loadConfig({});
assert.equal(none.ok, false);
assert.ok(none.error.includes('AIRCARD_API_KEY') && none.error.includes('AIRCARD_API_SALT'));
});
test('loadConfig: ungültiger Port oder Endpunkt, kurzes Salz warnt', () => {
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: 'abc' })).ok, false);
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '70000' })).ok, false);
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '-1' })).ok, false);
assert.equal(loadConfig(validEnv({ AIRCARD_APPWRITE_ENDPOINT: 'webklar-appwrite/v1' })).ok, false);
const short = loadConfig(validEnv({ AIRCARD_API_SALT: 'xyz-9' }));
assert.equal(short.ok, true);
assert.equal(short.warnings.length, 1);
assert.ok(short.warnings[0].includes('AIRCARD_API_SALT') && !short.warnings[0].includes('xyz-9'));
});
test('Start ohne Key oder ohne Salz: Exit-Code 1, Meldung ohne Werte', async () => {
for (const missing of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT']) {
const env = validEnv({ AIRCARD_API_PORT: '0' });
delete env[missing];
const { output, exited } = startProcess(env);
const { code } = await exited;
assert.equal(code, 1, missing);
assert.ok(output.text.includes(missing));
assert.ok(output.text.includes('[aircard-api]'));
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT));
}
});
test('Start als Prozess: Gesundheit, Melden über Fake-Appwrite mit Server-Key, sauberes Beenden bei SIGTERM', async () => {
const fake = await startFakeAppwrite({ project: 'proj-test', key: KEY });
fakes.push(fake);
fake.addRow(
'gallery',
'card-1',
{ title: 'Karte', imageFileId: 'i', previewFileId: 'p', ownerId: 'user-me', hidden: false, reportCount: 0 },
['read("any")'],
);
const { child, output, exited } = startProcess(validEnv({ AIRCARD_API_PORT: '0', AIRCARD_APPWRITE_ENDPOINT: fake.endpoint }));
await waitFor(() => /listening on :\d+/.test(output.text), 'Startmeldung');
const port = Number(/listening on :(\d+)/.exec(output.text)[1]);
const health = await httpRequest(port, 'GET', '/api/gesundheit');
assert.deepEqual(health.json, { ok: true });
const reported = await httpRequest(port, 'POST', '/api/melden', {
body: { id: 'card-1', reason: 'spam' },
headers: { 'X-Real-Ip': '203.0.113.5' },
});
assert.equal(reported.status, 200);
assert.deepEqual(reported.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.ok(fake.calls.length > 0);
for (const call of fake.calls) assert.equal(call.headers['x-appwrite-key'], KEY);
child.kill('SIGTERM');
const { code } = await exited;
assert.equal(code, 0);
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT), 'Ausgabe enthält weder Key noch Salz');
assert.ok(!output.text.includes('203.0.113.5'), 'Ausgabe enthält keine IP');
for (const line of output.text.trim().split('\n')) assert.ok(line.startsWith('[aircard-api]'), line);
});
+47 -3
View File
@@ -3,10 +3,12 @@
# URL: aircard-ios.org
# Repository: https://git.webklar.com/knso/AirCardIOS
# Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md).
# Starten NUR mit: docker compose up -d aircard-build aircard
# Starten NUR mit: docker compose up -d aircard-build aircard aircard-api
# (ein nacktes `up -d` würde andere Dienste wieder anlegen)
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml.
# AIRCARD_APPWRITE_PROJECT_ID kommt aus /home/webklar/apps/.env.
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml (Router `aircard-api@file`, Pfad /v1).
# Der API-Dienst `aircard-api` hängt per Label am Pfad /api (Router `aircard-gallery-api`, ein anderer Name,
# damit er nicht mit der Datei-Route kollidiert).
# AIRCARD_APPWRITE_PROJECT_ID, AIRCARD_API_KEY und AIRCARD_API_SALT kommen aus /home/webklar/apps/.env.
# ==============================================
aircard-build:
image: node:22-alpine
@@ -80,3 +82,45 @@
# - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)"
# - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}"
# - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true"
# --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) ---
# Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1.
# Kein `ports:`: erreichbar nur über Traefik (der Dienst vertraut dem Header X-Real-Ip).
aircard-api:
image: node:22-alpine
container_name: webklar-aircard-api
restart: unless-stopped
init: true
mem_limit: 128m
user: node
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
working_dir: /srv
# Log-Größe deckeln: /api/melden ist ohne Anmeldung erreichbar, der Docker-Daemon hat keine eigenen Log-Grenzen.
logging:
driver: json-file
options:
max-size: "5m"
max-file: "2"
environment:
- AIRCARD_API_PORT=8088
- AIRCARD_APPWRITE_ENDPOINT=http://webklar-appwrite/v1
- AIRCARD_APPWRITE_PROJECT_ID=${AIRCARD_APPWRITE_PROJECT_ID}
- AIRCARD_API_KEY=${AIRCARD_API_KEY}
- AIRCARD_API_SALT=${AIRCARD_API_SALT}
- NODE_OPTIONS=--max-old-space-size=64
volumes:
- ./aircard-ios.org/api:/srv:ro
networks:
- webklar-network
command: node /srv/server.js
labels:
- "traefik.enable=true"
- "traefik.http.routers.aircard-gallery-api.rule=Host(`aircard-ios.org`) && PathPrefix(`/api`)"
- "traefik.http.routers.aircard-gallery-api.entrypoints=websecure"
- "traefik.http.routers.aircard-gallery-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.aircard-gallery-api.priority=200"
- "traefik.http.services.aircard-gallery-api.loadbalancer.server.port=8088"
+40 -5
View File
@@ -2,11 +2,11 @@
Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console>
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabelle, Bucket) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports` mit Spalten und Indizes, Buckets `card-images` und `gallery-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
**Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein.
**Stand der Einstellungen:** Schritt 2 (Web-Plattform `aircard-ios.org`) ist Voraussetzung, ohne sie lehnt Appwrite die Anfragen der Seite ab. Die Schritte 3 und 4 sind empfohlene Härtung. Ob sie in der Console umgesetzt sind, prüft kein Skript (`--check` deckt nur Schema, Tabelle und Bucket ab). Solange du sie nicht selbst gesetzt hast, läuft das Live-Projekt mit den Appwrite-Standards für Auth und Dienste. Das Schema und die Zeilen-/Dateirechte sind davon unabhängig.
**Stand der Einstellungen:** Schritt 2 (Web-Plattform `aircard-ios.org`) ist Voraussetzung, ohne sie lehnt Appwrite die Anfragen der Seite ab. Die Schritte 3 und 4 sind empfohlene Härtung. Ob sie in der Console umgesetzt sind, prüft kein Skript (`--check` deckt nur das Schema ab: Tabellen, Spalten, Indizes, Buckets). Solange du sie nicht selbst gesetzt hast, läuft das Live-Projekt mit den Appwrite-Standards für Auth und Dienste. Das Schema und die Zeilen-/Dateirechte sind davon unabhängig.
## 1. Projekt anlegen
@@ -29,7 +29,7 @@ Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **
Linke Leiste → **Auth** → **Settings**. Registrieren und Anmelden funktionieren auch mit den Standardwerten; die folgenden Einstellungen begrenzen nur, was Besucher mit dem Projekt anstellen können.
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus).
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous** (steht dort auch **Invites**: ebenfalls aus). **JWT** bleibt **an** – ohne JWT kann die Community-API (Abschnitt „Community“) die Nutzer nicht prüfen.
2. **Session duration**: `30` Tage → **Update**.
3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**.
4. **Limit** (Nutzerlimit): `2000` → **Update**.
@@ -58,6 +58,8 @@ Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
4. **Create** klicken.
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
Die Indizes der Tabellen `gallery` und `reports` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
## 6. Key auf dem Server eintragen
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
@@ -73,6 +75,39 @@ Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern.
4. Den Key möglichst nicht im Chat teilen (wer ihn dort einfügt, gibt ihn in den Verlauf), nicht in Screenshots zeigen und nirgends einchecken. Das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`, dafür muss ihn niemand sonst sehen.
## 7. Bescheid geben
## 7. Community
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, Tabelle `cards` und Bucket `card-images` mit dem Skript an und prüft sie.
Für die Community-Galerie (Tabellen `gallery` und `reports`, Bucket `gallery-images`, API-Dienst `aircard-api`) sind drei Dinge in der Console nötig. Das Schema dazu legt dasselbe Skript an (`node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs`, vorher mit `--check` ansehen, was fehlt).
### 7.1 JWT muss an sein
JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten. Auth → **Settings** → **Auth methods** → **JWT** einschalten. Die API prüft damit, wer Veröffentlichen, Zurückziehen oder eine Admin-Aktion auslöst.
### 7.2 Label `admin` am eigenen Konto
Das Label `admin` macht dich zum Admin der Galerie: Die Website zeigt dir dann Ein-/Ausblenden-Knöpfe und den Galerie-Filter „Gemeldet“/„Ausgeblendet“.
1. Linke Leiste → **Auth** → **Users** → dein Konto öffnen.
2. Bei **Labels** den Wert `admin` eintragen → **Update**.
### 7.3 API-Key `aircard-api` anlegen
Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und nicht der Website.
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
3. Unter **Scopes** genau diese vier ankreuzen:
- `rows.read` und `rows.write`
- `files.read` und `files.write`
4. **Create** klicken und auf der Seite des Keys den **Secret**-Wert kopieren.
5. Auf dem Server den Wert verdeckt in die `.env` schreiben. Der Befehl fragt den Wert ohne Anzeige ab (`read -s`), hängt nur eine Zeile an und zeigt den Key nirgends an:
```
read -s -p "AIRCARD_API_KEY: " K; echo; printf 'AIRCARD_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K
```
Den Wert nicht im Chat teilen. Stand der Provisioning-Key aus Schritt 5 einmal im Chat, in der Console unter **API keys** löschen und bei Bedarf neu anlegen.
## 8. Bescheid geben
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt“.
@@ -0,0 +1,188 @@
# AirCard-IOS Startseite & Community-Galerie – Implementierungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Kompakte Startseite mit Community-Galerie, Veröffentlichen/Melden/Admin, Spenden-Banner und Hell/Dunkel-Schalter für https://aircard-ios.org.
**Architecture:** Der bestehende Vite-+-TS-Client bekommt neue Routen und Module (`web/src/community/*`, `web/src/shell/*`). Lesen der Galerie direkt aus Appwrite (öffentliche Zeilen), alle Galerie-Schreibvorgänge über einen neuen kleinen Node-Dienst `aircard-api` (`deploy/api/`) mit Server-Key, erreichbar unter `/api` per Traefik. Schema-Erweiterung über das bestehende Setup-Skript.
**Tech Stack:** wie bisher (TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom, appwrite 22.4.1), Node 22 für `aircard-api` (nur Built-ins), Appwrite 1.8.1 TablesDB, Traefik v3.6.
**Spec:** `docs/superpowers/specs/2026-10-04-aircard-community-design.md` (Commit 970e68e). Basis: `docs/superpowers/specs/2026-10-04-aircard-website-design.md`.
## Global Constraints
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
- Versionen unverändert gepinnt; keine neuen npm-Abhängigkeiten im Client; `deploy/api/` ohne Abhängigkeiten.
- Alle sichtbaren Texte Deutsch, Wortlaut aus der Spec.
- Appwrite: Projekt `6ac2130a00289343d6d5`; Datenbank `aircard`; neue Tabellen `gallery`, `reports`; neuer Bucket `gallery-images`; bestehende `cards`/`card-images` unverändert.
- Galerie-Zeilenrechte: sichtbar genau `['read("any")']`; ausgeblendet genau `['read("user:<owner>")','read("label:admin")']`. Galerie-Dateirechte sichtbar `['read("any")','delete("user:<owner>")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")','delete("user:<owner>")']`.
- Auto-Ausblenden ab **3** verschiedenen Meldern; Rate-Limit **10** Meldungen pro Stunde und IP; Seiten zu **24** Karten.
- Admin = Appwrite-Label `admin` (`account.get().labels`).
- `DONATE_URL` in `web/src/config.ts`; leer ⇒ Banner und Spenden-Knopf unsichtbar.
- Theme: `data-theme` auf `<html>`, `localStorage` `aircard.theme`; Banner `localStorage` `aircard.donateBannerClosed`. Jeder `localStorage`-Zugriff in try/catch.
- Hero: bei 1440 × 900 und 390 × 844 müssen „Community-Karten“-Überschrift und der obere Rand der ersten Kartenreihe im ersten Bildschirm sichtbar sein.
- Keine Secrets in Ausgaben, Logs, Commits; `.env` nie öffnen (Ausnahme: Controller-Ops).
- Tests: `cd web && npx vitest run`, `npx tsc --noEmit`, `npm run build`; `node --test deploy/api/*.test.js` (Glob in Anführungszeichen), bestehende node-Tests grün halten.
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Subagents: `sonnet` (Implementierung/Review), `haiku` für kleine Nachprüfungen; nie Subagents aus Subagents.
## Review Focus
- **Fremdes Veröffentlichen**: Nutzer schickt `imageFileId` einer fremden Datei an `/api/veroeffentlichen` → 403, nichts angelegt. Test in Task 3.
- **Meldungs-Flut / Selbst-Ausblenden**: dieselbe IP meldet 5× → zählt 1; 11. Meldung in einer Stunde → 429. Test in Task 3.
- **Ausgeblendete Karte per Direkt-Link**: Gast öffnet `/community/<id>` einer ausgeblendeten Karte → „nicht (mehr) verfügbar“, kein Bild. Test in Task 6 (Client) und Task 3 (Rechte).
- **Leere Galerie / leeres `DONATE_URL`**: Startseite ohne Karten zeigt Beispielmotiv und Leertext; ohne Link kein toter Spenden-Knopf. Tests in Tasks 1 und 5.
- **Theme-Wechsel mit offenem Editor**: Umschalten ändert nur UI-Tokens, nicht die Kartenfarben/PNG. Test in Task 1 (render unabhängig von Theme).
---
### Task 1: Shell – Routen, Kopfzeile, Theme, Spenden-Banner
**Files:**
- Create: `web/src/config.ts`, `web/src/shell/theme.ts`, `web/src/shell/donate-banner.ts`, `web/src/shell/header.ts`, `web/src/shell/shell.css`, `web/src/pages/home-page.ts` (Platzhalter mit Überschrift, Task 5 füllt), `web/src/pages/community-page.ts` (Platzhalter, Task 6 füllt)
- Modify: `web/index.html` (Kopfzeilen-Gerüst, Banner-Slot `#donate-banner`, Theme-Init-Skript gegen Aufblitzen), `web/src/pages/router.ts`, `web/src/main.ts`, `web/src/styles.css` (helle Tokens), `web/src/account/account-menu.ts` (Avatar-Kreis + Menü), `web/src/account/auth.ts` (`User.labels`, `isAdmin`), `web/src/editor/card-url.ts` und `web/src/pages/cards-page.ts` (Pfad `/gestalten?karte=`)
- Test: `web/src/pages/router.test.ts`, `web/src/shell/theme.test.ts`, `web/src/shell/donate-banner.test.ts`, `web/src/shell/header.test.ts`, `web/src/account/auth.test.ts` (labels)
**Interfaces:**
- Produces:
```ts
// router.ts (ersetzt RouteId)
type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset'
interface Route { id: RouteId; params: { id?: string } }
matchRoute(pathname: string): Route // '/' home, '/gestalten' gestalten, '/karten' cards, '/community/<id>' community {id}, '/passwort-neu' reset, sonst home
legacyRedirect(url: URL): string | null // '/?karte=X' → '/gestalten?karte=X', sonst null
navigate(path: string): void // pushState, render, scrollTo(0,0) außer bei Hash-Ziel (dann scrollIntoView des Ankers)
startRouter(render: (route: Route, root: HTMLElement) => void | (() => void)): void
ROUTE_TITLES / titleFor(route: Route, detailTitle?: string): string // Wortlaut Spec §2
// config.ts
export const DONATE_URL: string = '' // Kenso liefert den Link
// theme.ts
type Theme = 'light' | 'dark'
initialTheme(stored: string | null, prefersDark: boolean): Theme
applyTheme(t: Theme): void; toggleTheme(): Theme // speichert in localStorage (try/catch)
// donate-banner.ts
mountDonateBanner(slot: HTMLElement, url: string): void // nichts rendern wenn url leer oder geschlossen
// header.ts
mountHeader(el: HTMLElement, opts: { donateUrl: string }): () => void // Navigation, Suchfeld (Enter → navigate('/?q=…#community')), ☕-Knopf, Theme-Schalter, Menü-Icon < 900 px
// auth.ts
type User = { $id: string; email: string; labels: string[] }
isAdmin(user: User | null): boolean
```
- Kopfzeile: Logo `/favicon.svg?v=2` + „AirCard-IOS“ (→ `/`), Links „Community“ (`/#community`), „Gestalten“ (`/gestalten`), „Meine Karten“ (`/karten`), Suchfeld Platzhalter „Titel, Tags oder Herausgeber“, Knopf „☕ Unterstützen“, Theme-Knopf (`aria-label` „Hell/Dunkel umschalten“), Konto-Slot. Banner-Text exakt aus Spec §3.
- `index.html`: kleines Inline-Skript im `<head>` setzt `data-theme` aus `localStorage`/System vor dem ersten Paint (in try/catch).
- **Ergänzungen aus Plan-Review (verbindlich):** Helles Theme: Tokens `--danger-text`, `--warn-text`, `--ok-text` in beiden Themes; die harten Farben (#ff9a9d, #f2c66d, #8fd9a8) in `web/src/pages/cards.css`, `web/src/editor/editor.css`, `web/src/account/account.css` durch diese Tokens ersetzen; `color-scheme` je Theme. `User.labels` optional (`labels?: string[]`), `getCurrentUser` setzt `labels ?? []`, `isAdmin` nutzt `?? []`; betroffene Tests anpassen (auth.test.ts `toEqual`, cards-page.test.ts erwartet `navigate('/gestalten?karte=a')`). `cards-page.ts` „Zum Editor“-Link → `/gestalten`. Marken-Link bleibt statisch in `index.html` (header.ts ergänzt nur Navigation/Suche/Knöpfe drumherum); statischer `<title>` wird „AirCard-IOS – Kartendesigns für Apple Wallet“, `branding.test.ts` entsprechend anpassen. Platzhalter-Signaturen: `mountHomePage(root)`, `mountCommunityPage(root, id)`.
- [ ] **Step 1: Tests schreiben** – router: alle Routen inkl. `/community/abc123` → `{id:'community', params:{id:'abc123'}}`, `/community/` → home, unbekannt → home; `legacyRedirect(new URL('https://x/?karte=K1'))` → `/gestalten?karte=K1`, ohne `karte` → null; `navigate('/karten')` ruft `scrollTo(0,0)`; Titel je Route. theme: `initialTheme(null,true)` → dark, `('light',true)` → light, ungültiger Wert → System; `toggleTheme` setzt `data-theme` und speichert; `localStorage` wirft → kein Fehler. donate-banner: leere URL → nichts gerendert; Schließen setzt Flag und entfernt; Flag gesetzt → nichts. header: Suche Enter mit „ visa “ → `navigate('/?q=visa#community')`; leere Eingabe → nichts; kein ☕-Knopf bei leerem `donateUrl`. auth: `getCurrentUser` übernimmt `labels` (fehlend → `[]`), `isAdmin` true nur mit `'admin'`.
- [ ] **Step 2: Tests laufen lassen** → FAIL.
- [ ] **Step 3: Implementieren**; `main.ts`: Legacy-Umleitung vor `startRouter` (replaceState), Routen auf die Mount-Funktionen; Editor jetzt unter `gestalten`; `document.title` per `titleFor`.
- [ ] **Step 4: Prüfen** – Suite grün, `tsc`, `build`.
- [ ] **Step 5: Commit** – `feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner`
### Task 2: Schema-Erweiterung (Setup-Skript)
**Files:**
- Modify: `web/scripts/appwrite-setup.mjs`, `web/scripts/appwrite-setup.test.mjs`, `docs/appwrite-console.md`
**Interfaces:**
- `DESIRED` bekommt: Tabelle `gallery` (rowSecurity true, Tabellenrechte `[]`), Spalten gemäß Spec §7 (Typen string/boolean/integer, Standardwerte `hidden=false`, `reportCount=0`), Indizes `search_text` (fulltext, `[searchText]`), `owner` (key, `[ownerId]`), `hidden` (key, `[hidden]`); Tabelle `reports` (rowSecurity false, Tabellenrechte `['read("label:admin")']`), Spalten `galleryId` string 36 Pflicht, `reason` enum `[unangemessen, kartendaten, urheberrecht, spam, sonstiges]` Pflicht, `reporterHash` string 64 Pflicht, Index `gallery` (key, `[galleryId]`); Bucket `gallery-images` (Rechte `['create("users")']`, fileSecurity true, maximumFileSize 5242880, Endungen `[png, webp]`, compression none, antivirus false).
- REST: `POST …/columns/boolean {key, required:false, default:false}`, `…/columns/integer {key, required:false, default:0, min:0}`, `…/columns/enum {key, elements, required:true}`, Indizes `POST /tablesdb/{db}/tables/{t}/indexes {key, type: 'fulltext'|'key', columns}` erst nachdem alle Spalten `available`; Index-Status ebenfalls auf `available` warten. Vergleich (`--check`) deckt Spaltentyp, Standard, Enum-Elemente, Index-Typ/Spalten ab.
- `docs/appwrite-console.md`: Abschnitt „Community“: JWT muss **an** bleiben; Label `admin` setzen; Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) + `read -s`-Befehl für `AIRCARD_API_KEY`; Hinweis, dass der Provisioning-Key für Indizes zusätzlich `indexes.read/write` braucht.
- **Ergänzungen aus Plan-Review (verbindlich):** `DESIRED` umbauen auf `tables: [...]` (cards zuerst, dann gallery, reports) und `buckets: [...]` (card-images, gallery-images); `ensureTable/ensureColumns/waitAvailable/ensureBucket` parametrisieren, betroffene Tests umschreiben. `compareColumn` je Typ: string → size/required; boolean → required/default; integer → required/default (min/max der Antwort NICHT vergleichen); enum → elements (sortiert)/required. Index-Routen brauchen nur `tables.*`-Scopes (kein `indexes`-Scope) – in den Docs nicht verlangen. Docs: „JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten.“
- [ ] **Step 1: Tests** – Fake-Server um boolean/integer/enum/index-Routen erweitern; leerer Zustand → `--check` meldet alle neuen Objekte als FEHLT; Anlegen → alles da, Indizes erst nach `available`-Spalten; zweiter Lauf legt nichts an; abweichender Index-Typ → ABWEICHUNG.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4:** `node --test web/scripts/appwrite-setup.test.mjs` grün, **Step 5: Commit** `feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder`.
### Task 3: API-Dienst `aircard-api`
**Files:**
- Create: `deploy/api/appwrite.js` (REST-Helfer mit Server-Key), `deploy/api/app.js` (Routen, Validierung, Logik), `deploy/api/server.js` (Start: Env lesen, `http.createServer`), `deploy/api/app.test.js`, `deploy/api/README.md`
- Modify: `deploy/compose-snippet.yml` (Service `aircard-api`), `deploy/README.md`
**Interfaces:**
- `appwrite.js`: `createAppwrite({ endpoint, project, key, fetchImpl })` → `{ getRow, listRows(table, queries), createRow, updateRow(table, id, data, permissions?), deleteRow, getFile(bucket, id), updateFilePermissions(bucket, id, permissions), deleteFile, accountFromJwt(jwt) }` (letzteres `GET /account` mit `X-Appwrite-JWT`, ohne Key; der Client schickt das JWT im Header `X-Aircard-Jwt`). Queries als Appwrite-JSON-Strings (`{"method":"equal","attribute":"…","values":[…]}`).
- `app.js`: `createApp({ appwrite, salt, now = Date.now, log })` → `(req, res) => Promise<void>`; reine Helfer exportiert: `visibleRowPermissions()`, `hiddenRowPermissions(owner)`, `visibleFilePermissions(owner)`, `hiddenFilePermissions(owner)`, `reporterHash(salt, ip)` (HMAC-SHA256 hex), `clientIp(req)` (`x-real-ip`, sonst erstes `x-forwarded-for`, sonst Socket), `validatePublish(body)` → `{ok, data|message}` (Längen Spec §6, Tags normalisiert: trim, klein, leer raus, max. 5, je ≤ 20), `buildSearchText({title, issuer, tags})` (klein, ≤ 300).
- Endpunkte, Statuscodes und Texte exakt Spec §8/§11: `POST /api/veroeffentlichen` (401 ohne/ungültiges JWT, 400 Validierung, 403 Datei nicht meine/falscher Bucket, 409 „Diese Karte ist schon veröffentlicht.“, 201 `{id}`), `POST /api/zurueckziehen` (403 fremd, 404 fehlt, 200), `POST /api/melden` (400 Grund, 404 fehlt/ausgeblendet, 200 `{ok:true}` bzw. `{schonGemeldet:true}`, 429 „Zu viele Meldungen – bitte später nochmal.“), `POST /api/admin/sichtbarkeit` (403 kein Admin, 200), `GET /api/gesundheit`. Body max. 8 KB (413), nur `application/json` (415), sonst 404.
- `server.js`: Env `AIRCARD_API_PORT` (8088), `AIRCARD_APPWRITE_ENDPOINT` (`http://webklar-appwrite/v1`), `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY`, `AIRCARD_API_SALT`; fehlt Key oder Salt → Fehlermeldung ohne Wert, `exit(1)`.
- Compose-Service: `aircard-api` = `node:22-alpine`, `container_name webklar-aircard-api`, `init: true`, `mem_limit 128m`, `restart unless-stopped`, Volume `./aircard-ios.org/api:/srv:ro`, Command `node /srv/server.js`, Env wie oben aus `${…}`, Netz `webklar-network`, Labels Router `aircard-api` ``Host(`aircard-ios.org`) && PathPrefix(`/api`)`` websecure/letsencrypt Priorität 200, Port 8088.
- **Ergänzungen aus Plan-Review (verbindlich):** (a) Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen, sonst 400; in `appwrite.js` jedes Pfadsegment `encodeURIComponent`; Tests mit `../x`, `..`, Zahl statt String. (b) Deterministische Zeilen-IDs: Galerie `rowId` = erste 32 Hex von `sha256(ownerId + '|' + sourceCardId)` → Appwrite-409 (doppelt) → API 409 „Diese Karte ist schon veröffentlicht.“; Meldung `rowId` = erste 32 Hex von `sha256(galleryId + '|' + reporterHash)` → 409 → `schonGemeldet`; `reportCount` = Anzahl Meldungszeilen der Karte (limit 100). (c) Dateien: fehlt → 400 „Bild nicht gefunden.“; anderer Bucket oder fremd → 403; `mimeType` ∈ {image/png, image/webp} und `sizeOriginal` ≤ 5 MiB für beide (Vorschau darf PNG sein). (d) Body: `tags` ist `string[]` (Server normalisiert erneut, > 5 oder > 20 Zeichen → 400); Admin-Body `{id, sichtbar}`. (e) IPv6-Adressen vor dem Hashen auf /64 kürzen; abgelaufene Rate-Limit-Einträge entfernen; Appwrite-Aufrufe mit `AbortSignal.timeout(10000)`. (f) Traefik-Router heißt `aircard-gallery-api`; Kommentare „Starten NUR mit …“ in compose-snippet.yml/README um `aircard-api` ergänzen.
- [ ] **Step 1: Tests** (`node:test`, Fake-Appwrite im Speicher, echte HTTP-Requests auf Port 0): Veröffentlichen ok (Zeile mit sichtbaren Rechten, `searchText`, `ownerId`); ohne JWT 401; fremde Datei (Rechte ohne `delete("user:me")`) 403; Datei in falschem Bucket 403; doppelt 409; Validierung (Titel leer/zu lang, 6 Tags) 400. Melden: 3 verschiedene IPs → `hidden=true`, Zeilen- und Dateirechte = ausgeblendet; dieselbe IP 5× → `reportCount` 1, Antworten `schonGemeldet`; 11. Meldung derselben IP in 1 h → 429, nach `now`+1 h wieder erlaubt; ausgeblendete Karte melden → 404. Zurückziehen: Autor ok (Zeile, 2 Dateien, Meldungen weg), Admin ok, fremd 403. Admin: Nicht-Admin 403; Einblenden setzt sichtbare Rechte, `hidden=false`, `reportCount=0`, löscht Meldungen. Body > 8 KB → 413; Log enthält nie Key/Salt/JWT.
- [ ] **Step 2: FAIL** (`node --test "deploy/api/*.test.js"`), **Step 3: Implementieren**, **Step 4: PASS**, **Step 5: Commit** `feat(api): aircard-api für Veröffentlichen, Melden und Admin`.
### Task 4: Client-Datenschicht Community
**Files:**
- Create: `web/src/community/gallery.ts`, `web/src/community/api.ts`, `web/src/community/publish.ts`
- Modify: `web/src/account/appwrite.ts` (`IDS.gallery='gallery'`, `IDS.reports='reports'`, `IDS.galleryBucket='gallery-images'`), `web/src/account/cards.ts` (`fetchFile(bucketId, fileId)` verallgemeinern, bestehende Aufrufer anpassen)
- Test: `web/src/community/gallery.test.ts`, `api.test.ts`, `publish.test.ts`
**Interfaces:**
- `gallery.ts`: `interface GalleryCard { id; title; issuer; tags: string[]; displayName; imageFileId; previewFileId; sourceCardId; ownerId; hidden: boolean; reportCount: number; createdAt: string }`; `toGalleryCard(row)`; `galleryQueries(opts: { q?: string; cursor?: string; filter?: 'all'|'reported'|'hidden' }): string[]` (öffentlich immer `equal('hidden', false)`, `orderDesc('$createdAt')`, `limit(24)`, Suche `search('searchText', q)`, Admin-Filter laut Spec §7); `listGallery(opts)` → `{ cards, nextCursor | null }`; `getGalleryCard(id)` → `GalleryCard | null` (404 → null); `myPublications(userId)` → `Map<sourceCardId, GalleryCard>`; `listReports(galleryId)` (Admin); `loadGalleryImage(card, 'full'|'preview')` → Blob (über `fetchFile`).
- `api.ts`: `apiPost(path, body, { auth: boolean })` → JSON; mit `auth` holt `account.createJWT()` (Cache 10 min); Fehler als `ApiError { status, message }` mit Server-Text; Netzfehler → „Der Dienst ist gerade nicht erreichbar.“; `publishCard(input)`, `withdrawCard(id)`, `reportCard(id, reason)`, `setVisibility(id, visible)`.
- `publish.ts`: `publishSavedCard(card: SavedCard, form: { title; issuer; tags; displayName }, deps)` → `{ id }`: Bild laden, rendern (PNG 1536 + WebP 480 via `exportCard`), zwei Uploads in `gallery-images` mit `[Permission.read(Role.any()), Permission.delete(Role.user(uid))]`, dann `publishCard`; bei Fehler beide Dateien löschen (best effort) und Fehler weiterwerfen. `normalizeTags(input: string): string[]` (wie Server).
- **Ergänzungen aus Plan-Review (verbindlich):** JWT-Cache je Nutzer-ID, geleert bei `onAuthChange`/`onLogout`; API-401 → neues JWT holen, genau einmal wiederholen; kein `duration`. `ApiError` hat numerisches `code` (= HTTP-Status) und `message` (Servertext), damit `isUnauthorized`/`withLogin`/`errorMessage` greifen; Nicht-JSON, 502/503/504 oder HTML-200 → `code 503` „Der Dienst ist gerade nicht erreichbar.“; `createJWT`-Fehler `user_auth_method_unsupported` → „Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.“ In `cards.ts` eine eigenständige exportierte `fetchFile(bucketId, fileId)`; `CardsApi.fetchFile(fileId)` bleibt als Hülle über `IDS.bucket` (bestehende Tests unverändert). Tags an die API als `string[]`. PNG > 5 MiB vor dem Upload → „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“; Vorschau-Datei nach echtem Blob-Typ benennen. `myPublications`/`listReports` mit `limit(100)`. `nextCursor` = `cards.length === 24 ? letzte id : null`. Admin-Filter „Gemeldet“ = `greaterThan('reportCount', 0)` (ohne hidden-Bedingung). `getGalleryCard` mit ungültiger ID (Muster wie API) → `null` ohne Anfrage.
- [ ] **Step 1: Tests** – Queries exakt (inkl. Suche + Cursor + Admin-Filter); `toGalleryCard` mit fehlenden Feldern; `getGalleryCard` 404 → null; `apiPost` mit `auth` sendet Header `X-Aircard-Jwt: <jwt>`; JWT-Cache (zweiter Aufruf innerhalb 10 min ohne neues `createJWT`); Fehlertext aus JSON `message`; Netzfehler-Text. `publishSavedCard`: Reihenfolge Upload, Upload, API; API-Fehler → 2× `deleteFile`, Fehler kommt an; zweiter Upload scheitert → erster gelöscht.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4: PASS + tsc + build**, **Step 5: Commit** `feat(community): Datenschicht für Galerie, API und Veröffentlichen`.
### Task 5: Startseite (Hero + Galerie)
**Files:**
- Modify: `web/src/pages/home-page.ts` (Platzhalter aus Task 1 ersetzen)
- Create: `web/src/community/gallery-grid.ts`, `web/src/community/community.css`, `web/public/hero-beispiel.png` (1536 × 969, eigenes markenfreies Motiv: lila/blauer Verlauf mit abstrakten Wellen + AirCard-IOS-Symbol, erzeugt mit einem Skript im Scratchpad)
- Test: `web/src/pages/home-page.test.ts`, `web/src/community/gallery-grid.test.ts`
**Interfaces:**
- `mountHomePage(root, deps = defaultDeps)` – Hero laut Spec §4 (Texte exakt), Hero-Karte = Vollbild der neuesten sichtbaren Karte oder `/hero-beispiel.png`; Galerie-Abschnitt `id="community"`; liest `?q=` aus der URL; „Mehr laden“ hängt an; Leer-/Kein-Treffer-Texte exakt; Admin sieht Filter-Chips „Alle/Gemeldet/Ausgeblendet“.
- `mountGalleryGrid(el, { load, onTag })` – Kacheln (Vorschau per Object-URL, beim Cleanup freigeben), Titel, Herausgeber, ≤ 3 Tags als Knöpfe; Kachel verlinkt `/community/<id>` (`data-link`).
- CSS: Hero `min-height: auto; max-height: 60vh`, Raster `repeat(auto-fill, minmax(260px, 1fr))`; Hero-Karte schwebt (`@media (prefers-reduced-motion: no-preference)`).
- **Ergänzungen aus Plan-Review (verbindlich):** Tag-Klick sucht nicht per Volltext (min. 3 Zeichen, Stoppwörter), sondern navigiert zu `/?tag=<tag>#community` → Query `contains('tags', tag)`; Suchfeld nutzt Volltext ab 3 Zeichen, kürzere Begriffe → `contains('searchText', q)`. Mehrwort-Suche = ODER (akzeptiert).
- [ ] **Step 1: Tests** – leere Galerie → Beispielbild + Leertext; Suche aus `?q=` landet in Queries und zeigt aktiven Begriff mit „ד; „Mehr laden“ nur bei `nextCursor`; Tag-Klick navigiert zu `/?q=<tag>#community`; Object-URLs werden beim Cleanup freigegeben.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren** (inkl. Hero-Bild), **Step 4: PASS + build**; Sichtprüfung headless Chromium (`npx vite preview`) bei 1440 × 900 und 390 × 844 mit gemockter Galerie (Playwright `page.route` beantwortet `/v1/tablesdb/…/rows` mit 30 Fake-Zeilen und `/v1/storage/…/view` mit einem PNG) – Überschrift „Community-Karten“ und Oberkante der ersten Reihe im Viewport; Screenshots im Scratchpad. **Step 5: Commit** `feat(home): kompakter Hero und Community-Galerie`.
### Task 6: Detailseite `/community/<id>` und „Als Vorlage“
**Files:**
- Modify: `web/src/pages/community-page.ts` (Platzhalter aus Task 1 ersetzen), `web/src/editor/editor-page.ts` (`?vorlage=<id>`)
- Create: `web/src/community/report-dialog.ts`
- Test: `web/src/pages/community-page.test.ts`, `web/src/community/report-dialog.test.ts`, Ergänzung `web/src/editor/editor-page.test.ts`
**Interfaces:**
- Detailseite laut Spec §5 (Texte exakt): Herunterladen (Blob → `aircard-<slug>.png`), „Als Vorlage gestalten“ → `navigate('/gestalten?vorlage=<id>')`, Melden-Dialog (Gründe exakt, Ergebnis-Texte), Autor „Zurückziehen“ (Bestätigung via `confirmDialog`, danach `navigate('/')`), Admin „Ausblenden/Einblenden“, „Löschen“, „<n> Meldungen“ + Gründe (`listReports`). `document.title` = `titleFor(route, card.title)`.
- Editor `?vorlage=<id>`: Galerie-Vollbild laden → `setImage`, Vorlage `image`, kein `saved`; ungespeicherte Arbeit → bestehende Rückfrage; Fehler → Hinweis „Diese Vorlage ist nicht (mehr) verfügbar.“
- **Ergänzungen aus Plan-Review (verbindlich):** Nach dem `?vorlage=`-Import `history.replaceState` auf `/gestalten` (kein erneuter Import bei Zurück) und `saved`, `savedConfig`, `cardName` ausdrücklich zurücksetzen (sonst überschreibt Speichern eine zuvor geöffnete Karte).
- [ ] **Step 1: Tests** – nicht gefunden/ausgeblendet für Gast → Text + kein Bild; Autor sieht Zurückziehen, Fremder nicht, Admin sieht Admin-Knöpfe + Meldungszahl; Melden: doppelt → „Danke, du hast diese Karte schon gemeldet.“, 429 → Rate-Text; Vorlage setzt Template `image` und lässt `saved` null.
- [ ] **Step 2–5** wie üblich; Commit `feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage`.
### Task 7: Veröffentlichen aus „Meine Karten“
**Files:**
- Create: `web/src/community/publish-dialog.ts`
- Modify: `web/src/pages/cards-page.ts`, `web/src/pages/cards.css`
- Test: `web/src/community/publish-dialog.test.ts`, Ergänzung `web/src/pages/cards-page.test.ts`
**Interfaces:**
- Dialog laut Spec §6 (Felder, Grenzen, Pflicht-Checkbox-Text exakt), Absenden über `publishSavedCard`; Kachel-Status aus `myPublications(uid)`: „Veröffentlicht“ + „Ansehen“ (`/community/<id>`) + „Zurückziehen“ bzw. „Ausgeblendet (gemeldet)“; nicht veröffentlicht → „Veröffentlichen“. Nicht angemeldet → bestehendes `withLogin`.
- **Ergänzungen aus Plan-Review (verbindlich):** Ausgeblendete Kacheln behalten „Zurückziehen“ (Autor darf aufräumen und neu veröffentlichen).
- [ ] **Step 1: Tests** – Validierung im Dialog (Titel leer, > 60, 6 Tags, Checkbox fehlt); Erfolg → Badge + Ansehen-Link; 409 → Text; Zurückziehen → Status zurück.
- [ ] **Step 2–5**; Commit `feat(community): Veröffentlichen aus Meine Karten`.
### Task 8: Betrieb (Controller) und Abnahme
- **Ergänzungen aus Plan-Review (verbindlich):** Kenso prüft, dass JWT in den Auth-Methoden **an** ist (Live-Check: Veröffentlichen klappt). Kein `indexes`-Scope nötig. Nach erfolgreichem Schema-Lauf den alten, im Chat geposteten Provisioning-Key löschen lassen. Spoofing-Test: zweimal `/api/melden` über die öffentliche URL mit unterschiedlichen gefälschten `X-Real-Ip` → zweite Antwort `schonGemeldet` (Traefik überschreibt den Header).
- [ ] Kenso: Label `admin`, Key `aircard-api` per `read -s` als `AIRCARD_API_KEY` in `.env` (Controller liefert den Befehl); Provisioning-Key um `indexes.read/write` ergänzen oder Controller legt Indizes nach Kensos OK an.
- [ ] Controller: `AIRCARD_API_SALT` (32 Byte hex) in `.env` (Backup), Setup-Skript `--check` → anlegen → `--check` OK; `/home/webklar/apps/aircard-ios.org/api/` mit `deploy/api/*.js` füllen (`cp`); Compose-Backup, Service einfügen, `docker compose config -q`, `docker compose up -d aircard-api`; `curl https://aircard-ios.org/api/gesundheit` → `{"ok":true}`; Push → Webhook-Build.
- [ ] Live-Abnahme (Claude in Chrome, Kensos Sitzung): Startseite 1440 × 900 Karten angeschnitten; Hell/Dunkel; Veröffentlichen einer Testkarte; Suche nach Tag; Detailseite, Herunterladen, Als Vorlage; Melden (3 verschiedene `X-Real-Ip` per `docker exec webklar-aircard-api` lokal gegen `127.0.0.1:8088`) → ausgeblendet, Gast sieht „nicht (mehr) verfügbar“; Admin Einblenden; Zurückziehen.
- [ ] Final-Review (Workflow, 3 Linsen) → eine Fix-Welle → Push; Memory aktualisieren; Bericht an Kenso.
@@ -0,0 +1,131 @@
# AirCard-IOS – Startseite & Community-Galerie – Design
Stand: 2026-10-04 · Status: zur Freigabe durch Kenso · baut auf `2026-10-04-aircard-website-design.md` auf (live, main 562fac0)
## 1. Ziel und Rahmen
Die Website bekommt eine Startseite im Stil von Kensos Vorbild-Screenshot (bestehende „AirCards“-Seite), aber im eigenen AirCard-IOS-Look:
kompakter Hero, darunter sofort die **Community-Galerie**, Spenden-Banner und -Knopf, Hell/Dunkel-Schalter.
Nutzer können ihre gespeicherten Karten **sofort öffentlich veröffentlichen**; Besucher können melden; ab 3 Meldungen wird eine Karte automatisch ausgeblendet; Kenso moderiert als Admin.
| Punkt | Entscheidung (Kenso) |
|---|---|
| Umfang | Startseite + Community-Galerie; keine „Offiziellen Karten“ (echte Bank-Kartenmotive), keine Texte/Bilder vom Vorbild |
| Galerie-Inhalt | nur Community-Karten |
| Freigabe | sofort öffentlich + „Melden“; Auto-Ausblenden ab 3 verschiedenen Meldern |
| Extras | nur Hell/Dunkel-Schalter (kein Englisch, keine Anleitung-Seite, keine Highlights) |
| Hero | **nicht** Vollbild: erste Reihe Community-Karten muss im ersten Bildschirm anschneiden |
| Spenden | Buy Me a Coffee; öffentlicher Link kommt noch (Konfig-Wert) |
| Technik | Ansatz A: Appwrite + kleiner eigener API-Dienst `aircard-api` |
## 2. Seiten und Routen
| Route | Inhalt |
|---|---|
| `/` | Startseite: Hero + Community-Galerie (Suche über `?q=`, Anker `#community`) |
| `/gestalten` | der bisherige Editor (`?karte=<id>` öffnet eine gespeicherte Karte, `?vorlage=<galleryId>` übernimmt ein Community-Bild als „Nur Bild“) |
| `/karten` | Meine Karten (+ Veröffentlichen / Veröffentlicht-Status / Zurückziehen) |
| `/community/<id>` | Detailansicht einer Community-Karte |
| `/passwort-neu` | unverändert |
Alte Links `/?karte=<id>` leiten clientseitig auf `/gestalten?karte=<id>` um. `document.title` je Route („AirCard-IOS – Kartendesigns für Apple Wallet“, „Gestalten – AirCard-IOS“, „Meine Karten – AirCard-IOS“, „<Titel> – AirCard-IOS“).
## 3. Kopfzeile, Banner, Theme
- **Spenden-Banner** (ganz oben, schließbar, Zustand in `localStorage` `aircard.donateBannerClosed`): „☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.“ + Link „Unterstützen →“. Ziel = `DONATE_URL` aus `web/src/config.ts`. **Ist `DONATE_URL` leer, werden Banner und Spenden-Knopf nicht angezeigt** (bis Kenso den öffentlichen Link `buymeacoffee.com/<name>` liefert).
- **Kopfzeile**: Logo + „AirCard-IOS“ (→ `/`), Navigation „Community“ (→ `/#community`), „Gestalten“, „Meine Karten“; Suchfeld „Titel, Tags oder Herausgeber“ (Enter → `/?q=…#community`); Knopf „☕ Unterstützen“ (wenn `DONATE_URL`); Hell/Dunkel-Schalter (Sonne/Mond-Icon); Konto: abgemeldet „Anmelden“, angemeldet Avatar-Kreis mit Initiale (Menü: E-Mail, „Meine Karten“, „Abmelden“).
- Unter 900 px: Navigation in ein Menü-Icon, Suchfeld als eigene Zeile unter der Kopfzeile.
- **Theme**: `data-theme="light|dark"` auf `<html>`; Standard = System (`prefers-color-scheme`); Schalter speichert in `localStorage` `aircard.theme`. Helle Tokens: `--bg #F4F6FB; --panel #FFFFFF; --panel-2 #EEF1F7; --line #D9DFEA; --line-strong #B8C2D3; --sel-bg #E3ECFB; --sel-line #3A6FD8; --text #0F1828; --muted #5B6B82; --primary #2A4566; --primary-hover #34557C`. Die Kartenvorschau selbst bleibt unverändert (Kartenfarben hängen nicht vom Theme ab).
## 4. Startseite
- **Hero (kompakt)**: links Zeile „Ein bisschen Kunst für dein Wallet“, Überschrift „Kartendesigns für Apple Wallet“, ein Satz „Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.“, Knöpfe **„Karten entdecken ↓“** (scrollt zu `#community`) und **„Gestalten“**. Rechts eine große Karte (neueste sichtbare Community-Karte; ist die Galerie leer: eigenes Beispielmotiv `web/public/hero-beispiel.png`, markenfrei, von Claude gestaltet) mit dezenter Schwebe-Animation (`prefers-reduced-motion` respektieren).
- **Höhe**: Hero höchstens 60 vh (Desktop) bzw. so, dass bei 1440 × 900 und 390 × 844 die Überschrift „Community-Karten“ **und** der obere Rand der ersten Kartenreihe im ersten Bildschirm sichtbar sind (Abnahmekriterium).
- **Community-Galerie** (`#community`): Überschrift „Community-Karten“, Untertitel „Entdecke Motive der Community – und veröffentliche deine eigenen.“, Knopf „Gestalten & veröffentlichen“. Suchzeile mit aktivem Suchbegriff + „ד. Raster aus Kacheln (Kartenformat 1536:969, abgerundet): Vorschau, Titel, Herausgeber, bis 3 Tag-Chips (Klick = Suche). Sortierung neueste zuerst, 24 pro Seite, „Mehr laden“ (Cursor). Leer: „Noch keine Community-Karten – sei die erste!“ + „Gestalten“. Keine Treffer: „Nichts gefunden für „…““.
## 5. Detailseite `/community/<id>`
Große Karte, Titel, Herausgeber, Tags, Datum (`dd.mm.yyyy`), Anzeigename („von <Name>“ bzw. „von Anonym“). Knöpfe: **Herunterladen** (PNG 1536 × 969, Dateiname `aircard-<titel-slug>.png`), **Als Vorlage gestalten** (→ `/gestalten?vorlage=<id>`), **Melden** (Dialog mit Grund: „Unangemessen“, „Echte Kartendaten“, „Urheberrecht/Marke“, „Spam“, „Sonstiges“).
Autor (angemeldet, `ownerId` = ich): zusätzlich **Zurückziehen**. Admin (Label `admin`): **Ausblenden/Einblenden**, **Löschen**, Anzeige „<n> Meldungen“ und Liste der Gründe.
Nicht vorhanden/ausgeblendet für mich: „Diese Karte ist nicht (mehr) verfügbar.“ + Link zur Galerie.
## 6. Veröffentlichen (aus „Meine Karten“)
Kachel-Aktion **„Veröffentlichen“** → Dialog: Titel (Pflicht, ≤ 60), Herausgeber/Bank (optional, ≤ 40), Tags (optional, bis 5, je ≤ 20, Komma-getrennt, kleingeschrieben gespeichert), Anzeigename (optional, ≤ 30, Standard leer → „Anonym“; **nie** die E-Mail), Pflicht-Checkbox „Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.“
Ablauf: Karte rendern (PNG 1536 × 969 + WebP-Vorschau 480) → beide Dateien in Bucket `gallery-images` hochladen (Rechte `read("any")`, `delete("user:<ich>")`) → `POST /api/veroeffentlichen` → Zeile entsteht. Scheitert die API, löscht der Client die zwei Dateien wieder.
Danach zeigt die Kachel „Veröffentlicht“ + „Ansehen“ + „Zurückziehen“; ist die Karte ausgeblendet: „Ausgeblendet (gemeldet)“. Eine private Karte kann **einmal** veröffentlicht sein; „Erneut veröffentlichen“ = Zurückziehen + Veröffentlichen.
## 7. Daten (Appwrite, Projekt `6ac2130a00289343d6d5`, Setup-Skript erweitert)
**Tabelle `gallery`** (rowSecurity an, **keine** Tabellenrechte für Clients):
| Spalte | Typ | Inhalt |
|---|---|---|
| `title` | string 60, Pflicht | Titel |
| `issuer` | string 40 | Herausgeber/Bank |
| `tags` | string 120 | Tags, Komma-getrennt, klein |
| `displayName` | string 30 | Anzeigename oder leer |
| `imageFileId` | string 36, Pflicht | PNG im Bucket `gallery-images` |
| `previewFileId` | string 36, Pflicht | WebP-Vorschau |
| `sourceCardId` | string 36 | private Karte, aus der veröffentlicht wurde |
| `ownerId` | string 36, Pflicht | Nutzer-ID des Autors |
| `hidden` | boolean, Standard false | ausgeblendet |
| `reportCount` | integer, Standard 0 | Anzahl verschiedener Melder |
| `searchText` | string 300 | `title + issuer + tags`, klein; **Volltext-Index** |
Indizes: Volltext auf `searchText`; Key-Index auf `ownerId`; Key-Index auf `hidden`.
Zeilenrechte (setzt nur die API): sichtbar `["read(\"any\")"]`; ausgeblendet `["read(\"user:<owner>\")","read(\"label:admin\")"]`.
**Bucket `gallery-images`**: Bucket-Recht `create("users")`, fileSecurity an, max. 5 MiB, Endungen png/webp. Dateirechte beim Hochladen `read("any")`, `delete("user:<owner>")`; beim Ausblenden setzt die API `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`.
**Tabelle `reports`** (rowSecurity aus, Tabellenrecht `read("label:admin")`, Schreiben nur API): `galleryId` string 36, `reason` enum (unangemessen, kartendaten, urheberrecht, spam, sonstiges), `reporterHash` string 64 (HMAC-SHA256 der IP mit Server-Salz), Index auf `galleryId`.
**Lesen im Client**: Galerie `listRows(gallery, [equal('hidden', false), orderDesc('$createdAt'), limit(24), cursorAfter])`, Suche zusätzlich `search('searchText', q)`; eigene Veröffentlichungen `equal('ownerId', me)`; Admin-Filter „Gemeldet/Ausgeblendet“ `greaterThan('reportCount', 0)` bzw. `equal('hidden', true)`.
## 8. API-Dienst `aircard-api`
Node 22, ohne Abhängigkeiten, Container `webklar-aircard-api` (`node:22-alpine`, `init: true`, `mem_limit 128m`), Traefik-Router ``Host(`aircard-ios.org`) && PathPrefix(`/api`)`` Priorität 200, Port 8088. Spricht Appwrite intern (`http://webklar-appwrite/v1`, Header `X-Appwrite-Project`, `X-Appwrite-Key` = `AIRCARD_API_KEY`). Secrets aus `.env`: `AIRCARD_API_KEY` (neuer Key `aircard-api`, Scopes `rows.read/write`, `files.read/write`), `AIRCARD_API_SALT` (zufällig, 32 Byte hex). Antworten JSON, Fehlertexte deutsch.
| Endpunkt | Auth | Wirkung |
|---|---|---|
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | prüft Nutzer, Feldlängen, dass beide Dateien existieren, im Bucket `gallery-images` liegen und `delete("user:<ich>")` tragen, und dass für `sourceCardId` noch keine Veröffentlichung existiert → legt Zeile an (sichtbar) → `201 {id}` |
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor **oder** Admin) | löscht Zeile, beide Dateien und ihre Meldungen |
| `POST /api/melden` `{id, reason}` | keine | Rate-Limit 10/Stunde je IP (Speicher), ein Bericht je Melder-Hash und Karte (sonst `200 {schonGemeldet:true}`), speichert Meldung, setzt `reportCount` = Anzahl verschiedener Melder; ab **3** → Ausblenden (Zeilen- und Dateirechte, `hidden=true`) |
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | Einblenden (öffentliche Rechte, `hidden=false`, `reportCount=0`, Meldungen löschen) oder Ausblenden |
| `GET /api/gesundheit` | keine | `{ok:true}` |
JWT-Prüfung: `GET /v1/account` mit `X-Appwrite-JWT` → Nutzer (`$id`, `labels`); Admin = `labels` enthält `admin`. Echte Client-IP aus `X-Real-Ip` (Traefik). Körper max. 8 KB. Alles andere 404.
**Wichtig**: JWT muss in den Auth-Methoden des Projekts **an** bleiben (die frühere Klickliste sagte „aus“ – wird korrigiert).
## 9. Admin (Kenso)
Kenso bekommt in der Appwrite-Console das Label `admin` (Auth → Users → Konto → Labels). Die Website erkennt es über `account.get().labels` und zeigt Admin-Knöpfe und den Galerie-Filter „Gemeldet“/„Ausgeblendet“.
## 10. Was Kenso selbst erledigt
1. Label `admin` am eigenen Konto setzen.
2. Neuen API-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) anlegen und mit dem verdeckten `read -s`-Befehl in `.env` eintragen (nicht im Chat).
3. Danach den alten, im Chat geposteten Key löschen (Setup-Skript nutzt ab dann `aircard-api` nicht – es bekommt bei Bedarf einen neuen Provisioning-Key).
4. Öffentlichen Buy-Me-a-Coffee-Link schicken.
## 11. Fehlerbehandlung
Veröffentlichen scheitert (Netz/API/Validierung) → hochgeladene Dateien löschen, Meldung im Dialog („Veröffentlichen fehlgeschlagen – …“). Doppelt veröffentlicht → „Diese Karte ist schon veröffentlicht.“ Melden doppelt → „Danke, du hast diese Karte schon gemeldet.“ Rate-Limit → „Zu viele Meldungen – bitte später nochmal.“ Nicht angemeldet bei Veröffentlichen → Login-Dialog (bestehendes `withLogin`). API nicht erreichbar → „Der Dienst ist gerade nicht erreichbar.“ Galerie-Ladefehler → Hinweis + „Nochmal versuchen“.
## 12. Tests
- Vitest: Router (neue Routen, Umleitung `/?karte=`), Theme (System/Speichern), Banner (ausblenden, leeres `DONATE_URL`), Galerie-Abfragen (Queries für Liste/Suche/Admin-Filter), Veröffentlichen-Ablauf mit Fake-API (Aufräumen bei Fehler), Such-Text-Bildung, Tags-Normalisierung.
- node:test für `aircard-api`: JWT-Prüfung (Fake-Appwrite), Validierung, Datei-Eigentum, doppelte Veröffentlichung, Melden (Dedup, Rate-Limit, Auto-Ausblenden bei 3, Rechte-Strings), Admin ein/aus, Zurückziehen (Autor/Admin/fremd → 403).
- Setup-Skript: `--check` deckt `gallery`, `reports`, `gallery-images` inkl. Indizes ab.
- Live: Veröffentlichen, Suche, Detailseite, Herunterladen, Als Vorlage, Melden aus 3 verschiedenen Netzen/Fake-IPs (über die API mit `X-Real-Ip`-Test im Container), Admin aus-/einblenden, Startseite bei 1440 × 900 und 390 × 844 (erste Kartenreihe angeschnitten), Hell/Dunkel.
## 13. Nicht im Umfang
Offizielle/Bank-Karten, Likes/„Beliebt“-Sortierung, Kommentare, Englisch, Anleitung-Seite, Highlights, öffentliche Profilseiten, Bearbeiten veröffentlichter Karten (nur zurückziehen + neu), E-Mail-Benachrichtigung an Kenso bei Meldungen.
## 14. Risiken
- **Öffentliche Inhalte ohne Vorab-Freigabe**: Kenso haftet ab Kenntnis; Melden + Auto-Ausblenden + Admin-Löschen mildern. Ohne Kontaktadresse/Impressum fehlt ein Weg für Beschwerden von außen (Kenso-Entscheidung, hier vermerkt).
- **Marken/Urheberrecht**: Nutzer können Bank-Kartenmotive hochladen; Pflicht-Checkbox + Melden-Grund „Urheberrecht/Marke“.
- **Speicher**: keine Kontingente je Nutzer; Bucket 5 MiB je Datei. Missbrauch → Admin löscht; Auth-Limits in der Console (Kensos Schritt 3) empfohlen.
- **Rate-Limit im Speicher** geht bei Neustart verloren (Dedup über `reports` bleibt).
+15 -4
View File
@@ -3,19 +3,30 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AirCard-IOS – Kartenmotiv gestalten</title>
<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>
<meta name="theme-color" content="#0F1828" />
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
<script>
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
try {
var theme = null;
try {
theme = localStorage.getItem('aircard.theme');
} catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
</script>
</head>
<body>
<div id="donate-banner"></div>
<header id="app-header">
<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
<nav class="app-nav" aria-label="Hauptnavigation">
<a href="/karten" data-link>Meine Karten</a>
</nav>
<div id="account-slot"></div>
</header>
<main id="app-root"></main>
Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

+210 -68
View File
@@ -2,8 +2,9 @@
/**
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an.
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und
* Indizes) und die Buckets `card-images` und `gallery-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
*
* Aufruf (vom Repo-Wurzelverzeichnis):
@@ -13,8 +14,9 @@
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
*
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden
* nur verglichen – Abweichungen werden gemeldet, nie geändert.
* Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten
* der Tabelle `available` sind). Bestehende Objekte werden nur verglichen –
* Abweichungen werden gemeldet, nie geändert.
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
*
* Ausgabe: eine Zeile je Objekt –
@@ -34,30 +36,81 @@ export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
// Soll-Zustand
// ---------------------------------------------------------------------------
const str = (key, size, required) => ({ key, type: 'string', size, required });
export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' },
table: {
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [
{ key: 'name', size: 80, required: true },
{ key: 'config', size: 2000, required: true },
{ key: 'imageFileId', size: 36, required: true },
{ key: 'previewFileId', size: 36, required: true },
],
},
bucket: {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
// Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes).
tables: [
{
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)],
indexes: [],
},
{
// Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene.
id: 'gallery',
name: 'Gallery',
permissions: [],
rowSecurity: true,
columns: [
str('title', 60, true),
str('issuer', 40, false),
str('tags', 120, false),
str('displayName', 30, false),
str('imageFileId', 36, true),
str('previewFileId', 36, true),
str('sourceCardId', 36, false),
str('ownerId', 36, true),
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
str('searchText', 300, false),
],
indexes: [
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
id: 'reports',
name: 'Reports',
permissions: ['read("label:admin")'],
rowSecurity: false,
columns: [
str('galleryId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
],
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
},
],
buckets: [
{
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
{
id: 'gallery-images',
name: 'Gallery Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'webp'],
compression: 'none',
antivirus: false,
},
],
};
// ---------------------------------------------------------------------------
@@ -94,11 +147,35 @@ export function compareTable(actual, desired) {
return out;
}
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */
/**
* Vergleich einer Spalte, je nach Typ:
* string → type, size, required
* boolean → type, required, default
* integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen)
* enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`)
* Außerdem immer `status` (erwartet `available`).
*/
export function compareColumn(actual, desired) {
const out = [];
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) {
out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements)));
}
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) {
out.push(mismatch('default', actual.default, desired.default));
}
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
/** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */
export function compareIndex(actual, desired) {
const out = [];
const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []);
if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns));
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
@@ -197,7 +274,9 @@ async function createObject(ctx, path, body, what) {
// ---------------------------------------------------------------------------
const DB_ID = DESIRED.database.id;
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`;
const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`;
const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`;
const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`;
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
export async function ensureDatabase(ctx) {
@@ -217,13 +296,12 @@ export async function ensureDatabase(ctx) {
}
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
export async function ensureTable(ctx, parentExists) {
const t = DESIRED.table;
const label = `Tabelle ${t.id}`;
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
export async function ensureTable(ctx, table, parentExists) {
const label = `Tabelle ${table.id}`;
const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null;
if (actual) {
const deviations = compareTable(actual, t);
if (deviations.length) ctx.deviations(t.id, deviations);
const deviations = compareTable(actual, table);
if (deviations.length) ctx.deviations(table.id, deviations);
else ctx.ok(label);
return true;
}
@@ -234,31 +312,39 @@ export async function ensureTable(ctx, parentExists) {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables`,
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
{ tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity },
label,
);
ctx.created(label);
return true;
}
/** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */
const CHILD_KINDS = {
column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath },
index: { noun: 'Index', plural: 'Index(e)', path: indexPath },
};
/**
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column')
* bzw. Indizes (`kind` = 'index') der Tabelle `available` sind.
* Gibt eine Map Schlüssel → aktueller Zustand zurück.
*/
export async function waitAvailable(ctx, tableId, keys) {
export async function waitAvailable(ctx, tableId, keys, kind = 'column') {
const { noun, plural, path } = CHILD_KINDS[kind];
const deadline = Date.now() + ctx.timeoutMs;
const done = new Map();
let pending = [...keys];
for (;;) {
const still = [];
for (const key of pending) {
const label = `Spalte ${tableId}.${key}`;
const actual = await getObject(ctx, columnPath(key), label);
const label = `${noun} ${tableId}.${key}`;
const actual = await getObject(ctx, path(tableId, key), label);
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
if (actual.status === 'available') {
done.set(key, actual);
} else if (actual.status === 'failed' || actual.status === 'stuck') {
throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
} else {
still.push(key);
}
@@ -266,7 +352,7 @@ export async function waitAvailable(ctx, tableId, keys) {
if (!still.length) return done;
if (Date.now() >= deadline) {
throw new SetupError(
`Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
`${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
);
}
pending = still;
@@ -274,49 +360,102 @@ export async function waitAvailable(ctx, tableId, keys) {
}
}
/** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */
function columnRequest(table, col) {
const path = `${tablePath(table.id)}/columns/${col.type}`;
switch (col.type) {
case 'string':
return { path, body: { key: col.key, size: col.size, required: col.required } };
case 'boolean':
return { path, body: { key: col.key, required: col.required, default: col.default } };
case 'integer':
return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } };
case 'enum':
return { path, body: { key: col.key, elements: col.elements, required: col.required } };
default:
throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`);
}
}
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
* Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen,
* fehlende nacheinander anlegen, danach auf `available` warten (nur ohne
* --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück
* (`actual` ist dort gesetzt, wo das Objekt existiert).
* `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check.
*/
export async function ensureColumns(ctx, parentExists) {
const t = DESIRED.table;
async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) {
const { noun, path } = CHILD_KINDS[kind];
const entries = [];
for (const col of t.columns) {
const label = `Spalte ${t.id}.${col.key}`;
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
for (const item of items) {
const label = `${noun} ${table.id}.${item.key}`;
const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null;
if (actual) {
entries.push({ col, label, actual, created: false });
} else if (ctx.check) {
entries.push({ item, label, actual, created: false });
} else if (ctx.check || !canCreate) {
ctx.missing(label);
} else {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
{ key: col.key, size: col.size, required: col.required },
label,
);
const { path: createPath, body } = request(item);
await createObject(ctx, createPath, body, label);
ctx.created(label);
entries.push({ col, label, actual: null, created: true });
entries.push({ item, label, actual: null, created: true });
}
}
if (!ctx.check) {
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
if (waiting.length) {
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key));
for (const e of waiting) e.actual = ready.get(e.col.key);
const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind);
for (const e of waiting) e.actual = ready.get(e.item.key);
}
}
for (const e of entries) {
const deviations = compareColumn(e.actual, e.col);
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
const deviations = compare(e.actual, e.item);
if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations);
else if (!e.created) ctx.ok(e.label);
}
return entries;
}
export async function ensureBucket(ctx) {
const b = DESIRED.bucket;
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden).
* `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden
* und `available` sind (Voraussetzung für die Indizes).
*/
export async function ensureColumns(ctx, table, parentExists) {
const entries = await ensureChildren(ctx, table, parentExists, {
kind: 'column',
items: table.columns,
request: (col) => columnRequest(table, col),
compare: compareColumn,
});
return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available');
}
/**
* Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten
* `available` sind (`columnsReady`), sonst wird der Index nur als fehlend
* gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe).
*/
export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) {
const indexes = table.indexes ?? [];
if (!indexes.length) return;
await ensureChildren(ctx, table, parentExists, {
kind: 'index',
items: indexes,
request: (idx) => ({
path: `${tablePath(table.id)}/indexes`,
body: { key: idx.key, type: idx.type, columns: idx.columns },
}),
compare: compareIndex,
canCreate: columnsReady,
});
}
export async function ensureBucket(ctx, bucket) {
const b = bucket;
const label = `Bucket ${b.id}`;
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
if (actual) {
@@ -391,9 +530,12 @@ export async function main({
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
try {
const databaseExists = await ensureDatabase(ctx);
const tableExists = await ensureTable(ctx, databaseExists);
await ensureColumns(ctx, tableExists);
await ensureBucket(ctx);
for (const table of DESIRED.tables) {
const tableExists = await ensureTable(ctx, table, databaseExists);
const columnsReady = await ensureColumns(ctx, table, tableExists);
await ensureIndexes(ctx, table, tableExists, columnsReady);
}
for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket);
} catch (err) {
if (!(err instanceof SetupError)) throw err;
log(`ABBRUCH: ${err.message}`);
+553 -54
View File
@@ -14,6 +14,7 @@ import {
isMissing,
compareTable,
compareColumn,
compareIndex,
compareBucket,
main,
} from './appwrite-setup.mjs';
@@ -25,14 +26,24 @@ const KEY = 'test-key-0123456789abcdef-geheim';
// Fake-Appwrite (1.8-ähnliche REST-Formen, alles im Speicher)
// ---------------------------------------------------------------------------
function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuckColumns = false, inject = null } = {}) {
const HUGE_MIN = -9223372036854775808;
const HUGE_MAX = 9223372036854775807;
function createFakeAppwrite({
project = '6ac2130a00289343d6d5', key = KEY, stuckColumns = false, stuckIndexes = false, failedIndexes = false, inject = null,
} = {}) {
const state = {
databases: new Map(),
tables: new Map(), // "db/table"
columns: new Map(), // "db/table/key"
indexes: new Map(), // "db/table/key"
buckets: new Map(),
requests: [],
// Bei jedem Index-POST: waren zu diesem Zeitpunkt ALLE Spalten der Tabelle schon als `available` gemeldet?
indexPosts: [],
};
const columnAvailable = (col) => !stuckColumns && col.gets >= 2;
const invalid = (res, message) => fail(res, 400, 'general_argument_invalid', message);
const send = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
@@ -90,12 +101,33 @@ function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuck
const table = state.tables.get(`${seg[1]}/${seg[3]}`);
return table ? send(res, 200, table) : fail(res, 404, 'table_not_found', 'Table not found');
}
// POST /tablesdb/:db/tables/:t/columns/string
if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && seg[5] === 'string') {
// POST /tablesdb/:db/tables/:t/columns/{string,boolean,integer,enum}
if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && ['string', 'boolean', 'integer', 'enum'].includes(seg[5])) {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
const kind = seg[5];
if (typeof body.key !== 'string' || typeof body.required !== 'boolean') return invalid(res, 'key und required sind Pflicht');
if (body.required && body.default !== undefined && body.default !== null) {
return fail(res, 400, 'column_default_unsupported', 'Cannot set default value for required column');
}
const id = `${seg[1]}/${seg[3]}/${body.key}`;
if (state.columns.has(id)) return fail(res, 409, 'column_already_exists', 'exists');
const col = { key: body.key, type: 'string', size: body.size, required: body.required, array: false, status: 'processing', gets: 0 };
const base = { key: body.key, required: body.required, array: false, status: 'processing', gets: 0 };
let col;
if (kind === 'string') {
if (!Number.isInteger(body.size)) return invalid(res, 'size fehlt');
col = { ...base, type: 'string', size: body.size, default: body.default ?? null };
} else if (kind === 'boolean') {
col = { ...base, type: 'boolean', default: body.default ?? null };
} else if (kind === 'integer') {
// echte Antworten tragen riesige Standard-min/max – das Skript darf sie nicht vergleichen
col = { ...base, type: 'integer', min: HUGE_MIN, max: HUGE_MAX, default: body.default ?? null };
} else {
if (!Array.isArray(body.elements) || !body.elements.length) return invalid(res, 'elements fehlt');
col = {
...base, type: 'string', format: 'enum', elements: body.elements,
size: Math.max(...body.elements.map((e) => e.length)), default: body.default ?? null,
};
}
state.columns.set(id, col);
const { gets, ...out } = col;
return send(res, 202, out);
@@ -106,10 +138,44 @@ function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuck
const col = state.columns.get(`${seg[1]}/${seg[3]}/${seg[5]}`);
if (!col) return fail(res, 404, 'column_not_found', 'Column not found');
col.gets += 1;
const status = stuckColumns ? 'processing' : col.gets >= 2 ? 'available' : 'processing';
const status = col.forceStatus ?? (columnAvailable(col) ? 'available' : 'processing');
const { gets, ...out } = col;
return send(res, 200, { ...out, status });
}
// POST /tablesdb/:db/tables/:t/indexes
if (m === 'POST' && seg.length === 5 && seg[2] === 'tables' && seg[4] === 'indexes') {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
if (typeof body.key !== 'string' || !['key', 'fulltext', 'unique'].includes(body.type)
|| !Array.isArray(body.columns) || !body.columns.length) {
return invalid(res, 'key, type und columns sind Pflicht');
}
if (body.type === 'fulltext' && body.columns.length !== 1) return invalid(res, 'Fulltext-Index nur über genau eine Spalte');
const id = `${seg[1]}/${seg[3]}/${body.key}`;
if (state.indexes.has(id)) return fail(res, 409, 'index_already_exists', 'exists');
const tableCols = [...state.columns.entries()].filter(([k]) => k.startsWith(`${seg[1]}/${seg[3]}/`)).map(([, c]) => c);
for (const name of body.columns) {
const col = state.columns.get(`${seg[1]}/${seg[3]}/${name}`);
if (!col) return fail(res, 404, 'attribute_not_found', `Column ${name} not found`);
if (!columnAvailable(col)) return fail(res, 400, 'attribute_not_available', `Column ${name} is not available`);
}
state.indexPosts.push({ table: seg[3], key: body.key, allColumnsAvailable: tableCols.every(columnAvailable) });
const index = { key: body.key, type: body.type, columns: body.columns, lengths: [], orders: [], status: 'processing', gets: 0 };
state.indexes.set(id, index);
const { gets, ...out } = index;
return send(res, 202, out);
}
// GET /tablesdb/:db/tables/:t/indexes/:key
if (m === 'GET' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'indexes') {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
const index = state.indexes.get(`${seg[1]}/${seg[3]}/${seg[5]}`);
if (!index) return fail(res, 404, 'index_not_found', 'Index not found');
index.gets += 1;
let status = index.gets >= 2 ? 'available' : 'processing';
if (stuckIndexes) status = 'processing';
if (failedIndexes) status = 'failed';
const { gets, ...out } = index;
return send(res, 200, { ...out, status });
}
// POST /storage/buckets
if (m === 'POST' && path === '/v1/storage/buckets') {
if (state.buckets.has(body.bucketId)) return fail(res, 409, 'storage_bucket_already_exists', 'exists');
@@ -190,17 +256,24 @@ function runCli(args, env) {
// ---------------------------------------------------------------------------
describe('DESIRED', () => {
test('entspricht dem Schema aus der Spec', () => {
const table = (id) => DESIRED.tables.find((t) => t.id === id);
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
const str = (key, size, required) => ({ key, type: 'string', size, required });
test('Reihenfolge: cards zuerst, dann gallery, reports; Buckets card-images, gallery-images', () => {
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
assert.equal(DESIRED.table.id, 'cards');
assert.equal(DESIRED.table.name, 'Cards');
assert.deepEqual(DESIRED.table.permissions, ['create("users")']);
assert.equal(DESIRED.table.rowSecurity, true);
assert.deepEqual(
DESIRED.table.columns.map((c) => [c.key, c.size, c.required]),
[['name', 80, true], ['config', 2000, true], ['imageFileId', 36, true], ['previewFileId', 36, true]],
);
assert.deepEqual(DESIRED.bucket, {
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports']);
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images']);
});
test('cards entspricht dem bisherigen Schema (unverändert)', () => {
const t = table('cards');
assert.equal(t.name, 'Cards');
assert.deepEqual(t.permissions, ['create("users")']);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)]);
assert.deepEqual(t.indexes ?? [], []);
assert.deepEqual(bucket('card-images'), {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
@@ -212,6 +285,67 @@ describe('DESIRED', () => {
});
});
test('gallery entspricht Spec §7', () => {
const t = table('gallery');
assert.equal(t.name, 'Gallery');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [
str('title', 60, true),
str('issuer', 40, false),
str('tags', 120, false),
str('displayName', 30, false),
str('imageFileId', 36, true),
str('previewFileId', 36, true),
str('sourceCardId', 36, false),
str('ownerId', 36, true),
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
str('searchText', 300, false),
]);
assert.deepEqual(t.indexes, [
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
]);
});
test('reports entspricht Spec §7', () => {
const t = table('reports');
assert.equal(t.name, 'Reports');
assert.deepEqual(t.permissions, ['read("label:admin")']);
assert.equal(t.rowSecurity, false);
assert.deepEqual(t.columns, [
str('galleryId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
]);
assert.deepEqual(t.indexes, [{ key: 'gallery', type: 'key', columns: ['galleryId'] }]);
});
test('gallery-images entspricht Spec §7', () => {
assert.deepEqual(bucket('gallery-images'), {
id: 'gallery-images',
name: 'Gallery Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'webp'],
compression: 'none',
antivirus: false,
});
});
test('Fulltext-Indizes decken genau eine Spalte ab, alle Index-Spalten existieren', () => {
for (const t of DESIRED.tables) {
const keys = t.columns.map((c) => c.key);
for (const idx of t.indexes ?? []) {
if (idx.type === 'fulltext') assert.equal(idx.columns.length, 1, `${t.id}.${idx.key}`);
for (const col of idx.columns) assert.ok(keys.includes(col), `${t.id}.${idx.key}: Spalte ${col} fehlt`);
}
}
});
test('Standard-Endpoint ist der Server von WEBklar', () => {
assert.equal(DEFAULT_ENDPOINT, 'https://appwrite.webklar.com/v1');
});
@@ -239,59 +373,175 @@ describe('isMissing', () => {
});
describe('compareTable', () => {
const CARDS = DESIRED.tables[0];
const ok = () => ({ $id: 'cards', name: 'Cards', $permissions: ['create("users")'], rowSecurity: true });
test('passende Tabelle: keine Abweichung', () => {
assert.deepEqual(compareTable(ok(), DESIRED.table), []);
assert.deepEqual(compareTable(ok(), CARDS), []);
});
test('Rechte werden unabhängig von der Reihenfolge verglichen', () => {
const desired = { ...DESIRED.table, permissions: ['create("users")', 'read("any")'] };
const desired = { ...CARDS, permissions: ['create("users")', 'read("any")'] };
const actual = { ...ok(), $permissions: ['read("any")', 'create("users")'] };
assert.deepEqual(compareTable(actual, desired), []);
});
test('abweichende oder zusätzliche Rechte werden gemeldet', () => {
const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, DESIRED.table);
const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, CARDS);
assert.equal(wrong.length, 1);
assert.match(wrong[0], /^\$permissions ist \[read\("any"\)\], erwartet \[create\("users"\)\]$/);
const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, DESIRED.table);
const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, CARDS);
assert.equal(extra.length, 1);
assert.match(extra[0], /^\$permissions /);
});
test('rowSecurity aus wird gemeldet', () => {
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, DESIRED.table), [
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, CARDS), [
'rowSecurity ist false, erwartet true',
]);
});
test('mehrere Abweichungen ergeben mehrere Meldungen', () => {
assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, DESIRED.table).length, 2);
assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, CARDS).length, 2);
});
test('gallery (rowSecurity an, keine Tabellenrechte) und reports (Tabellenrecht für Admins)', () => {
const [, gallery, reports] = DESIRED.tables;
assert.deepEqual(compareTable({ $permissions: [], rowSecurity: true }, gallery), []);
assert.deepEqual(compareTable({ $permissions: ['read("any")'], rowSecurity: true }, gallery), [
'$permissions ist [read("any")], erwartet []',
]);
assert.deepEqual(compareTable({ $permissions: ['read("label:admin")'], rowSecurity: false }, reports), []);
assert.deepEqual(compareTable({ $permissions: ['read("label:admin")'], rowSecurity: true }, reports), [
'rowSecurity ist true, erwartet false',
]);
});
});
describe('compareColumn', () => {
const desired = DESIRED.table.columns[0]; // name, 80, required
const col = (id, key) => DESIRED.tables.find((t) => t.id === id).columns.find((c) => c.key === key);
const desired = col('cards', 'name'); // string 80, Pflicht
const ok = () => ({ key: 'name', type: 'string', size: 80, required: true, status: 'available' });
test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(ok(), desired), []);
});
test('size, required und status werden gemeldet', () => {
test('string: size, required und status werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...ok(), size: 100 }, desired), ['size ist 100, erwartet 80']);
assert.deepEqual(compareColumn({ ...ok(), required: false }, desired), ['required ist false, erwartet true']);
assert.deepEqual(compareColumn({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
});
test('fehlende Felder (z. B. falscher Spaltentyp ohne size) werden gemeldet', () => {
const [msg] = compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired);
assert.equal(msg, 'size ist undefined, erwartet 80');
test('string: der Standardwert wird nicht verglichen', () => {
assert.deepEqual(compareColumn({ ...ok(), default: 'x' }, desired), []);
});
test('falscher Spaltentyp wird gemeldet (zusätzlich fehlende Felder)', () => {
assert.deepEqual(compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired), [
'type ist integer, erwartet string',
'size ist undefined, erwartet 80',
]);
});
describe('boolean', () => {
const hidden = col('gallery', 'hidden');
const okBool = () => ({ key: 'hidden', type: 'boolean', required: false, default: false, array: false, status: 'available' });
test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(okBool(), hidden), []);
});
test('Standardwert, required, Typ und status werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...okBool(), default: true }, hidden), ['default ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), default: null }, hidden), ['default ist null, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), required: true }, hidden), ['required ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), type: 'string' }, hidden), ['type ist string, erwartet boolean']);
assert.deepEqual(compareColumn({ ...okBool(), status: 'failed' }, hidden), ['status ist failed, erwartet available']);
});
});
describe('integer', () => {
const reportCount = col('gallery', 'reportCount');
// so liefert Appwrite Integer-Spalten: riesige Standard-min/max
const okInt = () => ({
key: 'reportCount', type: 'integer', required: false, default: 0, array: false,
min: -9223372036854775808, max: 9223372036854775807, status: 'available',
});
test('passende Spalte: keine Abweichung, min/max der Antwort werden NICHT verglichen', () => {
assert.deepEqual(compareColumn(okInt(), reportCount), []);
assert.deepEqual(compareColumn({ ...okInt(), min: 0, max: 5 }, reportCount), []);
});
test('Standardwert, required und Typ werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...okInt(), default: 1 }, reportCount), ['default ist 1, erwartet 0']);
assert.deepEqual(compareColumn({ ...okInt(), default: null }, reportCount), ['default ist null, erwartet 0']);
assert.deepEqual(compareColumn({ ...okInt(), required: true }, reportCount), ['required ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okInt(), type: 'boolean' }, reportCount), ['type ist boolean, erwartet integer']);
});
});
describe('enum', () => {
const reason = col('reports', 'reason');
const okEnum = () => ({
key: 'reason', type: 'string', format: 'enum', required: true, array: false, size: 12, default: null,
elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], status: 'available',
});
test('passende Spalte: keine Abweichung, Elemente werden sortiert verglichen', () => {
assert.deepEqual(compareColumn(okEnum(), reason), []);
assert.deepEqual(compareColumn({ ...okEnum(), elements: ['spam', 'sonstiges', 'urheberrecht', 'kartendaten', 'unangemessen'] }, reason), []);
});
test('fehlende/zusätzliche Elemente, required und status werden gemeldet (Elemente sortiert ausgegeben)', () => {
assert.deepEqual(compareColumn({ ...okEnum(), elements: ['spam', 'sonstiges'] }, reason), [
'elements ist [sonstiges, spam], erwartet [kartendaten, sonstiges, spam, unangemessen, urheberrecht]',
]);
assert.equal(compareColumn({ ...okEnum(), elements: [...okEnum().elements, 'gewalt'] }, reason).length, 1);
assert.deepEqual(compareColumn({ ...okEnum(), required: false }, reason), ['required ist false, erwartet true']);
assert.deepEqual(compareColumn({ ...okEnum(), status: 'processing' }, reason), ['status ist processing, erwartet available']);
});
test('eine normale String-Spalte (ohne elements) ist keine Enum-Spalte', () => {
const { elements, format, ...plain } = okEnum();
assert.equal(compareColumn(plain, reason).length, 1);
assert.match(compareColumn(plain, reason)[0], /^elements ist \[\], erwartet \[/);
});
});
});
describe('compareIndex', () => {
const desired = { key: 'search_text', type: 'fulltext', columns: ['searchText'] };
const ok = () => ({ key: 'search_text', type: 'fulltext', columns: ['searchText'], lengths: [], orders: [], status: 'available' });
test('passender Index: keine Abweichung', () => {
assert.deepEqual(compareIndex(ok(), desired), []);
});
test('Index-Typ, Spalten und status werden gemeldet', () => {
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, desired), ['type ist key, erwartet fulltext']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['title'] }, desired), ['columns ist [title], erwartet [searchText]']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['title', 'searchText'] }, desired), [
'columns ist [title, searchText], erwartet [searchText]',
]);
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
});
test('ältere Antwortform mit `attributes` statt `columns` wird akzeptiert', () => {
const { columns, ...legacy } = ok();
assert.deepEqual(compareIndex({ ...legacy, attributes: ['searchText'] }, desired), []);
assert.deepEqual(compareIndex({ ...legacy, attributes: ['title'] }, desired), ['columns ist [title], erwartet [searchText]']);
});
test('ganz ohne Spaltenangabe wird gemeldet', () => {
const { columns, ...none } = ok();
assert.deepEqual(compareIndex(none, desired), ['columns ist [], erwartet [searchText]']);
});
});
describe('compareBucket', () => {
const CARD_IMAGES = DESIRED.buckets[0];
const ok = () => ({
$id: 'card-images', name: 'Card Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 5242880,
@@ -299,32 +549,44 @@ describe('compareBucket', () => {
});
test('passender Bucket: keine Abweichung', () => {
assert.deepEqual(compareBucket(ok(), DESIRED.bucket), []);
assert.deepEqual(compareBucket(ok(), CARD_IMAGES), []);
});
test('Endungen werden sortiert verglichen', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, DESIRED.bucket), []);
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, CARD_IMAGES), []);
});
test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, DESIRED.bucket), [
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, CARD_IMAGES), [
'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]',
]);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, DESIRED.bucket).length, 1);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, DESIRED.bucket).length, 1);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, CARD_IMAGES).length, 1);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, CARD_IMAGES).length, 1);
});
test('fileSecurity und maximumFileSize werden gemeldet', () => {
assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, DESIRED.bucket), ['fileSecurity ist false, erwartet true']);
assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, DESIRED.bucket), [
assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, CARD_IMAGES), ['fileSecurity ist false, erwartet true']);
assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, CARD_IMAGES), [
'maximumFileSize ist 30000000, erwartet 5242880',
]);
});
test('Rechte werden unabhängig von der Reihenfolge verglichen und Abweichungen gemeldet', () => {
const desired = { ...DESIRED.bucket, permissions: ['create("users")', 'read("any")'] };
const desired = { ...CARD_IMAGES, permissions: ['create("users")', 'read("any")'] };
assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []);
assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, DESIRED.bucket).length, 1);
assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, CARD_IMAGES).length, 1);
});
test('gallery-images: nur png/webp erlaubt', () => {
const gallery = DESIRED.buckets[1];
const okGallery = {
$id: 'gallery-images', name: 'Gallery Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['webp', 'png'], compression: 'none', antivirus: false,
};
assert.deepEqual(compareBucket(okGallery, gallery), []);
assert.deepEqual(compareBucket({ ...okGallery, allowedFileExtensions: ['png', 'webp', 'jpg'] }, gallery), [
'allowedFileExtensions ist [jpg, png, webp], erwartet [png, webp]',
]);
});
});
@@ -340,15 +602,82 @@ describe('main gegen Fake-Server', () => {
'Spalte cards.config',
'Spalte cards.imageFileId',
'Spalte cards.previewFileId',
'Tabelle gallery',
'Spalte gallery.title',
'Spalte gallery.issuer',
'Spalte gallery.tags',
'Spalte gallery.displayName',
'Spalte gallery.imageFileId',
'Spalte gallery.previewFileId',
'Spalte gallery.sourceCardId',
'Spalte gallery.ownerId',
'Spalte gallery.hidden',
'Spalte gallery.reportCount',
'Spalte gallery.searchText',
'Index gallery.search_text',
'Index gallery.owner',
'Index gallery.hidden',
'Tabelle reports',
'Spalte reports.galleryId',
'Spalte reports.reason',
'Spalte reports.reporterHash',
'Index reports.gallery',
'Bucket card-images',
'Bucket gallery-images',
];
const NEW_OBJECTS = EXPECTED_OBJECTS.filter((o) => /gallery|reports/.test(o));
const T = '/v1/tablesdb/aircard/tables';
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
const EXPECTED_POSTS = [
['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }],
[T, { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }],
str('cards', 'name', 80, true),
str('cards', 'config', 2000, true),
str('cards', 'imageFileId', 36, true),
str('cards', 'previewFileId', 36, true),
[T, { tableId: 'gallery', name: 'Gallery', permissions: [], rowSecurity: true }],
str('gallery', 'title', 60, true),
str('gallery', 'issuer', 40, false),
str('gallery', 'tags', 120, false),
str('gallery', 'displayName', 30, false),
str('gallery', 'imageFileId', 36, true),
str('gallery', 'previewFileId', 36, true),
str('gallery', 'sourceCardId', 36, false),
str('gallery', 'ownerId', 36, true),
[`${T}/gallery/columns/boolean`, { key: 'hidden', required: false, default: false }],
[`${T}/gallery/columns/integer`, { key: 'reportCount', required: false, default: 0, min: 0 }],
str('gallery', 'searchText', 300, false),
[`${T}/gallery/indexes`, { key: 'search_text', type: 'fulltext', columns: ['searchText'] }],
[`${T}/gallery/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/gallery/indexes`, { key: 'hidden', type: 'key', columns: ['hidden'] }],
[T, { tableId: 'reports', name: 'Reports', permissions: ['read("label:admin")'], rowSecurity: false }],
str('reports', 'galleryId', 36, true),
[`${T}/reports/columns/enum`, {
key: 'reason', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true,
}],
str('reports', 'reporterHash', 64, true),
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none', antivirus: false,
}],
['/v1/storage/buckets', {
bucketId: 'gallery-images', name: 'Gallery Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'],
compression: 'none', antivirus: false,
}],
];
test('--check auf leerem Projekt: Exit 1, alle 7 Objekte FEHLT, nichts angelegt', async () => {
test('--check auf leerem Projekt: Exit 1, alle 28 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.equal(EXPECTED_OBJECTS.length, 28);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(lines, 'OK').length, 0);
assert.ok(lines.includes('UNVOLLSTÄNDIG: 28 fehlend.'), lines.join('\n'));
assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0);
});
@@ -358,29 +687,14 @@ describe('main gegen Fake-Server', () => {
await withFake({}, async (fake) => {
// Anlegen
const first = await run(fake);
assert.equal(first.code, 0);
assert.equal(first.code, 0, first.lines.join('\n'));
assert.deepEqual(startsWith(first.lines, 'ANGELEGT'), EXPECTED_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.equal(startsWith(first.lines, 'FEHLT').length, 0);
assert.equal(startsWith(first.lines, 'ABWEICHUNG').length, 0);
assert.ok(first.lines.includes('OK: Schema vollständig'));
// Bodies und Reihenfolge exakt wie im Brief
assert.deepEqual(
fake.posts().map((r) => [r.path, r.body]),
[
['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }],
['/v1/tablesdb/aircard/tables', { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'name', size: 80, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'config', size: 2000, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'imageFileId', size: 36, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'previewFileId', size: 36, required: true }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none', antivirus: false,
}],
],
);
assert.deepEqual(fake.posts().map((r) => [r.path, r.body]), EXPECTED_POSTS);
// Jede Anfrage trägt die drei Header
for (const r of fake.state.requests) {
@@ -391,8 +705,15 @@ describe('main gegen Fake-Server', () => {
// Der Key taucht nie in der Ausgabe auf
assert.ok(!first.lines.join('\n').includes(KEY));
// Alle Spalten wurden vor dem Ende ausgewartet (Status available)
// Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available)
assert.equal(fake.state.columns.size, 18);
assert.equal(fake.state.indexes.size, 4);
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`);
// Indizes wurden erst angelegt, nachdem ALLE Spalten der Tabelle available waren
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), ['gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery']);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
// Zweite Prüfung
const postsBefore = fake.posts().length;
@@ -411,6 +732,75 @@ describe('main gegen Fake-Server', () => {
});
});
test('Bestehendes Schema (nur cards + card-images) wird ergänzt, Bestehendes nicht angefasst', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Zustand vor der Erweiterung nachstellen
for (const map of [fake.state.tables, fake.state.columns, fake.state.indexes]) {
for (const id of [...map.keys()]) if (/^aircard\/(gallery|reports)(\/|$)/.test(id)) map.delete(id);
}
fake.state.buckets.delete('gallery-images');
const requestsBefore = fake.state.requests.length;
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), NEW_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(fake.posts().length, EXPECTED_POSTS.length);
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), NEW_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.ok(apply.lines.includes('OK: Tabelle cards'));
assert.ok(apply.lines.includes('OK: Bucket card-images'));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(([path, body]) => (
/\/(gallery|reports)(\/|$)/.test(path) || ['gallery', 'reports', 'gallery-images'].includes(body.tableId ?? body.bucketId)
)));
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
});
});
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.delete('aircard/gallery/owner');
fake.state.indexes.delete('aircard/reports/gallery');
const postsBefore = fake.posts().length;
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), ['FEHLT: Index gallery.owner', 'FEHLT: Index reports.gallery']);
assert.equal(fake.posts().length, postsBefore);
const apply = await run(fake);
assert.equal(apply.code, 0);
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), ['ANGELEGT: Index gallery.owner', 'ANGELEGT: Index reports.gallery']);
assert.deepEqual(
fake.posts().slice(postsBefore).map((r) => [r.path, r.body]),
[
[`${T}/gallery/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
],
);
});
});
test('Spalte nicht available (failed): Index wird nicht angelegt, nur als FEHLT gemeldet', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.delete('aircard/gallery/owner');
fake.state.columns.get('aircard/gallery/title').forceStatus = 'failed';
const postsBefore = fake.posts().length;
const { code, lines } = await run(fake);
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), ['ABWEICHUNG: gallery.title.status ist failed, erwartet available']);
assert.deepEqual(startsWith(lines, 'FEHLT'), ['FEHLT: Index gallery.owner']);
assert.equal(fake.posts().length, postsBefore);
assert.ok(!lines.includes('OK: Schema vollständig'));
});
});
test('Abweichungen werden gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
@@ -442,6 +832,71 @@ describe('main gegen Fake-Server', () => {
});
});
test('Abweichender Index-Typ oder -Spalten: ABWEICHUNG, nichts wird geändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.get('aircard/gallery/search_text').type = 'key';
fake.state.indexes.get('aircard/gallery/owner').type = 'unique';
fake.state.indexes.get('aircard/gallery/hidden').columns = ['reportCount'];
const requestsBefore = fake.state.requests.length;
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: gallery.search_text.type ist key, erwartet fulltext',
'ABWEICHUNG: gallery.owner.type ist unique, erwartet key',
'ABWEICHUNG: gallery.hidden.columns ist [reportCount], erwartet [hidden]',
]);
assert.ok(lines.includes('OK: Index reports.gallery'));
assert.ok(!lines.includes('OK: Schema vollständig'));
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
}
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Abweichende neue Spalten (Standardwert, Typ, Enum-Elemente, Pflicht) und Buckets werden gemeldet', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.columns.get('aircard/gallery/hidden').default = true;
fake.state.columns.get('aircard/gallery/reportCount').default = 5;
fake.state.columns.get('aircard/gallery/title').size = 100;
fake.state.columns.get('aircard/gallery/ownerId').required = false;
fake.state.columns.get('aircard/reports/reason').elements = ['spam', 'sonstiges'];
fake.state.columns.get('aircard/reports/galleryId').type = 'integer';
fake.state.tables.get('aircard/gallery').$permissions = ['read("any")'];
fake.state.tables.get('aircard/reports').rowSecurity = true;
fake.state.buckets.get('gallery-images').allowedFileExtensions = ['png', 'webp', 'svg'];
const requestsBefore = fake.state.requests.length;
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: gallery.$permissions ist [read("any")], erwartet []',
'ABWEICHUNG: gallery.title.size ist 100, erwartet 60',
'ABWEICHUNG: gallery.ownerId.required ist false, erwartet true',
'ABWEICHUNG: gallery.hidden.default ist true, erwartet false',
'ABWEICHUNG: gallery.reportCount.default ist 5, erwartet 0',
'ABWEICHUNG: reports.rowSecurity ist true, erwartet false',
'ABWEICHUNG: reports.galleryId.type ist integer, erwartet string',
'ABWEICHUNG: reports.reason.elements ist [sonstiges, spam], erwartet [kartendaten, sonstiges, spam, unangemessen, urheberrecht]',
'ABWEICHUNG: gallery-images.allowedFileExtensions ist [png, svg, webp], erwartet [png, webp]',
]);
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
const col = fake.state.columns.get('aircard/gallery/reportCount');
assert.equal(col.min, -9223372036854775808);
assert.equal(col.max, 9223372036854775807);
assert.equal((await run(fake, { check: true })).code, 0);
});
});
test('404 project_not_found bricht klar ab und meldet nichts als fehlend', async () => {
await withFake({ project: 'anderes-projekt' }, async (fake) => {
for (const check of [true, false]) {
@@ -516,6 +971,46 @@ describe('main gegen Fake-Server', () => {
});
});
test('Index wird nicht verfügbar: Abbruch nach Wartezeit, danach nichts mehr angelegt', async () => {
await withFake({ stuckIndexes: true }, async (fake) => {
const { code, lines } = await run(fake, { timeoutMs: 60 });
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /gallery\.search_text/);
assert.match(abort[0], /nicht .*verfügbar/);
assert.equal(fake.state.tables.has('aircard/reports'), false);
assert.equal(fake.state.buckets.size, 0);
});
});
test('Index mit Status failed: Abbruch mit klarer Meldung', async () => {
await withFake({ failedIndexes: true }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /Index gallery\.search_text/);
assert.match(abort[0], /failed/);
assert.equal(fake.state.buckets.size, 0);
});
});
test('Zugriff verweigert beim Anlegen eines Index: Abbruch (Exit 2) ohne den Key auszugeben', async () => {
const inject = (method, path) => (method === 'POST' && path.endsWith('/gallery/indexes')
? { status: 401, type: 'general_unauthorized_scope', message: 'missing scope (tables.write)' }
: null);
await withFake({ inject }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /Index gallery\.search_text/);
assert.match(abort[0], /401/);
assert.ok(!lines.join('\n').includes(KEY));
});
});
test('Server nicht erreichbar: Abbruch mit klarer Meldung', async () => {
const probe = http.createServer();
await new Promise((r) => probe.listen(0, '127.0.0.1', r));
@@ -570,6 +1065,10 @@ describe('CLI', () => {
assert.equal(code, 1);
assert.match(stdout, /FEHLT: Datenbank aircard/);
assert.match(stdout, /FEHLT: Bucket card-images/);
assert.match(stdout, /FEHLT: Tabelle gallery/);
assert.match(stdout, /FEHLT: Index gallery\.search_text/);
assert.match(stdout, /FEHLT: Tabelle reports/);
assert.match(stdout, /FEHLT: Bucket gallery-images/);
assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0);
});
+140
View File
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
type TestUser = { $id: string; email: string; labels?: string[] };
const mocks = vi.hoisted(() => ({
user: null as TestUser | null,
listeners: new Set<(user: TestUser | null) => void>(),
logout: vi.fn(),
requireLogin: vi.fn(),
}));
vi.mock('./auth', () => ({
currentUser: () => mocks.user,
logout: mocks.logout,
onAuthChange: (fn: (user: TestUser | null) => void) => {
mocks.listeners.add(fn);
return () => mocks.listeners.delete(fn);
},
}));
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
import { mountAccountMenu, type AccountMenu } from './account-menu';
let slot: HTMLElement;
let menu: AccountMenu;
const avatar = () => slot.querySelector<HTMLButtonElement>('button.account-avatar');
const panel = () => slot.querySelector<HTMLElement>('.account-dropdown');
const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
function setUser(user: TestUser | null) {
mocks.user = user;
for (const fn of [...mocks.listeners]) fn(user);
}
beforeEach(() => {
mocks.user = null;
mocks.listeners.clear();
mocks.logout.mockReset();
mocks.requireLogin.mockReset();
document.body.innerHTML = '<div id="account-slot"></div>';
slot = document.getElementById('account-slot') as HTMLElement;
menu = mountAccountMenu(slot);
});
describe('Konto-Menü', () => {
it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
expect(slot.childElementCount).toBe(0);
});
it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
menu.ready();
buttonByText('Anmelden')!.click();
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
expect(avatar()).toBeNull();
});
it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
expect(avatar()?.textContent).toBe('K');
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
expect(panel()?.hidden).toBe(true);
});
it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
expect(panel()?.hidden).toBe(false);
expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
const link = panel()!.querySelector<HTMLAnchorElement>('a[data-link]')!;
expect(link.textContent).toBe('Meine Karten');
expect(link.getAttribute('href')).toBe('/karten');
expect(buttonByText('Abmelden')).toBeDefined();
});
it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(panel()?.hidden).toBe(true);
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(avatar());
});
it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(panel()?.hidden).toBe(true);
avatar()!.click();
panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(panel()?.hidden).toBe(true);
});
it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(panel()?.hidden).toBe(false);
});
it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
menu.ready();
avatar()!.click();
const signOut = buttonByText('Abmelden')!;
signOut.click();
expect(mocks.logout).toHaveBeenCalledOnce();
expect(signOut.disabled).toBe(true);
await vi.waitFor(() => expect(signOut.disabled).toBe(false));
expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
expect(panel()?.hidden).toBe(false);
});
it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
setUser(null);
expect(avatar()).toBeNull();
expect(buttonByText('Anmelden')).toBeDefined();
});
it('dispose leert den Slot und meldet den Listener ab', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
menu.dispose();
expect(slot.childElementCount).toBe(0);
expect(mocks.listeners.size).toBe(0);
});
});
+53 -10
View File
@@ -1,4 +1,5 @@
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
import './account.css';
import { el } from '../editor/dom';
import { currentUser, logout, onAuthChange } from './auth';
@@ -13,8 +14,12 @@ export interface AccountMenu {
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
let isReady = false;
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
let detach: (() => void) | null = null;
function render(): void {
detach?.();
detach = null;
if (!isReady) {
slot.replaceChildren();
return;
@@ -26,9 +31,52 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
return;
}
const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
const avatar = el(
'button',
{
type: 'button',
class: 'account-avatar',
'aria-label': 'Konto-Menü',
'aria-expanded': 'false',
'aria-controls': 'account-dropdown',
},
initial,
);
const email = el('span', { class: 'account-email', title: user.email }, user.email);
const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden');
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten');
const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden');
const error = el('span', { class: 'account-error', role: 'alert' });
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, avatar, dropdown);
let isOpen = false;
function setOpen(open: boolean): void {
isOpen = open;
dropdown.hidden = !open;
avatar.setAttribute('aria-expanded', String(open));
}
avatar.addEventListener('click', () => setOpen(!isOpen));
const onDocumentClick = (event: MouseEvent) => {
if (!isOpen) return;
const target = event.target as Element | null;
if (target && wrap.contains(target) && !target.closest('a')) return; // nur Links schließen das Menü selbst
setOpen(false);
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !isOpen) return;
setOpen(false);
avatar.focus();
};
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onKeydown);
detach = () => {
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onKeydown);
};
signOut.addEventListener('click', () => {
signOut.disabled = true;
error.textContent = '';
@@ -38,14 +86,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
signOut.disabled = false;
});
});
slot.replaceChildren(
el(
'div',
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
el('div', { class: 'account-row' }, email, signOut),
error,
),
);
slot.replaceChildren(wrap);
}
const off = onAuthChange(render);
@@ -58,6 +99,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
},
dispose() {
off();
detach?.();
detach = null;
slot.replaceChildren();
},
};
+86 -38
View File
@@ -25,6 +25,7 @@
.auth-btn-primary {
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
font-weight: 700;
}
@@ -35,7 +36,7 @@
.auth-btn-danger {
border-color: var(--danger);
color: #ff9a9d;
color: var(--danger-text);
}
.auth-btn-danger:hover:not(:disabled) {
@@ -70,43 +71,104 @@
cursor: not-allowed;
}
/* Konto-Menü im Seitenkopf */
#account-slot {
min-width: 0;
}
/* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */
.account-menu {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
align-items: center;
min-width: 0;
max-width: 100%;
}
.account-row {
.account-avatar {
appearance: none;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
background: var(--primary);
color: var(--on-primary);
font: inherit;
font-size: 1.05rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
transition: border-color 0.12s;
}
.account-avatar:hover,
.account-avatar[aria-expanded='true'] {
border-color: var(--sel-line);
}
.account-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
max-width: 100%;
flex-direction: column;
gap: 2px;
min-width: 220px;
max-width: min(320px, calc(100vw - 32px));
padding: 8px;
border: 1px solid var(--line-strong);
border-radius: 14px;
background: var(--panel);
box-shadow: var(--shadow-lg);
}
.account-dropdown[hidden] {
display: none;
}
.account-email {
min-width: 0;
max-width: 28ch;
margin-bottom: 4px;
padding: 6px 12px 10px;
overflow: hidden;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 0.95rem;
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-item {
appearance: none;
display: block;
width: 100%;
margin: 0;
padding: 10px 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.account-item:hover:not(:disabled) {
background: var(--panel-2);
}
.account-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.account-error {
color: #ff9a9d;
padding: 4px 12px 0;
color: var(--danger-text);
font-size: 0.85rem;
text-align: right;
}
.account-error:empty {
@@ -124,11 +186,11 @@
border-radius: 24px;
background: var(--panel);
color: var(--text);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
box-shadow: var(--shadow-lg);
}
.auth-dialog::backdrop {
background: rgb(6 11 20 / 0.72);
background: var(--backdrop);
}
.auth-card {
@@ -261,11 +323,11 @@
}
.auth-msg-error {
color: #ff9a9d;
color: var(--danger-text);
}
.auth-msg-ok {
color: #8fd9a8;
color: var(--ok-text);
}
.auth-aside {
@@ -289,29 +351,15 @@
margin: 0;
}
/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
@media (max-width: 599px) {
#app-header {
flex-wrap: wrap;
gap: 8px 16px;
}
#account-slot {
max-width: 100%;
}
.account-email {
max-width: none;
}
.auth-card {
padding: 24px 20px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.auth-btn {
.auth-btn,
.account-avatar {
transition: none;
}
}
+12 -2
View File
@@ -5,8 +5,18 @@ import { Account, Client, Storage, TablesDB } from 'appwrite';
export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || '6ac2130a00289343d6d5';
/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */
export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const;
/**
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie.
*/
export const IDS = {
database: 'aircard',
table: 'cards',
bucket: 'card-images',
gallery: 'gallery',
reports: 'reports',
galleryBucket: 'gallery-images',
} as const;
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
export const account: Account = new Account(client);
+54 -8
View File
@@ -29,7 +29,7 @@ describe('getCurrentUser', () => {
it('liefert den Nutzer und merkt sich die ID', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(auth.currentUserId()).toBe('u1');
});
@@ -71,7 +71,7 @@ describe('getCurrentUser', () => {
await auth.getCurrentUser();
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
await auth.getCurrentUser();
await auth.getCurrentUser();
@@ -80,6 +80,52 @@ describe('getCurrentUser', () => {
});
});
describe('Labels und isAdmin', () => {
it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
});
it('login liefert die Labels ebenfalls', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
expect(auth.currentUser()?.labels).toEqual(['admin']);
});
it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
});
it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
const auth = await freshAuth();
expect(auth.isAdmin(null)).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
});
});
describe('login', () => {
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
const auth = await freshAuth();
@@ -87,10 +133,10 @@ describe('login', () => {
auth.onAuthChange(listener);
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u1');
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
});
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
@@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
@@ -127,7 +173,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
});
@@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
});
});
@@ -188,7 +234,7 @@ describe('register', () => {
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
const params = mocks.create.mock.calls[0][0];
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
expect(typeof params.userId).toBe('string');
+14 -4
View File
@@ -4,7 +4,7 @@ import { ID } from 'appwrite';
import { account } from './appwrite';
import { errorType, isUnauthorized } from './errors';
export type User = { $id: string; email: string };
export type User = { $id: string; email: string; labels?: string[] };
const PASSWORD_MIN = 8;
const PASSWORD_MAX = 256;
@@ -27,10 +27,16 @@ function setUser(user: User | null): void {
function toUser(res: unknown): User {
const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>;
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
return { $id: data.$id, email: data.email };
const labels = Array.isArray(data.labels) ? data.labels.filter((l): l is string => typeof l === 'string') : [];
return { $id: data.$id, email: data.email, labels };
}
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */
/** Wahr nur für Konten mit dem Appwrite-Label `admin` (die Rechte prüft der Server; das hier steuert nur die Oberfläche). */
export function isAdmin(user: User | null): boolean {
return (user?.labels ?? []).includes('admin');
}
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen (auch der Labels). */
export async function getCurrentUser(): Promise<User | null> {
let next: User | null;
try {
@@ -39,7 +45,11 @@ export async function getCurrentUser(): Promise<User | null> {
if (!isUnauthorized(err)) throw err;
next = null;
}
if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next);
const same =
next?.$id === cachedUser?.$id &&
next?.email === cachedUser?.email &&
(next?.labels ?? []).join('\n') === (cachedUser?.labels ?? []).join('\n');
if (!same) setUser(next);
return next;
}
+50
View File
@@ -32,6 +32,7 @@ vi.mock('./appwrite', () => ({
import {
appwriteCardsApi,
createCardsService,
fetchFile,
normalizeName,
ownerPermissions,
toUploadFile,
@@ -711,3 +712,52 @@ describe('appwriteCardsApi', () => {
});
});
});
describe('fetchFile (eigenständig, beliebiger Bucket)', () => {
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
vi.unstubAllGlobals();
});
const prepare = () =>
mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({
uri: url.toString(),
options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' },
}));
it('holt die Datei aus dem übergebenen Bucket, mit Projekt-Header und Cookie', async () => {
prepare();
const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } }));
vi.stubGlobal('fetch', fetchMock);
const result = await fetchFile('gallery-images', 'f1');
expect(result.type).toBe('image/png');
expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/gallery-images/files/f1/view', {
method: 'GET',
headers: { 'X-Appwrite-Project': 'proj' },
credentials: 'include',
});
});
it('maskiert Bucket- und Datei-ID im Pfad', async () => {
prepare();
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
await fetchFile('a/b', '../x?y');
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/a%2Fb/files/..%2Fx%3Fy/view');
});
it('Fehlerantwort: AppwriteException mit Status und Typ des Servers', async () => {
prepare();
const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 });
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found' })));
const err = await fetchFile('gallery-images', 'f1').catch((e: unknown) => e);
expect(err).toBeInstanceOf(AppwriteException);
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
});
it('CardsApi.fetchFile bleibt die Hülle über den Karten-Bucket', async () => {
prepare();
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
await appwriteCardsApi().fetchFile('f1');
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/f1/view');
});
});
+8 -5
View File
@@ -47,7 +47,7 @@ export interface SaveInput {
export const DEFAULT_CARD_NAME = 'Meine Karte';
const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
export function normalizeName(name: string): string {
@@ -193,11 +193,13 @@ export function createCardsService(api: CardsApi, userId: () => string) {
export type CardsService = ReturnType<typeof createCardsService>;
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
async function fetchFile(fileId: string): Promise<Blob> {
/** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
export async function fetchFile(bucketId: string, fileId: string): Promise<Blob> {
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
const url = new URL(
`${APPWRITE_ENDPOINT}/storage/buckets/${encodeURIComponent(bucketId)}/files/${encodeURIComponent(fileId)}/view`,
);
const { uri, options } = client.prepareRequest('GET', url);
const res = await fetch(uri, options);
if (!res.ok) {
@@ -231,7 +233,8 @@ export function appwriteCardsApi(): CardsApi {
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
},
fetchFile,
// Die privaten Karten liegen im Karten-Bucket; die Galerie holt ihre Bilder über `fetchFile` mit eigenem Bucket.
fetchFile: (fileId) => fetchFile(IDS.bucket, fileId),
createRow: (data, permissions) =>
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
+3
View File
@@ -3,6 +3,9 @@ import { getCurrentUser } from './auth';
import { errorType, isUnauthorized } from './errors';
import { requireLogin } from './login-dialog';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert. */
export const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
export class LoginCancelled extends Error {
constructor() {
+1 -1
View File
@@ -14,7 +14,7 @@ function pngInfo(buf: Buffer) {
describe('Name und Kopfbereich', () => {
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
expect(indexHtml).toContain('<title>AirCard-IOS – Kartenmotiv gestalten</title>');
expect(indexHtml).toContain('<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>');
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
});
+3
View File
@@ -6,6 +6,9 @@ export const CARD_H = 969;
export const MARGIN = 56;
/** Nur für die Vorschau (CSS), nie im Export. */
export const PREVIEW_RADIUS = 57;
/** Vorschaudatei (WebP) einer gespeicherten oder veröffentlichten Karte: Breite in px und Qualität des Exports. */
export const PREVIEW_WIDTH = 480;
export const PREVIEW_QUALITY = 0.85;
export interface Box {
x: number;
+575
View File
@@ -0,0 +1,575 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
createJWT: vi.fn(),
userId: null as string | null,
authListeners: [] as Array<(user: unknown) => void>,
logoutListeners: [] as Array<() => void>,
}));
vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } }));
vi.mock('../account/auth', () => ({
currentUserId: () => mocks.userId,
onAuthChange: (fn: (user: unknown) => void) => {
mocks.authListeners.push(fn);
return () => undefined;
},
onLogout: (fn: () => void) => {
mocks.logoutListeners.push(fn);
return () => undefined;
},
}));
type Api = typeof import('./api');
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
const MINUTE = 60_000;
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
async function freshApi(): Promise<Api> {
vi.resetModules();
return import('./api');
}
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
function sentHeaders(call = 0): Record<string, string> {
return fetchMock.mock.calls[call][1].headers as Record<string, string>;
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-04T10:00:00Z'));
mocks.createJWT.mockReset();
mocks.userId = 'u1';
mocks.authListeners.length = 0;
mocks.logoutListeners.length = 0;
let n = 0;
mocks.createJWT.mockImplementation(async () => ({ jwt: `jwt${++n}` }));
fetchMock.mockReset();
fetchMock.mockImplementation(async () => json(200, { ok: true }));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('apiPost', () => {
it('sendet JSON per POST an den Pfad und liefert die JSON-Antwort', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(api.apiPost('/api/melden', { id: 'c1', reason: 'spam' }, { auth: false })).resolves.toEqual({
schonGemeldet: true,
});
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/melden');
expect(init.method).toBe('POST');
expect(sentHeaders()['Content-Type']).toBe('application/json');
expect(init.body).toBe(JSON.stringify({ id: 'c1', reason: 'spam' }));
});
it('ohne auth: kein JWT, kein createJWT', async () => {
const api = await freshApi();
await api.apiPost('/api/melden', {}, { auth: false });
expect(mocks.createJWT).not.toHaveBeenCalled();
expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined();
});
it('mit auth: sendet den Header X-Aircard-Jwt mit dem JWT von createJWT (ohne duration)', async () => {
const api = await freshApi();
await api.apiPost('/api/zurueckziehen', { id: 'c1' }, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(mocks.createJWT.mock.calls[0]).toEqual([]);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
describe('JWT-Cache', () => {
it('der zweite Aufruf innerhalb von 10 Minuten holt kein neues JWT', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
vi.setSystemTime(Date.now() + 9 * MINUTE + 59_000);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
});
it('nach 10 Minuten wird ein neues JWT geholt', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
vi.setSystemTime(Date.now() + 10 * MINUTE);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
});
it('gilt je Nutzer-ID: ein anderer Nutzer bekommt nie das JWT des vorigen', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
mocks.userId = 'u2';
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
});
it('wird bei einer Änderung des Nutzers (onAuthChange) geleert', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.authListeners).toHaveLength(1);
mocks.authListeners[0](null);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('wird beim Abmelden (onLogout) geleert', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.logoutListeners).toHaveLength(1);
mocks.logoutListeners[0]();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ein JWT, das erst nach einem Nutzerwechsel eintrifft, wird nicht gemerkt', async () => {
const api = await freshApi();
let release: (value: { jwt: string }) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const first = api.apiPost('/api/x', {}, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
mocks.authListeners[0](null); // Abmelden, während createJWT noch läuft
release({ jwt: 'late' });
await first;
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
});
it('gleichzeitige Aufrufe mit leerem Cache teilen sich ein einziges createJWT', async () => {
const api = await freshApi();
let release: (value: { jwt: string }) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const first = api.apiPost('/api/x', { n: 1 }, { auth: true });
const second = api.apiPost('/api/x', { n: 2 }, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
release({ jwt: 'shared' });
await Promise.all([first, second]);
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('shared');
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('shared');
});
it('scheitert das geteilte createJWT, bekommen beide den Fehler; der nächste Aufruf versucht es neu', async () => {
const api = await freshApi();
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
let reject: (reason: unknown) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((_resolve, rej) => (reject = rej)));
const first = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
const second = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
reject(failure);
expect(await Promise.all([first, second])).toEqual([failure, failure]);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ein anderer Nutzer teilt sich kein laufendes createJWT', async () => {
const api = await freshApi();
let release: (value: { jwt: string }) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const first = api.apiPost('/api/x', {}, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
mocks.userId = 'u2';
const second = api.apiPost('/api/x', {}, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledTimes(2));
release({ jwt: 'for-u1' });
await Promise.all([first, second]);
expect(sentHeaders(0)['X-Aircard-Jwt']).not.toBe(sentHeaders(1)['X-Aircard-Jwt']);
});
it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => {
const api = await freshApi();
mocks.userId = null;
await api.apiPost('/api/x', {}, { auth: true });
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
});
describe('401 von der API', () => {
it('holt ein neues JWT und wiederholt genau einmal', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
fetchMock.mockResolvedValueOnce(json(200, { ok: true }));
await expect(api.apiPost('/api/x', { a: 1 }, { auth: true })).resolves.toEqual({ ok: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('jwt1');
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ a: 1 }));
});
it('das neue JWT wird gemerkt, das alte nicht mehr', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
await api.apiPost('/api/x', {}, { auth: true });
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(2)['X-Aircard-Jwt']).toBe('jwt2');
});
it('bei einem zweiten 401 ist Schluss: ApiError mit code 401, keine dritte Anfrage', async () => {
const api = await freshApi();
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.' });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ohne auth wird nicht wiederholt', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(401, { error: 'Nein.' }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 401 });
expect(fetchMock).toHaveBeenCalledOnce();
});
it('andere Fehler (403) lösen keinen Wiederholungsversuch aus', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toMatchObject({ code: 403 });
expect(fetchMock).toHaveBeenCalledOnce();
expect(mocks.createJWT).toHaveBeenCalledOnce();
});
});
describe('Zeitlimit', () => {
it('jede Anfrage trägt ein Zeitlimit von 15 Sekunden (AbortSignal.timeout)', async () => {
const api = await freshApi();
const timeout = vi.spyOn(AbortSignal, 'timeout');
await api.apiPost('/api/x', {}, { auth: false });
expect(timeout).toHaveBeenCalledWith(15_000);
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it('Zeitüberschreitung gilt als verlorene Antwort: code 503, Ausgang offen', async () => {
const api = await freshApi();
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(
AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')),
);
fetchMock.mockImplementation(async (_url, init) => {
throw init.signal?.reason;
});
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
});
describe('Fehler', () => {
it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(400, { message: 'Bitte gib einen Titel an.' }));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toBeInstanceOf(Error);
expect(err).toMatchObject({ code: 400, status: 400, message: 'Bitte gib einen Titel an.' });
});
it('liest den Text auch aus `error` (so antwortet aircard-api tatsächlich)', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
await expect(api.apiPost('/api/melden', {}, { auth: false })).rejects.toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
});
});
it('JSON ohne Text: allgemeine Meldung mit dem Statuscode', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(500, {}));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({
code: 500,
message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.',
});
});
it('der Statuscode lässt isUnauthorized aus errors.ts greifen', async () => {
const api = await freshApi();
const { isUnauthorized } = await import('../account/errors');
fetchMock.mockResolvedValue(json(401, { error: 'x' }));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(isUnauthorized(err)).toBe(true);
});
it('Netzfehler: code 503 und „Der Dienst ist gerade nicht erreichbar.“', async () => {
const api = await freshApi();
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
});
it.each([502, 503, 504])('Status %i (auch mit JSON) gilt als nicht erreichbar: code 503', async (status) => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(status, { error: 'irgendwas' }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('eine Fehlerseite ohne JSON (z. B. Proxy) gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('<html>404 page not found</html>', { status: 404 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('eine HTML-Antwort mit Status 200 (Seitenfallback ohne API-Route) gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('<!doctype html><title>AirCard</title>', { status: 200 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('JSON, das kein Objekt ist, gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('"ok"', { status: 200 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503 });
});
it('createJWT mit user_auth_method_unsupported: Hinweis an den Betreiber, keine Anfrage', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(
Object.assign(new Error('JWT unsupported'), { code: 501, type: 'user_auth_method_unsupported' }),
);
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({
code: 501,
message: 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.',
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('createJWT mit 401 (keine Sitzung) geht unverändert durch, damit withLogin greift', async () => {
const api = await freshApi();
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
mocks.createJWT.mockRejectedValue(failure);
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toBe(failure);
expect(fetchMock).not.toHaveBeenCalled();
});
});
});
describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => {
it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => {
const api = await freshApi();
expect(new api.ApiError(code, 'x').definitive).toBe(true);
expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(true);
});
it.each([408, 429, 500, 501, 502, 503, 504])('ApiError %i (Zeitüberschreitung, Drosselung, Serverfehler): nein', async (code) => {
const api = await freshApi();
expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(false);
});
it('das Flag lässt sich setzen (JWT-Fehler: es ging keine Anfrage raus)', async () => {
const api = await freshApi();
expect(api.isDefinitiveFailure(new api.ApiError(501, 'x', true))).toBe(true);
expect(api.isDefinitiveFailure(new api.ApiError(400, 'x', false))).toBe(false);
});
it('ein anderer Fehler (z. B. aus createJWT vor der Anfrage) ist eindeutig', async () => {
const api = await freshApi();
expect(api.isDefinitiveFailure(Object.assign(new Error('x'), { code: 401 }))).toBe(true);
expect(api.isDefinitiveFailure(new TypeError('Failed to fetch'))).toBe(true);
});
const outcome = async (respond: () => Promise<Response>, api: Api) => {
fetchMock.mockImplementation(respond);
return api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
};
it.each([400, 401, 403, 409])('Antwort %i vom Dienst: eindeutig', async (status) => {
const api = await freshApi();
const err = await outcome(async () => json(status, { error: 'Nein.' }), api);
expect(err).toMatchObject({ code: status });
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it.each([408, 429, 500])('Antwort %i vom Dienst: Ausgang offen', async (status) => {
const api = await freshApi();
const err = await outcome(async () => json(status, { error: 'Nein.' }), api);
expect(err).toMatchObject({ code: status });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it.each([502, 503, 504])('Antwort %i (Proxy): Ausgang offen', async (status) => {
const api = await freshApi();
const err = await outcome(async () => json(status, { error: 'x' }), api);
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it('Netzfehler, Fehlerseite ohne JSON und HTML-200: Ausgang offen', async () => {
const api = await freshApi();
for (const respond of [
async () => Promise.reject(new TypeError('Failed to fetch')),
async () => new Response('<html>Bad Gateway</html>', { status: 404 }),
async () => new Response('<!doctype html>', { status: 200 }),
]) {
expect(api.isDefinitiveFailure(await outcome(respond, api))).toBe(false);
}
});
it('der Text einer abgebrochenen Antwort (res.text() scheitert): Ausgang offen', async () => {
const api = await freshApi();
const broken = { ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response;
expect(api.isDefinitiveFailure(await outcome(async () => broken, api))).toBe(false);
});
it('JWT-Methode am Server aus: eindeutig (es wurde nichts gesendet)', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it('createJWT scheitert mit 401 vor der Anfrage: eindeutig', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 401, type: 'general_unauthorized_scope' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(api.isDefinitiveFailure(err)).toBe(true);
});
});
describe('publishCard', () => {
const input = {
title: 'Meine Karte',
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'card1',
};
it('POST /api/veroeffentlichen mit JWT, Tags als Liste; liefert { id }', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, { id: 'g1' }));
await expect(api.publishCard(input)).resolves.toEqual({ id: 'g1' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/veroeffentlichen');
expect(JSON.parse(init.body as string)).toEqual(input);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('409: „Diese Karte ist schon veröffentlicht.“', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('403 „bereits veröffentlicht oder gesperrt“ (Dateien belegt) gilt ebenfalls als schon veröffentlicht', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('ein anderes 403 bleibt ein 403 mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 403, message: 'Diese Datei gehört dir nicht.' });
expect(api.isAlreadyPublished(err)).toBe(false);
});
it('400 (Validierung) behält den Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(400, { error: 'Bitte gib höchstens 5 Tags an.' }));
await expect(api.publishCard(input)).rejects.toMatchObject({ code: 400, message: 'Bitte gib höchstens 5 Tags an.' });
});
it('eine Antwort ohne id ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein)', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, {}));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it('die umgedeutete 409 (Dateien belegt) ist eindeutig', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true);
});
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
const api = await freshApi();
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
expect(api.isAlreadyPublished(new api.ApiError(400, 'x'))).toBe(false);
expect(api.isAlreadyPublished(Object.assign(new Error('x'), { code: 409 }))).toBe(false);
expect(api.isAlreadyPublished(null)).toBe(false);
});
});
describe('withdrawCard, reportCard, setVisibility', () => {
it('withdrawCard: POST /api/zurueckziehen { id } mit JWT', async () => {
const api = await freshApi();
await expect(api.withdrawCard('g1')).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/zurueckziehen');
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1' });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('reportCard: POST /api/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { ok: true }));
await expect(api.reportCard('g1', 'spam')).resolves.toEqual({ schonGemeldet: false });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/melden');
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1', reason: 'spam' });
expect(mocks.createJWT).not.toHaveBeenCalled();
});
it('reportCard: { schonGemeldet: true } bei doppelter Meldung', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(api.reportCard('g1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true });
});
it('reportCard: 429 mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
await expect(api.reportCard('g1', 'spam')).rejects.toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
});
});
it('setVisibility: POST /api/admin/sichtbarkeit { id, sichtbar } mit JWT', async () => {
const api = await freshApi();
await expect(api.setVisibility('g1', false)).resolves.toBeUndefined();
await api.setVisibility('g1', true);
expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/sichtbarkeit');
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ id: 'g1', sichtbar: false });
expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toEqual({ id: 'g1', sichtbar: true });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('setVisibility: 403 für Nicht-Admins mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
});
});
+222
View File
@@ -0,0 +1,222 @@
// Schreibzugriffe der Community-Galerie: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT.
// Lesen läuft direkt über Appwrite (gallery.ts). Fehler sind `ApiError` mit HTTP-Status als `code`, damit
// `isUnauthorized`/`withLogin`/`errorMessage` aus account/ sie wie Appwrite-Fehler behandeln.
import { account } from '../account/appwrite';
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
import { errorType } from '../account/errors';
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
export interface PublishInput {
title: string;
issuer: string;
tags: string[];
displayName: string;
imageFileId: string;
previewFileId: string;
sourceCardId: string;
}
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.';
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
const JWT_TTL_MS = 10 * 60 * 1000;
/** Zeitlimit je Anfrage; danach gilt die Antwort als verloren (503, Ausgang offen). */
const REQUEST_TIMEOUT_MS = 15_000;
/**
* Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext.
* `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt.
* Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was
* keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die
* Anfrage trotzdem gewirkt hat.
*/
export class ApiError extends Error {
readonly code: number;
readonly definitive: boolean;
constructor(code: number, message: string, definitive?: boolean) {
super(message);
this.name = 'ApiError';
this.code = code;
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
}
get status(): number {
return this.code;
}
}
/**
* Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes,
* oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`).
* Andere Fehler als `ApiError` kommen aus `apiPost` nur aus dem JWT-Abruf, also vor der Anfrage.
*/
export function isDefinitiveFailure(err: unknown): boolean {
return err instanceof ApiError ? err.definitive : true;
}
// ---- JWT (je Nutzer-ID gemerkt) ----------------------------------------------------------------------
let cached: { userId: string; jwt: string; expires: number } | null = null;
/** Zählt Leerungen: ein JWT, das erst nach einem Nutzerwechsel eintrifft, gehört dem alten Nutzer und wird nicht gemerkt. */
let generation = 0;
function clearJwt(): void {
cached = null;
generation++;
}
let subscribed = false;
/** Erst beim ersten JWT: so lässt sich das Modul importieren, ohne den Anmeldedienst zu berühren. */
function subscribe(): void {
if (subscribed) return;
subscribed = true;
onAuthChange(clearJwt);
onLogout(clearJwt);
}
/** Das gerade laufende createJWT, damit gleichzeitige Aufrufe mit leerem Cache nur eines auslösen. */
let pending: { userId: string | null; generation: number; promise: Promise<string> } | null = null;
async function mintJwt(userId: string | null, started: number): Promise<string> {
let jwt: string;
try {
// Ohne `duration`: die Standarddauer (15 Minuten) genügt.
jwt = (await account.createJWT()).jwt;
} catch (err) {
if (errorType(err) === 'user_auth_method_unsupported') {
const code = (err as { code?: unknown }).code;
// Es ging keine Anfrage an den API-Dienst raus: eindeutig.
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true);
}
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
}
if (userId && started === generation) cached = { userId, jwt, expires: Date.now() + JWT_TTL_MS };
return jwt;
}
function getJwt(): Promise<string> {
subscribe();
const userId = currentUserId();
if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return Promise.resolve(cached.jwt);
if (pending && pending.userId === userId && pending.generation === generation) return pending.promise;
const started = generation;
const promise: Promise<string> = mintJwt(userId, started).finally(() => {
if (pending?.promise === promise) pending = null;
});
pending = { userId, generation: started, promise };
return promise;
}
// ---- Anfrage ------------------------------------------------------------------------------------------
type Json = Record<string, unknown>;
function isRecord(value: unknown): value is Json {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Der deutsche Text einer Fehlerantwort: aircard-api sendet `{error}`, `{message}` wird ebenso akzeptiert. */
function serverMessage(body: Json): string | null {
for (const key of ['message', 'error']) {
const value = body[key];
if (typeof value === 'string' && value.trim() !== '') return value;
}
return null;
}
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (jwt) headers['X-Aircard-Jwt'] = jwt;
const payload = JSON.stringify(body);
let res: Response;
let text: string;
try {
res = await fetch(path, {
method: 'POST',
headers,
body: payload,
credentials: 'omit',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
text = await res.text();
} catch {
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
throw new ApiError(503, UNAVAILABLE);
}
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200).
if (res.status === 502 || res.status === 503 || res.status === 504) throw new ApiError(503, UNAVAILABLE);
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new ApiError(503, UNAVAILABLE);
}
if (!isRecord(data)) throw new ApiError(503, UNAVAILABLE);
if (!res.ok) throw new ApiError(res.status, serverMessage(data) ?? GENERIC);
return data;
}
/**
* POST mit JSON-Body an den API-Dienst. Mit `auth` geht das Nutzer-JWT im Header `X-Aircard-Jwt` mit;
* antwortet der Dienst mit 401, wird genau einmal mit einem frischen JWT wiederholt.
*/
export async function apiPost(path: string, body: unknown, opts: { auth: boolean }): Promise<Json> {
if (!opts.auth) return send(path, body, null);
try {
return await send(path, body, await getJwt());
} catch (err) {
if (!(err instanceof ApiError) || err.code !== 401) throw err;
clearJwt();
return send(path, body, await getJwt());
}
}
// ---- Endpunkte ----------------------------------------------------------------------------------------
/** Wahr, wenn die Karte schon veröffentlicht ist (`publishCard` meldet das immer als 409). */
export function isAlreadyPublished(err: unknown): boolean {
return err instanceof ApiError && err.code === 409;
}
/**
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an; alle anderen Fehler unverändert.
*/
export async function publishCard(input: PublishInput): Promise<{ id: string }> {
let res: Json;
try {
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
} catch (err) {
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
throw new ApiError(409, ALREADY_PUBLISHED);
}
throw err;
}
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
return { id: res.id };
}
/** Zieht eine Veröffentlichung zurück (Autor oder Admin): löscht Zeile, Dateien und Meldungen. */
export async function withdrawCard(id: string): Promise<void> {
await apiPost('/api/zurueckziehen', { id }, { auth: true });
}
/** Meldet eine Karte (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte sie schon gemeldet. */
export async function reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> {
const res = await apiPost('/api/melden', { id, reason }, { auth: false });
return { schonGemeldet: res.schonGemeldet === true };
}
/** Admin: blendet eine Karte ein oder aus. */
export async function setVisibility(id: string, visible: boolean): Promise<void> {
await apiPost('/api/admin/sichtbarkeit', { id, sichtbar: visible }, { auth: true });
}
+898
View File
@@ -0,0 +1,898 @@
/* Startseite: kompakter Hero mit schwebender Karte und die Community-Galerie darunter.
Wichtig (Abnahmekriterium): bei 1440 x 900 und 390 x 844 stehen die Überschrift „Community-Karten“ und
die Oberkante der ersten Kartenreihe schon im ersten Bildschirm. Der Hero bleibt deshalb niedrig. */
/* Knöpfe der Startseite (unabhängig von editor.css / account.css) */
.home-btn {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
margin: 0;
padding: 0 22px;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 700;
line-height: 1.2;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.home-btn:hover:not(:disabled) {
border-color: var(--sel-line);
background: var(--panel);
}
.home-btn-primary {
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
}
.home-btn-primary:hover:not(:disabled) {
border-color: var(--primary-hover);
background: var(--primary-hover);
}
.home-btn:disabled {
opacity: 0.55;
cursor: progress;
}
/* Hero */
.home-hero {
--hero-card-max: 540px;
--card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, wie in der Editor-Vorschau */
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
align-items: center;
gap: 24px 56px;
min-height: auto;
padding: 14px 0 10px;
}
/* Nur auf breiten, hohen Fenstern: der Hero nimmt höchstens 60 % der Höhe. Auf schmaleren oder niedrigeren Fenstern
bräche der Text in mehr Zeilen um und würde über die Grenze hinausragen (und die Galerie-Überschrift überdecken). */
@media (min-width: 1100px) and (min-height: 700px) {
.home-hero {
max-height: 60vh;
--hero-card-max: min(540px, calc((60vh - 48px) * 1536 / 969));
}
}
.home-hero-copy {
min-width: 0;
}
.home-hero-eyebrow {
margin: 0 0 10px;
color: var(--muted);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.02em;
}
#app-root .home-hero-title {
margin: 0 0 14px;
font-size: clamp(1.95rem, 4.4vw, 3.15rem);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.01em;
text-wrap: balance;
}
.home-hero-lead {
max-width: 34em;
margin: 0 0 22px;
color: var(--muted);
font-size: 1.1rem;
line-height: 1.5;
}
.home-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
}
.home-hero-visual {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
padding: 10px 0;
}
/* weicher Lichtschein hinter der Karte */
.home-hero-visual::before {
content: '';
position: absolute;
inset: -10% 0; /* seitlich nicht über den Hero hinaus: sonst entsteht waagerechtes Scrollen */
border-radius: 50%;
background: radial-gradient(closest-side, rgb(112 92 255 / 0.32), rgb(60 140 255 / 0.12) 60%, transparent);
filter: blur(14px);
pointer-events: none;
}
:root[data-theme='light'] .home-hero-visual::before {
background: radial-gradient(closest-side, rgb(112 92 255 / 0.22), rgb(60 140 255 / 0.1) 60%, transparent);
}
.hero-card {
position: relative;
width: min(100%, var(--hero-card-max));
aspect-ratio: 1536 / 969;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--panel-2);
box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06);
transform: rotate(-1.5deg);
}
.hero-card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-card-link {
display: block;
width: 100%;
height: 100%;
}
/* Schwebe-Animation: nur ohne „Bewegung reduzieren“ */
@media (prefers-reduced-motion: no-preference) {
.hero-card {
animation: hero-float 7s ease-in-out infinite;
}
}
@keyframes hero-float {
0%,
100% {
transform: translateY(0) rotate(-1.5deg);
}
50% {
transform: translateY(-10px) rotate(-0.7deg);
}
}
/* Galerie-Abschnitt */
.home-community {
margin-top: 36px;
scroll-margin-top: 16px;
}
.community-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 24px;
}
.community-head-text {
min-width: 0;
}
.community-title {
margin: 0;
font-size: clamp(1.5rem, 3vw, 1.95rem);
font-weight: 800;
line-height: 1.15;
}
.community-sub {
margin: 4px 0 0;
color: var(--muted);
}
.community-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 16px;
}
.community-toolbar > :empty {
display: none;
}
.community-active,
.community-filter {
margin-top: 16px;
}
.community-active {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.community-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 3px 4px 3px 14px;
border: 1px solid var(--sel-line);
border-radius: 999px;
background: var(--sel-bg);
font-size: 0.95rem;
}
.community-chip-label {
color: var(--muted);
}
.community-chip-value {
min-width: 0;
overflow-wrap: anywhere;
}
.community-chip-clear {
appearance: none;
display: grid;
flex: none;
place-items: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
font: inherit;
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
}
.community-chip-clear:hover {
background: rgb(127 127 127 / 0.22);
}
.community-filter {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.community-filter-btn {
appearance: none;
min-height: 36px;
margin: 0;
padding: 0 16px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.community-filter-btn:hover {
border-color: var(--sel-line);
}
.community-filter-btn[aria-pressed='true'] {
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
}
.community-status {
margin: 24px 0 12px;
color: var(--muted);
}
.community-status.is-error,
.community-more-msg.is-error {
color: var(--danger-text);
}
.community-empty {
margin-top: 20px;
padding: 36px 20px;
border: 1px dashed var(--line-strong);
border-radius: 18px;
color: var(--muted);
font-size: 1.05rem;
text-align: center;
}
.community-empty p {
margin: 0 0 16px;
}
.community-empty p:last-child {
margin-bottom: 0;
}
.community-more {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-top: 28px;
}
.community-more:empty {
margin-top: 0;
}
.community-more-msg {
margin: 0;
}
/* Kachelraster */
.gallery-grid {
--card-radius: 3.711% / 5.882%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 20px;
margin: 20px 0 0;
padding: 0;
list-style: none;
}
.gallery-tile {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 10px 10px 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
transition: border-color 0.12s, box-shadow 0.12s;
}
.gallery-tile:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-card);
}
.gallery-tile-link {
display: flex;
flex-direction: column;
gap: 10px;
border-radius: 12px;
color: inherit;
text-decoration: none;
}
.gallery-thumb {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1536 / 969;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--panel-2);
color: var(--muted);
font-size: 0.9rem;
text-align: center;
}
.gallery-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-title {
display: -webkit-box;
margin: 0;
padding: 0 4px;
overflow: hidden;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.gallery-issuer {
margin: 0;
padding: 0 4px;
overflow: hidden;
color: var(--muted);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.gallery-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: auto;
padding: 6px 4px 0;
}
.gallery-tag {
appearance: none;
min-height: 28px;
margin: 0;
padding: 2px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-2);
color: var(--muted);
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.gallery-tag:hover {
border-color: var(--sel-line);
color: var(--text);
}
.gallery-badges {
position: absolute;
top: 18px;
left: 18px;
display: flex;
flex-wrap: wrap;
gap: 6px;
pointer-events: none;
}
.gallery-badge {
padding: 2px 9px;
border-radius: 999px;
background: rgb(15 24 40 / 0.85);
color: #fff;
font-size: 0.78rem;
font-weight: 700;
}
.gallery-badge-hidden {
background: #b42318;
}
/* Detailseite /community/<id> */
.detail-page {
--card-radius: 3.711% / 5.882%;
}
.detail-back {
margin: 0 0 16px;
}
.detail-back a {
display: inline-flex;
align-items: center;
min-height: 36px;
font-weight: 600;
text-decoration: none;
}
.detail-back a:hover {
text-decoration: underline;
}
.detail-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
align-items: start;
gap: 24px 36px;
}
.detail-card {
position: relative;
aspect-ratio: 1536 / 969;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--panel-2);
box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06);
}
.detail-card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-card-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
padding: 16px;
color: var(--muted);
text-align: center;
}
.detail-card-empty p {
margin: 0;
}
.detail-info {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
#app-root .detail-title {
margin: 0;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 800;
line-height: 1.15;
overflow-wrap: anywhere;
}
.detail-issuer {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.detail-meta {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.detail-tag {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 2px 13px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-2);
color: var(--muted);
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
overflow-wrap: anywhere;
}
.detail-tag:hover {
border-color: var(--sel-line);
color: var(--text);
}
.detail-notice-slot:empty,
.detail-admin-slot:empty {
display: none;
}
.detail-hidden {
margin: 0;
padding: 10px 14px;
border: 1px solid var(--danger);
border-radius: 12px;
background: rgb(229 72 77 / 0.12);
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 6px;
}
.detail-btn-danger {
border-color: var(--danger);
color: var(--danger-text);
}
.detail-btn-danger:hover:not(:disabled) {
border-color: var(--danger);
background: rgb(229 72 77 / 0.16);
}
.detail-msg {
margin: 0;
color: var(--ok-text);
}
.detail-msg.is-error {
color: var(--danger-text);
}
.detail-msg:empty {
display: none;
}
.detail-admin {
margin-top: 8px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
}
.detail-admin-title {
margin: 0 0 8px;
font-size: 1.05rem;
}
.detail-reports-count {
margin: 0 0 8px;
font-weight: 700;
}
.detail-reasons {
margin: 0 0 12px;
padding-left: 20px;
color: var(--muted);
}
.detail-admin-note {
margin: 0 0 12px;
color: var(--warn-text);
}
.detail-admin-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.detail-unavailable {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
max-width: 560px;
padding: 24px 0;
}
.detail-load-error,
.detail-status {
margin: 24px 0;
color: var(--muted);
}
.detail-load-error {
margin: 0;
color: var(--danger-text);
}
/* Melden-Dialog (Knöpfe und Meldungen kommen aus account.css) */
.report-dialog {
width: min(420px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 0;
overflow: auto;
border: 1px solid var(--line-strong);
border-radius: 20px;
background: var(--panel);
color: var(--text);
box-shadow: var(--shadow-lg);
}
.report-dialog::backdrop {
background: var(--backdrop);
}
.report-card {
padding: 24px;
}
.report-title {
margin: 0 0 8px;
font-size: 1.25rem;
line-height: 1.25;
}
.report-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.report-text {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.report-reasons {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.report-legend {
margin-bottom: 4px;
padding: 0;
color: var(--muted);
font-size: 0.9rem;
font-weight: 600;
}
.report-reason {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
}
.report-reason:hover {
border-color: var(--sel-line);
}
.report-reason:has(input:checked) {
border-color: var(--sel-line);
background: var(--sel-bg);
}
.report-reason input {
flex: none;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--sel-line);
}
.report-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
/* Veröffentlichen-Dialog: Hülle, Meldungen und Knöpfe wie beim Melden-Dialog, dazu Felder und die Pflicht-Checkbox */
.publish-dialog {
width: min(480px, calc(100vw - 32px));
}
.publish-fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.publish-consent {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
}
.publish-consent:has(input:checked) {
border-color: var(--sel-line);
background: var(--sel-bg);
}
.publish-consent input {
flex: none;
width: 18px;
height: 18px;
margin: 2px 0 0;
accent-color: var(--sel-line);
}
.publish-consent span {
overflow-wrap: anywhere;
}
/* Tablet und Handy: eine Spalte, der Hero bleibt niedrig */
@media (max-width: 899px) {
.detail-layout {
grid-template-columns: minmax(0, 1fr);
}
.home-hero {
--hero-card-max: 400px;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
padding: 4px 0 6px;
}
.home-hero-visual {
justify-content: center;
}
}
@media (max-width: 599px) {
.detail-actions .home-btn {
flex: 1 1 100%;
}
.home-hero {
--hero-card-max: 250px;
gap: 8px;
margin-top: -4px;
padding: 0;
}
.home-hero-eyebrow {
margin-bottom: 4px;
font-size: 0.9rem;
}
#app-root .home-hero-title {
margin-bottom: 8px;
font-size: 1.85rem;
}
.home-hero-lead {
margin-bottom: 14px;
font-size: 0.97rem;
line-height: 1.45;
}
.home-btn {
min-height: 44px;
padding: 0 18px;
}
.home-hero-visual {
padding: 10px 0 4px; /* Luft nach oben für die Schwebe-Animation (10 px nach oben) */
}
.home-community {
margin-top: 20px;
}
.community-head {
gap: 10px;
}
.community-publish {
width: 100%;
min-height: 42px;
}
}
@media (prefers-reduced-motion: reduce) {
.home-btn,
.gallery-tile {
transition: none;
}
}
+178
View File
@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GalleryCard } from './gallery';
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id: `g${n}`,
title: `Karte ${n}`,
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: `img${n}`,
previewFileId: `pre${n}`,
sourceCardId: `src${n}`,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const blob = () => new Blob(['x'], { type: 'image/webp' });
const flush = async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
let host: HTMLElement;
let grid: GalleryGrid | null;
let counter: number;
const revoked: string[] = [];
beforeEach(() => {
host = document.createElement('div');
document.body.append(host);
grid = null;
counter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:t-${++counter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
});
afterEach(() => {
grid?.dispose();
host.remove();
});
const tiles = () => [...host.querySelectorAll<HTMLElement>('.gallery-tile')];
describe('mountGalleryGrid: Kacheln', () => {
it('zeigt Titel, Herausgeber und Kachel-Link auf die Detailseite', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1), card(2)]);
expect(tiles()).toHaveLength(2);
const first = tiles()[0];
const link = first.querySelector<HTMLAnchorElement>('a[data-link]');
expect(link?.getAttribute('href')).toBe('/community/g1');
expect(first.querySelector('.gallery-title')?.textContent).toBe('Karte 1');
expect(first.querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank');
});
it('kodiert die ID im Link und lässt den Herausgeber weg, wenn er fehlt', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1, { id: 'a/b c', issuer: '' })]);
expect(host.querySelector('a[data-link]')?.getAttribute('href')).toBe('/community/a%2Fb%20c');
expect(host.querySelector('.gallery-issuer')).toBeNull();
});
it('zeigt höchstens drei Tags als Knöpfe; Klick meldet den Tag', () => {
const onTag = vi.fn();
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag });
grid.append([card(1, { tags: ['visa', 'gold', 'dunkel', 'extra'] })]);
const buttons = [...host.querySelectorAll<HTMLButtonElement>('button.gallery-tag')];
expect(buttons.map((b) => b.textContent)).toEqual(['visa', 'gold', 'dunkel']);
expect(buttons.every((b) => b.type === 'button')).toBe(true);
buttons[1].click();
expect(onTag).toHaveBeenCalledWith('gold');
});
it('die Tags stehen außerhalb des Kachel-Links (kein Knopf im Link)', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1)]);
expect(host.querySelector('a button')).toBeNull();
});
it('setzt Titel und Herausgeber als Text, nie als HTML', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1, { title: '<img src=x onerror=alert(1)>', issuer: '<b>fett</b>', tags: ['<i>x</i>'] })]);
expect(host.querySelector('.gallery-title')?.textContent).toBe('<img src=x onerror=alert(1)>');
expect(host.querySelector('.gallery-tile img[src="x"]')).toBeNull();
expect(host.querySelector('.gallery-issuer b')).toBeNull();
expect(host.querySelector('.gallery-tag i')).toBeNull();
});
it('append hängt an, ohne die vorhandenen Kacheln zu ersetzen', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1)]);
const firstTile = tiles()[0];
grid.append([card(2), card(3)]);
expect(tiles()).toHaveLength(3);
expect(tiles()[0]).toBe(firstTile);
});
it('Status-Marken (Ausgeblendet, Meldungen) nur mit showStatus', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {}, showStatus: true });
grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 }), card(3)]);
const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent));
expect(badges).toEqual([['Ausgeblendet', '3 Meldungen'], ['1 Meldung'], []]);
grid.dispose();
host.replaceChildren();
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1, { hidden: true, reportCount: 3 })]);
expect(host.querySelector('.gallery-badge')).toBeNull();
});
});
describe('mountGalleryGrid: Vorschaubilder', () => {
it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => {
const load = vi.fn(async () => blob());
grid = mountGalleryGrid(host, { load, onTag: () => {} });
const cards = [card(1), card(2)];
grid.append(cards);
await flush();
expect(load).toHaveBeenCalledTimes(2);
expect(load.mock.calls.map((c) => (c as unknown as [GalleryCard])[0].id)).toEqual(['g1', 'g2']);
const srcs = [...host.querySelectorAll<HTMLImageElement>('.gallery-thumb img')].map((i) => i.getAttribute('src'));
expect(srcs).toEqual(['blob:t-1', 'blob:t-2']);
});
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
grid = mountGalleryGrid(host, { load: async () => blob(), onTag: () => {} });
grid.append([card(1), card(2), card(3)]);
await flush();
expect(counter).toBe(3);
expect(revoked).toEqual([]);
grid.dispose();
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']);
});
it('legt nach dem Aufräumen keine URL mehr an (späte Antwort) und stößt keine neuen Ladungen an', async () => {
const resolvers: Array<(b: Blob) => void> = [];
const load = vi.fn(() => new Promise<Blob>((r) => resolvers.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
expect(load).toHaveBeenCalledTimes(2);
grid.dispose();
for (const resolve of resolvers) resolve(blob());
await flush();
expect(counter).toBe(0);
grid.append([card(3)]);
expect(load).toHaveBeenCalledTimes(2);
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
});
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
const pending: Array<(b: Blob) => void> = [];
const load = vi.fn(() => new Promise<Blob>((r) => pending.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
expect(load).toHaveBeenCalledTimes(6);
pending[0](blob());
await flush();
expect(load).toHaveBeenCalledTimes(7);
});
it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => {
const load = vi.fn(async (c: GalleryCard) => {
if (c.id === 'g1') throw new Error('404');
return blob();
});
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
await flush();
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull();
});
});
+115
View File
@@ -0,0 +1,115 @@
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket) und werden als Object-URL angezeigt;
// `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
import { el } from '../editor/dom';
import type { GalleryCard } from './gallery';
export interface GalleryGridOptions {
/** Lädt die Vorschau einer Karte. */
load: (card: GalleryCard) => Promise<Blob>;
/** Ein Tag-Knopf wurde angeklickt. */
onTag: (tag: string) => void;
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
showStatus?: boolean;
}
export interface GalleryGrid {
/** Hängt Kacheln hinten an. */
append(cards: readonly GalleryCard[]): void;
/** Gibt alle Object-URLs frei und beendet laufende Ladungen (späte Antworten werden verworfen). */
dispose(): void;
}
const PARALLEL = 6;
const MAX_TAGS = 3;
const THUMB_W = 480;
const THUMB_H = 303; // 480 x 1536:969
/** „1 Meldung“, „3 Meldungen“. */
function reportsLabel(count: number): string {
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
}
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
const list = el('ul', { class: 'gallery-grid' });
container.replaceChildren(list);
let disposed = false;
const urls = new Set<string>();
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
let active = 0;
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
try {
const blob = await opts.load(card);
if (disposed) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
const url = URL.createObjectURL(blob);
urls.add(url);
thumb.replaceChildren(el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' }));
} catch {
if (disposed) return;
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
} finally {
thumb.removeAttribute('aria-busy');
}
}
function pump(): void {
while (!disposed && active < PARALLEL && queue.length > 0) {
const job = queue.shift() as { card: GalleryCard; thumb: HTMLElement };
active += 1;
void loadThumb(job.card, job.thumb).finally(() => {
active -= 1;
pump();
});
}
}
function buildTile(card: GalleryCard): HTMLElement {
const thumb = el('span', { class: 'gallery-thumb', 'aria-busy': 'true' });
const link = el(
'a',
{ class: 'gallery-tile-link', href: `/community/${encodeURIComponent(card.id)}`, 'data-link': true },
thumb,
el('h3', { class: 'gallery-title' }, card.title),
);
const tile = el('li', { class: 'gallery-tile' }, link);
if (card.issuer) tile.append(el('p', { class: 'gallery-issuer' }, card.issuer));
const tags = card.tags.slice(0, MAX_TAGS);
if (tags.length > 0) {
const row = el('div', { class: 'gallery-tags' });
for (const tag of tags) {
const button = el('button', { type: 'button', class: 'gallery-tag', title: 'Karten mit diesem Tag zeigen' }, tag);
button.addEventListener('click', () => opts.onTag(tag));
row.append(button);
}
tile.append(row);
}
if (opts.showStatus && (card.hidden || card.reportCount > 0)) {
const badges = el('div', { class: 'gallery-badges' });
if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, 'Ausgeblendet'));
if (card.reportCount > 0) badges.append(el('span', { class: 'gallery-badge' }, reportsLabel(card.reportCount)));
tile.append(badges);
}
queue.push({ card, thumb });
return tile;
}
return {
append(cards) {
if (disposed) return;
list.append(...cards.map((card) => buildTile(card)));
pump();
},
dispose() {
if (disposed) return;
disposed = true;
queue.length = 0;
for (const url of urls) URL.revokeObjectURL(url);
urls.clear();
},
};
}
+459
View File
@@ -0,0 +1,459 @@
import { AppwriteException, Query } from 'appwrite';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
listRows: vi.fn(),
getRow: vi.fn(),
fetchFile: vi.fn(),
}));
vi.mock('../account/appwrite', () => ({
IDS: {
database: 'db',
table: 'cards-table',
bucket: 'cards-bucket',
gallery: 'gallery-table',
reports: 'reports-table',
galleryBucket: 'gallery-bucket',
},
account: {},
client: {},
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
}));
// ID_PATTERN & Co. bleiben echt; nur das Laden der Datei geht nicht ins Netz.
vi.mock('../account/cards', async (importOriginal) => ({
...(await importOriginal<typeof import('../account/cards')>()),
fetchFile: mocks.fetchFile,
}));
import {
PAGE_SIZE,
galleryQueries,
getGalleryCard,
listGallery,
listReports,
loadGalleryImage,
myPublications,
toGalleryCard,
type GalleryCard,
} from './gallery';
const row = (n: number, patch: Record<string, unknown> = {}) => ({
$id: `g${n}`,
$createdAt: `2026-10-0${(n % 9) + 1}T10:00:00.000Z`,
title: `Karte ${n}`,
issuer: 'Beispielbank',
tags: 'visa,gold',
displayName: 'Kenso',
imageFileId: `img${n}`,
previewFileId: `pre${n}`,
sourceCardId: `src${n}`,
ownerId: 'u1',
hidden: false,
reportCount: 0,
searchText: 'karte beispielbank visa gold',
...patch,
});
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
beforeEach(() => {
mocks.listRows.mockReset();
mocks.getRow.mockReset();
mocks.fetchFile.mockReset();
});
describe('galleryQueries', () => {
it('öffentlich: nur sichtbare Karten, neueste zuerst, 24 pro Seite', () => {
expect(galleryQueries({})).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('die Abfragen sind genau die Appwrite-JSON-Strings', () => {
expect(galleryQueries({})).toEqual([
JSON.stringify({ method: 'equal', attribute: 'hidden', values: [false] }),
JSON.stringify({ method: 'orderDesc', attribute: '$createdAt' }),
JSON.stringify({ method: 'limit', values: [24] }),
]);
expect(PAGE_SIZE).toBe(24);
});
it('mit Cursor: cursorAfter(letzte ID)', () => {
expect(galleryQueries({ cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
]);
});
it('mit Suche: Volltext auf searchText, Leerraum zusammengefasst', () => {
expect(galleryQueries({ q: ' Visa Gold ' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.search('searchText', 'Visa Gold'),
]);
});
it('Suche mit höchstens 2 Zeichen: Teilstring (contains, klein) statt Volltext, der so kurze Wörter nicht findet', () => {
expect(galleryQueries({ q: 'Vi' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('searchText', 'vi'),
]);
expect(galleryQueries({ q: ' A ' })).toContain(Query.contains('searchText', 'a'));
});
it('ab 3 Zeichen Volltext (die Grenze liegt bei 3)', () => {
expect(galleryQueries({ q: 'ab' })).toContain(Query.contains('searchText', 'ab'));
expect(galleryQueries({ q: 'abc' })).toContain(Query.search('searchText', 'abc'));
expect(galleryQueries({ q: 'abc' }).join('')).not.toContain('contains');
expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö'));
});
it('Tag: contains auf tags, getrimmt und klein; die Galerie bleibt auf sichtbare Karten beschränkt', () => {
expect(galleryQueries({ tag: ' Visa ' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
]);
expect(JSON.stringify(Query.contains('tags', 'visa'))).toContain('contains');
});
it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => {
expect(galleryQueries({ tag: '' })).toEqual(galleryQueries({}));
expect(galleryQueries({ tag: ' ' })).toEqual(galleryQueries({}));
});
it('Tag, Suche und Cursor zusammen: immer mit hidden = false', () => {
expect(galleryQueries({ tag: 'Gold', q: 'visa', cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
Query.contains('tags', 'gold'),
Query.search('searchText', 'visa'),
]);
});
it('Suche und Cursor zusammen', () => {
expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
Query.search('searchText', 'visa'),
]);
});
it('eine leere oder nur aus Leerraum bestehende Suche wird ignoriert', () => {
expect(galleryQueries({ q: '' })).toEqual(galleryQueries({}));
expect(galleryQueries({ q: ' \t ' })).toEqual(galleryQueries({}));
});
it("Admin-Filter 'all' ist die öffentliche Ansicht", () => {
expect(galleryQueries({ filter: 'all' })).toEqual(galleryQueries({}));
});
it("Admin-Filter 'reported': reportCount > 0, ohne hidden-Bedingung (gemeldet und schon ausgeblendet)", () => {
const queries = galleryQueries({ filter: 'reported' });
expect(queries).toEqual([Query.greaterThan('reportCount', 0), Query.orderDesc('$createdAt'), Query.limit(24)]);
expect(queries.join('')).not.toContain('hidden');
});
it("Admin-Filter 'hidden': nur ausgeblendete Karten", () => {
expect(galleryQueries({ filter: 'hidden' })).toEqual([
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('Tag und kurze Suche mit Admin-Filter', () => {
expect(galleryQueries({ filter: 'hidden', tag: 'visa', q: 'x' })).toEqual([
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
Query.contains('searchText', 'x'),
]);
});
it('Admin-Filter lassen sich mit Suche und Cursor kombinieren', () => {
expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([
Query.greaterThan('reportCount', 0),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g5'),
Query.search('searchText', 'visa'),
]);
});
});
describe('toGalleryCard', () => {
it('übernimmt alle Felder; Tags werden zur Liste', () => {
expect(toGalleryCard(row(1, { hidden: true, reportCount: 3 }))).toEqual({
id: 'g1',
title: 'Karte 1',
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'src1',
ownerId: 'u1',
hidden: true,
reportCount: 3,
createdAt: row(1).$createdAt,
} satisfies GalleryCard);
});
it('fehlende Felder bekommen leere Standardwerte', () => {
expect(toGalleryCard({ $id: 'g9' })).toEqual({
id: 'g9',
title: '',
issuer: '',
tags: [],
displayName: '',
imageFileId: '',
previewFileId: '',
sourceCardId: '',
ownerId: '',
hidden: false,
reportCount: 0,
createdAt: '',
});
});
it('Felder mit falschem Typ gelten als fehlend', () => {
const card = toGalleryCard({
$id: 'g9',
title: 42,
tags: ['x'],
hidden: 'ja',
reportCount: 'viele',
displayName: null,
$createdAt: 5,
});
expect(card).toMatchObject({ title: '', tags: [], hidden: false, reportCount: 0, displayName: '', createdAt: '' });
});
it('Tags: getrimmt, Leere raus', () => {
expect(toGalleryCard(row(1, { tags: ' visa , ,gold,, ' })).tags).toEqual(['visa', 'gold']);
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
});
it('reportCount: nur endliche, nicht negative Zahlen', () => {
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: 2 })).reportCount).toBe(2);
});
});
describe('listGallery', () => {
it('fragt die Galerie-Tabelle mit den Abfragen ab und liefert Karten', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1), row(2)] });
const page = await listGallery({ q: 'visa' });
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'gallery-table',
queries: galleryQueries({ q: 'visa' }),
});
expect(page.cards.map((c) => c.id)).toEqual(['g1', 'g2']);
expect(page.cards[0].tags).toEqual(['visa', 'gold']);
});
it('weniger als 24 Zeilen: keine weitere Seite (nextCursor null)', async () => {
mocks.listRows.mockResolvedValue({ total: 23, rows: Array.from({ length: 23 }, (_, i) => row(i + 1)) });
expect((await listGallery({})).nextCursor).toBeNull();
});
it('keine Zeilen: leere Liste, nextCursor null', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await expect(listGallery({})).resolves.toEqual({ cards: [], nextCursor: null });
});
it('genau 24 Zeilen: nextCursor ist die ID der letzten Karte', async () => {
mocks.listRows.mockResolvedValue({ total: 60, rows: Array.from({ length: 24 }, (_, i) => row(i + 1)) });
const page = await listGallery({});
expect(page.cards).toHaveLength(24);
expect(page.nextCursor).toBe('g24');
});
it('Tag und kurze Suche gehen an die Abfrage', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await listGallery({ tag: 'Visa', q: 'ab' });
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
Query.contains('searchText', 'ab'),
]);
});
it('die nächste Seite schickt den Cursor mit', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await listGallery({ cursor: 'g24', filter: 'hidden' });
expect(mocks.listRows.mock.calls[0][0].queries).toEqual(galleryQueries({ cursor: 'g24', filter: 'hidden' }));
});
it('Fehler gehen durch', async () => {
const failure = Object.assign(new Error('down'), { code: 503 });
mocks.listRows.mockRejectedValue(failure);
await expect(listGallery({})).rejects.toBe(failure);
});
});
describe('getGalleryCard', () => {
it('liefert die Karte', async () => {
mocks.getRow.mockResolvedValue(row(7));
const card = await getGalleryCard('g7');
expect(card?.id).toBe('g7');
expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'gallery-table', rowId: 'g7' });
});
it('404 (nicht vorhanden oder ausgeblendet für mich) ergibt null', async () => {
mocks.getRow.mockRejectedValue(notFound());
await expect(getGalleryCard('g7')).resolves.toBeNull();
});
it('404 ohne Typ (z. B. Proxy) ergibt ebenfalls null', async () => {
mocks.getRow.mockRejectedValue(Object.assign(new Error('not found'), { code: 404 }));
await expect(getGalleryCard('g7')).resolves.toBeNull();
});
it('eine fehlende Tabelle (404 table_not_found) ist ein Konfigurationsfehler, kein „Karte weg“', async () => {
const failure = notFound('table_not_found');
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it.each([500, 503, 401, 429])('Fehler %i geht durch', async (code) => {
const failure = Object.assign(new Error('x'), { code, type: 'general_server_error' });
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it('Netzfehler gehen durch', async () => {
const failure = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it.each(['', '../x', 'a/b', 'a?b', '.hidden', '-x', 'x'.repeat(37), 'ä'])(
'ungültige ID %j: null, ohne Anfrage',
async (id) => {
await expect(getGalleryCard(id)).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
},
);
});
describe('myPublications', () => {
it('liest die Zeilen des Nutzers (höchstens 100) und ordnet sie nach sourceCardId', async () => {
mocks.listRows.mockResolvedValue({
total: 2,
rows: [row(1, { sourceCardId: 'cardA' }), row(2, { sourceCardId: 'cardB', hidden: true, reportCount: 3 })],
});
const map = await myPublications('u1');
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'gallery-table',
queries: [Query.equal('ownerId', 'u1'), Query.limit(100)],
});
expect(map).toBeInstanceOf(Map);
expect([...map.keys()]).toEqual(['cardA', 'cardB']);
expect(map.get('cardA')?.id).toBe('g1');
expect(map.get('cardB')).toMatchObject({ id: 'g2', hidden: true, reportCount: 3 });
});
it('ohne Veröffentlichungen: leere Map', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
expect((await myPublications('u1')).size).toBe(0);
});
it('Zeilen ohne sourceCardId (Altdaten) werden übersprungen', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1, { sourceCardId: '' }), row(2, { sourceCardId: 'cardB' })] });
expect([...(await myPublications('u1')).keys()]).toEqual(['cardB']);
});
it('ohne Nutzer-ID: leere Map, ohne Anfrage', async () => {
expect((await myPublications('')).size).toBe(0);
expect(mocks.listRows).not.toHaveBeenCalled();
});
});
describe('listReports', () => {
it('liest die Meldungen einer Karte (höchstens 100), neueste zuerst', async () => {
mocks.listRows.mockResolvedValue({
total: 2,
rows: [
{ $id: 'r2', $createdAt: '2026-10-04T11:00:00.000Z', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' },
{ $id: 'r1', $createdAt: '2026-10-04T10:00:00.000Z', galleryId: 'g1', reason: 'urheberrecht', reporterHash: 'def' },
],
});
const reports = await listReports('g1');
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'reports-table',
queries: [Query.equal('galleryId', 'g1'), Query.orderDesc('$createdAt'), Query.limit(100)],
});
expect(reports).toEqual([
{ id: 'r2', galleryId: 'g1', reason: 'spam', createdAt: '2026-10-04T11:00:00.000Z' },
{ id: 'r1', galleryId: 'g1', reason: 'urheberrecht', createdAt: '2026-10-04T10:00:00.000Z' },
]);
});
it('der Melder-Hash wird nicht weitergereicht', async () => {
mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' }] });
expect(JSON.stringify(await listReports('g1'))).not.toContain('abc');
});
it('ungültige ID: leere Liste, ohne Anfrage', async () => {
await expect(listReports('../x')).resolves.toEqual([]);
expect(mocks.listRows).not.toHaveBeenCalled();
});
});
describe('loadGalleryImage', () => {
const card = toGalleryCard(row(3));
const blob = new Blob(['x'], { type: 'image/png' });
it("'full' lädt das Bild aus dem Galerie-Bucket", async () => {
mocks.fetchFile.mockResolvedValue(blob);
await expect(loadGalleryImage(card, 'full')).resolves.toBe(blob);
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'img3');
});
it("'preview' lädt die Vorschau aus dem Galerie-Bucket", async () => {
mocks.fetchFile.mockResolvedValue(blob);
await loadGalleryImage(card, 'preview');
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'pre3');
});
it.each(['', '../x', 'a/b', 'a?b', '.hidden', 'x'.repeat(37)])(
'eine Datei-ID, die keine Appwrite-ID sein kann (%j): 404 „Datei fehlt“, ohne Anfrage',
async (fileId) => {
for (const size of ['full', 'preview'] as const) {
const err = await loadGalleryImage({ ...card, imageFileId: fileId, previewFileId: fileId }, size).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(AppwriteException);
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
}
expect(mocks.fetchFile).not.toHaveBeenCalled();
},
);
});
+173
View File
@@ -0,0 +1,173 @@
// Die öffentliche Galerie lesen: Tabelle `gallery` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen).
// Schreiben (Veröffentlichen, Zurückziehen, Melden, Ausblenden) läuft über den API-Dienst, siehe api.ts.
import { AppwriteException, Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite';
export interface GalleryCard {
id: string;
title: string;
issuer: string;
tags: string[];
displayName: string;
imageFileId: string;
previewFileId: string;
sourceCardId: string;
ownerId: string;
hidden: boolean;
reportCount: number;
createdAt: string;
}
export interface GalleryReport {
id: string;
galleryId: string;
reason: string;
createdAt: string;
}
/** Ansicht der Galerie: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */
export type GalleryFilter = 'all' | 'reported' | 'hidden';
export interface GalleryOptions {
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
q?: string;
/** Nur Karten mit diesem Tag (Teilstring in `tags`, klein). */
tag?: string;
cursor?: string;
filter?: GalleryFilter;
}
export const PAGE_SIZE = 24;
/** Obergrenze für eigene Veröffentlichungen und Meldungen einer Karte (eine Seite genügt). */
const LIST_MAX = 100;
type Row = Record<string, unknown>;
/** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */
type TableRow = Models.Row & Row;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Eine Zeile der Tabelle `gallery` als Karte; fehlende oder falsch typisierte Felder werden leer. */
export function toGalleryCard(row: Row): GalleryCard {
const count = row.reportCount;
return {
id: text(row.$id),
title: text(row.title),
issuer: text(row.issuer),
tags: text(row.tags)
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag !== ''),
displayName: text(row.displayName),
imageFileId: text(row.imageFileId),
previewFileId: text(row.previewFileId),
sourceCardId: text(row.sourceCardId),
ownerId: text(row.ownerId),
hidden: row.hidden === true,
reportCount: typeof count === 'number' && Number.isFinite(count) && count > 0 ? count : 0,
createdAt: text(row.$createdAt),
};
}
/** Ab dieser Länge sucht `q` im Volltext-Index; kürzere Texte finden dort nichts. */
const FULLTEXT_MIN = 3;
/**
* Abfragen für eine Seite der Galerie, neueste zuerst. Öffentlich immer nur sichtbare Karten;
* Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon ausgeblendete), „Ausgeblendet“ = hidden.
* Optional: Cursor, Tag (`contains` auf `tags`) und Suchtext (Volltext ab 3 Zeichen, sonst `contains` auf `searchText`).
*/
export function galleryQueries(opts: GalleryOptions): string[] {
const base =
opts.filter === 'reported'
? Query.greaterThan('reportCount', 0)
: opts.filter === 'hidden'
? Query.equal('hidden', true)
: Query.equal('hidden', false);
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
const tag = (opts.tag ?? '').trim().toLowerCase();
if (tag) queries.push(Query.contains('tags', tag));
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
if (q) {
queries.push(
[...q].length >= FULLTEXT_MIN ? Query.search('searchText', q) : Query.contains('searchText', q.toLowerCase()),
);
}
return queries;
}
/** Eine Seite der Galerie. `nextCursor` ist die ID der letzten Karte, solange die Seite voll war, sonst null. */
export async function listGallery(opts: GalleryOptions): Promise<{ cards: GalleryCard[]; nextCursor: string | null }> {
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.gallery,
queries: galleryQueries(opts),
});
const cards = list.rows.map((row) => toGalleryCard(row));
return { cards, nextCursor: cards.length === PAGE_SIZE ? cards[cards.length - 1].id : null };
}
/** „Die Zeile gibt es nicht (oder nicht für mich)“; fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */
function isRowMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 && (type === 'row_not_found' || type === undefined || type === '');
}
/**
* Eine Karte per ID. Gibt es sie nicht oder ist sie für mich ausgeblendet (Appwrite antwortet beides mit 404), ist das
* Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
*/
export async function getGalleryCard(id: string): Promise<GalleryCard | null> {
if (!ID_PATTERN.test(id)) return null;
try {
return toGalleryCard(await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.gallery, rowId: id }));
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/** Die eigenen Veröffentlichungen (auch ausgeblendete), nach privater Karte (`sourceCardId`) geordnet. */
export async function myPublications(userId: string): Promise<Map<string, GalleryCard>> {
const map = new Map<string, GalleryCard>();
if (!userId) return map;
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.gallery,
queries: [Query.equal('ownerId', userId), Query.limit(LIST_MAX)],
});
for (const row of list.rows) {
const card = toGalleryCard(row);
if (card.sourceCardId) map.set(card.sourceCardId, card);
}
return map;
}
/** Admin: die Meldungen einer Karte, neueste zuerst. Der Melder-Hash bleibt außen vor. */
export async function listReports(galleryId: string): Promise<GalleryReport[]> {
if (!ID_PATTERN.test(galleryId)) return [];
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.reports,
queries: [Query.equal('galleryId', galleryId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)],
});
return list.rows.map((row) => ({
id: text(row.$id),
galleryId: text(row.galleryId),
reason: text(row.reason),
createdAt: text(row.$createdAt),
}));
}
/** Lädt das Kartenbild (PNG 1536 px) oder die Vorschau (WebP 480 px) aus dem Galerie-Bucket. */
export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob> {
const fileId = size === 'full' ? card.imageFileId : card.previewFileId;
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
return fetchFile(IDS.galleryBucket, fileId);
}
+423
View File
@@ -0,0 +1,423 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { ApiError } from './api';
import { NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import {
CONSENT_TEXT,
publishDialog,
validatePublishForm,
type PublishFormValues,
type PublishOutcome,
} from './publish-dialog';
type Submit = (form: PublishForm) => Promise<{ id: string }>;
const submit = vi.fn<Submit>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const consent = () => input('consent');
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function fill(values: Partial<PublishFormValues> = {}): void {
const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values };
input('title').value = v.title;
input('issuer').value = v.issuer;
input('tags').value = v.tags;
input('displayName').value = v.displayName;
consent().checked = v.consent;
}
function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<PublishOutcome> {
return publishDialog({ submit, ...opts });
}
beforeEach(() => {
submit.mockReset();
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('Veröffentlichen-Dialog: Felder', () => {
it('zeigt die Felder der Spec in Reihenfolge, alle leer, die Checkbox ungesetzt', () => {
void open({ cardName: 'Urlaub' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'displayName', 'consent']);
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
'Titel (Pflicht)',
'Herausgeber / Bank (optional)',
'Tags (optional)',
'Anzeigename (optional)',
]);
for (const name of ['title', 'issuer', 'tags', 'displayName']) expect(input(name).value).toBe('');
expect(consent().type).toBe('checkbox');
expect(consent().checked).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Veröffentlichen')).toBeTruthy();
});
it('der Anzeigename ist leer und nennt „Anonym“ als Platzhalter (nie eine E-Mail)', () => {
void open();
expect(input('displayName').value).toBe('');
expect(input('displayName').placeholder).toBe('Anonym');
expect(dialogEl()!.textContent).not.toMatch(/@/);
});
it('die Pflicht-Checkbox hat genau den Wortlaut der Spec', () => {
void open();
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
expect(consent().closest('label')!.textContent).toBe(CONSENT_TEXT);
});
it('jedes Feld hat sein Label (label.control === Eingabe), alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => {
void open();
const labels = [...form().querySelectorAll('label')];
const inputs = [...form().querySelectorAll('input')];
expect(labels).toHaveLength(5);
expect(labels.map((l) => l.control)).toEqual(inputs);
for (const control of inputs) expect(control.labels).toHaveLength(1);
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
const heading = dialogEl()!.querySelector('h2')!;
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id);
expect(inputs.map((i) => i.id)).not.toContain(heading.id);
});
it('nennt den Kartennamen als Text, nie als HTML', () => {
void open({ cardName: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
const invalid = async (values: Partial<PublishFormValues>, message: string, field: string) => {
void open();
fill(values);
send();
await settle();
expect(errorText()).toBe(message);
expect(submit).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(input(field));
};
it('Titel leer (auch nur Leerzeichen)', async () => {
await invalid({ title: ' ' }, 'Bitte gib einen Titel ein.', 'title');
});
it('Titel länger als 60 Zeichen', async () => {
await invalid({ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).', 'title');
});
it('Herausgeber länger als 40 Zeichen', async () => {
await invalid({ issuer: 'x'.repeat(41) }, 'Herausgeber / Bank ist zu lang (höchstens 40 Zeichen).', 'issuer');
});
it('sechs Tags', async () => {
await invalid({ tags: 'a, b, c, d, e, f' }, 'Bitte gib höchstens 5 Tags an.', 'tags');
});
it('ein Tag länger als 20 Zeichen', async () => {
await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags');
});
it('Anzeigename länger als 30 Zeichen', async () => {
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
});
it('Checkbox fehlt', async () => {
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
});
it('Steuerzeichen werden abgelehnt (der Server täte es auch)', async () => {
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30), tags: 'A, a, , b, c, d, e,' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledOnce();
});
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
submit.mockResolvedValue({ id: 'g1' });
void open();
fill({ title: '' });
send();
expect(input('title').getAttribute('aria-invalid')).toBe('true');
fill({ title: 'Ok' });
send();
expect(input('title').getAttribute('aria-invalid')).toBeNull();
expect(errorText()).toBe('');
});
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
expect(validatePublishForm(ok)).toBeNull();
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
expect(validatePublishForm({ ...ok, consent: false })?.field).toBe('consent');
});
});
describe('Veröffentlichen-Dialog: Senden', () => {
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
expect(dialogEl()).toBeNull();
});
it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'Nur Titel' });
send();
await outcome;
expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' });
});
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open();
fill();
send();
expect(buttonByText('Veröffentlichen')!.disabled).toBe(true);
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
expect(input('title').disabled).toBe(true);
expect(consent().disabled).toBe(true);
expect(form().getAttribute('aria-busy')).toBe('true');
expect(statusText()).toBe('Wird veröffentlicht …');
send();
expect(submit).toHaveBeenCalledOnce();
finish({ id: 'g1' });
await expect(outcome).resolves.toBe('published');
});
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open();
fill();
send();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
finish({ id: 'g1' });
await outcome;
});
it('schließt der Browser den Dialog mitten im Senden (close-Ereignis), geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
let resolved: PublishOutcome | null = null;
const outcome = open().then((o) => (resolved = o));
fill();
send();
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
await settle();
expect(dialogEl()?.open).toBe(true);
dialogEl()!.dispatchEvent(new Event('close'));
await settle();
expect(resolved).toBeNull();
expect(dialogEl()?.open).toBe(true);
finish({ id: 'g1' });
await outcome;
expect(resolved).toBe('published');
expect(dialogEl()).toBeNull();
});
it('wurde der Dialog mitten im Senden geschlossen und das Senden scheitert, ist der Fehler sichtbar (Dialog wieder offen)', async () => {
let fail!: (e: Error) => void;
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
void open();
fill();
send();
dialogEl()!.close();
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await settle();
expect(dialogEl()?.open).toBe(true);
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
});
it('Fehler des Dienstes: Servertext im Dialog, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
const outcome = open();
fill();
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
expect(input('title').disabled).toBe(false);
expect(input('title').value).toBe('Meine Karte');
expect(consent().checked).toBe(true);
expect(statusText()).toBe('');
submit.mockResolvedValueOnce({ id: 'g1' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledTimes(2);
});
it('Validierungsfehler des Dienstes (400) erscheinen mit ihrem Text', async () => {
submit.mockRejectedValue(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
void open();
fill();
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.');
});
it('409: „Diese Karte ist schon veröffentlicht.“, kein neuer Versuch, Ergebnis „already“ nach „Schließen“', async () => {
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
const outcome = open();
fill();
send();
await settle();
expect(statusText()).toBe('Diese Karte ist schon veröffentlicht.');
expect(errorText()).toBe('');
expect(form().querySelector('input')).toBeNull();
expect(buttonByText('Veröffentlichen')).toBeUndefined();
send();
expect(submit).toHaveBeenCalledOnce();
buttonByText('Schließen')!.click();
await expect(outcome).resolves.toBe('already');
expect(dialogEl()).toBeNull();
});
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“', async () => {
submit.mockRejectedValue(new LoginCancelled());
void open();
fill();
send();
await settle();
expect(errorText()).toBe(LOGIN_NEEDED);
});
it('deutsche Meldungen aus dem Rendern und andere Fehler werden übersetzt', async () => {
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
void open();
fill();
send();
await settle();
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${TOO_LARGE}`);
submit.mockRejectedValueOnce(new Error(NOT_RENDERED));
send();
await settle();
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${NOT_RENDERED}`);
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Keine Verbindung zum Server.');
submit.mockRejectedValueOnce(new Error('boom: interne Details'));
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
});
describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open();
fill();
buttonByText('Abbrechen')!.click();
await expect(outcome).resolves.toBe('cancelled');
expect(submit).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen', async () => {
const first = open();
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
const second = open();
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBe('cancelled');
});
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
void open();
input('title').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Abbrechen-Signal (Seite verlassen) schließt den Dialog, auch während des Sendens', async () => {
const controller = new AbortController();
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open({ signal: controller.signal });
fill();
send();
controller.abort();
await expect(outcome).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
// Eine danach eintreffende Antwort bleibt folgenlos.
finish({ id: 'g1' });
await settle();
});
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
const controller = new AbortController();
let fail!: (e: Error) => void;
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
const outcome = open({ signal: controller.signal });
fill();
send();
controller.abort();
await outcome;
fail(new ApiError(503, 'weg'));
await settle();
expect(dialogEl()).toBeNull();
});
it('der Fokus kehrt zum auslösenden Knopf zurück', async () => {
const opener = document.createElement('button');
document.body.append(opener);
opener.focus();
const outcome = open();
buttonByText('Abbrechen')!.click();
await outcome;
expect(document.activeElement).toBe(opener);
});
});
+285
View File
@@ -0,0 +1,285 @@
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
import '../account/account.css';
import './community.css';
import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { ApiError, isAlreadyPublished } from './api';
import { NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
/** Wortlaut aus Spec § 11. */
export const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
const FAILED_PREFIX = 'Veröffentlichen fehlgeschlagen – ';
const RUNNING = 'Wird veröffentlicht …';
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60;
const ISSUER_MAX = 40;
const DISPLAY_NAME_MAX = 30;
const TAGS_MAX = 5;
const TAG_MAX = 20;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
export interface PublishFormValues extends PublishForm {
consent: boolean;
}
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
/** Das erste Problem in Formularreihenfolge (mit deutschem Text) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
const title = values.title.trim();
if (title === '') return { field: 'title', message: 'Bitte gib einen Titel ein.' };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: 'Der Titel enthält ungültige Zeichen.' };
if (title.length > TITLE_MAX) return { field: 'title', message: `Der Titel ist zu lang (höchstens ${TITLE_MAX} Zeichen).` };
const issuer = values.issuer.trim();
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: 'Herausgeber / Bank enthält ungültige Zeichen.' };
if (issuer.length > ISSUER_MAX) {
return { field: 'issuer', message: `Herausgeber / Bank ist zu lang (höchstens ${ISSUER_MAX} Zeichen).` };
}
const tags = normalizeTags(values.tags);
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: 'Tags enthalten ungültige Zeichen.' };
if (tags.some((tag) => tag.length > TAG_MAX)) {
return { field: 'tags', message: `Ein Tag darf höchstens ${TAG_MAX} Zeichen lang sein.` };
}
if (tags.length > TAGS_MAX) return { field: 'tags', message: `Bitte gib höchstens ${TAGS_MAX} Tags an.` };
const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) {
return { field: 'displayName', message: 'Der Anzeigename enthält ungültige Zeichen.' };
}
if (displayName.length > DISPLAY_NAME_MAX) {
return { field: 'displayName', message: `Der Anzeigename ist zu lang (höchstens ${DISPLAY_NAME_MAX} Zeichen).` };
}
if (!values.consent) return { field: 'consent', message: 'Bitte bestätige die Erklärung zum Bild.' };
return null;
}
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set([TOO_LARGE, NOT_RENDERED, READ_MESSAGE, PIXELS_MESSAGE]);
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return FAILED_PREFIX + err.message;
if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) return FAILED_PREFIX + err.message;
return FAILED_PREFIX + errorMessage(err, 'save');
}
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
export type PublishOutcome = 'published' | 'already' | 'cancelled';
export interface PublishDialogOptions {
/** Veröffentlicht die Karte (rendern, hochladen, API); wirft bei Fehler. */
submit: (form: PublishForm) => Promise<{ id: string }>;
/** Name der privaten Karte für den Einleitungstext (als Text). */
cardName?: string;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
}
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist. Fehler erscheinen im Dialog; er bleibt dann für einen neuen
* Versuch offen. Während des Sendens lässt er sich nicht schließen (Abbrechen gesperrt, Esc und Hintergrund wirkungslos),
* damit niemand glaubt, etwas abgebrochen zu haben, das dann doch veröffentlicht wird; nur `signal` schließt immer.
*/
export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcome> {
return new Promise<PublishOutcome>((resolve, reject) => {
if (opts.signal?.aborted) {
resolve('cancelled');
return;
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let outcome: PublishOutcome = 'cancelled';
let closed = false;
let busy = false;
const text = (name: string, extra: Record<string, string> = {}) =>
el('input', { class: 'auth-input', type: 'text', name, id: `publish-${name}`, autocomplete: 'off', ...extra });
const titleInput = text('title', { 'aria-required': 'true' });
const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
const nameInput = text('displayName', { placeholder: 'Anonym', 'aria-describedby': 'publish-displayName-hint' });
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
title: titleInput,
issuer: issuerInput,
tags: tagsInput,
displayName: nameInput,
consent: consentInput,
};
const field = (label: string, control: HTMLInputElement, hint?: string) =>
el(
'div',
{ class: 'auth-field' },
el('label', { class: 'auth-label', for: control.id }, label),
control,
hint ? el('p', { class: 'auth-hint', id: `${control.id}-hint` }, hint) : '',
);
const intro = el(
'p',
{ class: 'report-text' },
opts.cardName
? `„${opts.cardName}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.`
: 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.',
);
const fields = el(
'div',
{ class: 'publish-fields' },
field('Titel (Pflicht)', titleInput),
field('Herausgeber / Bank (optional)', issuerInput),
field('Tags (optional)', tagsInput, 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.'),
field('Anzeigename (optional)', nameInput, 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, CONSENT_TEXT)),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Veröffentlichen');
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit deutschen Texten statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, 'Karte veröffentlichen'), form),
);
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(outcome);
};
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
function reopen(): boolean {
if (dialog.open) return true;
try {
dialog.showModal();
return true;
} catch {
return false;
}
}
function setBusy(value: boolean): void {
busy = value;
form.setAttribute('aria-busy', String(value));
for (const control of Object.values(inputs)) control.disabled = value;
send.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? RUNNING : '';
}
function clearInvalid(): void {
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
}
function showAlready(): void {
outcome = 'already';
errorEl.textContent = '';
statusEl.textContent = ALREADY_PUBLISHED;
intro.remove();
fields.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
}
async function submit(): Promise<void> {
const values: PublishFormValues = {
title: titleInput.value.trim(),
issuer: issuerInput.value.trim(),
tags: tagsInput.value,
displayName: nameInput.value.trim(),
consent: consentInput.checked,
};
clearInvalid();
errorEl.textContent = '';
const problem = validatePublishForm(values);
if (problem) {
errorEl.textContent = problem.message;
inputs[problem.field].setAttribute('aria-invalid', 'true');
inputs[problem.field].focus();
return;
}
setBusy(true);
try {
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags, displayName: values.displayName });
} catch (err) {
if (closed) return;
if (isAlreadyPublished(err)) {
setBusy(false);
reopen();
showAlready();
return;
}
setBusy(false);
errorEl.textContent = publishFailureText(err);
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
return;
}
if (closed) return;
outcome = 'published';
finish();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || outcome !== 'cancelled') return;
void submit();
});
cancel.addEventListener('click', finish);
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Senden darf der Dialog nicht zugehen.
dialog.addEventListener('cancel', (event) => {
if (busy) event.preventDefault();
});
// Schließt der Browser den Dialog trotzdem mitten im Senden, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
dialog.addEventListener('close', () => {
if (busy && !closed) {
reopen();
return;
}
finish();
});
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
return;
}
titleInput.focus();
});
}
+344
View File
@@ -0,0 +1,344 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config';
import type { SavedCard } from '../account/cards';
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
vi.mock('../account/appwrite', () => ({
IDS: { galleryBucket: 'gallery-bucket' },
account: { createJWT: mocks.createJWT },
client: {},
tables: {},
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
}));
import { ApiError, publishCard } from './api';
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish';
const card: SavedCard = {
id: 'card1',
name: 'Meine Karte',
imageFileId: 'privImage',
previewFileId: 'privPreview',
updatedAt: '2026-10-04T10:00:00.000Z',
config: DEFAULT_CONFIG,
};
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' };
const png = () => new Blob(['png'], { type: 'image/png' });
const webp = () => new Blob(['webp'], { type: 'image/webp' });
type Call = { name: string; args: unknown[] };
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; Dateien bekommen die IDs f1, f2, … */
function fakeDeps(overrides: Partial<PublishDeps> = {}) {
const calls: Call[] = [];
let files = 0;
const log = <T>(name: string, args: unknown[], result: T): T => {
calls.push({ name, args });
return result;
};
const deps: PublishDeps = {
userId: () => log('userId', [], 'u1'),
loadImage: async (c) => log('loadImage', [c], new Blob(['src'], { type: 'image/jpeg' })),
render: async (c, image) => log('render', [c, image], { png: png(), preview: webp() }),
createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: `f${++files}` }),
deleteFile: async (id) => log('deleteFile', [id], undefined),
publishCard: async (input) => log('publishCard', [input], { id: 'g1' }),
...overrides,
};
return {
deps,
calls,
names: () => calls.map((c) => c.name),
of: (name: string) => calls.filter((c) => c.name === name),
};
}
describe('publishSavedCard', () => {
it('Reihenfolge: Nutzer, Bild laden, rendern, Upload, Upload, API; liefert { id }', async () => {
const fake = fakeDeps();
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'render', 'createFile', 'createFile', 'publishCard']);
expect(fake.of('loadImage')[0].args[0]).toBe(card);
expect(fake.of('render')[0].args[0]).toBe(card);
});
it('lädt zuerst das PNG, dann die Vorschau hoch; beide mit read(any) und delete(user:<ich>)', async () => {
const fake = fakeDeps();
await publishSavedCard(card, form, fake.deps);
const [first, second] = fake.of('createFile');
expect((first.args[0] as File).name).toBe('image.png');
expect((first.args[0] as File).type).toBe('image/png');
expect((second.args[0] as File).name).toBe('preview.webp');
expect((second.args[0] as File).type).toBe('image/webp');
for (const call of [first, second]) {
expect(call.args[1]).toEqual(['read("any")', 'delete("user:u1")']);
}
});
it('benennt die Vorschau nach dem echten Blob-Typ (Browser ohne WebP-Export liefern PNG)', async () => {
const fake = fakeDeps({ render: async () => ({ png: png(), preview: png() }) });
await publishSavedCard(card, form, fake.deps);
const preview = fake.of('createFile')[1].args[0] as File;
expect(preview.name).toBe('preview.png');
expect(preview.type).toBe('image/png');
});
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste)', async () => {
const fake = fakeDeps();
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,', displayName: ' K ' }, fake.deps);
expect(fake.of('publishCard')[0].args[0]).toEqual({
title: 'Meine Karte',
issuer: 'Bank',
tags: ['visa', 'gold'],
displayName: 'K',
imageFileId: 'f1',
previewFileId: 'f2',
sourceCardId: 'card1',
});
});
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
it.each([400, 401, 403, 404, 409])(
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
async (code) => {
const failure = new ApiError(code, 'Abgelehnt.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
},
);
it.each([408, 429, 500, 502, 503, 504])(
'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Dateien bleiben liegen, der Fehler kommt an',
async (code) => {
const failure = new ApiError(code, 'Unklar.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(0);
},
);
it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true);
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2);
});
it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => {
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2);
});
});
it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => {
const failure = new Error('upload kaputt');
let uploads = 0;
const fake = fakeDeps({
createFile: async (file, permissions) => {
fake.calls.push({ name: 'createFile', args: [file, permissions] });
if (++uploads === 2) throw failure;
return { $id: 'f1' };
},
});
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('erster Upload scheitert: nichts zu löschen, kein zweiter Upload', async () => {
const failure = new Error('upload kaputt');
const fake = fakeDeps({ createFile: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('löscht nie etwas anderes als die zwei Dateien dieses Versuchs (nicht die privaten Dateien der Karte)', async () => {
const fake = fakeDeps({ publishCard: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')) });
await publishSavedCard(card, form, fake.deps).catch(() => undefined);
const deleted = fake.of('deleteFile').map((c) => c.args[0]);
expect(deleted).not.toContain('privImage');
expect(deleted).not.toContain('privPreview');
expect(deleted.every((id) => id === 'f1' || id === 'f2')).toBe(true);
});
it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => {
const failure = new ApiError(400, 'Bitte gib einen Titel an.');
const attempted: string[] = [];
const fake = fakeDeps({
publishCard: async () => Promise.reject(failure),
deleteFile: async (id) => {
attempted.push(id);
throw new Error('delete kaputt');
},
});
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(attempted.sort()).toEqual(['f1', 'f2']);
});
it('bei Erfolg wird nichts gelöscht', async () => {
const fake = fakeDeps();
await publishSavedCard(card, form, fake.deps);
expect(fake.of('deleteFile')).toHaveLength(0);
});
describe('vor dem Hochladen', () => {
it('ein PNG über 5 MiB wird nicht hochgeladen: „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“', async () => {
const big = new Blob([new Uint8Array(PNG_MAX_BYTES + 1)], { type: 'image/png' });
const fake = fakeDeps({ render: async () => ({ png: big, preview: webp() }) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow(
'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).',
);
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('genau 5 MiB sind erlaubt', async () => {
const exact = new Blob([new Uint8Array(PNG_MAX_BYTES)], { type: 'image/png' });
const fake = fakeDeps({ render: async () => ({ png: exact, preview: webp() }) });
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
});
it('ein Bild mit unzulässigem Typ wird nicht hochgeladen', async () => {
const fake = fakeDeps({ render: async () => ({ png: new Blob(['x'], { type: 'image/jpeg' }), preview: webp() }) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow('Das Kartenbild konnte nicht erzeugt werden.');
expect(fake.of('createFile')).toHaveLength(0);
});
it('scheitert das Rendern, wird nichts hochgeladen oder gelöscht', async () => {
const failure = new Error('Das Bild konnte nicht gelesen werden.');
const fake = fakeDeps({ render: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.names()).toEqual(['userId', 'loadImage']);
});
it('scheitert das Laden des Bildes, passiert nichts weiter', async () => {
const failure = Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' });
const fake = fakeDeps({ loadImage: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.names()).toEqual(['userId']);
});
it('ohne angemeldeten Nutzer wird weder geladen noch gerendert', async () => {
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
const fake = fakeDeps({
userId: () => {
throw failure;
},
});
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.names()).toEqual([]);
});
});
});
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
beforeEach(() => {
mocks.createJWT.mockReset();
mocks.createJWT.mockResolvedValue({ jwt: 'jwt' });
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** Läuft den ganzen Ablauf mit der echten publishCard; `respond` erzeugt bei jedem Aufruf eine neue Antwort. */
async function run(respond: () => Promise<Response>) {
fetchMock.mockImplementation(respond);
const fake = fakeDeps({ publishCard });
const result = await publishSavedCard(card, form, fake.deps).catch((e: unknown) => e);
return { fake, result, deleted: fake.of('deleteFile').map((c) => c.args[0]).sort() };
}
it('201 mit id: Erfolg, nichts gelöscht', async () => {
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
expect(result).toEqual({ id: 'g1' });
expect(deleted).toEqual([]);
});
it.each([
['400 Validierung', async () => json(400, { error: 'Bitte gib einen Titel an.' })],
['401 (auch nach der Wiederholung)', async () => json(401, { error: 'Bitte melde dich an.' })],
['403 fremde Datei', async () => json(403, { error: 'Diese Datei gehört dir nicht.' })],
['403 Dateien belegt oder gesperrt', async () => json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })],
['409 schon veröffentlicht', async () => json(409, { error: 'Diese Karte ist schon veröffentlicht.' })],
])('%s: beide Dateien werden gelöscht', async (_name, respond) => {
const { result, deleted } = await run(respond);
expect(result).toBeInstanceOf(ApiError);
expect(deleted).toEqual(['f1', 'f2']);
});
it.each([
['429 Drosselung', async () => json(429, { error: 'Zu viele Anfragen.' })],
['408 Zeitüberschreitung', async () => json(408, { error: 'Zeit abgelaufen.' })],
['500 Serverfehler', async () => json(500, { error: 'Interner Fehler.' })],
['502 Dienst nicht erreichbar', async () => json(502, { error: 'Der Dienst ist gerade nicht erreichbar.' })],
['503 vom Proxy', async () => new Response('<html>503</html>', { status: 503 })],
['504 vom Proxy', async () => new Response('<html>504</html>', { status: 504 })],
['Netzfehler (Antwort verloren)', async () => Promise.reject(new TypeError('Failed to fetch'))],
['Zeitüberschreitung des Clients', async () => Promise.reject(new DOMException('timed out', 'TimeoutError'))],
['HTML mit Status 200', async () => new Response('<!doctype html>', { status: 200 })],
['201 ohne id', async () => json(201, {})],
])('%s: Ausgang offen, die Dateien bleiben liegen', async (_name, respond) => {
const { result, deleted } = await run(respond);
expect(result).toBeInstanceOf(ApiError);
expect(deleted).toEqual([]);
});
it('createJWT scheitert mit 401 (keine Sitzung): es ging nichts raus, die Dateien werden gelöscht', async () => {
mocks.createJWT.mockRejectedValue(Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }));
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
expect(result).toMatchObject({ code: 401 });
expect(fetchMock).not.toHaveBeenCalled();
expect(deleted).toEqual(['f1', 'f2']);
});
it('JWT-Methode am Server aus: es ging nichts raus, die Dateien werden gelöscht', async () => {
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
expect(result).toBeInstanceOf(ApiError);
expect(fetchMock).not.toHaveBeenCalled();
expect(deleted).toEqual(['f1', 'f2']);
});
});
describe('normalizeTags', () => {
it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => {
expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']);
});
it('leere Eingabe ergibt eine leere Liste', () => {
expect(normalizeTags('')).toEqual([]);
expect(normalizeTags(' , , ')).toEqual([]);
});
it('behält die Reihenfolge der ersten Nennung', () => {
expect(normalizeTags('b, a, b, c, a')).toEqual(['b', 'a', 'c']);
});
it('kürzt nichts: Grenzen (5 Tags, 20 Zeichen) prüft der Server mit deutscher Meldung', () => {
const long = 'x'.repeat(30);
expect(normalizeTags(`a,b,c,d,e,f,${long}`)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', long]);
});
it('Umlaute werden mit klein geschrieben', () => {
expect(normalizeTags('ÄÖÜ, Straße')).toEqual(['äöü', 'straße']);
});
});
+133
View File
@@ -0,0 +1,133 @@
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
// Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder.
import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets } from '../card/render';
import { decodeImage } from '../editor/upload';
import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880;
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
const GALLERY_TYPES = ['image/png', 'image/webp'];
export interface PublishForm {
title: string;
issuer: string;
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
tags: string;
displayName: string;
}
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
export interface PublishDeps {
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
userId(): string;
/** Das Originalbild der privaten Karte. */
loadImage(card: SavedCard): Promise<Blob>;
/** Rendert Kartenbild (PNG, 1536 px) und Vorschau (WebP, 480 px). */
render(card: SavedCard, image: Blob): Promise<{ png: Blob; preview: Blob }>;
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
deleteFile(fileId: string): Promise<void>;
publishCard(input: PublishInput): Promise<{ id: string }>;
}
/** Wie der Server: an Kommas trennen, trimmen, klein schreiben, Leere und Doppelte entfernen. Grenzen prüft der Server. */
export function normalizeTags(input: string): string[] {
const tags: string[] = [];
for (const part of input.split(',')) {
const tag = part.trim().toLowerCase();
if (tag !== '' && !tags.includes(tag)) tags.push(tag);
}
return tags;
}
async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
const image = await decodeImage(imageBlob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
const assets = await loadCardAssets(config, image);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
return { png, preview };
}
function defaultDeps(): PublishDeps {
const service = cardsService();
return {
userId: requireUserId,
loadImage: (card) => service.loadImage(card),
render: renderForGallery,
// Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat.
createFile: (file, permissions) =>
storage.createFile({ bucketId: IDS.galleryBucket, fileId: ID.unique(), file, permissions }),
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.galleryBucket, fileId });
},
publishCard,
};
}
/** Räumt auf, soweit es geht; ein Fehler (auch 404) darf den eigentlichen Fehler nie überdecken. */
async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void> {
await Promise.allSettled(fileIds.map((id) => deps.deleteFile(id)));
}
/**
* Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen.
*
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409, oder schon der JWT-Abruf), wurde nichts angelegt:
* beide Dateien werden gelöscht.
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
* Der Fehler wird in jedem Fall weitergeworfen.
*/
export async function publishSavedCard(
card: SavedCard,
form: PublishForm,
deps: PublishDeps = defaultDeps(),
): Promise<{ id: string }> {
// Ohne Nutzer wirft das hier, bevor etwas geladen oder gerendert wird.
const userId = deps.userId();
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
const source = await deps.loadImage(card);
const { png, preview } = await deps.render(card, source);
// Alles vorab prüfen: ein unzulässiges oder zu großes Bild darf keine halben Uploads hinterlassen.
if (png.type !== 'image/png' || !GALLERY_TYPES.includes(preview.type)) throw new Error(NOT_RENDERED);
if (png.size > PNG_MAX_BYTES || preview.size > PNG_MAX_BYTES) throw new Error(TOO_LARGE);
const imageFile = toUploadFile(png, 'image');
// Der Name folgt dem echten Typ: Browser ohne WebP-Export liefern für „webp“ ein PNG.
const previewFile = toUploadFile(preview, 'preview');
const uploaded: string[] = [];
try {
uploaded.push((await deps.createFile(imageFile, permissions)).$id);
uploaded.push((await deps.createFile(previewFile, permissions)).$id);
} catch (err) {
// Die API wurde noch nicht gerufen: es gibt keine Zeile, die auf die Dateien zeigen könnte.
await deleteQuietly(deps, uploaded);
throw err;
}
try {
return await deps.publishCard({
title: form.title.trim(),
issuer: form.issuer.trim(),
tags: normalizeTags(form.tags),
displayName: form.displayName.trim(),
imageFileId: uploaded[0],
previewFileId: uploaded[1],
sourceCardId: card.id,
});
} catch (err) {
if (isDefinitiveFailure(err)) await deleteQuietly(deps, uploaded);
throw err;
}
}
+241
View File
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// api.ts hängt am Appwrite-Client; der Dialog bekommt `send` von außen, ApiError wird nur als Klasse gebraucht.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { ApiError, type ReportReason } from './api';
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
type Send = (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
const send = vi.fn<Send>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const radios = () => [...(dialogEl()?.querySelectorAll<HTMLInputElement>('input[type="radio"]') ?? [])];
const choose = (value: string) => {
const radio = radios().find((r) => r.value === value) as HTMLInputElement;
radio.checked = true;
radio.dispatchEvent(new Event('change', { bubbles: true }));
};
const submit = () => dialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function open(opts: { cardTitle?: string; signal?: AbortSignal } = {}): Promise<ReportOutcome> {
return reportDialog({ send, ...opts });
}
beforeEach(() => {
send.mockReset();
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('Melden-Dialog', () => {
it('zeigt die fünf Gründe in der Reihenfolge der Spec, keiner ist vorgewählt', () => {
void open();
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
expect(REPORT_REASONS.map((r) => r.label)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
expect(radios().some((r) => r.checked)).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Melden')).toBeTruthy();
});
it('nennt den Kartentitel als Text, nie als HTML', () => {
void open({ cardTitle: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('ohne Grund: Hinweis, nichts wird gesendet', async () => {
void open();
submit();
await settle();
expect(errorText()).toBe('Bitte wähle einen Grund.');
expect(send).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
});
it.each([
['unangemessen', 'unangemessen'],
['kartendaten', 'kartendaten'],
['urheberrecht', 'urheberrecht'],
['spam', 'spam'],
['sonstiges', 'sonstiges'],
])('Grund %s geht als %s an die API', async (value, expected) => {
send.mockResolvedValue({ schonGemeldet: false });
void open();
choose(value);
submit();
await settle();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(expected);
});
it('neue Meldung: Dank im Dialog, „Schließen“ beendet mit „reported“', async () => {
send.mockResolvedValue({ schonGemeldet: false });
const result = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
expect(errorText()).toBe('');
expect(radios()).toHaveLength(0);
expect(buttonByText('Melden')).toBeUndefined();
expect(dialogEl()?.open).toBe(true);
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
expect(dialogEl()).toBeNull();
});
it('doppelte Meldung: „Danke, du hast diese Karte schon gemeldet.“ und „duplicate“', async () => {
send.mockResolvedValue({ schonGemeldet: true });
const result = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke, du hast diese Karte schon gemeldet.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Drosselung (429): der Servertext erscheint, der Dialog bleibt und lässt einen neuen Versuch zu', async () => {
send
.mockRejectedValueOnce(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'))
.mockResolvedValueOnce({ schonGemeldet: false });
const result = open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Zu viele Meldungen – bitte später nochmal.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Melden')!.disabled).toBe(false);
expect(radios().every((r) => !r.disabled)).toBe(true);
// Die Auswahl bleibt stehen.
expect(radios().find((r) => r.checked)?.value).toBe('spam');
submit();
await settle();
expect(send).toHaveBeenCalledTimes(2);
expect(errorText()).toBe('');
expect(statusText()).toBe('Danke für deine Meldung.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
});
it('andere Fehler des Dienstes zeigen ihren Servertext (z. B. 404, 503)', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
void open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr).');
send.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
submit();
await settle();
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
});
it('ein unerwarteter Fehler (kein ApiError) bekommt einen allgemeinen deutschen Text', async () => {
send.mockRejectedValueOnce(new TypeError('offline'));
void open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Keine Verbindung zum Server.');
});
it('während des Sendens ist alles gesperrt, ein zweites Absenden sendet nichts', async () => {
let finish!: (v: { schonGemeldet: boolean }) => void;
send.mockReturnValue(new Promise((r) => (finish = r)));
void open();
choose('spam');
submit();
await settle();
expect(buttonByText('Melden')!.disabled).toBe(true);
expect(radios().every((r) => r.disabled)).toBe(true);
expect(dialogEl()!.querySelector('form')!.getAttribute('aria-busy')).toBe('true');
submit();
await settle();
expect(send).toHaveBeenCalledTimes(1);
finish({ schonGemeldet: false });
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
});
it('Abbrechen schließt ohne zu senden: „cancelled“', async () => {
const result = open();
buttonByText('Abbrechen')!.click();
await expect(result).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
expect(send).not.toHaveBeenCalled();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen ebenfalls', async () => {
const first = open();
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
const second = open();
const dialog = dialogEl()!;
dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialog.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
it('ein Klick im Inneren schließt nicht', () => {
void open();
dialogEl()!.querySelector('.report-card')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Signal (Seite verlassen) schließt den Dialog; eine spät eintreffende Antwort ändert nichts', async () => {
let finish!: (v: { schonGemeldet: boolean }) => void;
send.mockReturnValue(new Promise((r) => (finish = r)));
const controller = new AbortController();
const result = open({ signal: controller.signal });
choose('spam');
submit();
await settle();
controller.abort();
await expect(result).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
finish({ schonGemeldet: false });
await settle();
expect(dialogEl()).toBeNull();
});
it('ein schon abgebrochenes Signal öffnet gar nicht erst', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
});
+156
View File
@@ -0,0 +1,156 @@
// Melden-Dialog der Detailseite: Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
import '../account/account.css';
import './community.css';
import { errorMessage } from '../account/errors';
import { el } from '../editor/dom';
import { ApiError, type ReportReason } from './api';
/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */
export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [
{ value: 'unangemessen', label: 'Unangemessen' },
{ value: 'kartendaten', label: 'Echte Kartendaten' },
{ value: 'urheberrecht', label: 'Urheberrecht/Marke' },
{ value: 'spam', label: 'Spam' },
{ value: 'sonstiges', label: 'Sonstiges' },
];
export const REPORT_THANKS = 'Danke für deine Meldung.';
/** Wortlaut aus Spec § 11. */
export const REPORT_DUPLICATE = 'Danke, du hast diese Karte schon gemeldet.';
const NO_REASON = 'Bitte wähle einen Grund.';
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled';
export interface ReportDialogOptions {
/** Sendet die Meldung (`reportCard` der API). */
send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
/** Titel der Karte für die Überschrift des Textes (als Text). */
cardTitle?: string;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
}
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem Ergebnis der letzten Meldung, sonst mit `cancelled`.
* Fehler des Dienstes erscheinen mit ihrem Servertext im Dialog; der Dialog bleibt dann offen für einen neuen Versuch.
*/
export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome> {
return new Promise<ReportOutcome>((resolve, reject) => {
if (opts.signal?.aborted) {
resolve('cancelled');
return;
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let outcome: ReportOutcome = 'cancelled';
let closed = false;
let busy = false;
const radios = REPORT_REASONS.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
const reasons = el(
'fieldset',
{ class: 'report-reasons' },
el('legend', { class: 'report-legend' }, 'Grund der Meldung'),
...REPORT_REASONS.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], label)),
);
const intro = el(
'p',
{ class: 'report-text' },
opts.cardTitle ? `Warum möchtest du „${opts.cardTitle}“ melden?` : 'Warum möchtest du diese Karte melden?',
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Melden');
const actions = el('div', { class: 'report-actions' }, cancel, send);
const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog', 'aria-labelledby': 'report-title' },
el('div', { class: 'report-card' }, el('h2', { id: 'report-title', class: 'report-title' }, 'Karte melden'), form),
);
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(outcome);
};
function setBusy(value: boolean): void {
busy = value;
form.setAttribute('aria-busy', String(value));
for (const radio of radios) radio.disabled = value;
send.disabled = value;
}
function showResult(duplicate: boolean): void {
outcome = duplicate ? 'duplicate' : 'reported';
errorEl.textContent = '';
statusEl.textContent = duplicate ? REPORT_DUPLICATE : REPORT_THANKS;
intro.remove();
reasons.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
}
async function submit(): Promise<void> {
const chosen = REPORT_REASONS.find((_, i) => radios[i].checked);
if (!chosen) {
statusEl.textContent = '';
errorEl.textContent = NO_REASON;
return;
}
setBusy(true);
errorEl.textContent = '';
statusEl.textContent = '';
try {
const res = await opts.send(chosen.value);
if (closed) return;
showResult(res.schonGemeldet);
} catch (err) {
if (closed) return;
// Der Dienst liefert deutsche Texte (auch für 429); alles andere (Netz, JWT) übersetzt errorMessage.
errorEl.textContent = err instanceof ApiError ? err.message : errorMessage(err, 'load');
setBusy(false);
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || outcome !== 'cancelled') return;
void submit();
});
cancel.addEventListener('click', finish);
// Esc schließt nativ und löst `close` aus.
dialog.addEventListener('close', finish);
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
}
});
}
+7
View File
@@ -0,0 +1,7 @@
// Konfiguration der Website.
/**
* Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/<name>`).
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
*/
export const DONATE_URL: string = '';
+5 -4
View File
@@ -67,7 +67,7 @@ beforeEach(() => {
describe('bindEditorToAuth', () => {
it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
design();
@@ -76,7 +76,7 @@ describe('bindEditorToAuth', () => {
expect(store.get().image).toBeNull();
expect(store.get().saved).toBeNull();
expect(location.search).toBe('');
expect(location.pathname).toBe('/');
expect(location.pathname).toBe('/gestalten');
});
it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => {
@@ -90,7 +90,7 @@ describe('bindEditorToAuth', () => {
});
it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
const { blob, config } = design();
@@ -172,10 +172,11 @@ describe('bindEditorToAuth', () => {
});
it('lässt andere Teile der Adresse stehen', () => {
history.replaceState(null, '', '/?karte=k1&x=1#h');
history.replaceState(null, '', '/gestalten?karte=k1&x=1#h');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
fake.logout();
expect(location.pathname).toBe('/gestalten');
expect(location.search).toBe('?x=1');
expect(location.hash).toBe('#h');
});
+16 -5
View File
@@ -1,9 +1,20 @@
// Die Adresse des Editors kann eine Karte nennen (`/?karte=<id>`).
// Die Adresse des Editors kann eine Karte (`/gestalten?karte=<id>`; alte Links `/?karte=<id>` leitet main.ts um)
// oder eine Community-Vorlage (`/gestalten?vorlage=<galleryId>`) nennen.
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
export function dropCardParam(): void {
/** Entfernt den Parameter aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen oder „Zurück“ nicht erneut lädt. */
function dropParam(name: string): void {
const url = new URL(location.href);
if (!url.searchParams.has('karte')) return;
url.searchParams.delete('karte');
if (!url.searchParams.has(name)) return;
url.searchParams.delete(name);
history.replaceState(null, '', url.pathname + url.search + url.hash);
}
/** Entfernt `karte` aus der Adresse. */
export function dropCardParam(): void {
dropParam('karte');
}
/** Entfernt `vorlage` aus der Adresse. */
export function dropTemplateParam(): void {
dropParam('vorlage');
}
+2 -2
View File
@@ -7,11 +7,11 @@
border-radius: 20px;
background: var(--panel);
color: var(--text);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
box-shadow: var(--shadow-lg);
}
.confirm-dialog::backdrop {
background: rgb(6 11 20 / 0.72);
background: var(--backdrop);
}
.confirm-card {
+7
View File
@@ -25,6 +25,13 @@ const ICONS = {
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
sun:
'<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/>' +
'<path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/>' +
'<path d="m19.07 4.93-1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
} as const;
export type IconName = keyof typeof ICONS;
+237 -9
View File
@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
getGalleryCard: vi.fn(),
loadGalleryImage: vi.fn(),
logoutListeners: new Set<() => void>(),
}));
@@ -37,6 +39,10 @@ vi.mock('../card/render', () => ({
loadCardAssets: mocks.loadCardAssets,
exportCard: mocks.exportCard,
}));
vi.mock('../community/gallery', () => ({
getGalleryCard: mocks.getGalleryCard,
loadGalleryImage: mocks.loadGalleryImage,
}));
vi.mock('./upload', async (importOriginal) => ({
...(await importOriginal<typeof import('./upload')>()),
decodeImage: mocks.decodeImage,
@@ -297,8 +303,8 @@ describe('Speichern', () => {
});
});
describe('Karte öffnen (/?karte=<id>)', () => {
const open = (id: string) => history.replaceState(null, '', `/?karte=${id}`);
describe('Karte öffnen (/gestalten?karte=<id>)', () => {
const open = (id: string) => history.replaceState(null, '', `/gestalten?karte=${id}`);
it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => {
open('k1');
@@ -531,7 +537,7 @@ describe('Karte öffnen (/?karte=<id>)', () => {
expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob);
expect(store.get().image).toBe(img);
expect(location.pathname + location.search).toBe('/');
expect(location.pathname + location.search).toBe('/gestalten');
});
it('Esc im Dialog verwirft nichts', async () => {
@@ -733,7 +739,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
});
it('ist nach dem Öffnen einer Karte da', async () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
@@ -758,7 +764,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
describe('„Neue Karte“', () => {
it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
const { config } = boundEditor();
mount();
newCardButton().click();
@@ -774,7 +780,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
expect(s.config).not.toBe(DEFAULT_CONFIG);
expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop);
expect(s.config).not.toBe(config);
expect(location.pathname + location.search).toBe('/');
expect(location.pathname + location.search).toBe('/gestalten');
expect(boundRow().hidden).toBe(true);
});
@@ -787,7 +793,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
});
it('bricht eine noch ladende Karte ab', async () => {
history.replaceState(null, '', '/?karte=k2');
history.replaceState(null, '', '/gestalten?karte=k2');
boundEditor();
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
@@ -869,7 +875,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
});
it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image(3000, 2000));
@@ -928,7 +934,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
describe('Abmelden während des Ladens', () => {
it('eine noch ladende Karte landet nicht mehr im Editor', async () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
@@ -975,3 +981,225 @@ describe('Speichern: Schnappschuss beim Klick', () => {
});
});
describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`);
const galleryCard = (patch: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Mitternachtsblau',
imageFileId: 'gimg1',
previewFileId: 'gpre1',
ownerId: 'u9',
hidden: false,
...patch,
});
const dialogButton = (label: string) =>
[...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
const available = (img: CardImage = image(1536, 969), blob = png()) => {
mocks.getGalleryCard.mockResolvedValue(galleryCard());
mocks.loadGalleryImage.mockResolvedValue(blob);
mocks.decodeImage.mockResolvedValue(img);
return { img, blob };
};
it('lädt das Galerie-Vollbild und setzt Bild, Vorlage „Nur Bild“ und einen leeren Speicherstand', async () => {
openTemplate('g1');
const { img, blob } = available();
mount();
expect(noticeText()).toBe('Vorlage wird geladen …');
await vi.waitFor(() => expect(store.get().image).toBe(img));
expect(mocks.getGalleryCard).toHaveBeenCalledWith('g1');
expect(mocks.loadGalleryImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full');
expect(mocks.decodeImage).toHaveBeenCalledWith(blob);
const s = store.get();
expect(s.imageBlob).toBe(blob);
expect(s.imageChanged).toBe(true);
expect(s.config.template).toBe('image');
expect(s.config.crop).toEqual({ x: 0, y: 0, zoom: 1 });
expect(s.saved).toBeNull();
expect(s.savedConfig).toBeNull();
expect(s.cardName).toBe('Meine Karte');
expect(noticeText()).toBe('');
expect(errorText()).toBe('');
// kein erneuter Import bei Zurück oder Neuladen
expect(location.pathname + location.search).toBe('/gestalten');
});
it('eine zuvor geöffnete gespeicherte Karte wird entkoppelt: Speichern legt eine neue Karte an', async () => {
openTemplate('g1');
const { blob } = available();
const config = { ...store.get().config, template: 'classic' as const, network: 'jcb' as const };
store.set({
image: image(),
imageBlob: png(),
imageChanged: false,
config,
savedConfig: config,
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
cardName: 'Sieben',
});
mount();
await vi.waitFor(() => expect(store.get().imageBlob).toBe(blob));
// gespeicherte Karte ohne Änderungen: keine Rückfrage
expect(discardDialogEl()).toBeNull();
const s = store.get();
expect(s.saved).toBeNull();
expect(s.savedConfig).toBeNull();
expect(s.cardName).toBe('Meine Karte');
expect(s.config.template).toBe('image');
mocks.save.mockResolvedValue(savedCard({ id: 'neu', name: 'Meine Karte' }));
saveButton().click();
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: null, name: 'Meine Karte', image: blob, imageChanged: true });
});
it('ungespeicherte Arbeit: Rückfrage; „Verwerfen“ übernimmt die Vorlage', async () => {
openTemplate('g1');
const { img } = available();
withImage();
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?');
// Solange gefragt wird, bleibt der Entwurf.
expect(store.get().image).not.toBe(img);
dialogButton('Verwerfen').click();
await vi.waitFor(() => expect(store.get().image).toBe(img));
expect(store.get().config.template).toBe('image');
expect(discardDialogEl()).toBeNull();
expect(noticeText()).toBe('');
expect(location.pathname + location.search).toBe('/gestalten');
});
it('„Abbrechen“: Entwurf bleibt, ?vorlage= verschwindet aus der Adresse', async () => {
openTemplate('g1');
available();
const { blob, img } = withImage();
const before = store.get();
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
dialogButton('Abbrechen').click();
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
await vi.waitFor(() => expect(noticeText()).toBe(''));
expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob);
expect(store.get().image).toBe(img);
expect(location.pathname + location.search).toBe('/gestalten');
});
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => {
openTemplate('g1');
available();
withImage();
const before = store.get();
mount();
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
unmount?.();
unmount = null;
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
await new Promise((r) => setTimeout(r, 0));
expect(store.get()).toBe(before);
});
it('nach dem Import lädt ein erneutes Öffnen der Seite die Vorlage nicht noch einmal', async () => {
openTemplate('g1');
const { img } = available();
mount();
await vi.waitFor(() => expect(store.get().image).toBe(img));
unmount?.();
unmount = null;
mount();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.getGalleryCard).toHaveBeenCalledTimes(1);
});
it('Vorlage nicht (mehr) vorhanden: Hinweis, der Editor bleibt', async () => {
openTemplate('weg');
mocks.getGalleryCard.mockResolvedValue(null);
const { blob } = withImage();
const before = store.get();
mount();
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
expect(mocks.loadGalleryImage).not.toHaveBeenCalled();
expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob);
expect(noticeText()).toBe('');
expect(discardDialogEl()).toBeNull();
});
it('Bild lässt sich nicht laden oder nicht dekodieren: derselbe Hinweis, nichts wird übernommen', async () => {
const remount = () => {
unmount?.();
unmount = null;
mount();
};
openTemplate('g1');
mocks.getGalleryCard.mockResolvedValue(galleryCard());
mocks.loadGalleryImage.mockRejectedValueOnce(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
expect(store.get().image).toBeNull();
expect(noticeText()).toBe('');
mocks.loadGalleryImage.mockResolvedValue(png());
mocks.decodeImage.mockRejectedValue(new Error('Das Bild konnte nicht gelesen werden.'));
remount();
await vi.waitFor(() => expect(mocks.decodeImage).toHaveBeenCalled());
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
expect(store.get().image).toBeNull();
mocks.getGalleryCard.mockRejectedValue(new TypeError('offline'));
remount();
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
expect(store.get().image).toBeNull();
});
it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => {
openTemplate('g1');
let finish!: (c: unknown) => void;
mocks.getGalleryCard.mockReturnValue(new Promise((r) => (finish = r)));
mount();
unmount?.();
unmount = null;
finish(galleryCard());
await new Promise((r) => setTimeout(r, 0));
expect(mocks.loadGalleryImage).not.toHaveBeenCalled();
expect(store.get().image).toBeNull();
});
it('wählt der Nutzer während des Ladens selbst ein Bild, gewinnt dieses', async () => {
openTemplate('g1');
let finish!: (c: unknown) => void;
mocks.getGalleryCard.mockReturnValue(new Promise((r) => (finish = r)));
mocks.decodeImage.mockResolvedValue(image(500, 500));
mount();
const file = new File(['x'], 'neu.png', { type: 'image/png' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
drop.dataTransfer = { types: ['Files'], files: [file] };
root.querySelector('.editor')!.dispatchEvent(drop);
await vi.waitFor(() => expect(store.get().image?.w).toBe(500));
finish(galleryCard());
await new Promise((r) => setTimeout(r, 0));
expect(mocks.loadGalleryImage).not.toHaveBeenCalled();
expect(store.get().image?.w).toBe(500);
expect(store.get().config.template).not.toBe('image');
expect(noticeText()).toBe('');
});
it('?karte= hat Vorrang: dann wird keine Vorlage geladen', async () => {
history.replaceState(null, '', '/gestalten?karte=k1&vorlage=g1');
mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(mocks.getGalleryCard).not.toHaveBeenCalled();
});
it('ohne ?vorlage wird nichts aus der Galerie geladen', async () => {
mount();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.getGalleryCard).not.toHaveBeenCalled();
});
});
+59 -6
View File
@@ -7,9 +7,10 @@ import { requireLogin } from '../account/login-dialog';
import { LoginCancelled, withLogin } from '../account/with-login';
import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type NetworkId, type TemplateId } from '../card/config';
import { clampCrop, panBy, zoomTo } from '../card/crop';
import { CARD_W } from '../card/geometry';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
import { dropCardParam } from './card-url';
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { dropCardParam, dropTemplateParam } from './card-url';
import { confirmDialog } from './confirm-dialog';
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
import { el } from './dom';
@@ -24,9 +25,9 @@ const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
const NO_IMAGE = 'Bitte zuerst ein Bild wählen.';
const LOADING_CARD = 'Karte wird geladen …';
const LOADING_TEMPLATE = 'Vorlage wird geladen …';
const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.';
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.';
const PREVIEW_WIDTH = 480;
const PREVIEW_QUALITY = 0.85;
const SAVED_FLASH_MS = 2000;
type Logos = Pick<CardAssets, 'network' | 'brand'>;
@@ -280,6 +281,52 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
}
}
// Community-Vorlage (/gestalten?vorlage=<galleryId>): das Galerie-Vollbild wird als „Nur Bild“ übernommen, als neue Karte.
// Teilt sich das Token mit openSavedCard: nur der zuletzt angeforderte Ladevorgang zählt.
async function openTemplate(galleryId: string): Promise<void> {
const token = ++loadToken;
const stale = () => disposed || token !== loadToken;
preview.setError(null);
preview.setWarning(null);
preview.setNotice(LOADING_TEMPLATE);
try {
// Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404).
const card = await getGalleryCard(galleryId);
if (stale()) return;
if (!card) throw new Error(TEMPLATE_UNAVAILABLE);
const blob = await loadGalleryImage(card, 'full');
if (stale()) return;
const image = await decodeImage(blob);
if (stale()) return;
// Die Vorlage würde einen ungespeicherten Entwurf ersetzen: erst fragen (wie beim Öffnen einer Karte).
if (hasUnsavedWork(store.get())) {
const discard = await askToDiscard();
if (stale()) return;
if (!discard) {
dropTemplateParam();
return;
}
}
setImage(store, image, blob);
// Die Vorlage ist eine neue Karte: sonst überschriebe Speichern eine zuvor geöffnete gespeicherte Karte.
store.set({
config: { ...store.get().config, template: 'image' },
saved: null,
savedConfig: null,
cardName: initialEditorState().cardName,
});
// Kein erneuter Import bei „Zurück“ oder Neuladen.
dropTemplateParam();
} catch {
if (stale()) return;
preview.setError(TEMPLATE_UNAVAILABLE);
} finally {
if (!stale()) preview.setNotice(null);
}
}
// Rückfrage vor dem Verwerfen von Änderungen („Neue Karte“, Öffnen einer anderen Karte).
let askingToDiscard = false;
async function askToDiscard(): Promise<boolean> {
@@ -398,8 +445,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
});
// Die Adresse wird nur beim Öffnen der Seite gelesen; hält der Editor die Karte schon, bleibt sein Stand.
const cardId = new URLSearchParams(location.search).get('karte');
if (cardId && store.get().saved?.id !== cardId) void openSavedCard(cardId);
const params = new URLSearchParams(location.search);
const cardId = params.get('karte');
const templateId = params.get('vorlage');
if (cardId) {
if (store.get().saved?.id !== cardId) void openSavedCard(cardId);
} else if (templateId) {
void openTemplate(templateId);
}
return () => {
disposed = true;
+11 -3
View File
@@ -183,6 +183,13 @@
-webkit-user-drag: none;
}
/* Die Logos sind für die dunkle Karte gemacht (weiß, silbern, Umriss): im hellen Theme stehen sie wie auf der Karte auf deren Grund. */
:root[data-theme='light'] .mark-img {
padding: 5px 8px;
border-radius: 8px;
background: #1b2738;
}
.mark-none {
width: 46px;
height: 46px;
@@ -356,7 +363,7 @@
height: auto;
border-radius: var(--card-radius);
background: #1b2738;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-card);
cursor: grab;
touch-action: none;
user-select: none;
@@ -402,11 +409,11 @@
}
.msg-error {
color: #ff9a9d;
color: var(--danger-text);
}
.msg-warning {
color: #f2c66d;
color: var(--warn-text);
}
.msg-notice {
@@ -456,6 +463,7 @@
.btn-primary {
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
font-weight: 700;
}
+32 -7
View File
@@ -2,18 +2,37 @@ import '@fontsource-variable/nunito-sans';
import './styles.css';
import { mountAccountMenu } from './account/account-menu';
import { getCurrentUser } from './account/auth';
import { startRouter } from './pages/router';
import { DONATE_URL } from './config';
import { legacyRedirect, startRouter } from './pages/router';
import { mountCardsPage } from './pages/cards-page';
import { mountCommunityPage } from './pages/community-page';
import { mountHomePage } from './pages/home-page';
import { mountResetPage } from './pages/reset-page';
import { mountEditor } from './editor/editor-page';
import { bindEditorToAuth } from './editor/auth-reset';
import { createStore, initialEditorState } from './editor/state';
import { mountDonateBanner } from './shell/donate-banner';
import { mountHeader } from './shell/header';
import { initTheme } from './shell/theme';
// Hell/Dunkel: das Skript in index.html hat es vor dem ersten Paint gesetzt; hier wird nur die Browserleiste angeglichen.
initTheme();
// Alte Editor-Links (`/?karte=<id>`) zeigen jetzt auf `/gestalten?karte=<id>`; der Verlauf bekommt keinen Eintrag dazu.
const legacyTarget = legacyRedirect(new URL(location.href));
if (legacyTarget) history.replaceState(null, '', legacyTarget);
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
const editorStore = createStore(initialEditorState());
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
bindEditorToAuth(editorStore);
// Spenden-Banner (ohne DONATE_URL bleibt der Platz leer) und Kopfzeile mit Navigation, Suche und Knöpfen.
const bannerSlot = document.getElementById('donate-banner');
if (bannerSlot) mountDonateBanner(bannerSlot, DONATE_URL);
const header = document.getElementById('app-header');
if (header) mountHeader(header, { donateUrl: DONATE_URL });
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
const accountSlot = document.getElementById('account-slot');
@@ -26,12 +45,18 @@ if (accountSlot) {
.finally(() => menu.ready());
}
// Der Router setzt `document.title` je Seite (titleFor) und ruft für jede Route die passende Mount-Funktion.
startRouter((route, root) => {
if (route === 'editor') {
return mountEditor(root, editorStore);
switch (route.id) {
case 'gestalten':
return mountEditor(root, editorStore);
case 'cards':
return mountCardsPage(root, editorStore);
case 'community':
return mountCommunityPage(root, route.params.id ?? '');
case 'reset':
return mountResetPage(root);
case 'home':
return mountHomePage(root);
}
if (route === 'reset') {
return mountResetPage(root);
}
return mountCardsPage(root, editorStore);
});
+576 -14
View File
@@ -3,6 +3,7 @@ import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config';
import { createStore, initialEditorState } from '../editor/state';
import type { GalleryCard } from '../community/gallery';
const mocks = vi.hoisted(() => ({
list: vi.fn(),
@@ -17,6 +18,9 @@ const mocks = vi.hoisted(() => ({
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
saveBlobAs: vi.fn(),
publishSavedCard: vi.fn(),
withdrawCard: vi.fn(),
myPublications: vi.fn(),
authListeners: new Set<(user: User | null) => void>(),
}));
@@ -52,7 +56,18 @@ vi.mock('../account/cards', async (importOriginal) => ({
}),
}));
import { formatDate, mountCardsPage } from './cards-page';
vi.mock('../community/publish', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/publish')>()),
publishSavedCard: mocks.publishSavedCard,
}));
vi.mock('../community/api', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/api')>()),
withdrawCard: mocks.withdrawCard,
}));
vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications }));
import { ApiError } from '../community/api';
import { mountCardsPage } from './cards-page';
const user: User = { $id: 'u1', email: 'a@b.de' };
@@ -86,6 +101,7 @@ beforeEach(() => {
store = createStore(initialEditorState());
mocks.getCurrentUser.mockResolvedValue(user);
mocks.requireLogin.mockResolvedValue(true);
mocks.myPublications.mockResolvedValue(new Map());
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
unmount = null;
});
@@ -105,17 +121,6 @@ const button = (scope: ParentNode, label: string) =>
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
describe('formatDate', () => {
it('schreibt dd.mm.yyyy', () => {
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('31.01.2026');
});
it('gibt bei unlesbarem Datum nichts zurück', () => {
expect(formatDate('kaputt')).toBe('');
});
});
describe('Meine Karten: Zustände', () => {
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
mocks.getCurrentUser.mockResolvedValue(null);
@@ -141,7 +146,7 @@ describe('Meine Karten: Zustände', () => {
mount();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!;
expect(link.getAttribute('href')).toBe('/');
expect(link.getAttribute('href')).toBe('/gestalten');
});
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
@@ -247,7 +252,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Öffnen').click();
expect(mocks.navigate).toHaveBeenCalledWith('/?karte=a');
expect(mocks.navigate).toHaveBeenCalledWith('/gestalten?karte=a');
});
it('Herunterladen: Bild laden, als PNG rendern und unter aircard-<name>.png speichern', async () => {
@@ -632,3 +637,560 @@ describe('Meine Karten: Seite wird verlassen', () => {
});
});
const published = (id: string, sourceCardId: string, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id,
title: 'Mein Motiv',
issuer: '',
tags: [],
displayName: '',
imageFileId: `gi-${id}`,
previewFileId: `gp-${id}`,
sourceCardId,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const labels = (scope: ParentNode) => [...scope.querySelectorAll('.card-buttons button, .card-buttons a')].map((n) => n.textContent);
const badge = (id: string) => tileFor(id).querySelector('.card-pub')?.textContent ?? '';
const publishDialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const dlgInput = (name: string) => publishDialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const sendForm = () =>
publishDialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const fillDialog = (title = 'Mein Motiv', values: Record<string, string> = {}) => {
dlgInput('title').value = title;
for (const [name, value] of Object.entries(values)) dlgInput(name).value = value;
dlgInput('consent').checked = true;
};
const dlgError = () => publishDialogEl()!.querySelector('[role="alert"]')!.textContent;
const openPublish = async (id: string) => {
await vi.waitFor(() => expect(button(tileFor(id), 'Veröffentlichen').disabled).toBe(false));
button(tileFor(id), 'Veröffentlichen').click();
await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true));
};
describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => {
const pubMap = (...cards: GalleryCard[]) => new Map(cards.map((c) => [c.sourceCardId, c]));
const view = (id: string) => tileFor(id).querySelector<HTMLAnchorElement>('a[data-action="view"]');
const mountWith = async (publications: Map<string, GalleryCard> = new Map()) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(publications);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen')).toBeTruthy());
await vi.waitFor(() => expect(tileFor('a').querySelector('[data-action="publish"], [data-action="withdraw"]')).not.toBeNull());
};
it('lädt den Status einmal je Seitenaufbau (für die angemeldete Nutzer-ID), nicht abgemeldet', async () => {
await mountWith();
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledWith('u1');
unmount?.();
unmount = null;
mocks.myPublications.mockClear();
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
expect(mocks.myPublications).not.toHaveBeenCalled();
});
it('nicht veröffentlicht: „Veröffentlichen“, kein Badge', async () => {
await mountWith();
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(badge('a')).toBe('');
expect(view('a')).toBeNull();
});
it('veröffentlicht: Badge „Veröffentlicht“, „Ansehen“ (/community/<id>) und „Zurückziehen“ statt „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a')));
expect(badge('a')).toBe('Veröffentlicht');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
expect(view('a')!.getAttribute('href')).toBe('/community/g1');
expect(view('a')!.hasAttribute('data-link')).toBe(true);
// Die andere Kachel bleibt unberührt.
expect(badge('b')).toBe('');
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
});
it('ausgeblendet (gemeldet): Badge „Ausgeblendet (gemeldet)“ und „Zurückziehen“, aber kein „Ansehen“ und kein „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a', { hidden: true, reportCount: 3 })));
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Zurückziehen', 'Löschen']);
expect(view('a')).toBeNull();
});
it('solange der Status lädt, gibt es weder „Veröffentlichen“ noch „Zurückziehen“; danach erscheint der Stand', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValue(new Promise((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Löschen']);
finish(pubMap(published('g1', 'a')));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('Status lässt sich nicht laden: Hinweis mit „Nochmal versuchen“, „Veröffentlichen“ bleibt möglich (409 fängt Doppeltes ab)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValueOnce(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('Der Veröffentlichungsstatus konnte nicht geladen werden.'));
expect(labels(tileFor('a'))).toContain('Veröffentlichen');
mocks.myPublications.mockResolvedValue(pubMap(published('g1', 'a')));
button(root, 'Nochmal versuchen').click();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(text()).not.toContain('konnte nicht geladen werden');
});
it('Abmelden räumt den Status weg, nach dem Anmelden wird er neu geholt', async () => {
await mountWith(pubMap(published('g1', 'a')));
for (const fn of mocks.authListeners) fn(null);
expect(tiles()).toHaveLength(0);
mocks.myPublications.mockResolvedValue(new Map());
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it('der Status überlebt Umbenennen (Liste wird neu aufgebaut) ohne neue Anfrage', async () => {
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
await mountWith(pubMap(published('g1', 'a')));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Anders';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Anders'));
expect(badge('a')).toBe('Veröffentlicht');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
});
const GENERIC_HINT =
'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.';
const openDelete = async (id: string) => {
button(tileFor(id), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
it('Löschen einer veröffentlichten Karte (Status bekannt) nennt, dass die Veröffentlichung bleibt und wo man sie zurückzieht', async () => {
await mountWith(pubMap(published('g1', 'a')));
await openDelete('a');
expect(dialog()!.textContent).toContain('bleibt in der Community-Galerie');
expect(dialog()!.textContent).toContain('Community-Seite');
expect(dialog()!.textContent).not.toContain('Falls diese Karte');
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
// Bekannt unveröffentlicht: kein Hinweis.
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
await openDelete('b');
expect(dialog()!.textContent).not.toContain('Community');
});
it('Löschen, solange der Status noch lädt: allgemeiner Hinweis', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockReturnValue(new Promise(() => {}));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openDelete('a');
expect(dialog()!.textContent).toContain(GENERIC_HINT);
});
it('Löschen, wenn der Status nicht geladen werden konnte: allgemeiner Hinweis', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden'));
await openDelete('a');
expect(dialog()!.textContent).toContain(GENERIC_HINT);
});
it('Status-Abfrage, die ein gleichzeitiges Zurückziehen überholt, lässt den Status nie in „lädt“ hängen', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockRejectedValueOnce(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden'));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
// Im Fehlerzustand lässt sich veröffentlichen; danach gibt es „Zurückziehen“.
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
// Zurückziehen läuft (Anfrage unterwegs) ...
let finishWithdraw!: () => void;
mocks.withdrawCard.mockReturnValue(new Promise<void>((r) => (finishWithdraw = r)));
await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false));
button(tileFor('a'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(mocks.withdrawCard).toHaveBeenCalledWith('g9'));
// ... währenddessen „Nochmal versuchen“ (die Abfrage hängt noch) ...
let finishFetch!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishFetch = r)));
button(root, 'Nochmal versuchen').click();
expect(labels(tileFor('b'))).not.toContain('Veröffentlichen'); // Status gilt wieder als unbekannt
// ... und das Zurückziehen wird fertig, bevor die Abfrage antwortet.
finishWithdraw();
await vi.waitFor(() => expect(badge('a')).toBe(''));
finishFetch(new Map());
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen'));
expect(labels(tileFor('a'))).toContain('Veröffentlichen');
expect(text()).not.toContain('konnte nicht geladen werden');
});
});
describe('Meine Karten: Veröffentlichen', () => {
const start = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
};
it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => {
await start();
await openPublish('a');
expect(dlgInput('title').value).toBe('');
expect(dlgInput('displayName').value).toBe('');
expect(dlgInput('displayName').placeholder).toBe('Anonym');
expect(publishDialogEl()!.textContent).not.toContain('a@b.de');
expect(publishDialogEl()!.textContent).toContain('„Alpha“');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Abbrechen veröffentlicht nichts, Fokus zurück auf „Veröffentlichen“', async () => {
await start();
await openPublish('a');
button(publishDialogEl()!, 'Abbrechen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')));
});
it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => {
await start();
await openPublish('a');
dlgInput('title').value = 'Mein Motiv';
sendForm();
expect(dlgError()).toBe('Bitte bestätige die Erklärung zum Bild.');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Erfolg: Badge „Veröffentlicht“ und „Ansehen“ erscheinen sofort, ohne die Liste neu zu laden', async () => {
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
await start();
await openPublish('a');
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.publishSavedCard).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a', name: 'Alpha' }),
{ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' },
);
expect(publishDialogEl()).toBeNull();
const link = tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!;
expect(link.textContent).toBe('Ansehen');
expect(link.getAttribute('href')).toBe('/community/g9');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
// Alle Knöpfe der Kachel sind wieder bedienbar, die andere Kachel ist unberührt.
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Der Fokus geht auf „Ansehen“ (der auslösende Knopf ist weg).
expect(document.activeElement).toBe(link);
});
it('während des Veröffentlichens sind die Kachel-Knöpfe gesperrt und der Dialog lässt sich nicht schließen', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
expect(button(publishDialogEl()!, 'Veröffentlichen').disabled).toBe(true);
expect(button(tileFor('a'), 'Löschen').disabled).toBe(true);
expect(tileFor('a').getAttribute('aria-busy')).toBe('true');
finish({ id: 'g9' });
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(tileFor('a').hasAttribute('aria-busy')).toBe(false);
});
it('Fehler (400): Text im Dialog, kein Status geändert, keine Nachprüfung, neuer Versuch möglich', async () => {
mocks.publishSavedCard.mockRejectedValueOnce(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.'));
expect(publishDialogEl()?.open).toBe(true);
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
mocks.publishSavedCard.mockResolvedValueOnce({ id: 'g9' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('409: „Diese Karte ist schon veröffentlicht.“, der Status wird neu geholt und zeigt „Veröffentlicht“', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(publishDialogEl()!.textContent).toContain('Diese Karte ist schon veröffentlicht.'));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
button(publishDialogEl()!, 'Schließen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(mocks.publishSavedCard).toHaveBeenCalledOnce();
});
describe('Ausgang offen (503, 429, 408, 5xx): Nachprüfung vor der Fehlermeldung', () => {
it('war die Karte doch veröffentlicht: Erfolgszustand statt Fehler', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(publishDialogEl()).toBeNull();
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it.each([408, 429, 500, 504])('auch bei %i wird nachgeprüft', async (code) => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(code, 'Offen.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('war sie es nicht: Fehlermeldung im Dialog, Status unverändert', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
expect(badge('a')).toBe('');
expect(publishDialogEl()?.open).toBe(true);
});
it('scheitert auch die Nachprüfung: die ursprüngliche Meldung bleibt', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('');
});
it('die Nachprüfung geht nicht verloren, wenn währenddessen eine andere Kachel ihren Status ändert', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
mocks.withdrawCard.mockResolvedValue(undefined);
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]]));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Zurückziehen'));
await openPublish('a');
fillDialog();
let finishCheck!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishCheck = r)));
sendForm();
await vi.waitFor(() => expect(mocks.myPublications).toHaveBeenCalledTimes(2));
// Während die Nachprüfung läuft, zieht der Nutzer in einer anderen Kachel eine Karte zurück.
button(tileFor('b'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen'));
finishCheck(new Map([['a', published('g1', 'a')]]));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(publishDialogEl()).toBeNull();
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('die Nachprüfung findet nur die Veröffentlichung dieser Karte (eine andere zählt nicht)', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]]));
sendForm();
await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'));
expect(badge('a')).toBe('');
expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.');
});
});
it('abgelaufene Sitzung (401): Anmelden, dann wird veröffentlicht', async () => {
mocks.publishSavedCard
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
.mockResolvedValueOnce({ id: 'g9' });
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.publishSavedCard).toHaveBeenCalledTimes(2);
});
it('Anmeldung abgebrochen: Hinweis im Dialog, nichts veröffentlicht', async () => {
mocks.requireLogin.mockResolvedValue(false);
mocks.publishSavedCard.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Bitte melde dich an, um fortzufahren.'));
expect(badge('a')).toBe('');
});
it('die Seite wird verlassen: der Dialog geht zu, eine spätere Antwort ändert nichts mehr', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
finish({ id: 'g9' });
await new Promise((r) => setTimeout(r, 0));
expect(root.querySelector('.card-badge')).toBeNull();
});
it('die Seite wird verlassen, solange der Dialog offen ist: er geht zu, nichts wird veröffentlicht', async () => {
await start();
await openPublish('a');
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Titel und Namen erscheinen als Text, nie als HTML', async () => {
mocks.list.mockResolvedValue([card('a', '<img src=x onerror=alert(1)>')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openPublish('a');
expect(publishDialogEl()!.querySelector('img')).toBeNull();
expect(publishDialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Meine Karten: Zurückziehen', () => {
const start = async (patch: Partial<GalleryCard> = {}) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a', patch)]]));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Zurückziehen'));
};
const openConfirm = async () => {
button(tileFor('a'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
it('fragt nach, zieht dann die Veröffentlichung (Galerie-ID) zurück; die Kachel zeigt wieder „Veröffentlichen“', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start();
await openConfirm();
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Mein Motiv“ zurückziehen?');
expect(dialog()!.textContent).toContain('Deine private Karte bleibt erhalten.');
expect(mocks.withdrawCard).not.toHaveBeenCalled();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(dialog()).toBeNull();
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Fokus auf dem neuen „Veröffentlichen“-Knopf.
expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'));
});
it('ausgeblendete Kacheln behalten „Zurückziehen“: danach lässt sich neu veröffentlichen', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start({ hidden: true, reportCount: 3 });
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
});
it('„Abbrechen“ zieht nichts zurück', async () => {
await start();
await openConfirm();
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
expect(mocks.withdrawCard).not.toHaveBeenCalled();
expect(badge('a')).toBe('Veröffentlicht');
await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false));
});
it('Fehler: Text des Dienstes an der Kachel, Status bleibt', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() =>
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('Veröffentlicht');
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
});
it('die Veröffentlichung gibt es schon nicht mehr (404): Ziel erreicht, die Kachel zeigt „Veröffentlichen“', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('abgelaufene Sitzung (401): Anmelden, dann wird zurückgezogen', async () => {
mocks.withdrawCard
.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.'))
.mockResolvedValueOnce(undefined);
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.withdrawCard).toHaveBeenCalledTimes(2);
});
it('Seite verlassen mit offener Rückfrage: nichts wird zurückgezogen', async () => {
await start();
await openConfirm();
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.withdrawCard).not.toHaveBeenCalled();
});
});
+226 -17
View File
@@ -1,33 +1,43 @@
// Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen.
// Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen,
// Veröffentlichen in der Community-Galerie und Zurückziehen (Status je Kachel aus den eigenen Veröffentlichungen).
import '../account/account.css';
import './cards.css';
import { getCurrentUser, onAuthChange } from '../account/auth';
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
import { errorMessage, errorType } from '../account/errors';
import { requireLogin } from '../account/login-dialog';
import { LoginCancelled, withLogin } from '../account/with-login';
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { clampCrop } from '../card/crop';
import { CARD_W } from '../card/geometry';
import { exportCard, loadCardAssets } from '../card/render';
import { ApiError, isAlreadyPublished, isDefinitiveFailure, withdrawCard } from '../community/api';
import { myPublications } from '../community/gallery';
import { publishSavedCard, type PublishForm } from '../community/publish';
import { publishDialog } from '../community/publish-dialog';
import { confirmDialog } from '../editor/confirm-dialog';
import { el } from '../editor/dom';
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
import type { EditorStore } from '../editor/state';
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
import { formatDate } from './format';
import { navigate } from './router';
const LOADING = 'Karten werden geladen …';
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
const EMPTY = 'Noch keine Karten gespeichert.';
const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
const PREVIEW_PARALLEL = 4;
const STATUS_FAILED = 'Der Veröffentlichungsstatus konnte nicht geladen werden.';
// Löschen einer privaten Karte lässt ihre Veröffentlichung bestehen; zurückgezogen wird dann auf der Community-Seite.
const PUBLISHED_NOTE =
'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst – das geht dann nur noch auf ihrer Community-Seite.';
const MAYBE_PUBLISHED_NOTE =
'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.';
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
export function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
/** Was die Kachel über die Veröffentlichung wissen muss. */
interface Publication {
id: string;
title: string;
hidden: boolean;
}
export function mountCardsPage(root: HTMLElement, store: EditorStore): () => void {
@@ -38,6 +48,19 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
let loadToken = 0;
let cards: SavedCard[] = [];
// Veröffentlichungen: einmal je Seitenaufbau geladen (`myPublications`), danach lokal nachgeführt.
// `loading`: noch unbekannt (keine Veröffentlichen-/Zurückziehen-Knöpfe); `error`: Laden gescheitert (Veröffentlichen bleibt
// möglich, ein Doppeltes fängt der Dienst mit 409 ab).
let userId = '';
let publications = new Map<string, Publication>();
let publicationsState: 'loading' | 'ready' | 'error' = 'loading';
/** Zählt Abfragen: nur die neueste darf den Stand setzen (Abmelden zählt auch, die alte Abfrage gehört dann dem alten Nutzer). */
let fetchToken = 0;
/** Zählt lokale Änderungen (`setPublication`): eine Abfrage, die währenddessen lief, kann älter sein und wird wiederholt. */
let changes = 0;
const tileUpdaters = new Map<string, () => void>();
let statusNotice: HTMLElement | null = null;
// Vorschaubilder: Object-URLs je Vorschau-Datei, beim Verlassen der Seite (und wenn sie nicht mehr gebraucht werden) freigegeben.
const previewUrls = new Map<string, string>();
const previewFailed = new Set<string>();
@@ -122,6 +145,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
function showLoggedOut(): void {
state = 'loggedOut';
cards = [];
publications = new Map();
fetchToken += 1; // eine noch laufende Abfrage gehört dem alten Nutzer
tileUpdaters.clear();
statusNotice = null;
queue.length = 0;
previewQueued.clear();
thumbSlots.clear();
@@ -155,14 +182,18 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
function renderList(focusId?: string, focusAction?: string): void {
state = 'list';
thumbSlots.clear();
tileUpdaters.clear();
statusNotice = null;
if (cards.length === 0) {
const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/', 'data-link': true }, 'Zum Editor');
const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, 'Zum Editor');
show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor));
return;
}
const grid = el('ul', { class: 'cards-grid' });
for (const card of cards) grid.append(buildTile(card));
show(grid);
statusNotice = el('p', { class: 'cards-msg cards-status', role: 'status' });
show(statusNotice, grid);
updateStatusNotice();
for (const card of cards) {
fillThumb(card);
queuePreview(card);
@@ -173,6 +204,96 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
}
}
// ---- Veröffentlichungen ----
function updateStatusNotice(): void {
if (!statusNotice) return;
if (publicationsState !== 'error') {
statusNotice.replaceChildren();
return;
}
const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Nochmal versuchen');
retry.addEventListener('click', () => {
publicationsState = 'loading';
updateAllTiles();
void fetchPublications();
});
statusNotice.replaceChildren(`${STATUS_FAILED} `, retry);
}
function updateAllTiles(): void {
for (const update of tileUpdaters.values()) update();
updateStatusNotice();
}
/**
* Holt die eigenen Veröffentlichungen neu. Wahr, wenn der Stand jetzt stimmt; bei Fehler bleibt der bisherige Stand
* (war er noch unbekannt, gilt er als „Fehler“). Ändert sich währenddessen lokal etwas (Veröffentlichen/Zurückziehen),
* kann die Antwort älter sein als diese Änderung und wird nicht übernommen, sondern neu angefragt. Verdrängt eine
* neuere Abfrage diese, setzt sie den Stand; der Status bleibt so nie in „lädt“ hängen.
*/
async function fetchPublications(): Promise<boolean> {
const token = ++fetchToken;
for (;;) {
const seen = changes;
let found: Awaited<ReturnType<typeof myPublications>>;
try {
found = await myPublications(userId);
} catch {
if (disposed || token !== fetchToken) return false;
if (publicationsState === 'loading') publicationsState = 'error';
updateAllTiles();
return false;
}
if (disposed || token !== fetchToken) return false;
if (changes !== seen) continue;
const next = new Map<string, Publication>();
for (const [cardId, entry] of found) next.set(cardId, { id: entry.id, title: entry.title, hidden: entry.hidden });
publications = next;
publicationsState = 'ready';
updateAllTiles();
return true;
}
}
/** Trägt eine Veröffentlichung (oder ihr Ende) ein und aktualisiert nur diese Kachel. */
function setPublication(cardId: string, entry: Publication | null): void {
changes += 1;
if (entry) publications.set(cardId, entry);
else publications.delete(cardId);
tileUpdaters.get(cardId)?.();
}
/**
* Veröffentlicht die Karte. Ist sie schon veröffentlicht (409), wird der Status neu geholt (der Dialog zeigt den Text).
* Bleibt der Ausgang offen (503, 429, 408, 5xx: die Veröffentlichung kann trotzdem angelegt worden sein), wird vor
* der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. Die Nachsicht ist eine eigene Abfrage,
* unabhängig vom Status-Laden der Seite, damit weder ein Neuladen noch eine andere Kachel sie verwirft.
*/
async function publishWithCheck(card: SavedCard, form: PublishForm): Promise<{ id: string }> {
try {
const { id } = await withLogin(() => publishSavedCard(card, form));
if (!disposed) setPublication(card.id, { id, title: form.title.trim(), hidden: false });
return { id };
} catch (err) {
if (isAlreadyPublished(err)) {
await fetchPublications();
throw err;
}
if (isDefinitiveFailure(err)) throw err;
let found: Publication | undefined;
try {
const entry = (await myPublications(userId)).get(card.id);
if (entry) found = { id: entry.id, title: entry.title, hidden: entry.hidden };
} catch {
// Auch die Nachsicht scheitert: es bleibt bei der ursprünglichen Meldung.
}
if (!found) throw err;
if (!disposed) setPublication(card.id, found);
return { id: found.id };
}
}
// ---- Kacheln und Aktionen ----
function buildTile(card: SavedCard): HTMLElement {
@@ -187,7 +308,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const downloadButton = action('download', 'Herunterladen');
const renameButton = action('rename', 'Umbenennen');
const deleteButton = action('delete', 'Löschen', 'auth-btn auth-btn-small auth-btn-danger');
const buttons = [openButton, downloadButton, renameButton, deleteButton];
// Die Knöpfe zur Veröffentlichung wechseln mit dem Status; die übrigen bleiben als dieselben Elemente stehen (Fokus).
const publishSlot = el('span', { class: 'card-pub-controls' });
const badge = el('p', { class: 'card-pub' });
const buttonRow = el('div', { class: 'card-buttons' }, openButton, downloadButton, renameButton, publishSlot, deleteButton);
const tile = el(
'li',
@@ -195,7 +319,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
thumb,
nameSlot,
date ? el('p', { class: 'card-date' }, el('time', { datetime: card.updatedAt }, date)) : '',
el('div', { class: 'card-buttons' }, ...buttons),
badge,
buttonRow,
message,
);
@@ -204,7 +329,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const label = busyButton?.textContent ?? '';
message.textContent = '';
message.classList.remove('is-warning');
for (const b of buttons) b.disabled = true;
const buttons = () => buttonRow.querySelectorAll('button');
for (const b of buttons()) b.disabled = true;
tile.setAttribute('aria-busy', 'true');
if (busyButton && busyText) busyButton.textContent = busyText;
try {
@@ -213,12 +339,89 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
if (!disposed) message.textContent = failureText(err);
} finally {
if (busyButton && busyText) busyButton.textContent = label;
for (const b of buttons) b.disabled = false;
// Auch Knöpfe, die erst während der Aktion entstanden sind (neuer Status), sind danach wieder bedienbar.
for (const b of buttons()) b.disabled = false;
tile.removeAttribute('aria-busy');
}
}
openButton.addEventListener('click', () => navigate(`/?karte=${encodeURIComponent(card.id)}`));
/** Nach einer Aktion mit Dialog: Fokus auf den ersten passenden Knopf (der auslösende kann ersetzt worden sein). */
function focusControl(selector: string): void {
if (disposed || !tile.isConnected) return;
tile.querySelector<HTMLElement>(selector)?.focus();
}
function renderPublication(): void {
const entry = publications.get(card.id);
badge.replaceChildren();
const controls: HTMLElement[] = [];
if (entry) {
badge.append(
el('span', { class: entry.hidden ? 'card-badge is-hidden' : 'card-badge' }, entry.hidden ? 'Ausgeblendet (gemeldet)' : 'Veröffentlicht'),
);
// Ausgeblendet sehen nur Autor und Admin; „Ansehen“ gibt es dann nicht, „Zurückziehen“ bleibt (aufräumen, neu veröffentlichen).
if (!entry.hidden) {
controls.push(
el(
'a',
{
class: 'auth-btn auth-btn-small cards-link-btn',
href: `/community/${encodeURIComponent(entry.id)}`,
'data-link': true,
'data-action': 'view',
},
'Ansehen',
),
);
}
const withdrawButton = action('withdraw', 'Zurückziehen');
withdrawButton.addEventListener('click', () => startWithdraw(entry));
controls.push(withdrawButton);
} else if (publicationsState !== 'loading') {
const publishButton = action('publish', 'Veröffentlichen');
publishButton.addEventListener('click', () => startPublish());
controls.push(publishButton);
}
publishSlot.replaceChildren(...controls);
}
/** Zusatz im Löschen-Dialog: die Veröffentlichung bleibt. Ist der Status unbekannt, ein allgemeiner Hinweis. */
function deleteNote(c: SavedCard): string {
if (publicationsState !== 'ready') return ` ${MAYBE_PUBLISHED_NOTE}`;
return publications.has(c.id) ? ` ${PUBLISHED_NOTE}` : '';
}
function startPublish(): void {
void run(async () => {
await publishDialog({ cardName: card.name, signal: disposal.signal, submit: (form) => publishWithCheck(card, form) });
}).then(() => focusControl('[data-action="view"], [data-action="withdraw"], [data-action="publish"]'));
}
function startWithdraw(entry: Publication): void {
void run(async () => {
const confirmed = await confirmDialog(
{
title: `Karte „${entry.title}“ zurückziehen?`,
text: 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.',
confirmLabel: 'Zurückziehen',
},
disposal.signal,
);
if (!confirmed) return;
try {
await withLogin(() => withdrawCard(entry.id));
} catch (err) {
// Die Veröffentlichung gibt es schon nicht mehr (anderer Tab, früherer Versuch mit verlorener Antwort): Ziel erreicht.
if (!(err instanceof ApiError && err.code === 404)) throw err;
}
if (!disposed) setPublication(card.id, null);
}).then(() => focusControl('[data-action="publish"], [data-action="withdraw"]'));
}
tileUpdaters.set(card.id, renderPublication);
renderPublication();
openButton.addEventListener('click', () => navigate(`/gestalten?karte=${encodeURIComponent(card.id)}`));
downloadButton.addEventListener('click', () => {
void run(async () => {
@@ -243,7 +446,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const confirmed = await confirmDialog(
{
title: `Karte „${card.name}“ löschen?`,
text: 'Die Karte und ihr Bild werden endgültig gelöscht.',
text: `Die Karte und ihr Bild werden endgültig gelöscht.${deleteNote(card)}`,
confirmLabel: 'Löschen',
},
disposal.signal,
@@ -328,6 +531,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
function failureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return err.message; // der Dienst antwortet auf Deutsch
if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message;
return errorMessage(err, 'load');
}
@@ -348,9 +552,14 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const list = await withLogin(() => service.list());
if (disposed || token !== loadToken) return;
cards = list;
userId = user.$id;
publications = new Map();
publicationsState = 'loading';
revokePreviews(new Set(list.map((c) => c.previewFileId)));
previewFailed.clear();
renderList();
// Der Status kommt nach: die Kacheln stehen sofort, die Knöpfe zur Veröffentlichung folgen.
void fetchPublications();
} catch (err) {
if (disposed || token !== loadToken) return;
if (err instanceof LoginCancelled) showLoggedOut();
+43 -3
View File
@@ -10,13 +10,25 @@
}
.cards-msg-error {
color: #ff9a9d;
color: var(--danger-text);
}
.cards-actions {
margin: 0;
}
/* Hinweis, wenn der Veröffentlichungsstatus nicht geladen werden konnte (leer = unsichtbar) */
.cards-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.cards-status:empty {
display: none;
}
.cards-link-btn {
display: inline-flex;
align-items: center;
@@ -83,9 +95,37 @@
gap: 8px;
}
/* Die Knöpfe zur Veröffentlichung wechseln als Gruppe; im Layout verhalten sie sich wie direkte Kinder von .card-buttons. */
.card-pub-controls {
display: contents;
}
.card-pub {
margin: 0;
}
.card-pub:empty {
display: none;
}
.card-badge {
display: inline-block;
padding: 3px 10px;
border: 1px solid var(--ok-text);
border-radius: 999px;
color: var(--ok-text);
font-size: 0.85rem;
font-weight: 600;
}
.card-badge.is-hidden {
border-color: var(--warn-text);
color: var(--warn-text);
}
.card-msg {
margin: 0;
color: #ff9a9d;
color: var(--danger-text);
font-size: 0.9rem;
}
@@ -94,7 +134,7 @@
}
.card-msg.is-warning {
color: #f2c66d;
color: var(--warn-text);
}
.card-rename {
+792
View File
@@ -0,0 +1,792 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import { ApiError, type ReportReason } from '../community/api';
import type { GalleryCard, GalleryReport } from '../community/gallery';
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
vi.mock('../account/appwrite', () => ({
account: {},
client: {},
tables: {},
storage: {},
IDS: {},
APPWRITE_ENDPOINT: '',
}));
import { LoginCancelled } from '../account/with-login';
import { mountCommunityPage, type CommunityDeps } from './community-page';
const card = (patch: Partial<GalleryCard> = {}): GalleryCard => ({
id: 'g1',
title: 'Mitternachtsblau',
issuer: 'Beispielbank',
tags: ['dunkel', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'src1',
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const report = (n: number, reason: string): GalleryReport => ({
id: `r${n}`,
galleryId: 'g1',
reason,
createdAt: '2026-10-05T12:00:00.000Z',
});
const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
const blob = () => new Blob(['png'], { type: 'image/png' });
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
const stranger: User = { $id: 'u2', email: 'x@example.com', labels: [] };
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
let root: HTMLElement;
let unmount: (() => void) | null;
let user: User | null;
let authListeners: Set<(u: User | null) => void>;
let urlCounter: number;
const revoked: string[] = [];
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
const navigate = vi.fn<(path: string) => void>();
const saveBlobAs = vi.fn<(b: Blob, name: string) => void>();
const withLogin = vi.fn(<T>(fn: () => Promise<T>): Promise<T> => fn());
const getUser = vi.fn<() => Promise<User | null>>();
function deps(): CommunityDeps {
return {
getCard,
listReports,
loadImage,
reportCard,
withdrawCard,
setVisibility,
currentUser: () => user,
getUser,
onAuthChange: (fn) => {
authListeners.add(fn);
return () => authListeners.delete(fn);
},
navigate,
saveBlobAs,
withLogin: <T>(fn: () => Promise<T>) => withLogin(fn) as Promise<T>,
};
}
async function mount(id = 'g1'): Promise<void> {
unmount = mountCommunityPage(root, id, deps());
await settle();
}
function login(next: User | null): void {
user = next;
for (const fn of [...authListeners]) fn(next);
}
const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
const buttonByText = (text: string) =>
qa<HTMLButtonElement>('button').find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const buttonTexts = () => qa<HTMLButtonElement>('button').map((b) => b.textContent);
const confirmEl = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
const confirmButton = (label: string) =>
[...confirmEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
const reportEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
const errorText = () => q('[role="alert"]')?.textContent ?? '';
const statusText = () => q('.detail-info [role="status"]')?.textContent ?? '';
beforeEach(() => {
for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser]) {
fn.mockReset();
}
getCard.mockResolvedValue(card());
listReports.mockResolvedValue([]);
loadImage.mockImplementation(async () => blob());
withdrawCard.mockResolvedValue(undefined);
setVisibility.mockResolvedValue(undefined);
getUser.mockResolvedValue(null);
user = null;
authListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
document.body.innerHTML = '<main id="app-root"></main>';
root = document.getElementById('app-root') as HTMLElement;
unmount = null;
document.title = 'AirCard-IOS';
});
afterEach(() => {
unmount?.();
document.body.innerHTML = '';
});
describe('Detailseite: nicht (mehr) verfügbar', () => {
it('Gast, Karte nicht vorhanden oder ausgeblendet (404): Text, Link zur Galerie, kein Bild, keine Knöpfe', async () => {
getCard.mockResolvedValue(null);
await mount('g1');
expect(getCard).toHaveBeenCalledWith('g1');
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
const link = q<HTMLAnchorElement>('a[href="/#community"]');
expect(link?.textContent).toBe('Zur Galerie');
expect(link?.hasAttribute('data-link')).toBe(true);
expect(q('img')).toBeNull();
expect(buttonTexts()).toEqual([]);
expect(loadImage).not.toHaveBeenCalled();
expect(listReports).not.toHaveBeenCalled();
expect(document.title).toBe('Community – AirCard-IOS');
});
it('eine ausgeblendete Karte für jemanden, der weder Autor noch Admin ist (Rechte-Fehler): ebenso, ohne Bild', async () => {
user = stranger;
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
await mount();
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
expect(q('img')).toBeNull();
expect(loadImage).not.toHaveBeenCalled();
expect(root.textContent).not.toContain('Mitternachtsblau');
});
it('Direktaufruf des Autors: der Anmeldestatus ist noch unbekannt, der Server hat die ausgeblendete Karte aber geliefert', async () => {
getCard.mockResolvedValue(card({ hidden: true }));
getUser.mockImplementation(async () => {
user = owner;
return owner;
});
await mount();
expect(getUser).toHaveBeenCalledTimes(1);
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
expect(buttonTexts()).toContain('Zurückziehen');
});
it('eine sichtbare Karte braucht keine Anmeldeabfrage', async () => {
await mount();
expect(getUser).not.toHaveBeenCalled();
});
it('Ladefehler (Netz): Hinweis und „Nochmal versuchen“, danach erscheint die Karte', async () => {
getCard.mockRejectedValueOnce(new TypeError('offline'));
await mount();
expect(errorText()).toBe('Die Karte konnte gerade nicht geladen werden.');
expect(q('img')).toBeNull();
buttonByText('Nochmal versuchen')!.click();
await settle();
expect(getCard).toHaveBeenCalledTimes(2);
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(errorText()).toBe('');
});
it('während des Ladens steht ein Hinweis', async () => {
getCard.mockReturnValue(new Promise(() => {}));
await mount();
expect(q('[role="status"]')?.textContent).toBe('Karte wird geladen …');
});
it('meldet sich der Autor einer ausgeblendeten Karte an, wird sie neu geladen und gezeigt', async () => {
getCard.mockResolvedValueOnce(null);
await mount();
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
getCard.mockResolvedValueOnce(card({ hidden: true }));
login(owner);
await settle();
expect(getCard).toHaveBeenCalledTimes(2);
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
});
});
describe('Detailseite: Karte', () => {
it('zeigt Titel, Herausgeber, „von …“, Datum, Tags und das große Bild; setzt den Seitentitel', async () => {
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-issuer')?.textContent).toBe('Beispielbank');
expect(q('.detail-meta')?.textContent).toBe('von Kenso · 04.10.2026');
const tags = qa<HTMLAnchorElement>('.detail-tag');
expect(tags.map((t) => t.textContent)).toEqual(['dunkel', 'gold']);
expect(tags[1].getAttribute('href')).toBe('/?tag=gold#community');
expect(tags[1].hasAttribute('data-link')).toBe(true);
expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full');
const img = q<HTMLImageElement>('.detail-card img');
expect(img?.getAttribute('src')).toBe('blob:detail-1');
expect(img?.getAttribute('alt')).toBe('Community-Karte „Mitternachtsblau“');
expect(document.title).toBe('Mitternachtsblau – AirCard-IOS');
expect(q('a[href="/#community"]')?.textContent).toContain('Zur Galerie');
});
it('ohne Anzeigenamen „von Anonym“; ohne Herausgeber und Tags fehlen die Zeilen', async () => {
getCard.mockResolvedValue(card({ displayName: '', issuer: '', tags: [] }));
await mount();
expect(q('.detail-meta')?.textContent).toBe('von Anonym · 04.10.2026');
expect(q('.detail-issuer')).toBeNull();
expect(q('.detail-tags')).toBeNull();
});
it('alles Nutzerseitige bleibt Text (kein HTML)', async () => {
getCard.mockResolvedValue(
card({
title: '<b>Fett</b>',
issuer: '<i>Bank</i>',
displayName: '<script>alert(1)</script>',
tags: ['<img src=x onerror=alert(1)>'],
}),
);
await mount();
expect(q('h1')?.textContent).toBe('<b>Fett</b>');
expect(q('.detail-issuer')?.textContent).toBe('<i>Bank</i>');
expect(q('.detail-meta')?.textContent).toContain('<script>alert(1)</script>');
expect(q('.detail-tag')?.textContent).toBe('<img src=x onerror=alert(1)>');
expect(root.querySelector('b, i, script')).toBeNull();
expect(qa('img')).toHaveLength(1); // nur das Kartenbild
});
it('der Gast sieht Herunterladen, Als Vorlage gestalten, Melden – sonst nichts', async () => {
await mount();
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
expect(listReports).not.toHaveBeenCalled();
expect(q('.detail-admin')).toBeNull();
});
it('Bild lädt nicht: Hinweis mit „Nochmal versuchen“; Herunterladen holt das Bild selbst nach', async () => {
loadImage.mockRejectedValueOnce(new TypeError('offline'));
await mount();
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
expect(q('.detail-card img')).toBeNull();
// Herunterladen versucht es erneut und gelingt
buttonByText('Herunterladen')!.click();
await settle();
expect(loadImage).toHaveBeenCalledTimes(2);
expect(saveBlobAs).toHaveBeenCalledTimes(1);
});
it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => {
loadImage.mockRejectedValueOnce(new TypeError('offline'));
await mount();
buttonByText('Nochmal versuchen')!.click();
await settle();
expect(loadImage).toHaveBeenCalledTimes(2);
expect(q('.detail-card img')?.getAttribute('src')).toBe('blob:detail-1');
});
it('ein Bild, das der Browser nicht darstellen kann, zeigt denselben Hinweis und gibt die URL frei', async () => {
await mount();
q('.detail-card img')!.dispatchEvent(new Event('error'));
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
expect(revoked).toEqual(['blob:detail-1']);
});
it('Object-URL wird beim Verlassen freigegeben; späte Antworten nach dem Verlassen bewirken nichts', async () => {
await mount();
unmount?.();
unmount = null;
expect(revoked).toEqual(['blob:detail-1']);
// Karte kommt erst nach dem Verlassen
let finishCard!: (c: GalleryCard) => void;
getCard.mockReturnValue(new Promise((r) => (finishCard = r)));
root.replaceChildren();
unmount = mountCommunityPage(root, 'g1', deps());
unmount();
unmount = null;
finishCard(card());
await settle();
expect(loadImage).toHaveBeenCalledTimes(1); // nur vom ersten Aufruf
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
// Bild kommt erst nach dem Verlassen
let finishImage!: (b: Blob) => void;
getCard.mockResolvedValue(card());
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
unmount = mountCommunityPage(root, 'g1', deps());
await settle();
unmount();
unmount = null;
const created = urlCounter;
finishImage(blob());
await settle();
expect(urlCounter).toBe(created);
});
});
describe('Detailseite: Rechte beim Ausführen', () => {
it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => {
user = admin;
await mount();
const hide = buttonByText('Ausblenden')!;
user = stranger; // ohne auth-Ereignis: der alte Knopf steht noch da
hide.click();
await settle();
expect(setVisibility).not.toHaveBeenCalled();
expect(withLogin).not.toHaveBeenCalled();
});
it('Admin-Recht verloren, Knopf noch im DOM: Löschen fragt nicht einmal nach', async () => {
user = admin;
await mount();
const del = buttonByText('Löschen')!;
user = stranger;
del.click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
});
it('Autor-Prüfung beim Ausführen: ein anderer Nutzer kann nicht zurückziehen', async () => {
user = owner;
await mount();
const withdraw = buttonByText('Zurückziehen')!;
user = stranger;
withdraw.click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
});
it('Recht geht während der Rückfrage verloren: nach „Löschen“ im Dialog wird nichts gesendet', async () => {
user = admin;
await mount();
buttonByText('Löschen')!.click();
await settle();
expect(confirmEl()?.open).toBe(true);
login(stranger);
confirmButton('Löschen').click();
await settle();
expect(withdrawCard).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(errorText()).toBe('');
});
it('Recht geht während der Rückfrage verloren: auch Zurückziehen sendet nichts', async () => {
user = owner;
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
login(null);
confirmButton('Zurückziehen').click();
await settle();
expect(withdrawCard).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
it('ein Admin, der nicht Autor ist, kann nicht „zurückziehen“, aber löschen', async () => {
user = admin;
await mount();
expect(buttonTexts()).not.toContain('Zurückziehen');
expect(buttonTexts()).toContain('Löschen');
});
});
describe('Detailseite: Aufräumen', () => {
it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => {
await mount();
expect(authListeners.size).toBe(1);
unmount?.();
unmount = null;
expect(authListeners.size).toBe(0);
});
it('Meldungen, die erst nach dem Verlassen eintreffen, werden verworfen', async () => {
user = admin;
let finish!: (r: GalleryReport[]) => void;
listReports.mockReturnValue(new Promise((r) => (finish = r)));
await mount();
unmount?.();
unmount = null;
const html = root.innerHTML;
finish([report(1, 'spam')]);
await settle();
expect(root.innerHTML).toBe(html);
});
});
describe('Detailseite: Herunterladen und Vorlage', () => {
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
const full = blob();
loadImage.mockResolvedValue(full);
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
await mount();
buttonByText('Herunterladen')!.click();
await settle();
expect(saveBlobAs).toHaveBeenCalledTimes(1);
expect(saveBlobAs).toHaveBeenCalledWith(full, 'aircard-gruener-wald.png');
// das schon geladene Bild wird wiederverwendet
expect(loadImage).toHaveBeenCalledTimes(1);
});
it('Herunterladen scheitert: Meldung, der Knopf geht wieder', async () => {
await mount();
q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher
loadImage.mockRejectedValueOnce(new TypeError('offline'));
buttonByText('Herunterladen')!.click();
await settle();
expect(saveBlobAs).not.toHaveBeenCalled();
expect(errorText()).toBe('Das Bild konnte nicht geladen werden.');
expect(buttonByText('Herunterladen')!.disabled).toBe(false);
});
it('„Als Vorlage gestalten“ führt zu /gestalten?vorlage=<id>', async () => {
await mount();
buttonByText('Als Vorlage gestalten')!.click();
expect(navigate).toHaveBeenCalledWith('/gestalten?vorlage=g1');
});
});
describe('Detailseite: Melden', () => {
const open = async () => {
buttonByText('Melden')!.click();
await settle();
};
const submitReason = async (value: string) => {
const radio = reportEl()!.querySelector<HTMLInputElement>(`input[value="${value}"]`)!;
radio.checked = true;
reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await settle();
};
it('Dialog mit den fünf Gründen; die Wahl geht als API-Wert an reportCard', async () => {
reportCard.mockResolvedValue({ schonGemeldet: false });
await mount();
await open();
expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
await submitReason('urheberrecht');
expect(reportCard).toHaveBeenCalledWith('g1', 'urheberrecht');
expect(reportEl()!.textContent).toContain('Danke für deine Meldung.');
});
it('doppelt gemeldet: „Danke, du hast diese Karte schon gemeldet.“', async () => {
reportCard.mockResolvedValue({ schonGemeldet: true });
await mount();
await open();
await submitReason('spam');
expect(reportEl()!.textContent).toContain('Danke, du hast diese Karte schon gemeldet.');
});
it('zu viele Meldungen (429): der Servertext erscheint im Dialog', async () => {
reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'));
await mount();
await open();
await submitReason('spam');
expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Zu viele Meldungen – bitte später nochmal.');
expect(reportEl()?.open).toBe(true);
});
it('Verlassen der Seite schließt den offenen Dialog', async () => {
await mount();
await open();
expect(reportEl()).not.toBeNull();
unmount?.();
unmount = null;
await settle();
expect(reportEl()).toBeNull();
});
it('lässt sich der Dialog nicht darstellen (showModal wirft), erscheint ein Hinweis statt eines unbehandelten Fehlers', async () => {
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => {
throw new Error('nicht darstellbar');
});
try {
await mount();
buttonByText('Melden')!.click();
await settle();
expect(errorText()).toBe('Der Melden-Dialog konnte nicht geöffnet werden.');
expect(reportEl()).toBeNull();
expect(buttonByText('Melden')!.disabled).toBe(false);
} finally {
spy.mockRestore();
}
});
it('eine ausgeblendete Karte (nur Autor/Admin sehen sie) hat keinen Melden-Knopf', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true }));
await mount();
expect(buttonTexts()).not.toContain('Melden');
});
});
describe('Detailseite: Autor', () => {
it('der Autor sieht Zurückziehen, ein Fremder nicht', async () => {
user = owner;
await mount();
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Zurückziehen']);
unmount?.();
unmount = null;
user = stranger;
await mount();
expect(buttonTexts()).not.toContain('Zurückziehen');
expect(buttonTexts()).not.toContain('Löschen');
expect(q('.detail-admin')).toBeNull();
});
it('die Knöpfe erscheinen auch, wenn der Nutzer erst nach dem Laden bekannt wird, und verschwinden beim Abmelden', async () => {
await mount();
expect(buttonTexts()).not.toContain('Zurückziehen');
login(owner);
expect(buttonTexts()).toContain('Zurückziehen');
login(null);
expect(buttonTexts()).not.toContain('Zurückziehen');
});
it('Zurückziehen fragt nach; „Abbrechen“ lässt alles, „Zurückziehen“ ruft die API und geht zur Startseite', async () => {
user = owner;
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
expect(confirmEl()?.open).toBe(true);
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ zurückziehen?');
confirmButton('Abbrechen').click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(withdrawCard).toHaveBeenCalledWith('g1');
expect(withLogin).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith('/');
});
it('Zurückziehen scheitert: Servertext, nicht navigiert, Knöpfe wieder bedienbar', async () => {
user = owner;
withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(navigate).not.toHaveBeenCalled();
expect(qa<HTMLButtonElement>('.detail-actions button').every((b) => !b.disabled)).toBe(true);
});
it('war die Karte schon weg (404), ist das Ziel erreicht: zur Startseite', async () => {
user = owner;
withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(navigate).toHaveBeenCalledWith('/');
expect(errorText()).toBe('');
});
it('Anmeldung abgebrochen: Hinweis, nicht navigiert', async () => {
user = owner;
withLogin.mockRejectedValue(new LoginCancelled());
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(errorText()).toBe('Bitte melde dich an, um fortzufahren.');
expect(navigate).not.toHaveBeenCalled();
});
it('während die Anfrage läuft, ist alles gesperrt und es geht nur eine raus', async () => {
user = owner;
let finish!: () => void;
withdrawCard.mockReturnValue(new Promise<void>((r) => (finish = r)));
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(qa<HTMLButtonElement>('.detail-actions button').every((b) => b.disabled)).toBe(true);
buttonByText('Zurückziehen')!.click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).toHaveBeenCalledTimes(1);
finish();
await settle();
expect(navigate).toHaveBeenCalledWith('/');
});
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts passiert', async () => {
user = owner;
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
expect(confirmEl()).not.toBeNull();
unmount?.();
unmount = null;
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
});
it('der Autor einer ausgeblendeten Karte sieht sie mit dem Hinweis „Ausgeblendet“', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
expect(q('.detail-hidden')?.textContent).toContain('für andere nicht sichtbar');
expect(q('.detail-card img')).not.toBeNull();
// Meldungszahl und Gründe sind Sache der Admins
expect(root.textContent).not.toContain('Meldungen');
expect(listReports).not.toHaveBeenCalled();
});
});
describe('Detailseite: Admin', () => {
it('Admin sieht Ausblenden, Löschen, die Meldungszahl und die Gründe', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 3 }));
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]);
await mount();
expect(listReports).toHaveBeenCalledWith('g1');
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Ausblenden', 'Löschen']);
expect(q('.detail-reports-count')?.textContent).toBe('3 Meldungen');
expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([
'Spam · 05.10.2026',
'Echte Kartendaten · 05.10.2026',
'unbekannt · 05.10.2026',
]);
expect(buttonTexts()).not.toContain('Zurückziehen');
});
it('eine Meldung heißt „1 Meldung“, keine „0 Meldungen“ ohne Liste', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 1 }));
listReports.mockResolvedValue([report(1, 'spam')]);
await mount();
expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung');
unmount?.();
unmount = null;
getCard.mockResolvedValue(card({ reportCount: 0 }));
listReports.mockResolvedValue([]);
await mount();
expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen');
expect(q('.detail-reasons')).toBeNull();
});
it('Gründe lassen sich nicht laden: Hinweis, Zahl und Knöpfe bleiben', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 2 }));
listReports.mockRejectedValue(new TypeError('offline'));
await mount();
expect(q('.detail-reports-count')?.textContent).toBe('2 Meldungen');
expect(q('.detail-admin')?.textContent).toContain('Die Gründe konnten nicht geladen werden.');
expect(buttonTexts()).toContain('Ausblenden');
});
it('Ausblenden ruft die API, zeigt „Ausgeblendet“ und wird zu „Einblenden“', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 1 }));
listReports.mockResolvedValue([report(1, 'spam')]);
await mount();
buttonByText('Ausblenden')!.click();
await settle();
expect(setVisibility).toHaveBeenCalledWith('g1', false);
expect(withLogin).toHaveBeenCalledTimes(1);
expect(buttonTexts()).toContain('Einblenden');
expect(buttonTexts()).not.toContain('Ausblenden');
expect(buttonTexts()).not.toContain('Melden');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
expect(statusText()).toBe('Karte ausgeblendet.');
// Die Meldungen bleiben beim Ausblenden
expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung');
});
it('Einblenden macht die Karte wieder sichtbar; der Server löscht die Meldungen, die Anzeige folgt', async () => {
user = admin;
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'spam'), report(3, 'sonstiges')]);
await mount();
expect(q('.detail-hidden')).not.toBeNull();
buttonByText('Einblenden')!.click();
await settle();
expect(setVisibility).toHaveBeenCalledWith('g1', true);
expect(buttonTexts()).toContain('Ausblenden');
expect(buttonTexts()).toContain('Melden');
expect(q('.detail-hidden')).toBeNull();
expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen');
expect(q('.detail-reasons')).toBeNull();
expect(statusText()).toBe('Karte eingeblendet.');
});
it('Ausblenden scheitert: Servertext, Zustand unverändert', async () => {
user = admin;
setVisibility.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.'));
await mount();
buttonByText('Ausblenden')!.click();
await settle();
expect(errorText()).toBe('Nur für Administratoren.');
expect(buttonTexts()).toContain('Ausblenden');
expect(q('.detail-hidden')).toBeNull();
expect(buttonByText('Ausblenden')!.disabled).toBe(false);
});
it('Löschen fragt nach und löscht über withdrawCard, danach zur Startseite', async () => {
user = admin;
await mount();
buttonByText('Löschen')!.click();
await settle();
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ löschen?');
confirmButton('Abbrechen').click();
await settle();
expect(withdrawCard).not.toHaveBeenCalled();
buttonByText('Löschen')!.click();
await settle();
confirmButton('Löschen').click();
await settle();
expect(withdrawCard).toHaveBeenCalledWith('g1');
expect(navigate).toHaveBeenCalledWith('/');
});
it('wird jemand erst später als Admin erkannt, erscheinen die Admin-Knöpfe und die Gründe; beim Verlust verschwinden sie', async () => {
getCard.mockResolvedValue(card({ reportCount: 1 }));
listReports.mockResolvedValue([report(1, 'spam')]);
await mount();
expect(q('.detail-admin')).toBeNull();
expect(listReports).not.toHaveBeenCalled();
login(admin);
await settle();
expect(listReports).toHaveBeenCalledTimes(1);
expect(buttonTexts()).toContain('Ausblenden');
expect(q('.detail-reasons li')?.textContent).toBe('Spam · 05.10.2026');
login(stranger);
expect(q('.detail-admin')).toBeNull();
expect(buttonTexts()).not.toContain('Löschen');
});
it('verliert ein Admin sein Recht, während er eine ausgeblendete Karte ansieht, wird sie unzugänglich', async () => {
user = admin;
getCard.mockResolvedValue(card({ hidden: true }));
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
login(stranger);
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
expect(q('img')).toBeNull();
});
});
+508
View File
@@ -0,0 +1,508 @@
// Detailseite „/community/<id>“: große Karte, Angaben, Herunterladen, „Als Vorlage gestalten“, Melden.
// Autor: Zurückziehen. Admin (Label `admin`): Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe.
// Ausgeblendete oder fehlende Karten sieht ein Besucher nur als „nicht (mehr) verfügbar“: Appwrite antwortet ihm mit 404,
// Autor und Admin dürfen die Zeile lesen und bekommen die Seite mit dem Hinweis „Ausgeblendet“.
import '../community/community.css';
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api';
import {
getGalleryCard,
listReports,
loadGalleryImage,
type GalleryCard,
type GalleryReport,
} from '../community/gallery';
import { REPORT_REASONS, reportDialog } from '../community/report-dialog';
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
import { el } from '../editor/dom';
import { downloadFileName, saveBlobAs } from '../editor/download';
import { formatDate } from './format';
import { navigate, titleFor } from './router';
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
export interface CommunityDeps {
getCard(id: string): Promise<GalleryCard | null>;
listReports(galleryId: string): Promise<GalleryReport[]>;
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
withdrawCard(id: string): Promise<void>;
setVisibility(id: string, visible: boolean): Promise<void>;
currentUser(): User | null;
/** Fragt den Anmeldestatus beim Server (bei einem Direktaufruf ist er noch nicht bekannt). */
getUser(): Promise<User | null>;
onAuthChange(fn: (user: User | null) => void): () => void;
navigate(path: string): void;
saveBlobAs(blob: Blob, filename: string): void;
/** Führt eine Anfrage mit Nutzer-JWT aus und meldet bei abgelaufener Sitzung einmal neu an. */
withLogin<T>(fn: () => Promise<T>): Promise<T>;
}
export const defaultDeps: CommunityDeps = {
getCard: getGalleryCard,
listReports,
loadImage: loadGalleryImage,
reportCard,
withdrawCard,
setVisibility,
currentUser,
getUser: getCurrentUser,
onAuthChange,
navigate,
saveBlobAs,
withLogin: (fn) => withLogin(fn),
};
const CARD_W = 1536;
const CARD_H = 969;
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
const LOADING = 'Karte wird geladen …';
const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.';
const IMAGE_LOADING = 'Bild wird geladen …';
const IMAGE_ERROR = 'Das Bild konnte nicht geladen werden.';
const REASONS_ERROR = 'Die Gründe konnten nicht geladen werden.';
const REPORT_ERROR = 'Der Melden-Dialog konnte nicht geöffnet werden.';
const HIDDEN_NOTICE = 'Diese Karte ist für andere nicht sichtbar.';
const REASON_LABELS: ReadonlyMap<string, string> = new Map(REPORT_REASONS.map((r) => [r.value, r.label]));
type Busy = 'download' | 'remove' | 'visibility' | null;
interface ReportsState {
status: 'idle' | 'loading' | 'ready' | 'error';
list: GalleryReport[];
}
/** „1 Meldung“, „3 Meldungen“. */
function reportsLabel(count: number): string {
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
}
/** Der Text für den Nutzer: Servertexte (deutsch) unverändert, alles andere über errorMessage. */
function failureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return err.message;
return errorMessage(err, 'load');
}
const isOwner = (c: GalleryCard, user: User | null): boolean => !!user && c.ownerId !== '' && c.ownerId === user.$id;
/** Ausgeblendete Karten dürfen nur Autor und Admin sehen (der Server liefert sie anderen gar nicht erst). */
const mayView = (c: GalleryCard, user: User | null): boolean => !c.hidden || isOwner(c, user) || isAdmin(user);
export function mountCommunityPage(root: HTMLElement, id: string, deps: CommunityDeps = defaultDeps): () => void {
let disposed = false;
const disposal = new AbortController(); // schließt offene Dialoge beim Verlassen der Seite
let loadToken = 0; // nur die zuletzt angeforderte Karte zählt
let imageToken = 0; // … und nur das zuletzt angeforderte Bild
let generation = 0; // zählt jede neue Ansicht: Antworten für eine frühere Ansicht werden verworfen
let view: 'loading' | 'unavailable' | 'error' | 'card' = 'loading';
let card: GalleryCard | null = null;
let reports: ReportsState = { status: 'idle', list: [] };
let busy: Busy = null;
let imageBlob: Blob | null = null;
let imageUrl: string | null = null;
const page = el('div', { class: 'detail-page' });
root.replaceChildren(page);
const route = { id: 'community', params: { id } } as const;
// ---- Bild ----
const imageSlot = el('div', { class: 'detail-card' });
function revokeImage(): void {
if (imageUrl) URL.revokeObjectURL(imageUrl);
imageUrl = null;
imageBlob = null;
}
function showImageError(forCard: GalleryCard): void {
const retry = el('button', { type: 'button', class: 'home-btn' }, 'Nochmal versuchen');
retry.addEventListener('click', () => startImage(forCard));
imageSlot.setAttribute('aria-busy', 'false');
imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_ERROR), retry));
}
function startImage(forCard: GalleryCard): void {
const token = ++imageToken;
revokeImage();
imageSlot.setAttribute('aria-busy', 'true');
imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING)));
deps.loadImage(forCard, 'full').then(
(blob) => {
if (disposed || token !== imageToken) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
imageBlob = blob;
imageUrl = URL.createObjectURL(blob);
const img = el('img', {
src: imageUrl,
alt: `Community-Karte „${forCard.title}“`,
width: CARD_W,
height: CARD_H,
});
// Die Datei kam an, lässt sich aber nicht darstellen.
img.addEventListener('error', () => {
if (disposed || token !== imageToken) return;
revokeImage();
showImageError(forCard);
});
imageSlot.setAttribute('aria-busy', 'false');
imageSlot.replaceChildren(img);
},
() => {
if (disposed || token !== imageToken) return;
showImageError(forCard);
},
);
}
// ---- Ansichten ohne Karte ----
/** Verlässt die Kartenansicht: Bild und Meldungen gehören nicht mehr dazu. */
function leaveCard(): void {
generation += 1;
imageToken += 1;
revokeImage();
card = null;
busy = null;
reports = { status: 'idle', list: [] };
}
function showUnavailable(): void {
leaveCard();
view = 'unavailable';
document.title = titleFor(route);
page.replaceChildren(
el(
'div',
{ class: 'detail-unavailable' },
el('h1', { class: 'detail-title' }, UNAVAILABLE),
el('a', { class: 'home-btn home-btn-primary', href: '/#community', 'data-link': true }, 'Zur Galerie'),
),
);
}
function showLoadError(): void {
leaveCard();
view = 'error';
const retry = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, 'Nochmal versuchen');
retry.addEventListener('click', () => void load());
page.replaceChildren(
el('div', { class: 'detail-unavailable' }, el('p', { class: 'detail-load-error', role: 'alert' }, LOAD_ERROR), retry),
);
}
// ---- Karte ----
const infoSlot = el('div', { class: 'detail-info' });
const noticeSlot = el('div', { class: 'detail-notice-slot' });
const actionsSlot = el('div', { class: 'detail-actions' });
const adminSlot = el('div', { class: 'detail-admin-slot' });
// Beide Meldungszeilen bleiben im DOM (leer ausgeblendet), damit Screenreader Änderungen melden.
const statusEl = el('p', { class: 'detail-msg', role: 'status' });
const errorEl = el('p', { class: 'detail-msg is-error', role: 'alert' });
function showMessage(text: string, kind: 'info' | 'error'): void {
statusEl.textContent = kind === 'info' ? text : '';
errorEl.textContent = kind === 'error' ? text : '';
}
function button(label: string, action: string, onClick: () => void, className = 'home-btn'): HTMLButtonElement {
const b = el('button', { type: 'button', class: className, 'data-action': action, disabled: busy !== null }, label);
b.addEventListener('click', onClick);
return b;
}
/** Baut Hinweis, Knöpfe und Admin-Bereich aus dem Zustand neu auf; der Fokus bleibt auf dem gleichen Knopf. */
function renderControls(): void {
if (!card) return;
const c = card;
const user = deps.currentUser();
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusedAction = focused && infoSlot.contains(focused) ? focused.closest('[data-action]')?.getAttribute('data-action') : null;
noticeSlot.replaceChildren(
...(c.hidden ? [el('p', { class: 'detail-hidden' }, el('strong', {}, 'Ausgeblendet'), ` – ${HIDDEN_NOTICE}`)] : []),
);
const buttons = [
button(busy === 'download' ? 'Wird geladen …' : 'Herunterladen', 'download', () => void download(), 'home-btn home-btn-primary'),
button('Als Vorlage gestalten', 'template', () => deps.navigate(`/gestalten?vorlage=${encodeURIComponent(c.id)}`)),
];
// Eine ausgeblendete Karte lässt sich nicht melden (der Dienst kennt sie für Besucher nicht).
if (!c.hidden) buttons.push(button('Melden', 'report', () => void reportThis()));
if (isOwner(c, user)) {
buttons.push(button('Zurückziehen', 'withdraw', () => void removeCard('withdraw'), 'home-btn detail-btn-danger'));
}
actionsSlot.replaceChildren(...buttons);
adminSlot.replaceChildren(...(isAdmin(user) ? [adminSection(c)] : []));
if (focusedAction) infoSlot.querySelector<HTMLElement>(`[data-action="${focusedAction}"]`)?.focus();
}
function adminSection(c: GalleryCard): HTMLElement {
const section = el(
'section',
{ class: 'detail-admin', 'aria-labelledby': 'detail-admin-title' },
el('h2', { id: 'detail-admin-title', class: 'detail-admin-title' }, 'Moderation'),
el('p', { class: 'detail-reports-count' }, reportsLabel(c.reportCount)),
);
if (reports.status === 'error') {
section.append(el('p', { class: 'detail-admin-note' }, REASONS_ERROR));
} else if (reports.list.length > 0) {
section.append(
el(
'ul',
{ class: 'detail-reasons' },
...reports.list.map((r) => {
const date = formatDate(r.createdAt);
return el('li', {}, `${REASON_LABELS.get(r.reason) ?? r.reason}${date ? ` · ${date}` : ''}`);
}),
),
);
}
section.append(
el(
'div',
{ class: 'detail-admin-actions' },
button(c.hidden ? 'Einblenden' : 'Ausblenden', 'visibility', () => void toggleVisibility()),
button('Löschen', 'delete', () => void removeCard('delete'), 'home-btn detail-btn-danger'),
),
);
return section;
}
/** Lädt die Meldungen, sobald ein Admin die Karte sieht (einmal je Ansicht). */
async function ensureReports(): Promise<void> {
if (!card || reports.status !== 'idle' || !isAdmin(deps.currentUser())) return;
const mine = generation;
const galleryId = card.id;
reports = { status: 'loading', list: [] };
try {
const list = await deps.listReports(galleryId);
if (disposed || mine !== generation) return;
reports = { status: 'ready', list };
} catch {
if (disposed || mine !== generation) return;
reports = { status: 'error', list: [] };
}
renderControls();
}
function showCard(c: GalleryCard): void {
leaveCard();
view = 'card';
card = c;
showMessage('', 'info');
document.title = titleFor(route, c.title);
const date = formatDate(c.createdAt);
const author = `von ${c.displayName !== '' ? c.displayName : 'Anonym'}`;
infoSlot.replaceChildren(
el('h1', { class: 'detail-title' }, c.title),
...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []),
el('p', { class: 'detail-meta' }, date ? `${author} · ${date}` : author),
...(c.tags.length > 0
? [
el(
'div',
{ class: 'detail-tags' },
...c.tags.map((tag) =>
el(
'a',
{
class: 'detail-tag',
href: `/?tag=${encodeURIComponent(tag)}#community`,
'data-link': true,
title: 'Karten mit diesem Tag zeigen',
},
tag,
),
),
),
]
: []),
noticeSlot,
actionsSlot,
statusEl,
errorEl,
adminSlot,
);
page.replaceChildren(
el('p', { class: 'detail-back' }, el('a', { href: '/#community', 'data-link': true }, '← Zur Galerie')),
el('div', { class: 'detail-layout' }, imageSlot, infoSlot),
);
renderControls();
startImage(c);
void ensureReports();
}
// ---- Aktionen ----
function setBusy(next: Busy): void {
busy = next;
renderControls();
}
async function download(): Promise<void> {
const c = card;
if (!c || busy) return;
const mine = generation;
showMessage('', 'info');
setBusy('download');
try {
// Normalerweise liegt das Bild schon vor (es ist die große Karte); sonst wird es jetzt geholt.
const blob = imageBlob ?? (await deps.loadImage(c, 'full'));
if (disposed || mine !== generation) return;
const created = new Date(c.createdAt);
deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created));
} catch {
if (disposed || mine !== generation) return;
showMessage(IMAGE_ERROR, 'error');
}
setBusy(null);
}
async function reportThis(): Promise<void> {
const c = card;
if (!c || busy) return;
try {
await reportDialog({
cardTitle: c.title,
signal: disposal.signal,
send: (reason) => deps.reportCard(c.id, reason),
});
} catch {
// Dialog nicht darstellbar (showModal wirft): kein unbehandelter Fehler, sondern ein Hinweis auf der Seite.
if (!disposed) showMessage(REPORT_ERROR, 'error');
}
}
async function ask(options: ConfirmOptions): Promise<boolean> {
try {
return await confirmDialog(options, disposal.signal);
} catch {
return false; // Dialog nicht darstellbar: dann lieber nichts löschen
}
}
/** Zurückziehen darf der Autor, Löschen ein Admin; geprüft wird beim Ausführen mit dem aktuellen Nutzer. */
function mayRemove(kind: 'withdraw' | 'delete', c: GalleryCard): boolean {
const user = deps.currentUser();
return kind === 'delete' ? isAdmin(user) : isOwner(c, user);
}
/** Zurückziehen (Autor) und Löschen (Admin) sind für den Dienst dasselbe: Zeile, Dateien und Meldungen verschwinden. */
async function removeCard(kind: 'withdraw' | 'delete'): Promise<void> {
const c = card;
if (!c || busy || !mayRemove(kind, c)) return;
const mine = generation;
const confirmed = await ask(
kind === 'withdraw'
? {
title: `Karte „${c.title}“ zurückziehen?`,
text: 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.',
confirmLabel: 'Zurückziehen',
}
: {
title: `Karte „${c.title}“ löschen?`,
text: 'Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.',
confirmLabel: 'Löschen',
},
);
// Während der Rückfrage kann die Sitzung gewechselt haben: dann nichts mehr senden.
if (!confirmed || disposed || mine !== generation || busy || !mayRemove(kind, c)) return;
showMessage('', 'info');
setBusy('remove');
try {
await deps.withLogin(() => deps.withdrawCard(c.id));
} catch (err) {
// Die Karte gibt es schon nicht mehr (anderer Tab, früherer Versuch mit verlorener Antwort): das Ziel ist erreicht.
if (!(err instanceof ApiError && err.code === 404)) {
if (disposed || mine !== generation) return;
showMessage(failureText(err), 'error');
setBusy(null);
return;
}
}
if (!disposed) deps.navigate('/');
}
async function toggleVisibility(): Promise<void> {
const c = card;
if (!c || busy || !isAdmin(deps.currentUser())) return;
const mine = generation;
const show = c.hidden;
showMessage('', 'info');
setBusy('visibility');
try {
await deps.withLogin(() => deps.setVisibility(c.id, show));
if (disposed || mine !== generation) return;
// Einblenden setzt beim Dienst die Meldungen zurück (Zähler 0, Meldungen gelöscht); Ausblenden lässt sie stehen.
card = { ...c, hidden: !show, reportCount: show ? 0 : c.reportCount };
if (show) reports = { status: 'ready', list: [] };
showMessage(show ? 'Karte eingeblendet.' : 'Karte ausgeblendet.', 'info');
} catch (err) {
if (disposed || mine !== generation) return;
showMessage(failureText(err), 'error');
}
setBusy(null);
}
// ---- Laden ----
/** `quiet`: die bisherige Ansicht bleibt stehen, bis die Antwort da ist (Wiederholen nach einer Anmeldung). */
async function load(quiet = false): Promise<void> {
const token = ++loadToken;
if (!quiet) {
leaveCard();
view = 'loading';
page.replaceChildren(el('p', { class: 'detail-status', role: 'status' }, LOADING));
}
try {
const found = await deps.getCard(id);
if (disposed || token !== loadToken) return;
let user = deps.currentUser();
if (found && !mayView(found, user)) {
// Der Server liefert eine ausgeblendete Karte nur Autor und Admin. Bei einem Direktaufruf ist die Anmeldung aber
// vielleicht noch nicht geklärt: dann erst den Anmeldestatus holen, statt dem Autor „nicht verfügbar“ zu zeigen.
user = await deps.getUser().catch(() => null);
if (disposed || token !== loadToken) return;
}
if (found && mayView(found, user)) showCard(found);
else showUnavailable();
} catch {
if (disposed || token !== loadToken) return;
if (!quiet || view !== 'card') showLoadError();
}
}
const stopAuth = deps.onAuthChange((user) => {
if (disposed) return;
// Wer sich jetzt anmeldet, darf die Karte vielleicht sehen, die ihm vorher fehlte.
if (view === 'unavailable' || view === 'error') {
void load(true);
return;
}
if (view !== 'card' || !card) return;
if (!mayView(card, user)) {
showUnavailable();
return;
}
if (!isAdmin(user)) reports = { status: 'idle', list: [] };
renderControls();
void ensureReports();
});
void load();
return () => {
disposed = true;
disposal.abort();
stopAuth();
leaveCard();
};
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { formatDate } from './format';
describe('formatDate', () => {
it('schreibt dd.mm.yyyy', () => {
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('31.01.2026');
});
it('gibt bei unlesbarem Datum nichts zurück', () => {
expect(formatDate('kaputt')).toBe('');
});
});
+9
View File
@@ -0,0 +1,9 @@
// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite).
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
export function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}
+432
View File
@@ -0,0 +1,432 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import type { GalleryCard, GalleryOptions } from '../community/gallery';
// gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage.
vi.mock('../account/appwrite', () => ({
account: {},
client: {},
tables: {},
storage: {},
IDS: {},
APPWRITE_ENDPOINT: '',
}));
import { mountHomePage, type HomeDeps } from './home-page';
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id: `g${n}`,
title: `Karte ${n}`,
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: `img${n}`,
previewFileId: `pre${n}`,
sourceCardId: `src${n}`,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
type Page = { cards: GalleryCard[]; nextCursor: string | null };
const page = (cards: GalleryCard[], nextCursor: string | null = null): Page => ({ cards, nextCursor });
const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
const blob = () => new Blob(['x'], { type: 'image/png' });
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
const member: User = { $id: 'u1', email: 'u@example.com', labels: [] };
let root: HTMLElement;
let unmount: (() => void) | null;
let user: User | null;
let authListeners: Set<(u: User | null) => void>;
let urlCounter: number;
const revoked: string[] = [];
const listGallery = vi.fn<(opts: GalleryOptions) => Promise<Page>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
const navigate = vi.fn<(path: string) => void>();
const scrollToHash = vi.fn<(hash: string) => void>();
function deps(): HomeDeps {
return {
listGallery,
loadImage,
currentUser: () => user,
onAuthChange: (fn) => {
authListeners.add(fn);
return () => authListeners.delete(fn);
},
navigate,
scrollToHash,
};
}
async function mount(url = '/'): Promise<void> {
history.replaceState(null, '', url);
unmount = mountHomePage(root, deps());
await settle();
}
const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
const buttonByText = (text: string) => qa<HTMLButtonElement>('button').find((b) => b.textContent === text);
beforeEach(() => {
listGallery.mockReset();
loadImage.mockReset();
navigate.mockReset();
scrollToHash.mockReset();
loadImage.mockImplementation(async () => blob());
listGallery.mockResolvedValue(page([]));
user = null;
authListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:home-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
document.body.innerHTML = '<input class="header-search-input" value="alt"><main id="app-root"></main>';
root = document.getElementById('app-root') as HTMLElement;
unmount = null;
});
afterEach(() => {
unmount?.();
history.replaceState(null, '', '/');
});
describe('Startseite: Hero', () => {
it('zeigt die Texte aus der Spec und die zwei Knöpfe', async () => {
await mount();
expect(q('.home-hero-eyebrow')?.textContent).toBe('Ein bisschen Kunst für dein Wallet');
expect(q('h1')?.textContent).toBe('Kartendesigns für Apple Wallet');
expect(q('.home-hero-lead')?.textContent).toBe(
'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.',
);
const discover = q<HTMLAnchorElement>('.home-hero a[href="#community"]');
expect(discover?.textContent).toBe('Karten entdecken ↓');
expect(discover?.hasAttribute('data-link')).toBe(true);
const design = q<HTMLAnchorElement>('.home-hero a[href="/gestalten"]');
expect(design?.textContent).toBe('Gestalten');
expect(design?.hasAttribute('data-link')).toBe(true);
});
it('leere Galerie: Beispielmotiv in der Hero-Karte und Leertext mit „Gestalten“', async () => {
await mount();
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
expect(q('.hero-card a')).toBeNull();
const empty = q('.community-empty');
expect(empty?.textContent).toContain('Noch keine Community-Karten – sei die erste!');
expect(empty?.querySelector('a[href="/gestalten"]')?.textContent).toBe('Gestalten');
expect(buttonByText('Mehr laden')).toBeUndefined();
expect(loadImage).not.toHaveBeenCalled();
});
it('Hero-Karte = Vollbild der neuesten Karte, verlinkt auf ihre Detailseite', async () => {
listGallery.mockResolvedValue(page([card(1), card(2)]));
await mount();
expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full');
const link = q<HTMLAnchorElement>('.hero-card a[data-link]');
expect(link?.getAttribute('href')).toBe('/community/g1');
expect(link?.querySelector('img')?.getAttribute('src')).toMatch(/^blob:home-/);
// genau eine Abfrage: die erste Seite liefert auch die neueste Karte
expect(listGallery).toHaveBeenCalledTimes(1);
});
it('fällt auf das Beispielmotiv zurück, wenn das Kartenbild nicht lädt', async () => {
listGallery.mockResolvedValue(page([card(1)]));
loadImage.mockImplementation(async (_c, size) => {
if (size === 'full') throw new Error('404');
return blob();
});
await mount();
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
});
it('Ladefehler der Galerie: Hero zeigt das Beispielmotiv', async () => {
listGallery.mockRejectedValue(new Error('offline'));
await mount();
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
});
});
describe('Startseite: Galerie', () => {
it('hat den Abschnitt #community mit Überschrift, Untertitel und „Gestalten & veröffentlichen“', async () => {
await mount();
const section = q('section#community');
expect(section).not.toBeNull();
expect(section?.querySelector('h2')?.textContent).toBe('Community-Karten');
expect(section?.querySelector('.community-sub')?.textContent).toBe(
'Entdecke Motive der Community – und veröffentliche deine eigenen.',
);
const publish = section?.querySelector<HTMLAnchorElement>('a[href="/gestalten"]');
expect(publish?.textContent).toBe('Gestalten & veröffentlichen');
});
it('lädt die erste Seite ohne Suche und zeigt die Kacheln mit Vorschau', async () => {
listGallery.mockResolvedValue(page([card(1), card(2), card(3)]));
await mount();
expect(listGallery).toHaveBeenCalledWith({ q: undefined, tag: undefined, filter: 'all' });
expect(qa('.gallery-tile')).toHaveLength(3);
expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g2' }), 'preview');
expect(q('.community-empty')).toBeNull();
});
it('„Mehr laden“ nur bei nextCursor, hängt die nächste Seite an und verschwindet am Ende', async () => {
listGallery.mockResolvedValueOnce(page([card(1), card(2)], 'g2'));
await mount();
const more = buttonByText('Mehr laden');
expect(more).toBeDefined();
listGallery.mockResolvedValueOnce(page([card(3)], null));
more!.click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: undefined, tag: undefined, filter: 'all', cursor: 'g2' });
expect(qa('.gallery-tile')).toHaveLength(3);
expect(buttonByText('Mehr laden')).toBeUndefined();
});
it('ohne nextCursor gibt es kein „Mehr laden“', async () => {
listGallery.mockResolvedValue(page([card(1)], null));
await mount();
expect(buttonByText('Mehr laden')).toBeUndefined();
});
it('„Mehr laden“ scheitert: Hinweis, Knopf bleibt, zweiter Versuch klappt', async () => {
listGallery.mockResolvedValueOnce(page([card(1)], 'g1'));
await mount();
listGallery.mockRejectedValueOnce(new Error('offline'));
buttonByText('Mehr laden')!.click();
await settle();
expect(q('.community-more-msg')?.textContent).toContain('konnten nicht geladen werden');
expect(qa('.gallery-tile')).toHaveLength(1);
listGallery.mockResolvedValueOnce(page([card(2)], null));
buttonByText('Mehr laden')!.click();
await settle();
expect(qa('.gallery-tile')).toHaveLength(2);
expect(buttonByText('Mehr laden')).toBeUndefined();
});
it('Ladefehler: Hinweis und „Nochmal versuchen“', async () => {
listGallery.mockRejectedValueOnce(new Error('offline'));
await mount();
expect(q('[role="alert"]')?.textContent).toContain('konnten gerade nicht geladen werden');
listGallery.mockResolvedValue(page([card(1)]));
buttonByText('Nochmal versuchen')!.click();
await settle();
expect(q('[role="alert"]')).toBeNull();
expect(qa('.gallery-tile')).toHaveLength(1);
});
it('Tag-Klick navigiert zu /?tag=<tag>#community', async () => {
listGallery.mockResolvedValue(page([card(1, { tags: ['visa', 'dunkel blau'] })]));
await mount();
qa<HTMLButtonElement>('button.gallery-tag')[0].click();
expect(navigate).toHaveBeenLastCalledWith('/?tag=visa#community');
qa<HTMLButtonElement>('button.gallery-tag')[1].click();
expect(navigate).toHaveBeenLastCalledWith('/?tag=dunkel%20blau#community');
});
});
describe('Startseite: Suche und Tag aus der Adresse', () => {
it('?q= landet in der Abfrage, zeigt den Begriff mit „ד und füllt das Suchfeld der Kopfzeile', async () => {
listGallery.mockResolvedValue(page([card(1)]));
await mount('/?q=visa%20gold#community');
expect(listGallery).toHaveBeenCalledWith({ q: 'visa gold', tag: undefined, filter: 'all' });
const active = q('.community-active');
expect(active?.textContent).toContain('visa gold');
const clear = active?.querySelector<HTMLButtonElement>('button');
expect(clear?.textContent).toBe('×');
expect(clear?.getAttribute('aria-label')).toBe('Suche zurücksetzen');
expect(document.querySelector<HTMLInputElement>('.header-search-input')?.value).toBe('visa gold');
});
it('„ד entfernt die Suche (zurück zu /#community)', async () => {
await mount('/?q=visa#community');
q<HTMLButtonElement>('.community-active button')!.click();
expect(navigate).toHaveBeenCalledWith('/#community');
});
it('ohne ?q= bleibt die Suchzeile weg und das Suchfeld der Kopfzeile wird geleert', async () => {
await mount('/');
expect(q('.community-active')).toBeNull();
expect(document.querySelector<HTMLInputElement>('.header-search-input')?.value).toBe('');
});
it('setzt das Suchfeld über .value (kein HTML)', async () => {
await mount('/?q=%3Cb%3Ex%3C%2Fb%3E');
const input = document.querySelector<HTMLInputElement>('.header-search-input')!;
expect(input.value).toBe('<b>x</b>');
expect(document.querySelector('.header-search-input b')).toBeNull();
expect(q('.community-active b')).toBeNull();
});
it('?tag= landet in der Abfrage und zeigt den Tag mit „ד', async () => {
listGallery.mockResolvedValue(page([card(1)]));
await mount('/?tag=gold#community');
expect(listGallery).toHaveBeenCalledWith({ q: undefined, tag: 'gold', filter: 'all' });
const active = q('.community-active');
expect(active?.textContent).toContain('Tag');
expect(active?.textContent).toContain('gold');
active!.querySelector<HTMLButtonElement>('button')!.click();
expect(navigate).toHaveBeenCalledWith('/#community');
});
it('mit Suche: Hero-Karte bleibt die neueste sichtbare Karte (eigene Abfrage ohne Suche)', async () => {
listGallery.mockImplementation(async (opts) =>
opts.q ? page([card(7, { title: 'Treffer' })]) : page([card(1, { title: 'Neueste' })]),
);
await mount('/?q=treffer');
expect(listGallery).toHaveBeenCalledWith({});
expect(q('.hero-card a')?.getAttribute('href')).toBe('/community/g1');
expect(qa('.gallery-title').map((t) => t.textContent)).toEqual(['Treffer']);
});
it('Mehrere Treffer-Seiten: der Cursor gilt für dieselbe Suche', async () => {
listGallery.mockImplementation(async (opts) =>
opts.q ? page([card(2)], 'g2') : page([card(1)], 'g1'),
);
await mount('/?q=abc');
listGallery.mockImplementation(async () => page([card(3)]));
buttonByText('Mehr laden')!.click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: 'abc', tag: undefined, filter: 'all', cursor: 'g2' });
});
it('keine Treffer: Nichts gefunden für „…“', async () => {
listGallery.mockResolvedValue(page([]));
await mount('/?q=xyz');
expect(q('.community-empty')?.textContent).toContain('Nichts gefunden für „xyz“');
expect(q('.community-empty')?.textContent).not.toContain('sei die erste');
});
it('keine Treffer bei Tag: der Tag steht im Text', async () => {
listGallery.mockResolvedValue(page([]));
await mount('/?tag=selten');
expect(q('.community-empty')?.textContent).toContain('Nichts gefunden für „selten“');
});
it('Anker: springt nach dem Laden zu #community, ohne Anker nicht', async () => {
await mount('/?q=abc#community');
expect(scrollToHash).toHaveBeenCalledWith('#community');
unmount!();
scrollToHash.mockClear();
await mount('/');
expect(scrollToHash).not.toHaveBeenCalled();
});
});
describe('Startseite: Admin-Filter', () => {
it('Gäste und normale Konten sehen keine Filter-Chips', async () => {
await mount();
expect(q('.community-filter')).toBeNull();
user = member;
for (const fn of authListeners) fn(member);
expect(q('.community-filter')).toBeNull();
});
it('Admin sieht „Alle“, „Gemeldet“, „Ausgeblendet“; Wechsel lädt neu mit dem Filter', async () => {
user = admin;
listGallery.mockResolvedValue(page([card(1)]));
await mount();
const chips = qa<HTMLButtonElement>('.community-filter button');
expect(chips.map((b) => b.textContent)).toEqual(['Alle', 'Gemeldet', 'Ausgeblendet']);
expect(chips.map((b) => b.getAttribute('aria-pressed'))).toEqual(['true', 'false', 'false']);
listGallery.mockResolvedValue(page([card(5, { hidden: true, reportCount: 3 })]));
chips[2].click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: undefined, tag: undefined, filter: 'hidden' });
expect(qa<HTMLButtonElement>('.community-filter button').map((b) => b.getAttribute('aria-pressed'))).toEqual([
'false',
'false',
'true',
]);
expect(qa('.gallery-tile')).toHaveLength(1);
// Admin-Ansicht zeigt die Status-Marken, und die Hero-Karte bleibt unverändert
expect(q('.gallery-badge')?.textContent).toBe('Ausgeblendet');
expect(q('.hero-card a')?.getAttribute('href')).toBe('/community/g1');
chips[1].click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith({ q: undefined, tag: undefined, filter: 'reported' });
});
it('leere Admin-Ansicht: eigener Text statt „sei die erste“', async () => {
user = admin;
listGallery.mockResolvedValue(page([card(1)]));
await mount();
listGallery.mockResolvedValue(page([]));
buttonByText('Gemeldet')!.click();
await settle();
expect(q('.community-empty')?.textContent).toContain('Keine Karten in dieser Ansicht.');
expect(q('.community-empty')?.textContent).not.toContain('sei die erste');
});
it('wird der Admin erst nach dem Start erkannt, erscheinen die Chips; Abmelden entfernt sie und setzt auf „Alle“', async () => {
listGallery.mockResolvedValue(page([card(1)]));
await mount();
expect(q('.community-filter')).toBeNull();
user = admin;
for (const fn of authListeners) fn(admin);
expect(qa('.community-filter button')).toHaveLength(3);
listGallery.mockResolvedValue(page([card(5)]));
buttonByText('Gemeldet')!.click();
await settle();
expect(listGallery).toHaveBeenLastCalledWith(expect.objectContaining({ filter: 'reported' }));
user = null;
for (const fn of authListeners) fn(null);
await settle();
expect(q('.community-filter')).toBeNull();
expect(listGallery).toHaveBeenLastCalledWith(expect.objectContaining({ filter: 'all' }));
});
it('eine späte Antwort der alten Ansicht überschreibt die neue nicht', async () => {
user = admin;
let resolveOld!: (p: Page) => void;
listGallery.mockResolvedValueOnce(page([card(1)])); // erste Seite
await mount();
listGallery.mockImplementationOnce(() => new Promise<Page>((r) => (resolveOld = r))); // „Gemeldet“ (langsam)
buttonByText('Gemeldet')!.click();
listGallery.mockResolvedValueOnce(page([card(9, { title: 'Neu' })])); // „Alle“ (schnell)
buttonByText('Alle')!.click();
await settle();
resolveOld(page([card(8, { title: 'Alt' })]));
await settle();
expect(qa('.gallery-title').map((t) => t.textContent)).toEqual(['Neu']);
});
});
describe('Startseite: Aufräumen', () => {
it('gibt beim Verlassen alle Object-URLs frei (Hero und Vorschauen) und hört nicht mehr auf Anmeldungen', async () => {
listGallery.mockResolvedValue(page([card(1), card(2)]));
await mount();
expect(urlCounter).toBe(3); // Hero + 2 Vorschauen
expect(authListeners.size).toBe(1);
unmount!();
unmount = null;
expect(revoked.sort()).toEqual(['blob:home-1', 'blob:home-2', 'blob:home-3']);
expect(authListeners.size).toBe(0);
});
it('legt nach dem Verlassen keine URLs mehr an (späte Antworten)', async () => {
let resolveList!: (p: Page) => void;
listGallery.mockImplementationOnce(() => new Promise<Page>((r) => (resolveList = r)));
history.replaceState(null, '', '/');
unmount = mountHomePage(root, deps());
unmount();
unmount = null;
resolveList(page([card(1)]));
await settle();
expect(urlCounter).toBe(0);
expect(loadImage).not.toHaveBeenCalled();
});
});
+341
View File
@@ -0,0 +1,341 @@
// Startseite „/“: kompakter Hero mit großer Karte und direkt darunter die Community-Galerie (Anker #community).
// Suche (`?q=`) und Tag (`?tag=`) kommen aus der Adresse; Label-Admins sehen zusätzlich die Filter Alle/Gemeldet/Ausgeblendet.
import '../community/community.css';
import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth';
import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid';
import { listGallery, loadGalleryImage, type GalleryCard, type GalleryFilter, type GalleryOptions } from '../community/gallery';
import { el } from '../editor/dom';
import { navigate, scrollToHash } from './router';
type Page = { cards: GalleryCard[]; nextCursor: string | null };
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
export interface HomeDeps {
listGallery(opts: GalleryOptions): Promise<Page>;
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
currentUser(): User | null;
onAuthChange(fn: (user: User | null) => void): () => void;
navigate(path: string): void;
scrollToHash(hash: string): void;
}
export const defaultDeps: HomeDeps = {
listGallery,
loadImage: loadGalleryImage,
currentUser,
onAuthChange,
navigate,
scrollToHash,
};
const EXAMPLE_IMAGE = '/hero-beispiel.png';
const EXAMPLE_ALT = 'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol';
const CARD_W = 1536;
const CARD_H = 969;
const LOADING = 'Karten werden geladen …';
const LOAD_ERROR = 'Die Community-Karten konnten gerade nicht geladen werden.';
const MORE_ERROR = 'Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.';
const EMPTY = 'Noch keine Community-Karten – sei die erste!';
const EMPTY_VIEW = 'Keine Karten in dieser Ansicht.';
const FILTERS: ReadonlyArray<readonly [GalleryFilter, string]> = [
['all', 'Alle'],
['reported', 'Gemeldet'],
['hidden', 'Ausgeblendet'],
];
/** `/` plus Suche und/oder Tag, mit Anker auf die Galerie. */
function communityPath(next: { q?: string; tag?: string }): string {
const parts: string[] = [];
if (next.q) parts.push(`q=${encodeURIComponent(next.q)}`);
if (next.tag) parts.push(`tag=${encodeURIComponent(next.tag)}`);
return `/${parts.length > 0 ? `?${parts.join('&')}` : ''}#community`;
}
export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): () => void {
const params = new URLSearchParams(location.search);
const query = (params.get('q') ?? '').trim();
const tag = (params.get('tag') ?? '').trim();
let disposed = false;
let filter: GalleryFilter = 'all';
let loadToken = 0;
let cursor: string | null = null;
let loadingMore = false;
let moreError = '';
let grid: GalleryGrid | null = null;
let heroUrl: string | null = null;
const options = (): GalleryOptions => ({ q: query || undefined, tag: tag || undefined, filter });
// Das Suchfeld der Kopfzeile gehört zu main.ts; hier bekommt es den Suchbegriff der Adresse (per .value, nie als HTML).
const headerInput = document.querySelector<HTMLInputElement>('.header-search-input');
if (headerInput) headerInput.value = query;
// ---- Hero ----
const heroCard = el('div', { class: 'hero-card is-loading' });
const hero = el(
'section',
{ class: 'home-hero', 'aria-label': 'Willkommen' },
el(
'div',
{ class: 'home-hero-copy' },
el('p', { class: 'home-hero-eyebrow' }, 'Ein bisschen Kunst für dein Wallet'),
el('h1', { class: 'home-hero-title' }, 'Kartendesigns für Apple Wallet'),
el('p', { class: 'home-hero-lead' }, 'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.'),
el(
'div',
{ class: 'home-hero-actions' },
el('a', { class: 'home-btn home-btn-primary', href: '#community', 'data-link': true }, 'Karten entdecken ↓'),
el('a', { class: 'home-btn', href: '/gestalten', 'data-link': true }, 'Gestalten'),
),
),
el('div', { class: 'home-hero-visual' }, heroCard),
);
function setHeroImage(src: string, alt: string, card: GalleryCard | null): void {
const img = el('img', { src, alt, width: CARD_W, height: CARD_H });
heroCard.classList.remove('is-loading');
heroCard.replaceChildren(
card ? el('a', { class: 'hero-card-link', href: `/community/${encodeURIComponent(card.id)}`, 'data-link': true }, img) : img,
);
}
const showExample = () => setHeroImage(EXAMPLE_IMAGE, EXAMPLE_ALT, null);
/** Die große Karte: Vollbild der neuesten sichtbaren Karte; ohne Karte oder bei Fehlern das Beispielmotiv. */
function showHero(newest: Promise<GalleryCard | null>): void {
newest.then(
async (card) => {
if (disposed) return;
if (!card) return showExample();
try {
const blob = await deps.loadImage(card, 'full');
if (disposed) return;
heroUrl = URL.createObjectURL(blob);
setHeroImage(heroUrl, `Community-Karte „${card.title}“`, card);
} catch {
if (!disposed) showExample();
}
},
() => {
if (!disposed) showExample();
},
);
}
// ---- Galerie-Abschnitt ----
const filterSlot = el('div', { class: 'community-filter-slot' });
const activeSlot = el('div', { class: 'community-active-slot' });
const statusSlot = el('div', { class: 'community-status-slot' });
const gridHost = el('div', { class: 'community-grid-host' });
const moreSlot = el('div', { class: 'community-more' });
const section = el(
'section',
{ id: 'community', class: 'home-community', 'aria-labelledby': 'community-title' },
el(
'div',
{ class: 'community-head' },
el(
'div',
{ class: 'community-head-text' },
el('h2', { id: 'community-title', class: 'community-title' }, 'Community-Karten'),
el('p', { class: 'community-sub' }, 'Entdecke Motive der Community – und veröffentliche deine eigenen.'),
),
el('a', { class: 'home-btn home-btn-primary community-publish', href: '/gestalten', 'data-link': true }, 'Gestalten & veröffentlichen'),
),
el('div', { class: 'community-toolbar' }, activeSlot, filterSlot),
statusSlot,
gridHost,
moreSlot,
);
root.replaceChildren(hero, section);
// Aktiver Suchbegriff / Tag mit „ד (zurück zur Galerie ohne diesen Begriff)
function chip(label: string, value: string, clearLabel: string, target: string): HTMLElement {
const clear = el('button', { type: 'button', class: 'community-chip-clear', 'aria-label': clearLabel }, '×');
clear.addEventListener('click', () => deps.navigate(target));
return el(
'span',
{ class: 'community-chip' },
el('span', { class: 'community-chip-label' }, `${label}:`),
el('strong', { class: 'community-chip-value' }, value),
clear,
);
}
if (query || tag) {
const active = el('div', { class: 'community-active' });
if (query) active.append(chip('Suche', query, 'Suche zurücksetzen', communityPath({ tag })));
if (tag) active.append(chip('Tag', tag, 'Tag-Filter zurücksetzen', communityPath({ q: query })));
activeSlot.append(active);
}
// Admin-Filter
const filterButtons = new Map<GalleryFilter, HTMLButtonElement>();
function syncFilterButtons(): void {
for (const [value, button] of filterButtons) button.setAttribute('aria-pressed', String(value === filter));
}
function renderFilter(user: User | null): void {
filterButtons.clear();
if (!isAdmin(user)) {
filterSlot.replaceChildren();
return;
}
const group = el('div', { class: 'community-filter', role: 'group', 'aria-label': 'Ansicht' });
for (const [value, label] of FILTERS) {
const button = el('button', { type: 'button', class: 'community-filter-btn' }, label);
button.addEventListener('click', () => setFilter(value));
filterButtons.set(value, button);
group.append(button);
}
filterSlot.replaceChildren(group);
syncFilterButtons();
}
function setFilter(next: GalleryFilter): void {
if (next === filter) return;
filter = next;
syncFilterButtons();
loadFirst(deps.listGallery(options()));
}
// ---- Laden ----
function showStatus(text: string, role: 'status' | 'alert', retry = false): void {
const message = el('p', { class: `community-status${role === 'alert' ? ' is-error' : ''}`, role }, text);
const nodes: Node[] = [message];
if (retry) {
const button = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, 'Nochmal versuchen');
button.addEventListener('click', () => loadFirst(deps.listGallery(options())));
nodes.push(button);
}
statusSlot.replaceChildren(...nodes);
}
function showEmpty(): void {
const nodes: Node[] = [];
if (filter !== 'all') {
nodes.push(el('p', {}, EMPTY_VIEW));
} else if (query || tag) {
nodes.push(el('p', {}, `Nichts gefunden für „${[query, tag].filter(Boolean).join(' · ')}“`));
} else {
nodes.push(
el('p', {}, EMPTY),
el('a', { class: 'home-btn home-btn-primary', href: '/gestalten', 'data-link': true }, 'Gestalten'),
);
}
statusSlot.replaceChildren(el('div', { class: 'community-empty' }, ...nodes));
}
function renderMore(): void {
if (!cursor) {
moreSlot.replaceChildren();
return;
}
const button = el('button', { type: 'button', class: 'home-btn community-more-btn' }, 'Mehr laden');
if (loadingMore) {
button.disabled = true;
button.setAttribute('aria-busy', 'true');
}
button.addEventListener('click', () => void loadMore());
moreSlot.replaceChildren(
...(moreError ? [el('p', { class: 'community-more-msg is-error', role: 'alert' }, moreError)] : []),
button,
);
}
/** Beginnt die Liste neu (andere Ansicht, Wiederholen); `pending` ist die schon laufende Abfrage der ersten Seite. */
function loadFirst(pending: Promise<Page>): void {
const token = ++loadToken;
grid?.dispose();
grid = mountGalleryGrid(gridHost, {
load: (card) => deps.loadImage(card, 'preview'),
onTag: (name) => deps.navigate(communityPath({ tag: name })),
showStatus: filter !== 'all', // Moderations-Marken nur in den Admin-Ansichten
});
cursor = null;
loadingMore = false;
moreError = '';
moreSlot.replaceChildren();
showStatus(LOADING, 'status');
pending.then(
(res) => {
if (disposed || token !== loadToken) return;
statusSlot.replaceChildren();
if (res.cards.length === 0) showEmpty();
else grid?.append(res.cards);
cursor = res.nextCursor;
renderMore();
},
() => {
if (disposed || token !== loadToken) return;
showStatus(LOAD_ERROR, 'alert', true);
},
);
}
async function loadMore(): Promise<void> {
if (!cursor || loadingMore) return;
const token = loadToken;
loadingMore = true;
moreError = '';
renderMore();
try {
const res = await deps.listGallery({ ...options(), cursor });
if (disposed || token !== loadToken) return;
loadingMore = false;
grid?.append(res.cards);
cursor = res.nextCursor;
} catch {
if (disposed || token !== loadToken) return;
loadingMore = false;
moreError = MORE_ERROR;
}
renderMore();
}
// ---- Start ----
renderFilter(deps.currentUser());
const stopAuth = deps.onAuthChange((user) => {
if (disposed) return;
renderFilter(user);
// Wer keine Admin-Rechte (mehr) hat, kann keine Admin-Ansicht offen haben.
if (!isAdmin(user) && filter !== 'all') {
filter = 'all';
loadFirst(deps.listGallery(options()));
}
});
const first = deps.listGallery(options());
loadFirst(first);
// Die Hero-Karte zeigt immer die neueste sichtbare Karte, auch bei Suche/Tag: dann braucht sie eine eigene Abfrage.
const unfiltered = !query && !tag;
showHero((unfiltered ? first : deps.listGallery({})).then((res) => res.cards[0] ?? null));
// Der Anker (z. B. /#community) existiert erst jetzt; nach dem ersten Laden noch einmal, weil die Seite dann länger ist.
if (location.hash) {
deps.scrollToHash(location.hash);
const hash = location.hash;
const again = () => {
if (!disposed) deps.scrollToHash(hash);
};
first.then(again, again);
}
return () => {
disposed = true;
stopAuth();
grid?.dispose();
grid = null;
if (heroUrl) URL.revokeObjectURL(heroUrl);
heroUrl = null;
};
}
+146 -19
View File
@@ -1,22 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { matchRoute, navigate, startRouter } from './router';
import { legacyRedirect, matchRoute, navigate, ROUTE_TITLES, startRouter, titleFor } from './router';
describe('matchRoute', () => {
it('ordnet / dem Editor zu', () => {
expect(matchRoute('/')).toBe('editor');
it('ordnet / der Startseite zu', () => {
expect(matchRoute('/')).toEqual({ id: 'home', params: {} });
});
it('ordnet /gestalten und /gestalten/ dem Editor zu', () => {
expect(matchRoute('/gestalten')).toEqual({ id: 'gestalten', params: {} });
expect(matchRoute('/gestalten/')).toEqual({ id: 'gestalten', params: {} });
});
it('ordnet /karten und /karten/ den Karten zu', () => {
expect(matchRoute('/karten')).toBe('cards');
expect(matchRoute('/karten/')).toBe('cards');
expect(matchRoute('/karten')).toEqual({ id: 'cards', params: {} });
expect(matchRoute('/karten/')).toEqual({ id: 'cards', params: {} });
});
it('ordnet /community/<id> der Detailseite zu und liefert die ID', () => {
expect(matchRoute('/community/abc123')).toEqual({ id: 'community', params: { id: 'abc123' } });
expect(matchRoute('/community/abc123/')).toEqual({ id: 'community', params: { id: 'abc123' } });
});
it('decodiert die ID; eine kaputte Kodierung fällt auf die Startseite zurück', () => {
expect(matchRoute('/community/a%20b')).toEqual({ id: 'community', params: { id: 'a b' } });
expect(matchRoute('/community/%E0%A4%A')).toEqual({ id: 'home', params: {} });
});
it('/community ohne ID und mit weiteren Segmenten ist unbekannt und führt auf die Startseite', () => {
expect(matchRoute('/community')).toEqual({ id: 'home', params: {} });
expect(matchRoute('/community/')).toEqual({ id: 'home', params: {} });
expect(matchRoute('/community/abc/def')).toEqual({ id: 'home', params: {} });
});
it('ordnet /passwort-neu dem Zurücksetzen zu', () => {
expect(matchRoute('/passwort-neu')).toBe('reset');
expect(matchRoute('/passwort-neu')).toEqual({ id: 'reset', params: {} });
});
it('fällt bei unbekannten Pfaden auf den Editor zurück', () => {
expect(matchRoute('/irgendwas')).toBe('editor');
it('fällt bei unbekannten Pfaden auf die Startseite zurück', () => {
expect(matchRoute('/irgendwas')).toEqual({ id: 'home', params: {} });
});
});
describe('legacyRedirect', () => {
it('leitet /?karte=<id> auf /gestalten?karte=<id> um', () => {
expect(legacyRedirect(new URL('https://x/?karte=K1'))).toBe('/gestalten?karte=K1');
});
it('behält weitere Parameter und den Hash bei', () => {
expect(legacyRedirect(new URL('https://x/?karte=K1&x=1#h'))).toBe('/gestalten?karte=K1&x=1#h');
});
it('ohne (oder mit leerem) karte-Parameter gibt es keine Umleitung', () => {
expect(legacyRedirect(new URL('https://x/'))).toBeNull();
expect(legacyRedirect(new URL('https://x/?q=visa'))).toBeNull();
expect(legacyRedirect(new URL('https://x/?karte='))).toBeNull();
});
it('nur die Startseite wird umgeleitet, andere Pfade bleiben unberührt', () => {
expect(legacyRedirect(new URL('https://x/gestalten?karte=K1'))).toBeNull();
expect(legacyRedirect(new URL('https://x/karten?karte=K1'))).toBeNull();
});
});
describe('Seitentitel', () => {
it('nennt je Route den Titel aus der Spec', () => {
expect(titleFor({ id: 'home', params: {} })).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
expect(titleFor({ id: 'gestalten', params: {} })).toBe('Gestalten – AirCard-IOS');
expect(titleFor({ id: 'cards', params: {} })).toBe('Meine Karten – AirCard-IOS');
expect(titleFor({ id: 'reset', params: {} })).toBe('Passwort neu setzen – AirCard-IOS');
});
it('Detailseite: „<Titel> – AirCard-IOS“, ohne Titel ein allgemeiner Name', () => {
const route = { id: 'community', params: { id: 'abc' } } as const;
expect(titleFor(route, 'Mein Motiv')).toBe('Mein Motiv – AirCard-IOS');
expect(titleFor(route)).toBe(ROUTE_TITLES.community);
expect(ROUTE_TITLES.community).toBe('Community – AirCard-IOS');
});
it('ein Titel wird nur auf der Detailseite eingesetzt', () => {
expect(titleFor({ id: 'cards', params: {} }, 'Fremd')).toBe('Meine Karten – AirCard-IOS');
});
});
@@ -39,7 +101,7 @@ describe('startRouter', () => {
const render = vi.fn();
startRouter(render);
expect(render).toHaveBeenCalledTimes(1);
expect(render).toHaveBeenCalledWith('cards', root);
expect(render).toHaveBeenCalledWith({ id: 'cards', params: {} }, root);
});
it('navigate setzt die URL per pushState und rendert neu', () => {
@@ -49,23 +111,23 @@ describe('startRouter', () => {
navigate('/passwort-neu');
expect(location.pathname).toBe('/passwort-neu');
expect(history.length).toBe(lengthBefore + 1);
expect(render).toHaveBeenLastCalledWith('reset', root);
expect(render).toHaveBeenLastCalledWith({ id: 'reset', params: {} }, root);
});
it('ruft vor dem nächsten Rendern den Cleanup der vorherigen Seite', () => {
const order: string[] = [];
startRouter((route) => {
order.push(`render:${route}`);
return () => order.push(`cleanup:${route}`);
order.push(`render:${route.id}`);
return () => order.push(`cleanup:${route.id}`);
});
navigate('/karten');
navigate('/');
expect(order).toEqual([
'render:editor',
'cleanup:editor',
'render:home',
'cleanup:home',
'render:cards',
'cleanup:cards',
'render:editor',
'render:home',
]);
});
@@ -91,7 +153,7 @@ describe('startRouter', () => {
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(location.pathname).toBe('/karten');
expect(render).toHaveBeenLastCalledWith('cards', root);
expect(render).toHaveBeenLastCalledWith({ id: 'cards', params: {} }, root);
});
it('lässt Klicks auf Links ohne data-link und Klicks mit Modifier-Taste unberührt', () => {
@@ -143,6 +205,60 @@ describe('startRouter', () => {
}
});
it('navigate zu einem Hash-Ziel scrollt den Anker ins Bild statt an den Seitenanfang', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
const anchor = document.createElement('section');
anchor.id = 'community';
const scrollIntoView = vi.fn();
anchor.scrollIntoView = scrollIntoView;
startRouter(() => {
root.append(anchor); // der Anker entsteht erst beim Rendern
});
navigate('/karten');
scrollTo.mockClear();
navigate('/?q=visa#community');
expect(location.pathname + location.search + location.hash).toBe('/?q=visa#community');
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollTo).not.toHaveBeenCalled();
} finally {
scrollTo.mockRestore();
}
});
it('ein Hash ohne passenden Anker springt an den Seitenanfang', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
startRouter(vi.fn());
navigate('/#gibt-es-nicht');
expect(scrollTo).toHaveBeenCalledWith(0, 0);
} finally {
scrollTo.mockRestore();
}
});
it('ändert sich nur der Hash, wird nicht neu gerendert, nur gescrollt', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
const anchor = document.createElement('section');
anchor.id = 'community';
const scrollIntoView = vi.fn();
anchor.scrollIntoView = scrollIntoView;
root.append(anchor);
const render = vi.fn();
startRouter(render);
render.mockClear();
const lengthBefore = history.length;
navigate('/#community');
expect(location.hash).toBe('#community');
expect(history.length).toBe(lengthBefore + 1);
expect(render).not.toHaveBeenCalled();
expect(scrollIntoView).toHaveBeenCalledTimes(1);
} finally {
scrollTo.mockRestore();
}
});
it('beim Start und bei popstate wird nicht gescrollt (das übernimmt der Browser)', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
@@ -159,13 +275,17 @@ describe('startRouter', () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
startRouter(vi.fn());
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
navigate('/gestalten');
expect(document.title).toBe('Gestalten – AirCard-IOS');
navigate('/karten');
expect(document.title).toBe('Meine Karten – AirCard-IOS');
navigate('/community/abc123');
expect(document.title).toBe('Community – AirCard-IOS');
navigate('/passwort-neu');
expect(document.title).toBe('Passwort neu setzen – AirCard-IOS');
navigate('/');
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
} finally {
vi.restoreAllMocks();
}
@@ -177,6 +297,13 @@ describe('startRouter', () => {
startRouter(render);
history.pushState(null, '', '/passwort-neu');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(render).toHaveBeenLastCalledWith('reset', root);
expect(render).toHaveBeenLastCalledWith({ id: 'reset', params: {} }, root);
});
it('übergibt die ID der Detailseite als Parameter', () => {
history.replaceState(null, '', '/community/abc123');
const render = vi.fn();
startRouter(render);
expect(render).toHaveBeenCalledWith({ id: 'community', params: { id: 'abc123' } }, root);
});
});
+62 -14
View File
@@ -1,33 +1,81 @@
export type RouteId = 'editor' | 'cards' | 'reset';
export type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset';
type Render = (route: RouteId, root: HTMLElement) => void | (() => void);
export function matchRoute(pathname: string): RouteId {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/karten') return 'cards';
if (path === '/passwort-neu') return 'reset';
return 'editor';
export interface Route {
id: RouteId;
params: { id?: string };
}
const TITLES: Record<RouteId, string> = {
editor: 'AirCard-IOS – Kartenmotiv gestalten',
type Render = (route: Route, root: HTMLElement) => void | (() => void);
const normalize = (pathname: string): string => pathname.replace(/\/+$/, '') || '/';
export function matchRoute(pathname: string): Route {
const path = normalize(pathname);
if (path === '/gestalten') return { id: 'gestalten', params: {} };
if (path === '/karten') return { id: 'cards', params: {} };
if (path === '/passwort-neu') return { id: 'reset', params: {} };
const detail = /^\/community\/([^/]+)$/.exec(path);
if (detail) {
try {
return { id: 'community', params: { id: decodeURIComponent(detail[1]) } };
} catch {
// kaputte Prozent-Kodierung: wie jede unbekannte Adresse
}
}
return { id: 'home', params: {} };
}
/** Alte Adresse des Editors: `/?karte=<id>` heißt jetzt `/gestalten?karte=<id>`; sonst null (keine Umleitung). */
export function legacyRedirect(url: URL): string | null {
if (normalize(url.pathname) !== '/' || !url.searchParams.get('karte')) return null;
return `/gestalten${url.search}${url.hash}`;
}
export const ROUTE_TITLES: Record<RouteId, string> = {
home: 'AirCard-IOS – Kartendesigns für Apple Wallet',
gestalten: 'Gestalten – AirCard-IOS',
cards: 'Meine Karten – AirCard-IOS',
community: 'Community – AirCard-IOS',
reset: 'Passwort neu setzen – AirCard-IOS',
};
/** Titel der Seite; `detailTitle` (Titel der Community-Karte) gilt nur für die Detailseite. */
export function titleFor(route: Route, detailTitle?: string): string {
if (route.id === 'community' && detailTitle) return `${detailTitle} – AirCard-IOS`;
return ROUTE_TITLES[route.id];
}
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null;
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. Seiten, die den Anker erst nach dem Laden bekommen, rufen es erneut auf. */
export function scrollToHash(hash: string): void {
let id = hash.slice(1);
try {
id = decodeURIComponent(id);
} catch {
// unverändert verwenden
}
const target = id ? document.getElementById(id) : null;
if (target) target.scrollIntoView();
else window.scrollTo(0, 0);
}
export function navigate(path: string): void {
const url = new URL(path, location.href);
const target = url.pathname + url.search + url.hash;
// Ändert sich nur der Hash (z. B. „Community“ auf der Startseite), bleibt die Seite stehen: nur zum Anker scrollen.
const hashOnly = !!url.hash && url.pathname + url.search === location.pathname + location.search;
if (target !== location.pathname + location.search + location.hash) {
history.pushState(null, '', target);
}
renderCurrent?.();
// Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück); bei popstate bleibt es dem Browser überlassen.
if (renderCurrent) window.scrollTo(0, 0);
if (!renderCurrent) return;
if (!hashOnly) renderCurrent();
// Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück), ein Hash-Ziel kommt ins Bild;
// bei popstate bleibt es dem Browser überlassen.
if (url.hash) scrollToHash(url.hash);
else window.scrollTo(0, 0);
}
export function startRouter(render: Render): void {
@@ -42,7 +90,7 @@ export function startRouter(render: Render): void {
cleanup?.();
cleanup = undefined;
const route = matchRoute(location.pathname);
document.title = TITLES[route];
document.title = titleFor(route);
cleanup = render(route, root);
};
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mountDonateBanner } from './donate-banner';
const KEY = 'aircard.donateBannerClosed';
const URL_ = 'https://buymeacoffee.com/aircard';
const TEXT = '☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.';
let slot: HTMLElement;
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '<div id="donate-banner"></div>';
slot = document.getElementById('donate-banner') as HTMLElement;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('mountDonateBanner', () => {
it('leere URL: nichts wird gerendert', () => {
mountDonateBanner(slot, '');
expect(slot.childElementCount).toBe(0);
mountDonateBanner(slot, ' ');
expect(slot.childElementCount).toBe(0);
});
it('zeigt Text, Link „Unterstützen →“ (neuer Tab) und Schließen-Knopf', () => {
mountDonateBanner(slot, URL_);
expect(slot.querySelector('.donate-banner-text')?.textContent).toBe(TEXT);
const link = slot.querySelector<HTMLAnchorElement>('a.donate-banner-link')!;
expect(link.textContent).toBe('Unterstützen →');
expect(link.getAttribute('href')).toBe(URL_);
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(link.hasAttribute('data-link')).toBe(false);
expect(slot.querySelector('button')?.getAttribute('aria-label')).toBe('Banner schließen');
});
it('Schließen setzt das Flag und entfernt das Banner', () => {
mountDonateBanner(slot, URL_);
slot.querySelector('button')!.click();
expect(localStorage.getItem(KEY)).toBe('1');
expect(slot.childElementCount).toBe(0);
});
it('Flag gesetzt: nichts wird gerendert', () => {
localStorage.setItem(KEY, '1');
mountDonateBanner(slot, URL_);
expect(slot.childElementCount).toBe(0);
});
it('zweimal eingehängt gibt es nur ein Banner', () => {
mountDonateBanner(slot, URL_);
mountDonateBanner(slot, URL_);
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
});
it('localStorage wirft beim Lesen: Banner wird gezeigt', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blockiert', 'SecurityError');
});
expect(() => mountDonateBanner(slot, URL_)).not.toThrow();
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
});
it('localStorage wirft beim Schreiben: Banner verschwindet trotzdem, kein Fehler', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
mountDonateBanner(slot, URL_);
expect(() => slot.querySelector('button')!.click()).not.toThrow();
expect(slot.childElementCount).toBe(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
// Schließbares Spenden-Banner ganz oben. Ohne Spenden-Link (config.ts) oder nach dem Schließen wird nichts angezeigt.
import './shell.css';
import { el } from '../editor/dom';
const CLOSED_KEY = 'aircard.donateBannerClosed';
function isClosed(): boolean {
try {
return localStorage.getItem(CLOSED_KEY) !== null;
} catch {
return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis die Seite neu geladen wird
}
}
function rememberClosed(): void {
try {
localStorage.setItem(CLOSED_KEY, '1');
} catch {
// gilt dann nur für diese Sitzung
}
}
export function mountDonateBanner(slot: HTMLElement, url: string): void {
slot.replaceChildren();
if (!url.trim() || isClosed()) return;
const close = el('button', { type: 'button', class: 'donate-banner-close', 'aria-label': 'Banner schließen' }, '×');
close.addEventListener('click', () => {
rememberClosed();
slot.replaceChildren();
});
slot.append(
el(
'div',
{ class: 'donate-banner' },
el(
'p',
{ class: 'donate-banner-text' },
el('span', { 'aria-hidden': 'true' }, '☕'),
' Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.',
),
el('a', { class: 'donate-banner-link', href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Unterstützen →'),
close,
),
);
}
+206
View File
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }));
vi.mock('../pages/router', () => ({ navigate: mocks.navigate }));
import { mountHeader } from './header';
const DONATE = 'https://buymeacoffee.com/aircard';
const html = document.documentElement;
let header: HTMLElement;
let dispose: (() => void) | null = null;
function mount(donateUrl = '') {
dispose = mountHeader(header, { donateUrl });
}
const search = () => header.querySelector<HTMLFormElement>('form.header-search')!;
const input = () => search().querySelector<HTMLInputElement>('input')!;
const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
beforeEach(() => {
mocks.navigate.mockReset();
localStorage.clear();
html.setAttribute('data-theme', 'dark');
document.body.innerHTML =
'<header id="app-header">' +
'<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>' +
'<div id="account-slot"></div>' +
'</header>';
header = document.getElementById('app-header') as HTMLElement;
});
afterEach(() => {
dispose?.();
dispose = null;
});
describe('mountHeader: Aufbau', () => {
it('lässt Marke und Konto-Slot stehen und ergänzt Navigation, Suche und Knöpfe', () => {
const brand = header.querySelector('.brand');
const slot = header.querySelector('#account-slot');
mount();
expect(header.querySelector('.brand')).toBe(brand);
expect(header.querySelector('#account-slot')).toBe(slot);
expect(header.firstElementChild).toBe(brand);
expect(header.lastElementChild).toBe(slot);
expect(nav()).not.toBeNull();
expect(search()).not.toBeNull();
});
it('Navigation: Community, Gestalten, Meine Karten als data-link', () => {
mount();
const links = [...nav().querySelectorAll<HTMLAnchorElement>('a')].filter((a) => !a.classList.contains('nav-donate'));
expect(links.map((a) => [a.textContent, a.getAttribute('href'), a.hasAttribute('data-link')])).toEqual([
['Community', '/#community', true],
['Gestalten', '/gestalten', true],
['Meine Karten', '/karten', true],
]);
expect(nav().getAttribute('aria-label')).toBe('Hauptnavigation');
});
it('Suchfeld mit Platzhalter und Beschriftung', () => {
mount();
expect(input().placeholder).toBe('Titel, Tags oder Herausgeber');
expect(input().type).toBe('search');
expect(input().getAttribute('aria-label')).toBe('Community-Karten durchsuchen');
expect(search().getAttribute('role')).toBe('search');
});
});
describe('mountHeader: Suche', () => {
it('Enter mit „ visa “ navigiert zu /?q=visa#community', () => {
mount();
input().value = ' visa ';
submit();
expect(mocks.navigate).toHaveBeenCalledOnce();
expect(mocks.navigate).toHaveBeenCalledWith('/?q=visa#community');
});
it('kodiert Sonderzeichen', () => {
mount();
input().value = 'a b&c=d#e';
submit();
expect(mocks.navigate).toHaveBeenCalledWith('/?q=a%20b%26c%3Dd%23e#community');
});
it('leere oder nur aus Leerzeichen bestehende Eingabe: nichts passiert', () => {
mount();
input().value = '';
submit();
input().value = ' ';
submit();
expect(mocks.navigate).not.toHaveBeenCalled();
});
it('das Absenden lädt die Seite nicht neu (Standardaktion verhindert)', () => {
mount();
input().value = 'visa';
const event = new Event('submit', { bubbles: true, cancelable: true });
search().dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
});
describe('mountHeader: Spenden-Knopf', () => {
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
mount('');
expect(header.querySelector('.header-donate')).toBeNull();
expect(header.querySelector('.nav-donate')).toBeNull();
expect(header.textContent).not.toContain('☕');
});
it('mit donateUrl: Link „☕ Unterstützen“ in neuem Tab', () => {
mount(DONATE);
const link = header.querySelector<HTMLAnchorElement>('a.header-donate')!;
expect(link.getAttribute('href')).toBe(DONATE);
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(link.textContent).toContain('☕');
expect(link.textContent).toContain('Unterstützen');
expect(link.hasAttribute('data-link')).toBe(false);
});
});
describe('mountHeader: Hell/Dunkel', () => {
it('Knopf „Hell/Dunkel umschalten“ wechselt data-theme und speichert', () => {
mount();
const button = header.querySelector<HTMLButtonElement>('button.theme-btn')!;
expect(button.getAttribute('aria-label')).toBe('Hell/Dunkel umschalten');
button.click();
expect(html.getAttribute('data-theme')).toBe('light');
expect(localStorage.getItem('aircard.theme')).toBe('light');
button.click();
expect(html.getAttribute('data-theme')).toBe('dark');
});
});
describe('mountHeader: Menü unter 900 px', () => {
it('Menü-Knopf öffnet und schließt die Navigation (aria-expanded)', () => {
mount();
const button = menuButton();
expect(button.getAttribute('aria-label')).toBe('Menü');
expect(button.getAttribute('aria-controls')).toBe(nav().id);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(nav().classList.contains('is-open')).toBe(false);
button.click();
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(nav().classList.contains('is-open')).toBe(true);
button.click();
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(nav().classList.contains('is-open')).toBe(false);
});
it('Escape schließt das Menü und gibt den Fokus an den Knopf', () => {
mount();
menuButton().click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(nav().classList.contains('is-open')).toBe(false);
expect(menuButton().getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(menuButton());
});
it('ein Klick auf einen Navigationslink oder außerhalb schließt das Menü', () => {
mount();
menuButton().click();
nav().querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(nav().classList.contains('is-open')).toBe(false);
menuButton().click();
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(nav().classList.contains('is-open')).toBe(false);
});
it('Suchen schließt das Menü ebenfalls', () => {
mount();
menuButton().click();
input().value = 'visa';
submit();
expect(nav().classList.contains('is-open')).toBe(false);
});
});
describe('mountHeader: Aufräumen', () => {
it('entfernt Ergänztes; Marke und Konto-Slot bleiben', () => {
mount(DONATE);
dispose?.();
dispose = null;
expect([...header.children].map((c) => c.className || c.id)).toEqual(['brand', 'account-slot']);
});
it('meldet alle Dokument-Listener wieder ab', () => {
const added = vi.spyOn(document, 'addEventListener');
const removed = vi.spyOn(document, 'removeEventListener');
mount(DONATE);
expect(added).toHaveBeenCalled();
dispose?.();
dispose = null;
for (const [type, listener] of added.mock.calls) {
expect(removed.mock.calls.some(([t, l]) => t === type && l === listener)).toBe(true);
}
vi.restoreAllMocks();
});
});
+113
View File
@@ -0,0 +1,113 @@
// Kopfzeile: Marke und Konto-Slot stehen in index.html, hier kommen Navigation, Suche und Knöpfe dazu.
// Ab 900 px Breite liegt alles in einer Zeile; darunter steckt die Navigation hinter dem Menü-Knopf
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
import './shell.css';
import { el, icon } from '../editor/dom';
import { navigate } from '../pages/router';
import { toggleTheme } from './theme';
export interface HeaderOptions {
/** Spenden-Link; leer = kein Spenden-Knopf. */
donateUrl: string;
}
export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => void {
const brand = header.querySelector('.brand');
const accountSlot = header.querySelector('#account-slot');
const hasDonate = opts.donateUrl.trim() !== '';
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
const nav = el(
'nav',
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
link('Community', '/#community'),
link('Gestalten', '/gestalten'),
link('Meine Karten', '/karten'),
);
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
const input = el('input', {
class: 'header-search-input',
type: 'search',
name: 'q',
placeholder: 'Titel, Tags oder Herausgeber',
'aria-label': 'Community-Karten durchsuchen',
autocomplete: 'off',
enterkeyhint: 'search',
});
const searchButton = el('button', { class: 'header-search-btn', type: 'submit', 'aria-label': 'Suchen' }, icon('search'));
const search = el('form', { class: 'header-search', role: 'search' }, input, searchButton);
const themeButton = el(
'button',
{ class: 'header-btn theme-btn', type: 'button', 'aria-label': 'Hell/Dunkel umschalten', title: 'Hell/Dunkel umschalten' },
icon('sun', 'theme-icon-sun'),
icon('moon', 'theme-icon-moon'),
);
themeButton.addEventListener('click', () => void toggleTheme());
const menuButton = el(
'button',
{ class: 'header-btn menu-btn', type: 'button', 'aria-label': 'Menü', 'aria-expanded': 'false', 'aria-controls': nav.id },
icon('menu'),
);
const tools = el('div', { class: 'header-tools' });
if (hasDonate) {
tools.append(
el(
'a',
{ class: 'header-donate', ...donateAttrs },
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
),
);
}
tools.append(themeButton, menuButton);
// Menü (nur unter 900 px sichtbar)
let open = false;
function setOpen(next: boolean): void {
open = next;
nav.classList.toggle('is-open', next);
menuButton.setAttribute('aria-expanded', String(next));
}
menuButton.addEventListener('click', () => setOpen(!open));
const onDocumentClick = (event: MouseEvent) => {
if (!open) return;
const target = event.target as Element | null;
if (target && menuButton.contains(target)) return; // das erledigt der Knopf selbst
if (target && nav.contains(target) && !target.closest('a')) return;
setOpen(false);
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !open) return;
setOpen(false);
menuButton.focus();
};
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onKeydown);
search.addEventListener('submit', (event) => {
event.preventDefault();
const q = input.value.trim();
if (!q) return;
setOpen(false);
input.blur(); // schließt auf dem Handy die Bildschirmtastatur
navigate(`/?q=${encodeURIComponent(q)}#community`);
});
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
return () => {
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onKeydown);
nav.remove();
search.remove();
tools.remove();
};
}
+416
View File
@@ -0,0 +1,416 @@
/* Rahmen der Seite: Spenden-Banner, Kopfzeile (Marke, Navigation, Suche, Knöpfe, Konto). */
/* Spenden-Banner */
.donate-banner {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2px 14px;
padding: 8px 48px 8px 16px;
background: var(--primary);
color: var(--on-primary);
font-size: 0.95rem;
line-height: 1.35;
text-align: center;
}
.donate-banner-text {
margin: 0;
}
.donate-banner-link {
color: inherit;
font-weight: 800;
text-decoration: underline;
text-underline-offset: 3px;
white-space: nowrap;
}
.donate-banner-link:hover {
text-decoration-thickness: 2px;
}
.donate-banner :focus-visible {
outline-color: var(--on-primary);
}
.donate-banner-close {
appearance: none;
position: absolute;
top: 50%;
right: 6px;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: -20px 0 0;
padding: 0;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font: inherit;
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
}
.donate-banner-close:hover {
background: rgb(255 255 255 / 0.16);
}
/* Kopfzeile */
#app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 20px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
margin-right: 6px;
color: var(--text);
font-size: 1.35rem;
font-weight: 800;
letter-spacing: 0.01em;
text-decoration: none;
white-space: nowrap;
}
.brand img {
display: block;
flex: none;
width: 28px;
height: 28px;
}
.app-nav {
display: flex;
align-items: center;
gap: 2px;
}
.app-nav a {
padding: 8px 12px;
border-radius: 10px;
color: var(--muted);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.app-nav a:hover {
background: var(--panel-2);
color: var(--text);
}
.nav-donate {
display: none;
}
/* Suche */
.header-search {
display: flex;
flex: 1 1 120px; /* kleine Basis: die Zeile bricht erst um, wenn auch das Suchfeld nicht mehr schrumpfen kann */
align-items: center;
max-width: 360px;
min-width: 0;
height: 42px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--bg);
}
.header-search:focus-within {
border-color: var(--sel-line);
outline: 2px solid var(--sel-line);
outline-offset: 1px;
}
.header-search-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 4px 0 16px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 1rem; /* mindestens 16 px, sonst zoomt iOS beim Fokussieren */
outline: none;
appearance: none;
}
.header-search-input::placeholder {
color: var(--muted);
opacity: 1;
}
.header-search-input::-webkit-search-cancel-button {
appearance: none;
}
.header-search-btn {
appearance: none;
display: grid;
flex: none;
place-items: center;
width: 40px;
height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 0 999px 999px 0;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.header-search-btn:hover {
color: var(--text);
}
/* Knöpfe rechts: Spenden, Hell/Dunkel, Menü */
.header-tools {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.header-btn {
appearance: none;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: transparent;
color: var(--text);
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.header-btn:hover {
border-color: var(--sel-line);
background: var(--panel-2);
}
.header-btn .icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.header-search-btn .icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Sonne im dunklen Theme (Klick = hell), Mond im hellen (Klick = dunkel); ohne Attribut gilt dunkel */
:root[data-theme='light'] .theme-icon-sun,
:root:not([data-theme='light']) .theme-icon-moon {
display: none;
}
.menu-btn {
display: none;
}
.header-donate {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--sel-line);
border-radius: 12px;
background: var(--sel-bg);
color: var(--text);
font-weight: 700;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.12s;
}
.header-donate:hover {
background: var(--panel-2);
}
/* Konto-Slot */
#account-slot {
display: flex;
align-items: center;
min-width: 0;
}
/* Bis 1179 px zeigt der Spenden-Knopf nur die Tasse; der Text bleibt für Screenreader */
@media (max-width: 1179px) {
.header-donate {
justify-content: center;
width: 40px;
padding: 0;
}
.header-donate-text {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
/* Desktop mit wenig Platz (900–1099 px): alles etwas enger, damit die Zeile nicht umbricht */
@media (min-width: 900px) and (max-width: 1099px) {
#app-header {
gap: 10px;
}
.brand {
margin-right: 0;
font-size: 1.2rem;
}
.app-nav a {
padding: 8px 9px;
font-size: 0.95rem;
}
}
/* Unter 900 px: Navigation hinter dem Menü-Knopf, Suchfeld als eigene Zeile darunter */
@media (max-width: 899px) {
#app-header {
padding: 10px 16px;
gap: 10px 8px;
}
.menu-btn {
display: grid;
}
.header-tools {
order: 1;
}
#account-slot {
order: 2;
}
.app-nav {
display: none;
flex-basis: 100%;
flex-direction: column;
align-items: stretch;
order: 3;
padding: 6px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--bg);
}
.app-nav.is-open {
display: flex;
}
.app-nav a {
padding: 12px 14px;
font-size: 1.05rem;
}
.header-search {
flex: 1 1 100%;
order: 4;
max-width: none;
}
}
/* Unter 600 px: Marke und Knöpfe etwas kleiner, damit „Anmelden“ noch in die erste Zeile passt;
der Spenden-Knopf wandert ins aufgeklappte Menü */
@media (max-width: 599px) {
#app-header {
gap: 10px 6px;
}
.brand {
gap: 8px;
margin-right: 0;
font-size: 1.2rem;
}
.brand img {
width: 24px;
height: 24px;
}
.header-tools {
gap: 6px;
}
.header-btn {
width: 38px;
height: 38px;
}
#account-slot .auth-btn-small {
min-height: 38px;
padding: 0 12px;
}
.header-donate {
display: none;
}
.nav-donate {
display: block;
color: var(--text);
}
}
/* Sehr schmale Telefone */
@media (max-width: 379px) {
#app-header {
padding: 10px 12px;
}
.brand {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.header-btn,
.header-donate {
transition: none;
}
}
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config';
import { renderCard, type CardAssets, type Ctx } from '../card/render';
import { applyTheme, currentTheme, initialTheme, initTheme, toggleTheme } from './theme';
const KEY = 'aircard.theme';
const html = document.documentElement;
function mockMatchMedia(prefersDark: boolean) {
window.matchMedia = vi.fn((query: string) => ({
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
beforeEach(() => {
localStorage.clear();
html.removeAttribute('data-theme');
document.head.innerHTML = '<meta name="theme-color" content="#0F1828" />';
mockMatchMedia(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initialTheme', () => {
it('ohne gespeicherten Wert gilt das System', () => {
expect(initialTheme(null, true)).toBe('dark');
expect(initialTheme(null, false)).toBe('light');
});
it('ein gespeicherter Wert schlägt das System', () => {
expect(initialTheme('light', true)).toBe('light');
expect(initialTheme('dark', false)).toBe('dark');
});
it('ein ungültiger Wert wird ignoriert (System)', () => {
expect(initialTheme('blau', true)).toBe('dark');
expect(initialTheme('blau', false)).toBe('light');
expect(initialTheme('', false)).toBe('light');
expect(initialTheme('LIGHT', true)).toBe('dark');
});
});
describe('applyTheme', () => {
it('setzt data-theme auf <html> und die Browserfarbe, speichert aber nichts', () => {
applyTheme('light');
expect(html.getAttribute('data-theme')).toBe('light');
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#F4F6FB');
applyTheme('dark');
expect(html.getAttribute('data-theme')).toBe('dark');
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#0F1828');
expect(localStorage.getItem(KEY)).toBeNull();
});
it('kommt auch ohne theme-color-Tag klar', () => {
document.head.innerHTML = '';
expect(() => applyTheme('light')).not.toThrow();
expect(html.getAttribute('data-theme')).toBe('light');
});
});
describe('initTheme', () => {
it('nimmt den gespeicherten Wert', () => {
localStorage.setItem(KEY, 'light');
expect(initTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
});
it('ohne gespeicherten Wert das System (dunkel bzw. hell)', () => {
expect(initTheme()).toBe('dark');
mockMatchMedia(false);
expect(initTheme()).toBe('light');
});
it('wirft localStorage beim Lesen, gilt das System', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blockiert', 'SecurityError');
});
mockMatchMedia(false);
expect(initTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
});
it('fehlt matchMedia, bleibt es dunkel', () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
expect(initTheme()).toBe('dark');
});
});
describe('toggleTheme', () => {
it('wechselt zwischen dunkel und hell, setzt data-theme und speichert', () => {
applyTheme('dark');
expect(toggleTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
expect(localStorage.getItem(KEY)).toBe('light');
expect(toggleTheme()).toBe('dark');
expect(html.getAttribute('data-theme')).toBe('dark');
expect(localStorage.getItem(KEY)).toBe('dark');
});
it('ohne data-theme geht es vom Systemwert aus', () => {
mockMatchMedia(false); // System hell → Wechsel auf dunkel
expect(toggleTheme()).toBe('dark');
});
it('wirft localStorage beim Schreiben, gibt es keinen Fehler und der Wechsel gilt trotzdem', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
applyTheme('dark');
expect(() => toggleTheme()).not.toThrow();
expect(html.getAttribute('data-theme')).toBe('light');
expect(currentTheme()).toBe('light');
});
});
describe('Theme und Kartenausgabe', () => {
// Umschalten verändert nur UI-Tokens: der Renderer liest weder data-theme noch CSS-Variablen.
function drawLog(): string[] {
const log: string[] = [];
const state: Record<string, unknown> = {};
const ctx = new Proxy(state, {
get(target, prop: string) {
if (prop in target) return target[prop];
if (prop === 'measureText') return (text: string) => ({ width: text.length * 20 });
if (prop === 'createLinearGradient') {
return (...args: number[]) => {
log.push(`gradient(${args.join(',')})`);
return { addColorStop: (offset: number, color: string) => log.push(`stop(${offset},${color})`) };
};
}
return (...args: unknown[]) => void log.push(`${prop}(${args.map((a) => (typeof a === 'object' ? 'obj' : String(a))).join(',')})`);
},
set(target, prop: string, value) {
target[prop] = value;
log.push(`${prop}=${String(value)}`);
return true;
},
});
const assets: CardAssets = { image: null, network: {} as CanvasImageSource, brand: {} as CanvasImageSource };
renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath'));
return log;
}
it('hell und dunkel zeichnen dieselbe Karte', () => {
applyTheme('dark');
const dark = drawLog();
applyTheme('light');
const light = drawLog();
expect(dark.length).toBeGreaterThan(10);
expect(light).toEqual(dark);
expect(dark.join('\n')).toContain('fillStyle=#1B2738');
});
});
+63
View File
@@ -0,0 +1,63 @@
// Hell/Dunkel: `data-theme` auf <html>, Auswahl in localStorage `aircard.theme`, Standard = System.
// Das Skript im <head> von index.html setzt das Attribut schon vor dem ersten Paint (kein Aufblitzen).
// Das Theme betrifft nur die Oberfläche (CSS-Tokens); die Karte selbst (Vorschau, PNG) bleibt davon unberührt.
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'aircard.theme';
// Farbe der Browserleiste (Handy), passend zu `--bg` des jeweiligen Themes.
const BROWSER_COLOR: Record<Theme, string> = { dark: '#0F1828', light: '#F4F6FB' };
/** Gespeicherter Wert, sonst System; ein ungültiger Wert zählt als „nichts gespeichert“. */
export function initialTheme(stored: string | null, prefersDark: boolean): Theme {
if (stored === 'light' || stored === 'dark') return stored;
return prefersDark ? 'dark' : 'light';
}
function readStored(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt das System
}
}
function systemPrefersDark(): boolean {
try {
// Ohne Angabe (alter Browser) bleibt es beim bisherigen dunklen Look.
return window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : true;
} catch {
return true;
}
}
/** Setzt das Theme (Attribut und Browserleiste), ohne etwas zu speichern. */
export function applyTheme(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', BROWSER_COLOR[theme]);
}
/** Das gerade angezeigte Theme (Attribut, sonst gespeicherter Wert bzw. System). */
export function currentTheme(): Theme {
const attr = document.documentElement.getAttribute('data-theme');
return attr === 'light' || attr === 'dark' ? attr : initialTheme(readStored(), systemPrefersDark());
}
/** Wendet beim Start das gespeicherte bzw. das System-Theme an. */
export function initTheme(): Theme {
const theme = initialTheme(readStored(), systemPrefersDark());
applyTheme(theme);
return theme;
}
/** Wechselt Hell ↔ Dunkel und merkt es sich; ein gesperrter Speicher ist kein Fehler. */
export function toggleTheme(): Theme {
const next: Theme = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// gilt dann nur für diese Sitzung
}
return next;
}
+33 -58
View File
@@ -1,4 +1,5 @@
:root {
:root,
:root[data-theme='dark'] {
--bg: #0F1828;
--panel: #162133;
--panel-2: #1B2738;
@@ -10,12 +11,43 @@
--muted: #9DB0C8;
--primary: #2A4566;
--primary-hover: #34557C;
--on-primary: #E8EEF7;
--danger: #E5484D;
--danger-text: #ff9a9d;
--warn-text: #f2c66d;
--ok-text: #8fd9a8;
--backdrop: rgb(6 11 20 / 0.72);
--shadow-lg: 0 24px 80px rgb(0 0 0 / 0.55);
--shadow-card: 0 10px 34px rgb(0 0 0 / 0.45);
--radius: 14px;
color-scheme: dark;
}
/* Hell: nur die Oberfläche; die Kartenfarben (Vorschau, PNG) hängen nicht vom Theme ab. */
:root[data-theme='light'] {
--bg: #F4F6FB;
--panel: #FFFFFF;
--panel-2: #EEF1F7;
--line: #D9DFEA;
--line-strong: #B8C2D3;
--sel-bg: #E3ECFB;
--sel-line: #3A6FD8;
--text: #0F1828;
--muted: #5B6B82;
--primary: #2A4566;
--primary-hover: #34557C;
--on-primary: #FFFFFF;
--danger-text: #B42318;
--warn-text: #8A5A00;
--ok-text: #1B6B3A;
--backdrop: rgb(15 24 40 / 0.5);
--shadow-lg: 0 24px 80px rgb(15 24 40 / 0.28);
--shadow-card: 0 10px 34px rgb(15 24 40 / 0.22);
color-scheme: light;
}
*,
*::before,
*::after {
@@ -47,63 +79,6 @@ a {
outline-offset: 2px;
}
#app-header {
display: flex;
align-items: center;
gap: 24px;
padding: 14px 20px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 1.35rem;
font-weight: 800;
letter-spacing: 0.01em;
text-decoration: none;
white-space: nowrap;
}
.brand img {
display: block;
flex: none;
width: 28px;
height: 28px;
}
/* Schmale Bildschirme: Marke etwas kleiner, damit sie samt „Meine Karten“ in die erste Kopfzeile passt */
@media (max-width: 599px) {
.brand {
gap: 8px;
font-size: 1.2rem;
}
.brand img {
width: 24px;
height: 24px;
}
}
.app-nav a {
color: var(--muted);
font-weight: 600;
text-decoration: none;
}
.app-nav a:hover {
color: var(--text);
}
#account-slot {
display: flex;
align-items: center;
margin-left: auto;
}
#app-root {
flex: 1;
width: 100%;