Files
AirCardIOS/docs/superpowers/plans/2026-10-04-aircard-community.md
T
2026-10-04 13:23:02 +00:00

189 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.