49 KiB
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, Branchmain, 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()auseditor/dom.ts). - Ab Task 1 gehen alle neuen sichtbaren Texte (Text,
aria-label,title, Platzhalter, Meldungen) durcht(); Konstanten werden mitmsg()markiert und beim Anzeigen mitt()übersetzt. Jeder neue Text bekommt seinen Eintrag inweb/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, Datenbankaircard; neue Tabellenlogos,logo_likes,logo_reports; neuer Bucketlogo-images; Bestehendes unverändert. - Logo-Zeilenrechte: sichtbar genau
['read("any")'], ausgeblendet genau['read("user:<owner>")','read("label:admin")']. Logo-Dateirechte: beim Hochladen['read("any")','delete("user:<owner>")'], nach dem Anlegen genau['read("any")'], ausgeblendet['read("user:<owner>")','read("label:admin")']. Like-Zeilen: IDhash32("<logoId>|<userId>"), Rechte['read("user:<userId>")']. - 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(KonstanteGITHUB_URLinweb/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;
.envnie öffnen (Ausnahme: Controller-Betrieb in Task 12). - Commits enden mit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - Subagents:
sonnetfür Umsetzung und Review,haikufür kleine Nachprüfungen; nie Subagents aus Subagents.
Review Focus
- Fremde oder falsche Datei beim Hochladen:
fileIdeiner fremden Logo-Datei, einer Datei ausgallery-imagesoder 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: Das Seitenverhältnis kommt aus der viewBox (ohne brauchbare viewBox: quadratisch, entspricht den 1000 × 1000 der Spec); gerastert wird immer mit genau 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(Iconsgithub,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<title>prüft) - Test:
web/src/shell/header.test.ts,web/src/pages/router.test.ts
Interfaces:
-
Produces:
// 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 inheader.test.tsundrouter.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.setLangsetztdocument.documentElement.lang, schreibt mitpersist: falsenichts inlocalStorage, ruft Listener genau einmal je echtem Wechsel.- Fußzeile: deutsch der Spec-Text (§ 7); nach
setLang('en')und erneutemmountFooterder englische. - Kopfzeile: Link mit
href=GITHUB_URL,target="_blank",rel="noopener noreferrer",title„AirCard-iOS auf GitHub“; Sprachknopf zeigt bei Deutsch „EN“ mitaria-label„Switch to English“ undlang="en", Klick →currentLang() === 'en'; danach (neu gemountet) „DE“ mit „Auf Deutsch umschalten“; das Menü enthält einen zweiten GitHub-Link (Klassenav-github). - Router:
rerender()ruft das Aufräumen der aktuellen Seite und dannrendermit derselben Route erneut auf;scrollTowird nicht aufgerufen; nachsetLang('en')lieferttitleFor({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 = beienEN[text] ?? text, sonsttext; dann{name}durchString(params[name])ersetzen, wenn vorhanden.coverage.test.ts(Muster wiesingle-sources.test.ts): sammelt in allen Nicht-Test-.tsuntersrc/die String-Literale aust(…)/msg(…)('…',"…",`…`ohne${) und prüft: (a) jedes hat einen Eintrag inEN, (b) jederEN-Eintrag wird benutzt, (c) Platzhalter{…}in Schlüssel und Übersetzung sind dieselbe Menge, (d) keint(/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</title>,<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG.">; im bestehenden Inline-Skript zusätzlichlangsetzen (gleiche Regel wieinitialLang, Kommentar verweist darauf);<footer id="app-footer"></footer>leer (Text kommt ausmountFooter).- Icons in
dom.ts(Lucide, Strich):github: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'. - Kopfzeile: Reihenfolge in
.header-tools: GitHub (a.header-githubmit Icon +span.header-github-text„GitHub“), Sprachknopf (button.header-btn.lang-btn), Spenden, Hell/Dunkel, Menü. CSS: zwischen 600 und 1179 px.header-github-textausblenden (wie.header-donate-text); unter 600 px.header-githubausblenden und.nav-githubim Menü zeigen (sonst.nav-githubausgeblendet). Alle übrigen Kopfzeilen- und Banner-Texte durcht()/msg(). - Router:
ROUTE_TITLES-Werte mitmsg(),titleForliefertt(…);rerender()ruft die internerenderCurrentauf. main.ts: beim StartsetLang(currentLang(), { persist: false })undclient.setLocale(lang);onLangChange(lang => …):client.setLocale(lang), Kopfzeile aufräumen und neu mounten,mountDonateBannererneut, Konto-Menüdispose()+ neu mounten (+ready(), wenn der Anmeldestatus schon geholt war),mountFooter, dannrerender(). Der Editor-Store bleibt dasselbe Objekt.- Englische Einträge für alle neuen
t()/msg()-Texte inen.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.tableszusätzlich (in dieser Reihenfolge nachreports) undDESIRED.bucketszusätzlich:{ 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:
DESIREDenthält die drei Tabellen und den Bucket mit genau diesen Werten; ein Lauf gegen den Test-Server legt sie samt Indizes an (Mehrspalten-Indexuser_logomitcolumns: ['userId','logoId']im Request);--checkmeldetFEHLTfü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:
DESIREDergä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(ausapp.jsherausgelö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 auscommon.js, exportiert weiter alles wie bisher, hängt die Logo-Routen an, sendetcode),deploy/api/appwrite.js(+countRows),deploy/api/fake-appwrite.js(Tabellenlogos,logo_likes,logo_reportsmit Spaltenlängen, Bucketlogo-images,totalin 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:
// 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äterercode, 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“.'. AlleMSG-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,setVisibilitysind das Vorbild). Sperren:logo-owner:<userId>beim Hochladen,logo:<id>für alles je Logo. Gelöscht werden Herzen und Meldungen in Runden (listRowsmitequal logoId,limit 100, höchstens 50 Runden).likeCountundreportCountimmer auscountRows. -
Step 1: Tests schreiben (
logos.test.jsim Stil vonapp.test.js; dort zusätzlich diecode-Fälle):- Hochladen: gültig → 201
{id: fileId}, Zeile sichtbar mitlikeCount 0, Datei-Rechte danach genau['read("any")']; fremde Datei → 403code:'foreignFile'; Datei ausgallery-images→ 403; Datei ohneread("any")→ 403filesTaken; WebP → 400logoFileType; PNG 721 × 100 oder 7 × 7 → 400logoFormat; > 1 MiB → 400logoFileSize; dieselbe Datei zweimal → 409logoTaken; 31. Logo → 429tooManyLogos; scheitert das Setzen der Dateirechte → Zeile gelöscht, Datei hat wiederdelete("user:<ich>"); ohne JWT → 401unauthorized; Name leer → 400logoNameMissing;mono: 'ja'→ 400badMono. - Herz: an →
{herz:true, likeCount:1}, nochmal an → weiter 1; aus → 0, nochmal aus → 0; zwei Nutzer → 2; ausgeblendetes oder fehlendes Logo → 404logoGone;herzkein Boolean → 400badHeart; Like-Zeile hat Rechte['read("user:<id>")']und IDlogoLikeId. - 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 → 404logoGone. - Admin-Sichtbarkeit: ausblenden/einblenden (Einblenden: Meldungen weg,
reportCount 0, Rechte sichtbar); Nicht-Admin → 403adminOnly. - Bestehende Endpunkte: jede Fehlerantwort hat
code(z. B./api/meldenmit unbekannter ID →{error:'Diese Karte ist nicht (mehr) verfügbar.', code:'cardGone'});MSG-Texte sind eindeutig. countRowsinappwrite.test.js: lieferttotal, wirft bei fehlendemtotal.
- Hochladen: gültig → 201
-
Step 2:
node --test "deploy/api/*.test.js"→ FAIL. -
Step 3: Umsetzen:
common.jsherauslösen (reine Verschiebung, Verhalten gleich),handle()sendet{error: err.message, code: err.code}(Appwrite-Fehler:codeunavailablebzw.internal), Logo-Modul, Fake-Erweiterung, Doku (Endpunkt-Tabelle inapi/README.md, Kopierbefehl mit fünf Dateien indeploy/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 undcode(Task 3);fetchFile,ID_PATTERNausaccount/cards.ts;apiPost,ApiError,isDefinitiveFailureauscommunity/api.ts. -
Produces:
// 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<string, unknown>): CommunityLogo; export function logoQueries(opts: LogoListOptions): string[]; export function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }>; export function getLogo(id: string): Promise<CommunityLogo | null>; // ungültige ID oder 404 → null export function myHearts(userId: string, logoIds: readonly string[]): Promise<Set<string>>; export function listLogoReports(logoId: string): Promise<LogoReport[]>; // neueste zuerst, limit 100 export function loadLogoBlob(logo: CommunityLogo): Promise<Blob>; // 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<void>; export function setLogoVisibility(id: string, visible: boolean): Promise<void>; // 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<Record<string, string>>; // 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'ohnelikeCount-Sortierung;filter:'reported'→greaterThan('reportCount',0),'hidden'→equal('hidden',true); mitcursorzusätzlichcursorAfter.toLogomacht aus kaputten Feldern leere Werte,likeCount/reportCount< 0 oder kein Zahlwert → 0,mono/hiddennur beitrue.listLogos:nextCursornur bei voller Seite;getLogo('kaputt id')→ null ohne Anfrage, 404 → null, 500 → wirft.myHearts('u', [])→ leere Menge ohne Anfrage; sonst Abfrageequal('userId','u'),equal('logoId',[…]),limit(100).logo-api: richtige Pfade und Bodies (/api/logos/herzmit{id, herz},/api/admin/logo-sichtbarkeitmit{id, sichtbar}); Antwort ohneidbeiuploadLogo→ApiError503, nicht eindeutig.ApiError.serverCodewird aus{error, code}gelesen; Client-Fehler tragenunavailable,generic,jwtUnsupported,alreadyPublished,filesTaken,tooManyPublicationspassend.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.tslädtMSGausdeploy/api/common.js(createRequire(import.meta.url)) und prüft für jeden SchlüsselAPI_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.tsundcommunity/api.ts;en.tsum alleAPI_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ßeraircards-symbol.svgundaircards-text.svg(19 Dateien,git rm)
Interfaces:
-
Consumes:
getLogo,loadLogoBlob,CommunityLogo(Task 4);t,msg(Task 1). -
Produces:
// 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<LogoAsset | null>): Promise<LoadedAssets>; // 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<LogoAsset | null>; 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;serializeConfigschreibt 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 VorlageclassicwirddrawImage(source, fitLogo(2).x, …)aufgerufen; mitimagenicht; ohneassets.logonicht. loadCardAssets:logo: null→logoMissing falseohne Loader-Aufruf; Loader liefert null →logo null, logoMissing true; Loader wirft →loadCardAssetswirft.loadLogoAsset(Fakes fürgetLogo,loadLogoBlob, Bilddekodierung und Canvas über Parameter oder Modul-Mocks): fehlende Zeile oder 404-Datei → null; einfarbig → Canvas mitglobalCompositeOperation 'source-in'undfillStyle#FFFFFF(light) bzw.#111111(dark); gleiche (id, tone) lädt nur einmal; nachforgetLogowird 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; keinLOGO_ERRORbei fehlendem Logo (nur bei echtem Ladefehler). - Veröffentlichen:
rendermitlogoMissing→ Fehler mit TextLOGO_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.
logoKeyim Editor =`${c.logo ?? ''}|${c.details}|${c.brand}`; der Logo-Zustand hält zusätzlichmissing. Bilddekodierung inlogo-asset.ts:createImageBitmap(blob), sonstImageüber Object-URL; einfarbig = eigenes Canvas in Bildgröße, Bild zeichnen,source-in, mitTONE_COLOR[tone]füllen.logo-asset.tsimportiertLogoAssetnur alsimport typeauscard/render.ts(kein Laufzeit-Kreis).marks.tsbehält nur die AirCard-Marken (BRAND_MARKS,getBrandMark,svgAspect,tintSvg,loadMarkImage). Neue Texte inen.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/srcfindet 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);isDefinitiveFailureauscommunity/api.ts;storage,IDSausaccount/appwrite.ts;requireUserIdausaccount/cards.ts. -
Produces:
// 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(aspect: number): { w: number; h: number }; // längste Seite genau 2048; ungültiges Seitenverhältnis → 2048 × 2048 export function hasTransparency(data: Uint8ClampedArray): boolean; export function prepareLogo(file: File): Promise<PreparedLogo>; // 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<void>; uploadLogo(input: LogoUploadInput): Promise<{ id: string }> } export function uploadPreparedLogo(prepared: PreparedLogo, form: LogoForm, deps?: UploadLogoDeps): Promise<CommunityLogo>; -
Step 1: Tests schreiben:
validateLogoFile:image/gif→ „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“; leerer Typ + Namelogo.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(1)→{w:2048,h:2048};(2)→{w:2048,h:1024};(3)→{w:2048,h:683};(0.5)→{w:1024,h:2048};(NaN)/(0)→{w:2048,h:2048}.hasTransparency: alles 255 → false; ein Alpha 254 → true.uploadPreparedLogo:createFilemit Dateinamelogo.png, Typimage/png, Rechten['read("any")','delete("user:u1")'];uploadLogobekommt getrimmten Namen/Anzeigenamen,mononurtrue, wennhasTransparency; Erfolg →CommunityLogomitid,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 mitcreateImageBitmap(file)(Maße vor dem Zeichnen gegenLOGO_SOURCE_MAX_PIXELSprüfen, sonst „Das Bild ist zu groß (max. 40 Megapixel).“), SVG: Text lesen, Seitenverhältnis mitsvgAspectauscard/marks.ts(viewBox),width/heightaussvgRasterSizeins Wurzelelement schreiben (wiewithIntrinsicSizeinmarks.ts), dann überImageaus einer Object-URL laden (nur als Bild); 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'),hasTransparencyauf den Endpixeln; nicht lesbar → „Das Logo konnte nicht gelesen werden.“ Neue Texte inen.ts. (prepareLogoselbst 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);errorMessageausaccount/errors.ts. -
Produces:
// 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<PreparedLogo>; upload?: (p: PreparedLogo, form: LogoForm) => Promise<CommunityLogo> } 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<CommunityLogo | null>; // 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 ausrememberedDisplayName(); „Einfarbig“ ist deaktiviert, wennhasTransparencyfalse; Absenden mit gültigen Werten ruftuploadgenau einmal (Doppelklick sendet nicht zweimal), speichert den Anzeigenamen und löst mit dem Logo auf; Fehler → „Hochladen fehlgeschlagen – “ (beiApiErrorüberapiErrorText), Dialog bleibt offen; während des Sendens sind Abbrechen/Esc wirkungslos;signalschließ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<dialog>,showModal, Fokus zurück an den Auslöser, Texte nur als Text). Vorschau: PNG als Object-URL in<img>; bei „Einfarbig“ stattdessen ein Element mitmask-image/-webkit-mask-imageauf 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 inen.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(Iconsheart,plus),web/src/editor/editor-page.ts(+ Test),web/src/editor/options-panel.ts(nur fallslogoSlotangepasst werden muss)
Interfaces:
-
Consumes: Tasks 4–7 (
listLogos,getLogo,myHearts,loadLogoBlob,setHeart,rememberLogo,logoUploadDialog,CommunityLogo,LogoSort,LogoFilter);currentUser,currentUserId,isAdmin,onAuthChangeausaccount/auth.ts;requireLoginausaccount/login-dialog.ts. -
Produces:
export interface LogoPickerDeps { listLogos; getLogo; myHearts; setHeart; loadLogoBlob; // Typen wie in Task 4 userId(): string | null; isAdmin(): boolean; requireLogin(): Promise<boolean>; onAuthChange(fn: () => void): () => void; uploadDialog(): Promise<CommunityLogo | null> } 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: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',plus: '<path d="M5 12h14"/><path d="M12 5v14"/>'. -
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
nextCursorund 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-pressedfolgt der Auswahl. - Herzen: angemeldet → Klick schaltet sofort (
aria-pressed, Zahl ± 1), ruftsetHeart(id, true), übernimmtlikeCountder Antwort; Fehler → Rücksprung + Text im Statusbereich (role="alert"); zweiter Klick während laufender Anfrage → kein zweiter Aufruf; Gast →requireLogin();false→ keinsetHeart, Herz aus, kein Fehlertext;true→setHeart(id, true); nach dem Laden einer Seite (angemeldet)myHeartsmit 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(); danachuploadDialog(); Ergebnis-Logo steht als erste Logo-Kachel,rememberLogoundonSelect(id)werden gerufen. - Editor: Picker sitzt in
options.logoSlot; Auswahl setztconfig.logo;syncruftpicker.select(config.logo); Seite verlassen ruftdispose.
- 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
-
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<img>, einfarbig Element mitmask-imagein Weiß; Bildfläche immer auf dunklem Kartengrund#1b2738(beide Themes, wie bisher.mark-imgim hellen Theme). Raster 2 Spalten, unter 900 pxrepeat(auto-fill, minmax(140px, 1fr)). Herz gefüllt, wenn gedrückt (fill: currentColor). Object-URLs beidisposefreigeben. Alle Texte mitt(), Einträge inen.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);confirmDialogauseditor/confirm-dialog.ts;apiErrorText(Task 4). -
Produces:
// 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<boolean>, reportDialog(opts): Promise<ReportOutcome> -
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 sendetreportLogo(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. beihidden„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 inen.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 mitmsg()markierten Export anders erwartet
Interfaces:
-
Consumes:
t,msg(Task 1). Exportierte Text-Konstanten bleiben deutsche Strings mit gleichem Namen (jetztmsg('…')), damit Vergleiche und Tests weiter funktionieren; übersetzt wird an der Stelle der Anzeige mitt(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 vont()/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 undconsole.*-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 inen.ts. Seitentitel/aria-label/Platzhalter/titleeingeschlossen. Fehlermeldungen inerrors.tswerden 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 undshell/,logos/ausweiten)
Interfaces:
-
Consumes:
t,msg,currentLang(Task 1);apiErrorText(Task 4). -
Produces:
formatDate(iso)– Deutschdd.mm.yyyy(wie bisher), EnglischIntl.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) undOct 4, 2026(en);reportsLabel(1)/(3)in beiden Sprachen; Anzeige einesApiErrormitserverCode:'alreadyPublished'im Veröffentlichen-Dialog bei Englisch → „Publishing failed – This card is already published.“ (Wortlaut ausen.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 einesApiError-Textes (err.message) durchapiErrorText(err)ersetzen (Logik, dieerr.messagevergleicht, bleibt beim deutschen Text); englische Einträge inen.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--checkanlegen →--checkExit 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};curlauf/api/logos/meldenmit unbekannter ID → 404 mitcode:"logoGone";/api/logos/herzohne JWT → 401code:"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.
curlauf die Tabelle) sieht das Logo; Herz an/aus, Zahl und Sortierung „Beliebt“; Melden aus 3 Netzen (perdocker exec webklar-aircard-apigegen127.0.0.1:8088mitX-Real-Ip) → ausgeblendet; Löschen; Sprachwechsel mit offenem Entwurf (bleibt), englische Startsprache bei englischem Browser,<html lang>, 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.