From 562e7601910860dc719ceb6cbafa505dd4942eba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:03:45 +0000 Subject: [PATCH] docs: Plan Community-Logos, Englisch und GitHub-Link Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-04-aircard-logos-sprache.md | 436 ++++++++++++++++++ ...2026-10-04-aircard-logos-sprache-design.md | 2 +- 2 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-04-aircard-logos-sprache.md diff --git a/docs/superpowers/plans/2026-10-04-aircard-logos-sprache.md b/docs/superpowers/plans/2026-10-04-aircard-logos-sprache.md new file mode 100644 index 0000000..80ee3bd --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-aircard-logos-sprache.md @@ -0,0 +1,436 @@ +# AirCard-IOS Community-Logos, Englisch und GitHub-Link – 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:** Eingebaute Marken-Logos durch Community-Logos (Hochladen, Herzen, Melden) ersetzen, die Seite zweisprachig (DE/EN) machen und GitHub-Link + Sprachknopf in die Kopfzeile setzen. + +**Architecture:** Der Vite-+-TS-Client bekommt ein kleines i18n-Modul im gettext-Stil (`t('Deutscher Text')`, Wörterbuch Deutsch → Englisch) und neue Module unter `web/src/logos/`. Gelesen wird direkt aus Appwrite (öffentliche Zeilen), geschrieben nur über den Node-Dienst `aircard-api`, der ein neues Modul `deploy/api/logos.js` bekommt; gemeinsame Helfer wandern nach `deploy/api/common.js`. Das Schema wächst ü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 nur mit Built-ins für `aircard-api`, Appwrite 1.8.1 TablesDB. + +**Spec:** `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` (Commit 067e428). Basis: die Website- und Community-Specs im selben Ordner. + +## Global Constraints + +- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht). +- Keine neuen npm-Abhängigkeiten; `deploy/api/` bleibt ohne Abhängigkeiten. Versionen unverändert. +- Kommentare und Bezeichner im Stil des Bestands (deutsche Kommentare, gleiche Idiome, `el()`/`icon()` aus `editor/dom.ts`). +- Ab Task 1 gehen **alle neuen sichtbaren Texte** (Text, `aria-label`, `title`, Platzhalter, Meldungen) durch `t()`; Konstanten werden mit `msg()` markiert und beim Anzeigen mit `t()` übersetzt. Jeder neue Text bekommt seinen Eintrag in `web/src/i18n/en.ts` (kurz, freundlich, US-Englisch). Deutscher Wortlaut aus der Spec. +- In Tests ist die Sprache Deutsch (`web/src/test-setup.ts`); bestehende Tests bleiben unverändert grün, außer wo der Task sie ausdrücklich anpasst. +- Appwrite: Projekt `6ac2130a00289343d6d5`, Datenbank `aircard`; neue Tabellen `logos`, `logo_likes`, `logo_reports`; neuer Bucket `logo-images`; Bestehendes unverändert. +- Logo-Zeilenrechte: sichtbar genau `['read("any")']`, ausgeblendet genau `['read("user:")','read("label:admin")']`. Logo-Dateirechte: beim Hochladen `['read("any")','delete("user:")']`, nach dem Anlegen genau `['read("any")']`, ausgeblendet `['read("user:")','read("label:admin")']`. Like-Zeilen: ID `hash32("|")`, Rechte `['read("user:")']`. +- Grenzen: 30 Logos je Konto; Logo-PNG höchstens 1 MiB (1048576 Byte), 8 × 8 bis 720 × 250 px; Name ≤ 40, Anzeigename ≤ 30 Zeichen; Seiten zu 24; Ausblenden ab 3 Meldern; Rate-Limit 10 Meldungen/Stunde je Anschluss, gemeinsam für Karten und Logos. +- `localStorage`: `aircard.lang` (`de`|`en`), `aircard.displayName`; jeder Zugriff in try/catch. +- GitHub-Ziel `https://github.com/Mak5er/AirCard-iOS` (Konstante `GITHUB_URL` in `web/src/config.ts`). +- Tests: `cd web && npx vitest run`, `npx tsc --noEmit`, `npm run build`; `node --test "deploy/api/*.test.js"` (Glob in Anführungszeichen); `node --test web/scripts/appwrite-setup.test.mjs`. +- Keine Secrets in Ausgaben, Logs, Commits; `.env` nie öffnen (Ausnahme: Controller-Betrieb in Task 12). +- Commits enden mit `Co-Authored-By: Claude Opus 5.5 `. +- Subagents: `sonnet` für Umsetzung und Review, `haiku` für kleine Nachprüfungen; nie Subagents aus Subagents. + +## Review Focus + +- **Fremde oder falsche Datei beim Hochladen**: `fileId` einer fremden Logo-Datei, einer Datei aus `gallery-images` oder einer schon veröffentlichten Logo-Datei an `/api/logos/hochladen` → 403, keine Zeile, Datei unverändert. Test in Task 3. +- **Gespeicherte Karte mit gelöschtem/ausgeblendetem Logo**: Öffnen zeichnet die Karte ohne Logo, zeigt „Das gewählte Logo ist nicht mehr verfügbar.“, kein Fehler-Loop, Download funktioniert; Veröffentlichen bricht mit dem Text aus Spec § 2 ab. Tests in Task 5. +- **Sprachwechsel mit ungespeichertem Editor-Entwurf**: Bild, Ausschnitt und Einstellungen bleiben nach dem Umschalten erhalten, die Seite wird neu aufgebaut, nicht neu geladen. Test in Task 1 (Router baut neu auf, Store bleibt) und Live in Task 12. +- **Herz doppelt geklickt / Gast bricht Login ab**: Solange eine Herz-Anfrage für ein Logo läuft, sind weitere Klicks auf dieses Herz wirkungslos; bricht ein Gast den Login ab, geht keine Anfrage raus und das Herz bleibt aus, ohne Fehlertext. Tests in Task 8. +- **SVG ohne Größe oder mit riesiger viewBox**: Ohne eigene Größe gilt 1000 × 1000; gerastert wird höchstens mit 2048 px an der längsten Seite, nie größer; Skripte im SVG laufen nie (Laden nur als Bild). Test in Task 6 (`svgRasterSize`). + +--- + +### Task 1: i18n-Kern, Kopfzeile (GitHub + Sprache), Fußzeile, Sprachwechsel ohne Neuladen + +**Files:** +- Create: `web/src/i18n/i18n.ts`, `web/src/i18n/en.ts`, `web/src/i18n/i18n.test.ts`, `web/src/i18n/coverage.test.ts`, `web/src/test-setup.ts`, `web/src/shell/footer.ts`, `web/src/shell/footer.test.ts` +- Modify: `web/vite.config.ts` (`test.setupFiles: ['src/test-setup.ts']`), `web/index.html`, `web/src/config.ts`, `web/src/editor/dom.ts` (Icons `github`, `globe`), `web/src/shell/header.ts`, `web/src/shell/shell.css`, `web/src/shell/donate-banner.ts`, `web/src/pages/router.ts`, `web/src/main.ts`, `web/src/branding.test.ts` (falls es den Fußzeilentext oder `` prüft) +- Test: `web/src/shell/header.test.ts`, `web/src/pages/router.test.ts` + +**Interfaces:** +- Produces: + ```ts + // web/src/i18n/i18n.ts + export type Lang = 'de' | 'en'; + export const LANG_KEY = 'aircard.lang'; + export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang; + export function currentLang(): Lang; // beim ersten Aufruf: initialLang(localStorage, navigator.languages bzw. [navigator.language]) + export function setLang(lang: Lang, opts?: { persist?: boolean }): void; // persist Standard true; setzt <html lang>; meldet nur bei Wechsel + export function onLangChange(fn: (lang: Lang) => void): () => void; + export function t(text: string, params?: Readonly<Record<string, string | number>>): string; + export function msg<T extends string>(text: T): T; // Identität, markiert Konstanten für den Abdeckungstest + // web/src/i18n/en.ts + export const EN: Readonly<Record<string, string>>; // Deutsch → Englisch + // web/src/config.ts + export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS'; + // web/src/pages/router.ts + export function rerender(): void; // räumt die aktuelle Seite auf und baut sie neu (ohne Scrollen); ohne Router: nichts + // web/src/shell/footer.ts + export function mountFooter(footer: HTMLElement): void; // ersetzt den Inhalt durch <p> mit dem Fußzeilentext der aktuellen Sprache + ``` + +- [ ] **Step 1: Tests schreiben** (`i18n.test.ts`, `footer.test.ts`, Ergänzungen in `header.test.ts` und `router.test.ts`): + - `initialLang(null, ['de-DE','en'])` → `'de'`; `(null, ['en-US','de'])` → `'en'`; `('en', ['de-DE'])` → `'en'`; `('xx', ['de-AT'])` → `'de'`; `(null, [])` → `'en'`. + - `setLang('en')`: `t('Gestalten')` → `'Create'`; unbekannter Text bleibt deutsch; `t('Nichts gefunden für „{q}“', { q: 'x' })` ersetzt den Platzhalter in beiden Sprachen; unbekannter Platzhalter bleibt `{name}` stehen. + - `setLang` setzt `document.documentElement.lang`, schreibt mit `persist: false` nichts in `localStorage`, ruft Listener genau einmal je echtem Wechsel. + - Fußzeile: deutsch der Spec-Text (§ 7); nach `setLang('en')` und erneutem `mountFooter` der englische. + - Kopfzeile: Link mit `href` = `GITHUB_URL`, `target="_blank"`, `rel="noopener noreferrer"`, `title` „AirCard-iOS auf GitHub“; Sprachknopf zeigt bei Deutsch „EN“ mit `aria-label` „Switch to English“ und `lang="en"`, Klick → `currentLang() === 'en'`; danach (neu gemountet) „DE“ mit „Auf Deutsch umschalten“; das Menü enthält einen zweiten GitHub-Link (Klasse `nav-github`). + - Router: `rerender()` ruft das Aufräumen der aktuellen Seite und dann `render` mit derselben Route erneut auf; `scrollTo` wird nicht aufgerufen; nach `setLang('en')` liefert `titleFor({id:'gestalten',params:{}})` „Create – AirCard-IOS“. +- [ ] **Step 2:** `cd web && npx vitest run src/i18n src/shell src/pages/router.test.ts` → FAIL (Module fehlen). +- [ ] **Step 3: Umsetzen.** + - `t()`: Basistext = bei `en` `EN[text] ?? text`, sonst `text`; dann `{name}` durch `String(params[name])` ersetzen, wenn vorhanden. + - `coverage.test.ts` (Muster wie `single-sources.test.ts`): sammelt in allen Nicht-Test-`.ts` unter `src/` die String-Literale aus `t(…)`/`msg(…)` (`'…'`, `"…"`, `` `…` `` ohne `${`) und prüft: (a) jedes hat einen Eintrag in `EN`, (b) jeder `EN`-Eintrag wird benutzt, (c) Platzhalter `{…}` in Schlüssel und Übersetzung sind dieselbe Menge, (d) kein `t(`/`msg(` mit Template-Literal, das `${` enthält. + - `test-setup.ts`: `beforeEach(() => setLang('de', { persist: false }))`. + - `index.html`: `<title>AirCard-IOS – Card designs for Apple Wallet`, ``; im bestehenden Inline-Skript zusätzlich `lang` setzen (gleiche Regel wie `initialLang`, Kommentar verweist darauf); `
` leer (Text kommt aus `mountFooter`). + - Icons in `dom.ts` (Lucide, Strich): `github: ''`, `globe: ''`. + - Kopfzeile: Reihenfolge in `.header-tools`: GitHub (`a.header-github` mit Icon + `span.header-github-text` „GitHub“), Sprachknopf (`button.header-btn.lang-btn`), Spenden, Hell/Dunkel, Menü. CSS: zwischen 600 und 1179 px `.header-github-text` ausblenden (wie `.header-donate-text`); unter 600 px `.header-github` ausblenden und `.nav-github` im Menü zeigen (sonst `.nav-github` ausgeblendet). Alle übrigen Kopfzeilen- und Banner-Texte durch `t()`/`msg()`. + - Router: `ROUTE_TITLES`-Werte mit `msg()`, `titleFor` liefert `t(…)`; `rerender()` ruft die interne `renderCurrent` auf. + - `main.ts`: beim Start `setLang(currentLang(), { persist: false })` und `client.setLocale(lang)`; `onLangChange(lang => …)`: `client.setLocale(lang)`, Kopfzeile aufräumen und neu mounten, `mountDonateBanner` erneut, Konto-Menü `dispose()` + neu mounten (+ `ready()`, wenn der Anmeldestatus schon geholt war), `mountFooter`, dann `rerender()`. Der Editor-Store bleibt dasselbe Objekt. + - Englische Einträge für alle neuen `t()`/`msg()`-Texte in `en.ts` (Seitentitel: „Card designs for Apple Wallet“, „Create“, „My cards“, „Community“, „Set new password“). +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → alles grün. +- [ ] **Step 5:** Commit `feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile`. + +### Task 2: Schema für Logos (Setup-Skript) + +**Files:** +- Modify: `web/scripts/appwrite-setup.mjs` (`DESIRED`, Kopfkommentar), `web/scripts/appwrite-setup.test.mjs` + +**Interfaces:** +- Produces: `DESIRED.tables` zusätzlich (in dieser Reihenfolge nach `reports`) und `DESIRED.buckets` zusätzlich: + ```js + { id: 'logos', name: 'Logos', permissions: [], rowSecurity: true, + columns: [str('name', 40, true), str('displayName', 30, false), str('fileId', 36, true), str('ownerId', 36, true), + { key: 'mono', type: 'boolean', required: false, default: false }, + { key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 }, + { key: 'hidden', type: 'boolean', required: false, default: false }, + { key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 }], + indexes: [{ key: 'owner', type: 'key', columns: ['ownerId'] }, { key: 'hidden', type: 'key', columns: ['hidden'] }, + { key: 'likes', type: 'key', columns: ['likeCount'] }] }, + { id: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true, + columns: [str('logoId', 36, true), str('userId', 36, true)], + indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }] }, + { id: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false, + columns: [str('logoId', 36, true), + { key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, + str('reporterHash', 64, true)], + indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }] }, + // Bucket + { id: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 1048576, + allowedFileExtensions: ['png'], compression: 'none', antivirus: false }, + ``` + +- [ ] **Step 1: Tests** nach dem Muster der bestehenden: `DESIRED` enthält die drei Tabellen und den Bucket mit genau diesen Werten; ein Lauf gegen den Test-Server legt sie samt Indizes an (Mehrspalten-Index `user_logo` mit `columns: ['userId','logoId']` im Request); `--check` meldet `FEHLT` für jedes fehlende neue Objekt und Exit 1; bestehende Tabellen bleiben unberührt. +- [ ] **Step 2:** `node --test web/scripts/appwrite-setup.test.mjs` → FAIL. +- [ ] **Step 3:** `DESIRED` ergänzen; Kopfkommentar nennt die neuen Objekte. Falls der Index-Request heute nur eine Spalte unterstützt, auf Listen erweitern. +- [ ] **Step 4:** `node --test web/scripts/appwrite-setup.test.mjs` → PASS. +- [ ] **Step 5:** Commit `feat(appwrite): Schema für Community-Logos, Herzen und Logo-Meldungen`. + +### Task 3: API – Fehlercodes, gemeinsame Helfer, Logo-Endpunkte + +**Files:** +- Create: `deploy/api/common.js` (aus `app.js` herausgelöst: `MSG`, `HttpError`, `isId`, `isPlainObject`, `parseJsonObject`, `readImageSize`, `hash32`, `hmacHex`, `reporterHash`, `reporterNetwork`, `normalizeIp`, `clientIp`, `CONTROL_CHARACTERS`, `ID_PATTERN`, `isDuplicate`, `visibleRowPermissions`, `hiddenRowPermissions`, `uploadFilePermissions`, `visibleFilePermissions`, `hiddenFilePermissions`, `REASONS`), `deploy/api/logos.js`, `deploy/api/logos.test.js` +- Modify: `deploy/api/app.js` (importiert aus `common.js`, exportiert weiter alles wie bisher, hängt die Logo-Routen an, sendet `code`), `deploy/api/appwrite.js` (+ `countRows`), `deploy/api/fake-appwrite.js` (Tabellen `logos`, `logo_likes`, `logo_reports` mit Spaltenlängen, Bucket `logo-images`, `total` in Listen-Antworten), `deploy/api/app.test.js`, `deploy/api/appwrite.test.js`, `deploy/api/README.md`, `deploy/README.md` (Betrieb kopiert fünf Dateien: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`) + +**Interfaces:** +- Produces: + ```js + // common.js + class HttpError extends Error { constructor(status, message, headers = {}, code = codeFor(status, message)) } // .status .headers .code + function codeFor(status, message) // Schlüssel aus MSG mit genau diesem Text, sonst status 400 → 'badRequest', sonst 'error' + // appwrite.js + countRows(table, queries = []) // GET rows mit queries + limit(1), liefert body.total (Zahl), sonst AppwriteError(502, 'invalid_response') + // logos.js + function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) // → Array<[routeKey, {name, json: true, run}]> + function logoLikeId(logoId, userId) // hash32(`${logoId}|${userId}`) + function logoReportId(logoId, reporter) // hash32(`logo|${logoId}|${reporter}`) + function validateLogoUpload(body) // {ok:true, data:{name, displayName, mono, fileId}} | {ok:false, message} + ``` +- Neue `MSG`-Einträge (Schlüssel = späterer `code`, Texte genau so): `logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.'`, `badLogoId: 'Ungültige Logo-ID.'`, `logoNameMissing: 'Bitte gib einen Namen an.'`, `logoFileType: 'Das Logo muss eine PNG-Datei sein.'`, `logoFileSize: 'Das Logo ist größer als 1 MB.'`, `logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).'`, `logoTaken: 'Diese Datei wird schon für ein Logo verwendet.'`, `tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'`, `badHeart: 'Ungültige Angabe zum Herz.'`, `badMono: 'Ungültige Angabe zu „einfarbig“.'`. Alle `MSG`-Texte bleiben paarweise verschieden. +- Routen: `POST /api/logos/hochladen`, `POST /api/logos/herz`, `POST /api/logos/melden`, `POST /api/logos/loeschen`, `POST /api/admin/logo-sichtbarkeit`. Verhalten, Statuscodes, Reihenfolge der Schritte und Rücknahme genau wie Spec § 9 und die bestehenden Karten-Endpunkte (`publish`, `reportCard`, `withdraw`, `setVisibility` sind das Vorbild). Sperren: `logo-owner:` beim Hochladen, `logo:` für alles je Logo. Gelöscht werden Herzen und Meldungen in Runden (`listRows` mit `equal logoId`, `limit 100`, höchstens 50 Runden). `likeCount` und `reportCount` immer aus `countRows`. + +- [ ] **Step 1: Tests schreiben** (`logos.test.js` im Stil von `app.test.js`; dort zusätzlich die `code`-Fälle): + - Hochladen: gültig → 201 `{id: fileId}`, Zeile sichtbar mit `likeCount 0`, Datei-Rechte danach genau `['read("any")']`; fremde Datei → 403 `code:'foreignFile'`; Datei aus `gallery-images` → 403; Datei ohne `read("any")` → 403 `filesTaken`; WebP → 400 `logoFileType`; PNG 721 × 100 oder 7 × 7 → 400 `logoFormat`; > 1 MiB → 400 `logoFileSize`; dieselbe Datei zweimal → 409 `logoTaken`; 31. Logo → 429 `tooManyLogos`; scheitert das Setzen der Dateirechte → Zeile gelöscht, Datei hat wieder `delete("user:")`; ohne JWT → 401 `unauthorized`; Name leer → 400 `logoNameMissing`; `mono: 'ja'` → 400 `badMono`. + - Herz: an → `{herz:true, likeCount:1}`, nochmal an → weiter 1; aus → 0, nochmal aus → 0; zwei Nutzer → 2; ausgeblendetes oder fehlendes Logo → 404 `logoGone`; `herz` kein Boolean → 400 `badHeart`; Like-Zeile hat Rechte `['read("user:")']` und ID `logoLikeId`. + - Melden: Dedup je Netz (`{schonGemeldet:true}`), 3 Netze → `hidden: true`, Zeile/Datei mit ausgeblendeten Rechten; Rate-Limit greift auch, wenn 6 Karten- und 5 Logo-Meldungen vom selben Anschluss kommen (11. → 429). + - Löschen: Hochlader → 200, Datei, Herzen, Meldungen, Zeile weg; Admin → 200; Fremder → 403 `forbidden`; fehlt → 404 `logoGone`. + - Admin-Sichtbarkeit: ausblenden/einblenden (Einblenden: Meldungen weg, `reportCount 0`, Rechte sichtbar); Nicht-Admin → 403 `adminOnly`. + - Bestehende Endpunkte: jede Fehlerantwort hat `code` (z. B. `/api/melden` mit unbekannter ID → `{error:'Diese Karte ist nicht (mehr) verfügbar.', code:'cardGone'}`); `MSG`-Texte sind eindeutig. + - `countRows` in `appwrite.test.js`: liefert `total`, wirft bei fehlendem `total`. +- [ ] **Step 2:** `node --test "deploy/api/*.test.js"` → FAIL. +- [ ] **Step 3:** Umsetzen: `common.js` herauslösen (reine Verschiebung, Verhalten gleich), `handle()` sendet `{error: err.message, code: err.code}` (Appwrite-Fehler: `code` `unavailable` bzw. `internal`), Logo-Modul, Fake-Erweiterung, Doku (Endpunkt-Tabelle in `api/README.md`, Kopierbefehl mit fünf Dateien in `deploy/README.md`). +- [ ] **Step 4:** `node --test "deploy/api/*.test.js"` → PASS (alte und neue Tests). +- [ ] **Step 5:** Commit `feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer`. + +### Task 4: Client-Datenschicht für Logos und übersetzbare API-Fehler + +**Files:** +- Create: `web/src/logos/logos.ts`, `web/src/logos/logos.test.ts`, `web/src/logos/logo-api.ts`, `web/src/logos/logo-api.test.ts`, `web/src/i18n/api-messages.ts`, `web/src/i18n/api-messages.test.ts` +- Modify: `web/src/account/appwrite.ts` (`IDS`), `web/src/community/api.ts` (+ Test) + +**Interfaces:** +- Consumes: `t`, `msg`, `currentLang` (Task 1); API-Routen und `code` (Task 3); `fetchFile`, `ID_PATTERN` aus `account/cards.ts`; `apiPost`, `ApiError`, `isDefinitiveFailure` aus `community/api.ts`. +- Produces: + ```ts + // account/appwrite.ts – IDS zusätzlich + logos: 'logos', logoLikes: 'logo_likes', logoReports: 'logo_reports', logoBucket: 'logo-images' + // logos/logos.ts + export interface CommunityLogo { id: string; name: string; displayName: string; fileId: string; ownerId: string; + mono: boolean; likeCount: number; hidden: boolean; reportCount: number; createdAt: string } + export type LogoSort = 'popular' | 'new'; + export type LogoFilter = 'all' | 'reported' | 'hidden'; + export interface LogoListOptions { sort: LogoSort; filter?: LogoFilter; cursor?: string } + export interface LogoReport { id: string; logoId: string; reason: string; createdAt: string } + export const LOGO_PAGE_SIZE = 24; + export function toLogo(row: Record): CommunityLogo; + export function logoQueries(opts: LogoListOptions): string[]; + export function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }>; + export function getLogo(id: string): Promise; // ungültige ID oder 404 → null + export function myHearts(userId: string, logoIds: readonly string[]): Promise>; + export function listLogoReports(logoId: string): Promise; // neueste zuerst, limit 100 + export function loadLogoBlob(logo: CommunityLogo): Promise; // fetchFile(IDS.logoBucket, logo.fileId) + // logos/logo-api.ts + export interface LogoUploadInput { name: string; displayName: string; mono: boolean; fileId: string } + export function uploadLogo(input: LogoUploadInput): Promise<{ id: string }>; + export function setHeart(id: string, herz: boolean): Promise<{ herz: boolean; likeCount: number }>; + export function reportLogo(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>; + export function deleteLogo(id: string): Promise; + export function setLogoVisibility(id: string, visible: boolean): Promise; // Body {id, sichtbar} + // community/api.ts + class ApiError { constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null); readonly serverCode: string | null } + export function apiErrorText(err: ApiError): string; // = apiMessage(err.serverCode, err.code, err.message) + // i18n/api-messages.ts + export const API_MESSAGES: Readonly>; // code → msg('deutscher Servertext'); dazu Client-Codes 'generic', 'jwtUnsupported' + export function apiMessage(serverCode: string | null, status: number, serverText: string): string; + ``` + +- [ ] **Step 1: Tests schreiben:** + - `logoQueries({sort:'popular'})` = `[equal('hidden',false), orderDesc('likeCount'), orderDesc('$createdAt'), limit(24)]` (als Query-Strings des SDK); `'new'` ohne `likeCount`-Sortierung; `filter:'reported'` → `greaterThan('reportCount',0)`, `'hidden'` → `equal('hidden',true)`; mit `cursor` zusätzlich `cursorAfter`. + - `toLogo` macht aus kaputten Feldern leere Werte, `likeCount`/`reportCount` < 0 oder kein Zahlwert → 0, `mono`/`hidden` nur bei `true`. + - `listLogos`: `nextCursor` nur bei voller Seite; `getLogo('kaputt id')` → null ohne Anfrage, 404 → null, 500 → wirft. + - `myHearts('u', [])` → leere Menge ohne Anfrage; sonst Abfrage `equal('userId','u')`, `equal('logoId',[…])`, `limit(100)`. + - `logo-api`: richtige Pfade und Bodies (`/api/logos/herz` mit `{id, herz}`, `/api/admin/logo-sichtbarkeit` mit `{id, sichtbar}`); Antwort ohne `id` bei `uploadLogo` → `ApiError` 503, nicht eindeutig. + - `ApiError.serverCode` wird aus `{error, code}` gelesen; Client-Fehler tragen `unavailable`, `generic`, `jwtUnsupported`, `alreadyPublished`, `filesTaken`, `tooManyPublications` passend. + - `apiMessage`: bekannter Code → `t(Text)` (deutsch und englisch); unbekannter Code deutsch → Servertext; unbekannter Code englisch → „Invalid request.“ (400), „You are not allowed to do that.“ (403), „Not found.“ (404), sonst „Something went wrong. Please try again.“ + - **Gleichlauf**: `api-messages.test.ts` lädt `MSG` aus `deploy/api/common.js` (`createRequire(import.meta.url)`) und prüft für jeden Schlüssel `API_MESSAGES[key] === MSG[key]`. +- [ ] **Step 2:** `cd web && npx vitest run src/logos src/i18n src/community/api.test.ts` → FAIL. +- [ ] **Step 3:** Umsetzen nach dem Muster von `community/gallery.ts` und `community/api.ts`; `en.ts` um alle `API_MESSAGES`-Texte ergänzen. +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün. +- [ ] **Step 5:** Commit `feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler`. + +### Task 5: Kartenmodell ohne Marken-Logos, Renderer mit Community-Logo + +**Files:** +- Create: `web/src/logos/logo-asset.ts`, `web/src/logos/logo-asset.test.ts` +- Modify: `web/src/card/config.ts`, `web/src/card/marks.ts`, `web/src/card/geometry.ts`, `web/src/card/render.ts` (+ ihre Tests), `web/src/editor/options-panel.ts`, `web/src/editor/editor-page.ts` (+ Test), `web/src/community/publish.ts` (+ Test), `web/src/branding.test.ts`, `web/src/single-sources.test.ts` (falls betroffen) +- Delete: in `web/src/assets/marks/` alle SVGs außer `aircards-symbol.svg` und `aircards-text.svg` (19 Dateien, `git rm`) + +**Interfaces:** +- Consumes: `getLogo`, `loadLogoBlob`, `CommunityLogo` (Task 4); `t`, `msg` (Task 1). +- Produces: + ```ts + // card/config.ts + export type BrandId = 'aircards-symbol' | 'aircards-text' | 'none'; + export interface CardConfig { v: 1; template: TemplateId; logo: string | null; details: DetailsTone; brand: BrandId; crop: Crop } + // card/geometry.ts (ersetzt fitNetwork und Layout.network) + export const LOGO_MAX_H = 125; + export function fitLogo(aspect: number, maxH?: number): Box; // rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch + // Layout.logo: boolean (image: false; minimal, classic, signature: true) + // card/render.ts + export interface LogoAsset { source: CanvasImageSource; w: number; h: number } + export interface CardAssets { image: CardImage | null; logo: LogoAsset | null; brand: CanvasImageSource | null } + export interface LoadedAssets extends CardAssets { logoMissing: boolean } + export function loadCardAssets(config: CardConfig, image: CardImage | null, + loadLogo?: (id: string, tone: DetailsTone) => Promise): Promise; // Standard: loadLogoAsset + // logos/logo-asset.ts + export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.'); + export function loadLogoAsset(id: string, tone: DetailsTone): Promise; + export function rememberLogo(logo: CommunityLogo): void; // legt die Zeile in den Cache (spart getLogo) + export function forgetLogo(id: string): void; // nach Löschen/Ausblenden: Zeile und Bilder aus dem Cache + // editor/options-panel.ts + export interface OptionsHandlers { onDetails(tone: DetailsTone): void; onBrand(id: BrandId): void } + export interface OptionsPanel { el: HTMLElement; select(config: CardConfig): void; logoSlot: HTMLElement } // logoSlot: leerer Platz oben, Task 8 füllt ihn + // community/publish.ts + export const LOGO_MISSING_PUBLISH = msg('Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.'); + ``` + +- [ ] **Step 1: Tests schreiben:** + - `parseConfig('{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}')` → `logo: null`, `brand: 'aircards-symbol'`, Rest übernommen; `logo: 'abc123'` bleibt; `logo: 'kaputt id'`/Zahl → null; `serializeConfig` schreibt genau `{v,template,logo,details,brand,crop}` in dieser Reihenfolge. + - `fitLogo(2, 125)` → `{x: 1230, y: 788, w: 250, h: 125}`; `fitLogo(10, 125)` → Breite 360. + - Renderer: mit `assets.logo` (w 200, h 100) und Vorlage `classic` wird `drawImage(source, fitLogo(2).x, …)` aufgerufen; mit `image` nicht; ohne `assets.logo` nicht. + - `loadCardAssets`: `logo: null` → `logoMissing false` ohne Loader-Aufruf; Loader liefert null → `logo null, logoMissing true`; Loader wirft → `loadCardAssets` wirft. + - `loadLogoAsset` (Fakes für `getLogo`, `loadLogoBlob`, Bilddekodierung und Canvas über Parameter oder Modul-Mocks): fehlende Zeile oder 404-Datei → null; einfarbig → Canvas mit `globalCompositeOperation 'source-in'` und `fillStyle` `#FFFFFF` (light) bzw. `#111111` (dark); gleiche (id, tone) lädt nur einmal; nach `forgetLogo` wird neu geladen; ein fehlgeschlagener Ladevorgang bleibt nicht im Cache. + - Editor: gespeicherte Karte mit Logo, Loader liefert null → Vorschau-Warnung `LOGO_MISSING`, Karte wird gezeichnet; Wechsel auf ein vorhandenes Logo → Warnung weg; kein `LOGO_ERROR` bei fehlendem Logo (nur bei echtem Ladefehler). + - Veröffentlichen: `render` mit `logoMissing` → Fehler mit Text `LOGO_MISSING_PUBLISH`, keine Datei hochgeladen. + - Options-Panel: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit drei Kacheln (Symbol, Text, Kein Logo), kein Apple. +- [ ] **Step 2:** `cd web && npx vitest run src/card src/logos src/editor src/community/publish.test.ts` → FAIL. +- [ ] **Step 3:** Umsetzen. `logoKey` im Editor = `` `${c.logo ?? ''}|${c.details}|${c.brand}` ``; der Logo-Zustand hält zusätzlich `missing`. Bilddekodierung in `logo-asset.ts`: `createImageBitmap(blob)`, sonst `Image` über Object-URL; einfarbig = eigenes Canvas in Bildgröße, Bild zeichnen, `source-in`, mit `TONE_COLOR[tone]` füllen. `logo-asset.ts` importiert `LogoAsset` nur als `import type` aus `card/render.ts` (kein Laufzeit-Kreis). `marks.ts` behält nur die AirCard-Marken (`BRAND_MARKS`, `getBrandMark`, `svgAspect`, `tintSvg`, `loadMarkImage`). Neue Texte in `en.ts`. +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün; `grep -rn "visa\|mastercard\|unionpay\|amex\|discover\|jcb\|apple.svg" web/src` findet nur noch Kommentare/Tests ohne Dateibezug oder nichts. +- [ ] **Step 5:** Commit `feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer`. + +### Task 6: Logo verarbeiten und hochladen (Ablauf ohne Oberfläche) + +**Files:** +- Create: `web/src/logos/prepare-logo.ts`, `web/src/logos/prepare-logo.test.ts`, `web/src/logos/upload-logo.ts`, `web/src/logos/upload-logo.test.ts` + +**Interfaces:** +- Consumes: `uploadLogo`, `LogoUploadInput`, `CommunityLogo` (Task 4); `isDefinitiveFailure` aus `community/api.ts`; `storage`, `IDS` aus `account/appwrite.ts`; `requireUserId` aus `account/cards.ts`. +- Produces: + ```ts + // logos/prepare-logo.ts + export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg'; + export const LOGO_MAX_W = 720, LOGO_MAX_H = 250, LOGO_MIN = 8, ALPHA_EMPTY = 8, SVG_FALLBACK_SIZE = 1000, SVG_RASTER_MAX = 2048; + export const LOGO_SOURCE_MAX_BYTES = 5_242_880, LOGO_SOURCE_MAX_PIXELS = 40_000_000; + export interface PreparedLogo { png: Blob; width: number; height: number; hasTransparency: boolean } + export function validateLogoFile(f: { type: string; size: number; name: string }): { ok: true } | { ok: false; message: string }; + export function trimBox(data: Uint8ClampedArray, w: number, h: number, threshold?: number): { x: number; y: number; w: number; h: number } | null; + export function fitWithin(w: number, h: number, maxW: number, maxH: number): { w: number; h: number }; // nie > 1, gerundet, mind. 1 + export function svgRasterSize(naturalW: number, naturalH: number): { w: number; h: number }; // 0 → 1000×1000; längste Seite ≤ 2048 + export function hasTransparency(data: Uint8ClampedArray): boolean; + export function prepareLogo(file: File): Promise; // wirft Error mit übersetztem Text (Spec § 4/§ 11) + // logos/upload-logo.ts + export interface LogoForm { name: string; displayName: string; mono: boolean } + export interface UploadLogoDeps { userId(): string; createFile(file: File, permissions: string[]): Promise<{ $id: string }>; + deleteFile(fileId: string): Promise; uploadLogo(input: LogoUploadInput): Promise<{ id: string }> } + export function uploadPreparedLogo(prepared: PreparedLogo, form: LogoForm, deps?: UploadLogoDeps): Promise; + ``` + +- [ ] **Step 1: Tests schreiben:** + - `validateLogoFile`: `image/gif` → „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“; leerer Typ + Name `logo.SVG` → ok; 5 242 881 Byte → „Die Datei ist größer als 5 MB.“ + - `trimBox`: 4 × 3-Bild nur mit einem Pixel (2,1) Alpha 9 → `{x:2,y:1,w:1,h:1}`; Alpha ≤ 8 überall → null; volles Bild → ganze Fläche. + - `fitWithin(1440, 500, 720, 250)` → `{w:720,h:250}`; `(300, 100, 720, 250)` → unverändert; `(2000, 20, 720, 250)` → `{w:720,h:7}`. + - `svgRasterSize(0, 0)` → `{w:1000,h:1000}`; `(100000, 50000)` → `{w:2048,h:1024}`; `(300, 100)` → 2048 × 683 (gerundet). + - `hasTransparency`: alles 255 → false; ein Alpha 254 → true. + - `uploadPreparedLogo`: `createFile` mit Dateiname `logo.png`, Typ `image/png`, Rechten `['read("any")','delete("user:u1")']`; `uploadLogo` bekommt getrimmten Namen/Anzeigenamen, `mono` nur `true`, wenn `hasTransparency`; Erfolg → `CommunityLogo` mit `id`, `fileId = id`, `ownerId u1`, `likeCount 0`, keine Löschung; eindeutige Ablehnung (403) → Datei gelöscht, Fehler weitergeworfen; offener Ausgang (503) → Datei bleibt, Fehler weitergeworfen; Fehler beim Löschen überdeckt nie den eigentlichen Fehler. +- [ ] **Step 2:** `cd web && npx vitest run src/logos/prepare-logo.test.ts src/logos/upload-logo.test.ts` → FAIL. +- [ ] **Step 3:** Umsetzen. `prepareLogo`: Rasterbilder mit `createImageBitmap(file)` (Maße vor dem Zeichnen gegen `LOGO_SOURCE_MAX_PIXELS` prüfen, sonst „Das Bild ist zu groß (max. 40 Megapixel).“), SVG über `Image` aus Object-URL (nur als Bild, Größe aus `svgRasterSize`); zeichnen, `getImageData`, `trimBox` (null → „Das Logo ist leer.“), ausschneiden, `fitWithin(…, 720, 250)`, unter 8 × 8 → „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“, `toBlob('image/png')`, `hasTransparency` auf den Endpixeln; nicht lesbar → „Das Logo konnte nicht gelesen werden.“ Neue Texte in `en.ts`. (`prepareLogo` selbst prüft Task 12 live; die reinen Helfer tragen die Tests.) +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün. +- [ ] **Step 5:** Commit `feat(logos): Logo zuschneiden, verkleinern und hochladen`. + +### Task 7: Dialog „Logo hochladen“ + +**Files:** +- Create: `web/src/logos/logo-dialog.ts`, `web/src/logos/logo-dialog.test.ts`, `web/src/logos/logos.css`, `web/src/account/display-name.ts`, `web/src/account/display-name.test.ts` +- Modify: `web/src/community/publish-dialog.ts` (+ Test: Vorbelegung und Speichern des Anzeigenamens) + +**Interfaces:** +- Consumes: `prepareLogo`, `PreparedLogo`, `validateLogoFile`, `LOGO_ACCEPT`, `uploadPreparedLogo`, `LogoForm` (Task 6); `CommunityLogo` (Task 4); `apiErrorText`, `ApiError` (Task 4); `errorMessage` aus `account/errors.ts`. +- Produces: + ```ts + // account/display-name.ts + export const DISPLAY_NAME_KEY = 'aircard.displayName'; + export function rememberedDisplayName(): string; // '' bei Fehler/leer + export function rememberDisplayName(name: string): void; // leer → Schlüssel entfernen + // logos/logo-dialog.ts + export interface LogoDialogOptions { signal?: AbortSignal; + prepare?: (file: File) => Promise; upload?: (p: PreparedLogo, form: LogoForm) => Promise } + export function validateLogoForm(v: { hasFile: boolean; name: string; displayName: string; consent: boolean }): + { field: 'file' | 'name' | 'displayName' | 'consent'; message: string } | null; + export function logoUploadDialog(opts?: LogoDialogOptions): Promise; // null = abgebrochen + ``` + +- [ ] **Step 1: Tests schreiben:** + - `validateLogoForm`: ohne Datei → `file` „Bitte wähle eine Datei.“; Name leer → „Bitte gib einen Namen an.“; 41 Zeichen → „Der Name ist zu lang (höchstens 40 Zeichen).“; Steuerzeichen → „Der Name enthält ungültige Zeichen.“; Anzeigename 31 → „Der Anzeigename ist zu lang (höchstens 30 Zeichen).“; ohne Häkchen → „Bitte bestätige, dass du das Logo teilen darfst.“ + - Dialog: Datei wählen ruft `prepare`, zeigt zwei Vorschauen (dunkel/hell); Name wird mit dem Dateinamen ohne Endung vorbelegt (gekürzt auf 40); Anzeigename aus `rememberedDisplayName()`; „Einfarbig“ ist deaktiviert, wenn `hasTransparency` false; Absenden mit gültigen Werten ruft `upload` genau einmal (Doppelklick sendet nicht zweimal), speichert den Anzeigenamen und löst mit dem Logo auf; Fehler → „Hochladen fehlgeschlagen – “ (bei `ApiError` über `apiErrorText`), Dialog bleibt offen; während des Sendens sind Abbrechen/Esc wirkungslos; `signal` schließt immer (→ null). + - Veröffentlichen-Dialog: Anzeigename vorbelegt aus `aircard.displayName`, nach Erfolg gespeichert. +- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-dialog.test.ts src/account/display-name.test.ts src/community/publish-dialog.test.ts` → FAIL. +- [ ] **Step 3:** Umsetzen nach dem Muster von `publish-dialog.ts` (eigenes ``, `showModal`, Fokus zurück an den Auslöser, Texte nur als Text). Vorschau: PNG als Object-URL in ``; bei „Einfarbig“ stattdessen ein Element mit `mask-image`/`-webkit-mask-image` auf dieselbe URL und Hintergrundfarbe Weiß (dunkle Fläche) bzw. `#111111` (helle Fläche). Object-URLs beim Schließen freigeben. Pflicht-Häkchen-Text und alle Texte aus Spec § 4/§ 11; neue Texte in `en.ts`. +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün. +- [ ] **Step 5:** Commit `feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename`. + +### Task 8: Editor – Bereich „Logo unten rechts“ mit Raster, Sortierung, Auswahl und Herzen + +**Files:** +- Create: `web/src/logos/logo-picker.ts`, `web/src/logos/logo-picker.test.ts` +- Modify: `web/src/logos/logos.css`, `web/src/editor/dom.ts` (Icons `heart`, `plus`), `web/src/editor/editor-page.ts` (+ Test), `web/src/editor/options-panel.ts` (nur falls `logoSlot` angepasst werden muss) + +**Interfaces:** +- Consumes: Tasks 4–7 (`listLogos`, `getLogo`, `myHearts`, `loadLogoBlob`, `setHeart`, `rememberLogo`, `logoUploadDialog`, `CommunityLogo`, `LogoSort`, `LogoFilter`); `currentUser`, `currentUserId`, `isAdmin`, `onAuthChange` aus `account/auth.ts`; `requireLogin` aus `account/login-dialog.ts`. +- Produces: + ```ts + export interface LogoPickerDeps { listLogos; getLogo; myHearts; setHeart; loadLogoBlob; // Typen wie in Task 4 + userId(): string | null; isAdmin(): boolean; requireLogin(): Promise; + onAuthChange(fn: () => void): () => void; uploadDialog(): Promise } + export interface LogoPicker { + el: HTMLElement; + select(logoId: string | null): void; // markiert die Auswahl, holt ein nicht geladenes Logo per getLogo und heftet es an + /** Die Zeile des gewählten Logos, sobald bekannt (für die Infozeile in Task 9); null = kein Logo oder unbekannt. */ + selected(): CommunityLogo | null; + dispose(): void; + } + export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps?: LogoPickerDeps): LogoPicker; + ``` +- Icons: `heart: ''`, `plus: ''`. + +- [ ] **Step 1: Tests schreiben** (alles mit Fake-Deps): + - Aufbau: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“, Satz „Logos werden von der Community hochgeladen.“, Umschalter „Beliebt“ (gedrückt) / „Neu“; erste Kachel „Kein Logo“; je Logo eine Kachel mit Name, „von “ bzw. „von Anonym“ und Herz-Knopf „“ (eigener `button`, nicht im Auswahlknopf). + - Sortierung: Klick auf „Neu“ lädt mit `sort:'new'` neu; Admin sieht zusätzlich „Gemeldet“/„Ausgeblendet“ (`filter`), Nicht-Admin nicht. + - „Mehr laden“ erscheint nur bei `nextCursor` und hängt die nächste Seite an. + - Auswahl: Klick auf eine Kachel ruft `onSelect(id)`; „Kein Logo“ → `onSelect(null)`; `select('x')` mit nicht geladenem Logo → `getLogo('x')` und Kachel direkt nach „Kein Logo“; `aria-pressed` folgt der Auswahl. + - Herzen: angemeldet → Klick schaltet sofort (`aria-pressed`, Zahl ± 1), ruft `setHeart(id, true)`, übernimmt `likeCount` der Antwort; Fehler → Rücksprung + Text im Statusbereich (`role="alert"`); zweiter Klick während laufender Anfrage → kein zweiter Aufruf; Gast → `requireLogin()`; `false` → kein `setHeart`, Herz aus, kein Fehlertext; `true` → `setHeart(id, true)`; nach dem Laden einer Seite (angemeldet) `myHearts` mit genau den IDs der Seite; Abmelden → alle Herzen aus. + - Leer → „Noch keine Community-Logos – lade das erste hoch.“; Ladefehler → „Die Logos konnten nicht geladen werden.“ + „Nochmal versuchen“ (lädt neu). + - Hochladen: Gast → zuerst `requireLogin()`; danach `uploadDialog()`; Ergebnis-Logo steht als erste Logo-Kachel, `rememberLogo` und `onSelect(id)` werden gerufen. + - Editor: Picker sitzt in `options.logoSlot`; Auswahl setzt `config.logo`; `sync` ruft `picker.select(config.logo)`; Seite verlassen ruft `dispose`. +- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-picker.test.ts src/editor/editor-page.test.ts` → FAIL. +- [ ] **Step 3:** Umsetzen. Kachelbild: Object-URL aus `loadLogoBlob`; mehrfarbig ``, einfarbig Element mit `mask-image` in Weiß; Bildfläche immer auf dunklem Kartengrund `#1b2738` (beide Themes, wie bisher `.mark-img` im hellen Theme). Raster 2 Spalten, unter 900 px `repeat(auto-fill, minmax(140px, 1fr))`. Herz gefüllt, wenn gedrückt (`fill: currentColor`). Object-URLs bei `dispose` freigeben. Alle Texte mit `t()`, Einträge in `en.ts`. +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün. +- [ ] **Step 5:** Commit `feat(editor): Community-Logos unten rechts mit Herzen und Sortierung`. + +### Task 9: Editor – Infozeile, Melden, Löschen und Admin für Logos + +**Files:** +- Modify: `web/src/logos/logo-picker.ts` (+ Test), `web/src/logos/logos.css`, `web/src/community/report-dialog.ts` (+ Test) + +**Interfaces:** +- Consumes: `reportLogo`, `deleteLogo`, `setLogoVisibility`, `listLogoReports` (Task 4); `forgetLogo` (Task 5); `confirmDialog` aus `editor/confirm-dialog.ts`; `apiErrorText` (Task 4). +- Produces: + ```ts + // community/report-dialog.ts – Erweiterung, Standard bleibt die Karte + export interface ReportDialogOptions { send; cardTitle?; signal?; subject?: 'card' | 'logo' } + // logo: Gründe ohne „Echte Kartendaten“; Danke-Text bei Duplikat „Danke, du hast dieses Logo schon gemeldet.“; Einleitung nennt „dieses Logo“ + // LogoPickerDeps zusätzlich: reportLogo, deleteLogo, setLogoVisibility, listLogoReports, confirm(opts): Promise, reportDialog(opts): Promise + ``` + +- [ ] **Step 1: Tests schreiben:** + - Infozeile für das gewählte Logo: „ · von · ♥ “ und „Melden“ (auch für Gäste); ohne Auswahl leer; gewähltes Logo nicht auffindbar → „Das gewählte Logo ist nicht mehr verfügbar.“ + - Melden öffnet den Dialog mit `subject:'logo'` und sendet `reportLogo(id, reason)`; der Logo-Dialog zeigt keine Option „Echte Kartendaten“. + - Hochlader (`ownerId === userId()`): „Löschen“ → Rückfrage; bestätigt → `deleteLogo(id)`, `forgetLogo(id)`, Kachel weg, `onSelect(null)`; abgelehnt → nichts; Fehler → Text im Statusbereich. + - Admin: „ Meldungen“ (`listLogoReports`, Gründe als Text), „Ausblenden“ bzw. bei `hidden` „Einblenden“ → `setLogoVisibility(id, false|true)` und Anzeige aktualisiert, „Löschen“ wie oben; Nicht-Admin und Nicht-Hochlader sehen weder Löschen noch Admin-Knöpfe. + - Karten-Meldedialog unverändert (bestehende Tests grün). +- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-picker.test.ts src/community/report-dialog.test.ts` → FAIL. +- [ ] **Step 3:** Umsetzen; Texte mit `t()`, Einträge in `en.ts`. +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün. +- [ ] **Step 5:** Commit `feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen`. + +### Task 10: Übersetzung des Bestands, Teil A – Konto, Editor, Karte, Passwort-Seite + +**Files:** +- Modify: `web/src/account/*.ts` (ohne Tests), `web/src/editor/*.ts` (ohne Tests), `web/src/card/*.ts` (ohne Tests), `web/src/pages/reset-page.ts`, `web/src/i18n/en.ts`; Tests nur, wo ein Test einen jetzt mit `msg()` markierten Export anders erwartet + +**Interfaces:** +- Consumes: `t`, `msg` (Task 1). Exportierte Text-Konstanten bleiben deutsche Strings mit gleichem Namen (jetzt `msg('…')`), damit Vergleiche und Tests weiter funktionieren; übersetzt wird an der Stelle der Anzeige mit `t(KONSTANTE)`. + +- [ ] **Step 1: Test schreiben** in `web/src/i18n/coverage.test.ts`: Für die Dateien dieses Tasks (`account/`, `editor/`, `card/`, `pages/reset-page.ts`) steht kein deutsches Text-Literal mehr außerhalb von `t()`/`msg()` – Heuristik: String-Literale mit Umlaut/ß oder mit mindestens zwei Wörtern aus Kleinbuchstaben, ausgenommen Kommentare, Import-Pfade, CSS-Klassen, `data-`/`aria-`-Attributnamen, Appwrite-Fehlertypen und `console.*`-Aufrufe; eine kleine Ausnahmeliste im Test ist erlaubt (je Eintrag mit Grund). +- [ ] **Step 2:** `cd web && npx vitest run src/i18n/coverage.test.ts` → FAIL (zeigt die noch offenen Texte). +- [ ] **Step 3:** Alle sichtbaren Texte der genannten Dateien durch `t()`/`msg()`; englische Einträge in `en.ts`. Seitentitel/`aria-label`/Platzhalter/`title` eingeschlossen. Fehlermeldungen in `errors.ts` werden beim Zurückgeben übersetzt. +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün. +- [ ] **Step 5:** Commit `feat(i18n): Konto, Editor und Karte auf Englisch`. + +### Task 11: Übersetzung des Bestands, Teil B – Community, Startseite, Detailseite, Meine Karten, API-Fehler + +**Files:** +- Modify: `web/src/community/*.ts` (ohne Tests), `web/src/pages/home-page.ts`, `web/src/pages/community-page.ts`, `web/src/pages/cards-page.ts`, `web/src/pages/format.ts` (+ `format.test.ts`), `web/src/i18n/en.ts`, `web/src/i18n/coverage.test.ts` (Heuristik aus Task 10 auf diese Dateien und `shell/`, `logos/` ausweiten) + +**Interfaces:** +- Consumes: `t`, `msg`, `currentLang` (Task 1); `apiErrorText` (Task 4). +- Produces: `formatDate(iso)` – Deutsch `dd.mm.yyyy` (wie bisher), Englisch `Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' })` („Oct 4, 2026“); `reportsLabel(count)` übersetzt („1 report“, „3 reports“). + +- [ ] **Step 1: Tests schreiben:** `formatDate('2026-10-04T12:00:00Z')` → `04.10.2026` (de) und `Oct 4, 2026` (en); `reportsLabel(1)`/`(3)` in beiden Sprachen; Anzeige eines `ApiError` mit `serverCode:'alreadyPublished'` im Veröffentlichen-Dialog bei Englisch → „Publishing failed – This card is already published.“ (Wortlaut aus `en.ts`); Heuristik-Test aus Task 10 deckt jetzt auch diese Dateien ab. +- [ ] **Step 2:** `cd web && npx vitest run src/pages/format.test.ts src/i18n src/community` → FAIL. +- [ ] **Step 3:** Alle sichtbaren Texte durch `t()`/`msg()`; jede Anzeige eines `ApiError`-Textes (`err.message`) durch `apiErrorText(err)` ersetzen (Logik, die `err.message` vergleicht, bleibt beim deutschen Text); englische Einträge in `en.ts`. +- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün. +- [ ] **Step 5:** Commit `feat(i18n): Community, Startseite und Meine Karten auf Englisch`. + +### Task 12: Betrieb und Abnahme (Controller) + +- [ ] **Schema:** `cd /home/webklar/apps/aircard-ios.org/dev && node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` (erwartet FEHLT für die neuen Objekte) → ohne `--check` anlegen → `--check` Exit 0. +- [ ] **API:** Backup `aircard-ios.org/api/` → die fünf Dateien kopieren → `cd /home/webklar/apps && docker compose restart aircard-api` → `curl -s https://aircard-ios.org/api/gesundheit` = `{"ok":true}`; `curl` auf `/api/logos/melden` mit unbekannter ID → 404 mit `code:"logoGone"`; `/api/logos/herz` ohne JWT → 401 `code:"unauthorized"`. +- [ ] **Web:** Push `main` → Webhook-Build → neue Bundles live (Hash im HTML geändert). +- [ ] **Live-Abnahme** (Claude in Chrome in Kensos Sitzung; Konten/Passwörter gibt nur Kenso ein): Logo hochladen (PNG transparent + einfarbig, SVG, JPG) → Kachel, Karte, Download-PNG enthält Logo; Gast (zweiter Tab ohne Sitzung bzw. `curl` auf die Tabelle) sieht das Logo; Herz an/aus, Zahl und Sortierung „Beliebt“; Melden aus 3 Netzen (per `docker exec webklar-aircard-api` gegen `127.0.0.1:8088` mit `X-Real-Ip`) → ausgeblendet; Löschen; Sprachwechsel mit offenem Entwurf (bleibt), englische Startsprache bei englischem Browser, ``, Titel; GitHub-Link; 1440 × 900 und 390 × 844, Hell/Dunkel; keine Marken-Logos mehr im Editor. +- [ ] **Abschluss:** Whole-Branch-Review (ein `sonnet`-Reviewer), eine Fix-Welle nur für Critical/Important, Push, `README.md` (Funktionsliste, Sprachen, Logos), Memory aktualisieren, Bericht an Kenso. diff --git a/docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md b/docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md index 4785f7d..1399460 100644 --- a/docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md +++ b/docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md @@ -198,7 +198,7 @@ Bestehende Tabellen und Buckets bleiben unverändert. ## 9. API-Dienst `aircard-api` (Erweiterung) -Die neuen Endpunkte stehen in einem eigenen Modul `deploy/api/logos.js`. `app.js` reicht ihm die gemeinsamen Helfer (Anmeldung, Sperre je Schlüssel, Rate-Limit, Log, Melder-Hash) und hängt seine Routen an. Der Betrieb kopiert künftig vier Dateien (`appwrite.js`, `app.js`, `logos.js`, `server.js`). `appwrite.js` bekommt `countRows(table, queries)`, das `total` einer Abfrage mit `limit(1)` liefert. +Die neuen Endpunkte stehen in einem eigenen Modul `deploy/api/logos.js`. Reine gemeinsame Helfer (Meldetexte, Prüfungen, Rechte, Melder-Hash) wandern nach `deploy/api/common.js`. `app.js` reicht dem Logo-Modul seine laufenden Helfer (Anmeldung, Sperre je Schlüssel, Rate-Limit, Log) und hängt dessen Routen an. Der Betrieb kopiert künftig fünf Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`). `appwrite.js` bekommt `countRows(table, queries)`, das `total` einer Abfrage mit `limit(1)` liefert. | Endpunkt | Auth | Wirkung | |---|---|---|