Compare commits

..
25 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 d85cce0bf3 docs: Community-Logos, Englisch und Reihenfolge Schema → API → Seite nachgetragen
Schlussreview F8. Die Dokumentation kannte die Community-Logos, die Herzen, die zweite Sprache und den GitHub-Link noch nicht.

- README.md: Einleitung ohne „Netzwerk-Logo“, neuer Abschnitt „Funktionen“ (Editor, Community-Logos mit Herzen und Moderation,
  Galerie, Deutsch/Englisch mit GitHub-Link), Modulübersicht um logos/, community/, i18n/ und shell/ ergänzt und die Routen vervollständigt,
  Tabellen und Buckets (cards, gallery, reports, logos, logo_likes, logo_reports; card-images, gallery-images, logo-images), Testbefehle
  (API-Tests, Build mit tsconfig.build.json), Hinweis auf die Reihenfolge beim Einführen.
- docs/appwrite-console.md: Schema-Liste (oben, Schritt 5, Abschnitt 7 und 8) mit den Tabellen und dem Bucket der Logos, das Label
  `admin` gilt auch für die Logos, die Scopes des Keys `aircard-api` genügen für die Logo-Endpunkte.
- deploy/README.md: neuer Abschnitt „Reihenfolge bei Änderungen an Schema, API und Seite“: 1. Schema (--check, anlegen, noch einmal
  prüfen), 2. API (fünf Dateien, Neustart, Prüfung: POST /api/logos/herz ohne JWT ergibt 401 mit code unauthorized, 404 hieße alte
  API), 3. erst dann main pushen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:23:23 +00:00
ClaudeandClaude Opus 5.5 7fec92c20a build(web): Produktions-Build prüft keine Testdateien mehr
Schlussreview F7. `npm run build` rief `tsc --noEmit` mit der vollen tsconfig.json auf und prüfte dabei auch alle 53 Testdateien samt
Test-Setup und Englisch-Hilfen: für den Build im Container (node:22-alpine, 15 Minuten Zeitlimit, begrenzter Speicher) unnötige Last.

- Neu: web/tsconfig.build.json (erweitert tsconfig.json, schließt **/*.test.ts, src/test-setup.ts und src/i18n/english-check.ts aus).
- Das Build-Skript lautet jetzt `tsc --noEmit -p tsconfig.build.json && vite build` (deploy.sh ruft `npm run build`).
- `npx tsc --noEmit` prüft weiter alles einschließlich der Tests (für die Entwicklung).
- Gemessen: Prüfung 112 → 57 Dateien (0 Testdateien), 560 MB → 411 MB Speicher (Spitze), 6,3 s → 2,8 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:21:12 +00:00
ClaudeandClaude Opus 5.5 4232afdf35 fix(logos): unerwartete Fehler beim Vorbereiten eines Logos in die Konsole
Schlussreview F6. prepareLogo machte aus jedem Fehler außer seinen eigenen („leer“, „zu klein“ …) die Meldung „Das Logo konnte nicht
gelesen werden.“ und verlor dabei den Anlass (Fehler des Browsers beim Zeichnen, Dekodierfehler): bei einer Rückmeldung eines Nutzers
war nichts mehr nachzuvollziehen.

- Vor dem Umwandeln steht jetzt genau eine console.warn mit dem Originalfehler. Der Text für den Nutzer bleibt unverändert (in beiden
  Sprachen). Fehler mit eigener Meldung (LogoError: Typ, Größe, zu viele Pixel, leer, zu klein, kein Canvas, kein PNG) sind erwartet und
  warnen nicht.
- Tests: Browserfehler beim Zeichnen (Raster und SVG), Dekodierfehler, Englisch, keine Warnung bei den erwarteten Fehlern und bei
  Erfolg. Die Tests der Gruppe „Bild und Canvas gestellt“ ersetzen console.warn durch eine Attrappe, damit die Ausgabe sauber bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:20:28 +00:00
ClaudeandClaude Opus 5.5 df1cf29d42 fix(logos): 404 „logoGone“ beim Löschen gilt als Erfolg
Schlussreview F5. Geht die Antwort einer Löschanfrage verloren und der Nutzer klickt noch einmal auf „Löschen“, antwortet der Dienst mit
404 `logoGone` (das Logo ist ja schon weg). Die Infozeile zeigte dann „Dieses Logo ist nicht (mehr) verfügbar.“ als Fehler, die Kachel
blieb stehen.

- Dieser Fehler (ApiError mit Status 404 und Schlüssel `logoGone`) gilt wie ein gelungenes Löschen: forgetLogo, die Kachel verschwindet,
  eine Auswahl wird aufgehoben (onSelect(null)), kein Fehlertext – wie bei „Zurückziehen“ einer schon zurückgezogenen Karte.
- Jeder andere Fehler bleibt einer, auch ein 404 mit anderem oder ohne Schlüssel (etwa ein Dienst, der die Anfrage nicht kennt).
- Tests in logo-info.test.ts (Hochlader, Admin, Seite verlassen, Fehler, die Fehler bleiben) und im Picker mit Raster und Auswahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:19:17 +00:00
ClaudeandClaude Opus 5.5 4aedb1d322 fix(logos): Logo vor Veröffentlichen und Herunterladen frisch prüfen
Schlussreview F4. Zeilen und Bilder der Community-Logos (logo-asset.ts) bleiben bis zum Neuladen der Seite im Speicher. Wurde ein Logo
danach ausgeblendet oder gelöscht, konnte es aus diesem Zwischenspeicher in ein veröffentlichtes Kartenbild oder in ein heruntergeladenes
PNG gelangen.

- renderForGallery (Veröffentlichen), der Download im Editor und der Download in „Meine Karten“ rufen vor dem Laden der Bilder
  forgetLogo(config.logo) (falls eines gewählt ist): Zeile und Bild werden neu vom Server geholt, hidden/fehlt wird also neu geprüft.
  Veröffentlichen bricht bei einem fehlenden Logo wie bisher mit LOGO_MISSING_PUBLISH ab, „Meine Karten“ meldet es an der Kachel.
- Der Download im Editor berücksichtigt assets.logoMissing wie der Hinweis im Editor: das PNG entsteht ohne das fehlende Logo (die Vorschau
  zeigte es bis dahin noch), der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ erscheint und die Vorschau zeichnet ohne Logo, damit
  PNG und Vorschau dasselbe zeigen. Ist ein Logo wieder da, folgt die Vorschau ebenso. Das gilt nur, solange die Auswahl noch dieselbe ist.
- Tests mit dem echten Zwischenspeicher (nur Zeile und Datei aus logos.ts ersetzt): erst geladen, dann ausgeblendet bzw. gelöscht → neu
  geprüft, Veröffentlichen bricht ab / „Meine Karten“ meldet / der Editor lädt ohne Logo herunter, zeigt den Hinweis (auch zusammen mit dem
  Größenhinweis) und zeichnet neu; sichtbares Logo wird frisch geholt; Karte ohne Logo holt nichts; „Nur Bild“ bleibt unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:18:32 +00:00
ClaudeandClaude Opus 5.5 b3c6e65801 fix(passwort): Erfolgsansicht überlebt den Neuaufbau der Seite beim Sprachwechsel
Schlussreview F3. showDone() entfernt userId und secret aus der Adresse. Wechselte der Nutzer danach die Sprache, baute main.ts die Seite
neu auf und sie las eine Adresse ohne Link: „Der Link ist ungültig oder abgelaufen.“ statt des Erfolgs.

- Ein Stand außerhalb der Seite (`passwordChanged`, Modulvariable) merkt, dass das Passwort gesetzt wurde; ein Neuaufbau zeigt dann wieder
  die Erfolgsansicht (in der neuen Sprache) mit „Anmelden“.
- Ein frischer Link (userId und secret in der Adresse) setzt den Stand zurück und zeigt das Formular. Gemerkt wird nur der Erfolg, ein
  Fehlschlag nicht.
- Tests: Neuaufbau nach dem Erfolg (deutsch, mehrfach, englisch), frischer Link danach, Seite ohne Link bleibt ungültig, Fehlschlag wird
  nicht als Erfolg gemerkt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:13:59 +00:00
ClaudeandClaude Opus 5.5 ec4e80c62c fix(logos): „Mehr laden“ beginnt von vorn, wenn der Cursor nicht mehr existiert
Schlussreview F2. Hat jemand das Logo gelöscht oder ausgeblendet, hinter dem „Mehr laden“ ansetzt (der Cursor), antwortet Appwrite mit
400 `general_cursor_not_found`. „Nochmal versuchen“ wiederholte dann denselben Cursor und scheiterte immer wieder.

- Dieser Fehlertyp beim Nachladen löst jetzt `load('reset')` aus: die erste Seite der aktuellen Ansicht (gleiche Sortierung, gleicher Filter)
  wird neu geladen, die Kachel des gewählten Logos bleibt stehen. Kein Fehlertext, kein „Nochmal versuchen“ für den toten Cursor.
- Jeder andere Fehler beim Nachladen und jeder Fehler beim Laden der ersten Seite bleibt wie bisher (Meldung und „Nochmal versuchen“);
  die erste Seite hat keinen Cursor, ein Neuladen im Kreis ist ausgeschlossen.
- Tests mit einem Fehler {code: 400, type: 'general_cursor_not_found'}: Neuladen von Seite 1 mit Anfragefolge, Ansicht „Gemeldet“ bleibt,
  gewählte Kachel bleibt, Neuladen scheitert selbst, anderer Fehlertyp, Fehlertyp beim ersten Laden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:12:59 +00:00
ClaudeandClaude Opus 5.5 202254c09e fix(editor): Karte ohne Logo zeichnen, wenn ein Logo nicht lädt; erneute Wahl versucht es noch einmal
Schlussreview F1. Scheiterte das Laden der Bilder (Netz, Server), bricht draw() ab, solange logos.key nicht zur Auswahl passt: Vorschau
und Miniaturen blieben leer, und ein erneuter Klick auf dieselbe Kachel änderte nichts, weil failedKey dem Schlüssel entsprach.

- Nach einem echten Ladefehler lädt der Editor einmal die Marke allein (loadCardAssets mit logo: null) und zeichnet die Karte damit ohne
  Logo (`failed`); scheitert auch das, fehlt die Marke ebenfalls. LOGO_ERROR bleibt stehen, die Auswahl auch. Ein anderes Logo wird nie mit
  den Bildern der gescheiterten Auswahl gezeichnet (der Schlüssel entscheidet wie bisher).
- Der Picker meldet jeden Klick, auch auf das schon gewählte Logo: ist es das Logo, dessen Laden scheiterte, hebt der Editor die Sperre
  (failedKey) auf und versucht es noch einmal. Ein geladenes Logo wird dabei nicht neu geladen, ein Fehlschlag wiederholt sich nicht im Kreis.
- Tests: Karte ohne Logo nach dem Fehler (Vorschau und vier Miniaturen), Wiederholung per Klick (Erfolg und erneuter Fehlschlag, kein Loop),
  Marke lädt auch nicht, anderes Logo nach dem Fehler, Klick auf geladenes Logo lädt nichts neu; Picker meldet den Klick auf die gewählte
  Kachel. Zwei bestehende Tests angepasst: „nicht gezeichnet nach Fehler“ gilt nicht mehr, und die Zahl der loadCardAssets-Aufrufe zählt
  jetzt den zusätzlichen Lauf für die Marke nicht mehr mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:12:11 +00:00
ClaudeandClaude Opus 5.5 7e66cd0f0f feat(i18n): Community, Startseite und Meine Karten auf Englisch
Task 11: alle sichtbaren Texte von community/, pages/home-page.ts, pages/community-page.ts, pages/cards-page.ts und pages/format.ts
(Texte, aria-label, title, Platzhalter, Alt-Texte, Rückfragen, Meldungen) gehen durch t()/msg(); en.ts bekommt 82 Einträge (214 → 296).

- Konstanten bleiben deutsche Strings per msg() und gehen beim Anzeigen durch t() (publish.ts, publish-dialog.ts, gallery.ts und die Seiten);
  geworfene Fehler tragen weiter den deutschen Text, damit die Vergleiche gehen. Funktionen, die Text für Nutzer liefern, übersetzen beim
  Zurückgeben (validatePublishForm, publishFailureText, formatDate, reportsLabel). Template-Literale sind Platzhalter geworden ({max},
  {reason}, {name}, {title}, {query}, {count}); der deutsche Text bleibt Zeichen für Zeichen derselbe.
- API-Fehler: jeder ApiError wird mit apiErrorText() angezeigt (Veröffentlichen-Dialog, Detailseite, Meine Karten), nach dem Code des Dienstes
  statt nach dem deutschen Servertext. Logik, die err.message vergleicht (publishCard, 404 beim Zurückziehen), bleibt unverändert.
- Bekannte Reste aus Task 10 behoben: LOGIN_NEEDED ging in publish-dialog.ts, community-page.ts und cards-page.ts ohne t() an die Oberfläche;
  die Meldungen zu Bild lesen/Pixelgrenze/fehlendem Logo (cards-page.ts, GERMAN_MESSAGES in publish-dialog.ts) erscheinen jetzt übersetzt;
  die Zahl der Meldungen in der Logo-Infozeile („3 reports“).
- pages/format.ts: formatDate liefert Deutsch dd.mm.yyyy und Englisch „Oct 4, 2026“ (Intl.DateTimeFormat en-US), reportsLabel „1 report“ /
  „3 reports“. „Herausgeber“ heißt als Bank „Issuer / bank“.
- logos/prepare-logo.ts: der Helfer problem() ist weg, die sechs Stellen schreiben new LogoError(t(MELDUNG)) selbst (gleiches Verhalten);
  so sieht der neue Prüfer unten jede Verwendung einer Konstante.
- i18n/coverage.test.ts: Die Heuristik für deutsche Literale deckt jetzt die ganze Oberfläche ab (account/, card/, community/, editor/, logos/,
  pages/, shell/, main.ts, i18n/api-messages.ts). Sie erkennt zusätzlich Wörter mit Klammer („Ausgeblendet (gemeldet)“) und nimmt rel und
  Namen in Klammerschreibweise (headers['X-Aircard-Jwt']) aus. Acht begründete Ausnahmen kommen dazu (zwei AppwriteException-Meldungen,
  vier interne Logo-Fehler, der Sprachknopf in der Zielsprache). Neu: ein Prüfer über den Syntaxbaum, der verlangt, dass jede Konstante
  mit msg() nur in t(), new Error(), Vergleichen, case und Listen/Mengen vorkommt (auch über Importe und Umbenennungen); ohne Ausnahmen.
- Tests: format.test.ts (beide Sprachen), Englisch-Blöcke in publish-dialog, gallery-grid, home-page, community-page, cards-page und
  logo-info (Texte, Fehlerwege, „Publishing failed – This card is already published.“, kein deutscher Wörterbuchtext auf der Seite). Die
  Hilfen texts()/germanLeft() liegen jetzt in i18n/english-check.ts (vorher in english-ui.test.ts). Der bestehende Englisch-Test in
  cards-page.test.ts sucht den Knopf „Download“ statt „Herunterladen“.
- Vitest 53 Dateien, 1812 Tests grün (vorher 1779), tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:34:10 +00:00
ClaudeandClaude Opus 5.5 1ff1873b27 feat(i18n): Konto, Editor und Karte auf Englisch
Task 10: alle sichtbaren Texte von account/, editor/, card/ und der Seite „Passwort neu setzen“ (Texte, aria-label, title, Meldungen,
Rückfragen) gehen durch t()/msg(); en.ts bekommt 87 Einträge.

- account/: Konto-Menü, Login-Dialog (Titel, Reiter, Felder, Meldungen) und errors.ts (errorMessage übersetzt beim Zurückgeben).
  validatePassword nutzt {min}/{max}. Der Login-Dialog wird nur einmal gebaut: die Beschriftung des Schließen-Knopfs wird bei jedem
  Öffnen in der aktuellen Sprache gesetzt. LOGIN_NEEDED und DEFAULT_CARD_NAME bleiben deutsche Konstanten (msg), angezeigt werden sie
  mit t(); der Standardname einer neuen Karte folgt damit der Sprache („My card“).
- editor/: Vorlagen-, Options- und Vorschauspalte, Bild-Upload, Speichern, Download, Rückfragen. Konstanten (upload.ts, download.ts,
  editor-page.ts) bleiben deutsche Strings per msg(); validateImageFile, pngSizeWarning und das Speichern („Speichern fehlgeschlagen –
  {reason}“) liefern den Text schon übersetzt, Fehler tragen weiter die deutsche Konstante (Vergleiche mit READ_MESSAGE/PIXELS_MESSAGE
  gehen weiter) und werden beim Anzeigen mit t(error.message) übersetzt. Zoom „100 %“ / „100%“ und „Bearbeitet: {name}“ mit Platzhaltern.
- card/: Bezeichnungen der Marken (msg) und die Meldungen des Renderers (msg, Anzeige im Editor über t()).
- pages/reset-page.ts, account/login-dialog.ts: Die Meldung „Die Passwörter stimmen nicht überein.“ ist nicht mehr der Schlüssel zum
  Fokus: es zählt die Bedingung selbst, nicht der Vergleich mit dem (nun übersetzten) Text.
- i18n/coverage.test.ts: Heuristik für deutsche Text-Literale außerhalb von t()/msg() (Umlaut/ß, mindestens zwei Wörter, einzelnes
  großgeschriebenes Wort; gelesen wird der Syntaxbaum) für account/, editor/, card/ und pages/reset-page.ts; Ausnahmen mit Grund, jede
  muss noch zutreffen; Scanner mit eigenen Tests. Die Liste der Bereiche ist eine Konstante für Task 11.
- i18n/english-ui.test.ts (neu): Stichproben auf Englisch (Konto-Menü, Login-Dialog, Passwort-Seite, Editor inkl. Fehlerwege) und
  „kein deutscher Wörterbuchtext auf der englischen Seite“.
- test-setup.ts: Wer Module mit vi.resetModules() frisch lädt, bekam eine neue Kopie von i18n.ts mit der Browsersprache (happy-dom: en-US);
  der Browser meldet im Test jetzt Deutsch, damit auch diese Tests deutsch beginnen. main.test.ts: nach dem Wechsel auf Englisch heißt
  der Knopf des Konto-Menüs „Sign in“.
- Tests: Vitest 53 Dateien, 1779 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:58:49 +00:00
ClaudeandClaude Opus 5.5 a75be2d44c feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen
Task 9: die Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6).

- logos/logo-info.ts (neu): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste; ein ausgeblendetes Logo lässt
  sich nicht melden). Der Hochlader sieht „Löschen“ mit Rückfrage (Titel mit Namen, Folgen im Text), Admins „<n> Meldungen“ mit den
  Gründen als Text (listLogoReports, einmal je Logo und Stand), „Ausblenden“/„Einblenden“ und „Löschen“. Gelöscht: deleteLogo,
  forgetLogo, Kachel weg, onSelect(null); ein-/ausgeblendet: setLogoVisibility, forgetLogo (ein Null-Bild bleibt nicht im Zwischen-
  speicher), die Zeile des Pickers wird fortgeschrieben (Einblenden setzt reportCount auf 0). Gewähltes Logo nicht auffindbar: „Das
  gewählte Logo ist nicht mehr verfügbar.“ (ein Netzfehler von getLogo sagt nichts). Zugleich die Meldezeile (role="alert") für Fehler
  von Herz, Hochladen und diesen Aktionen; ein Wechsel der Auswahl räumt sie ab. Immer nur eine Aktion zugleich; die Rechte werden
  nach der Rückfrage noch einmal geprüft; der Aufbau steht einmal da, update() ändert nur Texte und Sichtbarkeit (Fokus bleibt).
- logos/logo-picker.ts: hängt die Infozeile als Letztes unter das Raster (info.el). Herz- und Upload-Fehler gehen in die Infozeile,
  der Statusbereich des Pickers bleibt den Ladefehlern der Liste vorbehalten (ein Klick auf ein Herz löscht ihren Text nicht mehr).
  Aus dem Review von Task 8: requireLogin, das wirft, zeigt den Grund statt einer unbehandelten Ablehnung; select() nach dispose()
  ist wirkungslos. Löschen entfernt Kachel, Zeile, Herz und Eintrag der Liste, setzt den Cursor von „Mehr laden“ auf das Logo davor
  (Appwrite lehnt einen Cursor auf eine gelöschte Zeile ab), hebt die Auswahl über onSelect(null) auf und holt den Fokus nach
  „Kein Logo“, wenn er auf „Löschen“ lag. LogoPickerDeps erweitert um reportLogo, deleteLogo, setLogoVisibility, listLogoReports,
  confirm und reportDialog.
- community/report-dialog.ts: subject 'card' (Standard, unverändert) oder 'logo': Überschrift „Logo melden“, Einleitung „dieses
  Logo“, vier Gründe ohne „Echte Kartendaten“, Duplikat-Dank „Danke, du hast dieses Logo schon gemeldet.“. Der ganze Dialog geht
  durch t()/msg(); Fehler des Dienstes über apiErrorText.
- logos/logos.css: das Herz sitzt in der Textzeile unter der Bildfläche (nicht mehr auf dem Logo, folgt dem Theme), die Kachel füllt
  die Zelle (height: 100%), damit „Kein Logo“ so hoch ist wie sein Nachbar; Stile der Infozeile.
- editor/editor-page.ts: Aus dem Review von Task 8: der Ladefehler der Logos verschwindet, sobald ein Logo danach lädt, aber nur
  der, den der Logo-Zustand selbst ins Fehlerfeld geschrieben hat; jeder andere Text im Feld bleibt.
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen und zum Melden-Dialog.
- Tests: logo-info.test.ts (neu), logo-picker.test.ts (Infozeile, Löschen, Ein-/Ausblenden, Fehler, Cursor, Fokus), report-dialog.test.ts
  (Logo und Englisch), editor-page.test.ts (Fehlerfeld). Vitest 52 Dateien, 1764 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:21:33 +00:00
ClaudeandClaude Opus 5.5 4cf9fe192d feat(editor): Community-Logos unten rechts mit Herzen und Sortierung
Task 8: der Bereich „Logo unten rechts“ in der Optionsspalte des Editors (Spec § 3 und § 5).

- logos/logo-picker.ts (neu): createLogoPicker baut Kopf (Überschrift, Plakette „Community“, „+ Hochladen“, Satz zur Community), den
  Umschalter „Beliebt“/„Neu“ (Admins zusätzlich „Gemeldet“/„Ausgeblendet“, je eine Ansicht mit sort und filter) und das Raster:
  erste Kachel „Kein Logo“, je Logo eine Kachel mit Bild, Name, „von <Anzeigename>“ bzw. „von Anonym“ und einem eigenen Herz-Knopf
  neben dem Auswahlknopf. „Mehr laden“ hängt die nächste Seite an (Duplikate nur einmal), Leer-Text und Ladefehler mit „Nochmal
  versuchen“ (wiederholt genau den gescheiterten Schritt); nur die jüngste Antwort einer Ansicht zählt. Die Auswahl gehört dem
  Editor: ein Klick meldet onSelect, markiert wird über select(); ein Logo außerhalb der Liste wird nach dem Laden der ersten Seite
  per getLogo geholt und als zweite Kachel angeheftet (steht es in der Liste, gibt es keine Anfrage). selected() liefert die Zeile
  für die Infozeile in Task 9. Herzen: optimistisch mit Rücksprung und Fehlertext (role="alert"); solange eine Anfrage für ein
  Logo läuft, bleiben weitere Klicks auf sein Herz wirkungslos; Gäste öffnen erst den Login (abgebrochen: nichts passiert, kein
  Fehlertext); die Herzen einer Seite kommen mit genau deren IDs, beim Anmelden für alle gezeigten Logos, beim Abmelden aus; eine
  späte Antwort überschreibt kein inzwischen selbst geschaltetes Herz. „Hochladen“ meldet Gäste zuerst an, öffnet den Dialog (mit
  Signal, das beim Verlassen der Seite schließt), setzt das neue Logo als erste Kachel, ruft rememberLogo und onSelect(id).
- logos/logo-image.ts (neu): das PNG einer Kachel laden, auf die Pixelgrenze prüfen (MAX_LOGO_PIXELS, so wie checkGalleryImage die
  Galerie-Bilder prüft; darüber, nicht lesbar oder Ladefehler: neutraler Platzhalter statt Absturz) und zeigen: mehrfarbig als <img>,
  einfarbig als weiße Maske, immer auf dem dunklen Kartengrund. Die Object-URL gibt die zurückgegebene Funktion frei; eine Datei, die
  erst danach eintrifft, wird nicht mehr angezeigt.
- logos/logo-asset.ts: MAX_LOGO_PIXELS wird exportiert (eine Quelle für Renderer und Kacheln).
- logos/logos.css: Kopf, Umschalter, Raster (ab 1024 px zwei Spalten, sonst auto-fill ab 140 px), Kacheln mit Bildfläche auf #1B2738,
  Herz (gefüllt, wenn gedrückt), Leer- und Fehlerzeilen; Bild und Maske teilen sich die Regeln mit der Dialog-Vorschau.
- editor/editor-page.ts: der Picker sitzt in options.logoSlot, die Wahl setzt config.logo, sync ruft picker.select(config.logo), das
  Verlassen der Seite ruft dispose. Übernommen aus dem Review von Task 5: failedKey blockiert ein Logo nicht mehr für immer, eine
  andere Wahl (Logo, Details, Marke) hebt die Sperre auf; derselbe Schlüssel wird weiter nicht im Kreis neu versucht.
- editor/dom.ts: Symbole heart und plus. i18n/en.ts: englische Texte zu allen neuen Beschriftungen.
- Tests: logo-picker.test.ts (Aufbau, Sortierung, Mehr laden, Auswahl und angeheftetes Logo, Herzen mit Rennen und Login, Leer und
  Ladefehler, Hochladen, Kachelbilder, dispose, Englisch), logo-image.test.ts, editor-page.test.ts (Verdrahtung, Regressionstest zu
  failedKey). Vitest 51 Dateien, 1666 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:40:37 +00:00
ClaudeandClaude Opus 5.5 e22919f4dc feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename
Task 7: der Dialog zum Hochladen eines Community-Logos (Spec § 4) und der zuletzt benutzte Anzeigename, den er mit dem
Veröffentlichen-Dialog teilt.

- logos/logo-dialog.ts (neu): logoUploadDialog öffnet ein eigenes <dialog> nach dem Muster von publish-dialog.ts (showModal, Fokus
  zurück an den Auslöser, Texte nur als Text; beim Hochladen sind Abbrechen, Esc und Hintergrund wirkungslos, nur das Signal schließt
  immer; ein zweites Absenden wird ignoriert). Die gewählte Datei geht erst durch validateLogoFile, dann durch prepare (Standard
  prepareLogo); währenddessen ist „Hochladen“ gesperrt, eine neue Wahl macht die laufende Verarbeitung hinfällig. Das fertige PNG
  erscheint als Object-URL zweimal, auf dunklem und hellem Grund; „Einfarbig“ (nur wählbar mit transparenten Bereichen, bei jeder
  neuen Datei zurückgesetzt) zeigt stattdessen eine Maske auf dieselbe URL, gefüllt mit TONE_COLOR (Weiß bzw. #111111). Die URL wird
  beim Schließen und beim Ersetzen freigegeben. Der Name wird aus dem Dateinamen ohne Endung vorbelegt (höchstens 40 Zeichen, nie
  mitten in einem Emoji), ein selbst getippter Name bleibt. Hochladen über uploadPreparedLogo mit withLogin (abgelaufene Sitzung →
  Anmeldung); Fehler erscheinen als „Hochladen fehlgeschlagen – <Grund>“ (ApiError über apiErrorText, Texte von prepareLogo im
  Wortlaut, sonst errorMessage), eine abgebrochene Anmeldung als „Bitte melde dich an“. validateLogoForm prüft Datei, Name (leer,
  Steuerzeichen, 40), Anzeigename (Steuerzeichen, 30) und Pflicht-Häkchen mit den Texten aus Spec § 4 und § 11.
- logos/logos.css (neu): Breite des Dialogs, Datei-Feld, die zwei Vorschauflächen und die Maske; Hülle, Felder und Knöpfe kommen aus
  community.css und account.css.
- account/display-name.ts (neu): DISPLAY_NAME_KEY (aircard.displayName), rememberedDisplayName und rememberDisplayName; jeder Zugriff
  in try/catch, ein leerer Name entfernt den Schlüssel.
- community/publish-dialog.ts: der Anzeigename ist aus dem gemerkten Namen vorbelegt und wird nach erfolgreichem Veröffentlichen
  gemerkt; CONTROL_CHARACTERS und DISPLAY_NAME_MAX werden exportiert (der Logo-Dialog prüft mit denselben Regeln).
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen, Hinweisen und Meldungen („Einfarbig“ heißt „Single color“).
- Tests: logo-dialog.test.ts (validateLogoForm, Aufbau, Dateiwahl mit Vorschau, Namensvorschlag, Maske, Object-URLs, veraltete
  Verarbeitung, Prüfung, Senden mit Doppelklick und Sperren, Fehlertexte in beiden Sprachen, Standardwerte, Schließen),
  display-name.test.ts, publish-dialog.test.ts (Vorbelegung und Speichern). Vitest 49 Dateien, 1566 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:00:12 +00:00
ClaudeandClaude Opus 5.5 56d841b8e2 fix(logos): SVG-Größe aus Breite und Höhe mit ergänzter viewBox, Pixelgrenze vor dem Dekodieren
Entscheidungen des Controllers zu Task 6 (Bedenken 2 bis 4).

- logos/prepare-logo.ts: sizeSvg(svg) ersetzt sizeSvgRoot und liefert { svg, w, h }. Das Seitenverhältnis kommt aus der brauchbaren
  viewBox des Wurzelelements, sonst aus dessen Breite und Höhe (Pixel oder ohne Einheit), sonst ist es quadratisch; Prozent und
  andere Einheiten, 0 und negative Werte zählen als unbrauchbar. Ohne brauchbare viewBox, aber mit Größe, wird viewBox="0 0 <Breite>
  <Höhe>" ergänzt (eine unbrauchbare viewBox wird dabei ersetzt, nie doppelt geschrieben), damit der Inhalt auf das Raster skaliert
  statt in Originalgröße oben links zu stehen. Eine brauchbare viewBox bleibt immer unberührt. Gelesen werden nur die Attribute des
  Wurzelelements (vorher die erste viewBox irgendwo im Text, etwa die eines inneren <symbol>); Anführungszeichen " und ' gehen
  beide. Tokenizer und Längenparser laufen linear (zwei quadratische Entwürfe fielen im Zeittest durch).
- logos/prepare-logo.ts: Raster und SVG werden über decodeImage aus editor/upload.ts geladen: erst die Maße lesen und gegen
  40 Megapixel prüfen, erst dann dekodieren (vorher dekodierte createImageBitmap zuerst). decodeImages Text für „zu groß“ wird zum
  Text des Logos, jeder andere Fehler bleibt „Das Logo konnte nicht gelesen werden.“ Die eigene Ladefunktion für SVG und
  createImageBitmap entfallen. LOGO_SOURCE_MAX_PIXELS ist jetzt MAX_IMAGE_PIXELS (eine Quelle, Wert 40 000 000 per Test festgehalten).
- logos/prepare-logo.ts: SVG_FALLBACK_SIZE entfernt (nichts benutzte es).
- card/marks.ts: neu viewBoxAspect(value) mit den Regeln aus svgAspect; svgAspect ruft es auf (Verhalten unverändert), so stehen die
  viewBox-Regeln nur einmal da.
- Tests: sizeSvg als Tabelle (Größe ohne Einheit, px, hochkant, Prozent, mm/em/pt/auto, nur Breite oder Höhe, 0 und negativ,
  vorhandene viewBox bleibt, unbrauchbare viewBox wird ersetzt, innere Elemente zählen nicht, XML-Kopf, „>“ im Wert, Zeitgrenze),
  prepareLogo mit gestelltem Image: Zahl der Pixel wird vor decode() geprüft (8001 x 5000 und 20 000 x 20 000 ohne decode, genau
  8000 x 5000 mit), SVG nur mit Breite und Höhe wird 2048 x 614 gerastert und bekommt viewBox="0 0 200 60"; marks.test.ts für
  viewBoxAspect. Vitest 47 Dateien, 1478 Tests grün, tsc sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:26:30 +00:00
ClaudeandClaude Opus 5.5 0f66136d82 feat(logos): Logo zuschneiden, verkleinern und hochladen
Task 6: Verarbeitung und Hochladen eines Logos als Ablauf ohne Oberfläche (der Dialog folgt in Task 7).

- logos/prepare-logo.ts (neu): validateLogoFile (PNG, JPG, WebP, SVG; ohne Typ zählt die Endung .svg; höchstens 5 MB), die reinen
  Helfer trimBox (Alpha ≤ 8 gilt als leer), fitWithin (nie größer, gerundet, mindestens 1), svgRasterSize (längste Seite genau 2048,
  ungültiges Seitenverhältnis quadratisch), hasTransparency und planLogo (Ausschnitt und Zielgröße, „leer“ und „zu klein“ nach der
  fertigen Größe) sowie sizeSvgRoot. prepareLogo fügt sie zusammen: Raster über createImageBitmap (Breite × Höhe wird vor dem
  Zeichnen gegen 40 Megapixel geprüft, das Bild danach freigegeben), SVG nur als Bild aus einer Object-URL (Seitenverhältnis aus
  der viewBox, Größe ins Wurzelelement geschrieben), Zuschnitt, Verkleinern auf höchstens 720 × 250, PNG, hasTransparency auf den
  Endpixeln. Jeder Fehler beim Dekodieren oder Zeichnen heißt „Das Logo konnte nicht gelesen werden.“; alle Meldungen sind schon
  übersetzt (t() beim Auslösen), die deutschen Konstanten LOGO_*_MESSAGE stehen über msg().
- sizeSvgRoot ersetzt eine eigene width/height des Wurzelelements, statt ein zweites Attribut anzuhängen (doppelte Attribute machen
  ein SVG ungültig, so scheiterten viele echte SVGs). Es macht genau einen Versuch ab dem ersten „<svg“: ein Suchmuster, das an
  jedem „<svg“ neu ansetzt, brauchte bei einer Datei voller offener „<svg x="“ quadratisch viel Zeit (160 kB: 6 s).
- logos/upload-logo.ts (neu): uploadPreparedLogo legt logo.png mit read("any") und delete("user:<ich>") in den Bucket logo-images,
  ruft uploadLogo und liefert das neue CommunityLogo (ID = Datei-ID, likeCount 0). mono gilt nur bei durchsichtigen Stellen. Wie
  beim Veröffentlichen wird nur bei eindeutigem Scheitern aufgeräumt (genau die Datei dieses Versuchs, ein Fehler dabei überdeckt
  nie den eigentlichen), bei offenem Ausgang bleibt die Datei. Ausnahme: 409 mit serverCode logoTaken löscht nichts, ein anderes
  Logo trägt diese Datei-ID.
- i18n/en.ts: englische Texte zu den sechs neuen Meldungen.
- Tests: prepare-logo.test.ts (Helfer einzeln, dazu prepareLogo mit gestelltem Dekoder und Canvas: Zuschnitt, Skalierung, Rasterung,
  Fehlertexte, 40-Megapixel-Grenze, SVG nie in der Seite) und upload-logo.test.ts (Reihenfolge, Rechte, Felder, mono, Aufräumen je
  Status, logoTaken, Standard-Abhängigkeiten gegen den Bucket logo-images). Vitest 47 Dateien, 1452 Tests grün, tsc sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:05:38 +00:00
ClaudeandClaude Opus 5.5 8205f7c37f fix(card): fehlendes Logo bricht nur ab, wenn die Vorlage es zeichnet; Download aus „Meine Karten“ meldet es
Entscheidung des Controllers zu Task 5: Ein fehlendes Community-Logo ist beim Rendern einer gespeicherten Karte nur dann ein
Problem, wenn die Vorlage es zeichnet (layoutFor(template).logo). Bei „Nur Bild“ wird ein Logo nie gezeichnet, dort fehlt
nichts. Der Editor-Hinweis (Auswahlzustand) und der Download im Editor bleiben unverändert.

- community/publish.ts: neue gemeinsame Prüfung assertLogoAvailable(config, assets); sie wirft LOGO_MISSING_PUBLISH, wenn
  assets.logoMissing gilt und die Vorlage das Logo zeichnet (minimal, classic, signature). renderForGallery nutzt sie: bei
  einer zeichnenden Vorlage Abbruch vor dem Rendern (nichts hochgeladen), bei „Nur Bild“ wird normal gerendert und
  veröffentlicht.
- pages/cards-page.ts: der Download einer gespeicherten Karte ruft dieselbe Prüfung. Statt stillschweigend ohne Logo
  herunterzuladen, zeigt die Kachel den Text LOGO_MISSING_PUBLISH über den vorhandenen Fehlermechanismus (run() und
  failureText() schreiben in .card-msg), übersetzt mit t(); es wird nichts exportiert oder gespeichert. Bei „Nur Bild“ läuft
  der Download normal.
- Tests: publish.test.ts (classic/minimal/signature mit fehlendem Logo brechen ab, „Nur Bild“ veröffentlicht normal,
  Wahrheitstabelle für assertLogoAvailable) und cards-page.test.ts (Download mit fehlendem Logo: Meldung, kein Download;
  „Nur Bild“ und vorhandenes Logo: Download wie sonst; zweiter Versuch; Englisch). Vitest 45 Dateien, 1338 Tests grün, tsc
  und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:38:23 +00:00
ClaudeandClaude Opus 5.5 0be5abd954 feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
  sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
  wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
  ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
  der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
  loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
  Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
  createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
  gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
  Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
  rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
  Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
  ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
  auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
  fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
  es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
  logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
  Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:32:45 +00:00
ClaudeandClaude Opus 5.5 3051b3b3f9 feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler
- logos/logos.ts (Muster community/gallery.ts): CommunityLogo, toLogo, logoQueries (Beliebt/Neu, Admin-Filter Gemeldet und
  Ausgeblendet, Cursor), listLogos (nextCursor nur bei voller Seite von 24), getLogo (ungültige ID oder 404 -> null),
  myHearts (eigene Herzen einer Seite, ohne IDs keine Anfrage), listLogoReports (ohne Melder-Hash), loadLogoBlob.
- logos/logo-api.ts: uploadLogo, setHeart, reportLogo (ohne Anmeldung), deleteLogo, setLogoVisibility über apiPost.
  uploadLogo behandelt das Limit (429 tooManyLogos) wie publishCard als eindeutige Ablehnung (die hochgeladene Datei darf
  aufgeräumt werden); eine Antwort ohne id (und bei setHeart ohne Zustand und Zähler) ist 503 mit offenem Ausgang.
- account/appwrite.ts: IDS um logos, logoLikes, logoReports und logoBucket.
- community/api.ts: ApiError.serverCode (der Schlüssel aus {error, code}; code bleibt der HTTP-Status, message der deutsche
  Text). Client-Fehler tragen unavailable (auch 502/503/504 vom Proxy, vor dem Lesen des Bodys), generic, jwtUnsupported,
  alreadyPublished, tooManyPublications. Neu: unavailableError() und apiErrorText(err). Die deutschen Texte stehen einmal
  in api-messages.ts, die Vergleiche in publishCard laufen unverändert über err.message.
- i18n/api-messages.ts: API_MESSAGES (alle 33 Texte von MSG plus die Client-Codes generic und jwtUnsupported) und
  apiMessage: bekannter Code -> t(Text); unbekannt: Deutsch der Servertext, Englisch eine Meldung je Status (400, 403, 404,
  sonst allgemein). Der Code badRequest trägt auch die dynamischen Prüftexte des Dienstes, Deutsch zeigt dann den
  genaueren Servertext.
- i18n/en.ts: englische Texte zu allen API_MESSAGES.
- Tests: api-messages.test.ts lädt MSG aus deploy/api/common.js (createRequire) und prüft den Gleichlauf in beide
  Richtungen; logos.test.ts, logo-api.test.ts und neue Fälle in api.test.ts (der Helfer freshApi stellt die frische
  i18n-Instanz auf Deutsch). 156 neue Tests, die bisherigen unverändert grün (1285 gesamt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:59:08 +00:00
ClaudeandClaude Opus 5.5 fc9e5f5577 fix(api): Logo-Datei nach dem Festlegen der Rechte noch einmal prüfen
Der Hochlader behält delete auf seiner Datei, bis die API sie auf read("any") festlegt. Zwischen der ersten Prüfung
(Typ, Größe, PNG-Kopf, Maße) und dem Festlegen ließ sich die Datei unter derselben ID löschen und mit anderem Inhalt
neu anlegen (z. B. 4000 × 4000). Jetzt liest die API den Dateianfang nach dem Festlegen noch einmal, wenn der
Hochlader die Datei nicht mehr ändern kann:

- PNG-Kopf und Maße 8 × 8 bis 720 × 250 werden erneut geprüft. Fällt die Datei durch oder fehlt sie, löscht die API die
  Datei (Server-Key) und die Zeile (beides wird versucht) und antwortet 400 logoFormat bzw. fileMissing. Log:
  "logo <id> removed after upload check (<code>)", bleibt etwas stehen: "rollback failed for logo <id>".
- Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (502, Zeile zurückgenommen, die
  Datei bekommt delete(owner) zurück).
- fake-appwrite.js: Testhilfe swapContentAfterPermissions (der Inhalt ändert sich nach dem nächsten Rechte-Update).
- Tests: 7 neue in logos.test.js (Austausch auf 4000 × 4000, weitere ungültige Inhalte, Datei weg, Appwrite-Fehler,
  Aufräumen scheitert, Ablauffolge, weiterhin gültiger Inhalt), der Logtest prüft das neue Ereignis mit. Der
  Karten-Ablauf ist unverändert (app.js und common.js nicht angefasst).
- Doku: api/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:28:12 +00:00
ClaudeandClaude Opus 5.5 dc5de99611 feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer
- common.js (aus app.js herausgelöst, Verhalten gleich): MSG, HttpError, Prüfungen, Rechte-Strings, Melder-Hash,
  Bildmaße und die Dateiprüfungen checkFileExists/checkFileOwnership. Neu: die Logo-Texte in MSG, codeFor,
  likeRowPermissions. app.js exportiert weiter alles wie bisher.
- Fehlerantworten sind jetzt {error, code}: code = MSG-Schlüssel des Textes, dynamische Prüftexte badRequest,
  Appwrite-Fehler unavailable (502) bzw. internal (500).
- logos.js: POST /api/logos/hochladen, /herz, /melden, /loeschen und /api/admin/logo-sichtbarkeit nach dem Vorbild der
  Karten-Endpunkte (Datei prüfen, Zeile anlegen, Datei auf read("any") festlegen, sonst Rücknahme; likeCount und
  reportCount immer aus countRows; ein gemeinsames Rate-Limit für Karten- und Logo-Meldungen).
- appwrite.js: countRows. fake-appwrite.js: Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket
  logo-images und total in den Listen-Antworten.
- Tests: logos.test.js (59), code-Fälle und MSG-Eindeutigkeit in app.test.js (+7), countRows in appwrite.test.js (+2);
  alle 111 bisherigen Tests unverändert grün (179 gesamt).
- Doku: api/README.md (Endpunkte, Fehlercodes, Logo-Regeln), deploy/README.md (Betrieb kopiert fünf Dateien).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:20:25 +00:00
ClaudeandClaude Opus 5.5 88abc2061d feat(appwrite): Schema für Community-Logos, Herzen und Logo-Meldungen
- Setup-Skript: Tabellen `logos`, `logo_likes` (Mehrspalten-Index `user_logo` über userId, logoId)
  und `logo_reports` sowie der Bucket `logo-images` (nur PNG, höchstens 1 MiB); der Kopfkommentar
  nennt die neuen Objekte. Der Index-Request schickt `columns` schon als Liste, dort war nichts zu ändern.
- Tests: DESIRED mit genau diesen Werten, Anlegen samt Indizes gegen den Fake-Server, --check meldet jedes
  fehlende Objekt (51 statt 28) mit Exit 1, bestehende Tabellen und Buckets bleiben unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:51:36 +00:00
ClaudeandClaude Opus 5.5 e1fb632cdd feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile
- i18n-Kern im gettext-Stil (t/msg, Wörterbuch en.ts); Startsprache aus localStorage, sonst Browsersprache;
  <html lang> setzt auch das Inline-Skript in index.html. Ein Test prüft t()/msg() und en.ts in beide Richtungen.
- Kopfzeile: GitHub-Link und Sprachknopf (Zielsprache, Beschriftung und lang in der Zielsprache), Fußzeile aus mountFooter,
  Banner und Seitentitel übersetzt, statisches index.html englisch.
- Sprachwechsel ohne Neuladen (main.ts): Kopfzeile, Banner, Konto-Menü und Fußzeile neu aufgebaut, rerender() des Routers baut
  die Seite neu auf, der Editor-Store bleibt; der Appwrite-Client bekommt die Sprache.
- Kopfzeile bleibt in einer Zeile: einzeilig ab 1000 px (vorher 900), GitHub-Text ab 1230 px, auf schmalen Telefonen steht
  „Anmelden“ neben der Suche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:33:15 +00:00
ClaudeandClaude Opus 5.5 9455013833 docs: Plan – SVG-Rastergröße aus der viewBox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:04:53 +00:00
ClaudeandClaude Opus 5.5 562e760191 docs: Plan Community-Logos, Englisch und GitHub-Link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:03:45 +00:00
ClaudeandClaude Opus 5.5 067e428340 docs: Spec Community-Logos, Englisch und GitHub-Link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:54:44 +00:00
134 changed files with 17899 additions and 1148 deletions

No files matched your search

+22 -7
View File
@@ -1,14 +1,25 @@
# AirCard-IOS
Inoffizielles Hobbyprojekt, mit dem man eigene Kartenmotive für Apple Wallet unter https://aircard-ios.org gestalten kann. Bilder hochladen, Vorlage und Netzwerk-Logo wählen, herunterladen – oder mit Konto speichern. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover.
Inoffizielles Hobbyprojekt, mit dem man eigene Kartenmotive für Apple Wallet unter https://aircard-ios.org gestalten kann. Bild hochladen, Vorlage und Community-Logo wählen, als PNG herunterladen – oder mit Konto speichern und in der Community-Galerie veröffentlichen. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover; Logos und Motive stammen von Nutzern, alle Marken gehören ihren Inhabern.
## Funktionen
- **Editor** (`/gestalten`): Bild hochladen, Vorlage, Ausschnitt, Details (Hell/Dunkel) und das Logo oben links (AirCard-IOS) wählen, als PNG herunterladen oder mit Konto speichern („Meine Karten“, `/karten`).
- **Community-Logos** unten rechts auf der Karte: Angemeldete laden Logos hoch (PNG, JPG, WebP oder SVG; der Browser schneidet sie zu, verkleinert sie auf höchstens 720 × 250 px und lädt ein PNG hoch), alle können sie wählen. Eingebaute Marken-Logos gibt es nicht. Je Konto und Logo gibt es ein Herz (nur angemeldet); die Herzen bestimmen die Sortierung „Beliebt“. Logos lassen sich melden (ab 3 Meldern aus verschiedenen Netzen werden sie ausgeblendet) und vom Hochlader löschen; Admins (Label `admin`) sehen zusätzlich „Gemeldet“ und „Ausgeblendet“.
- **Community-Galerie** (Startseite `/` und `/community/<id>`): Karten veröffentlichen, ansehen, melden und zurückziehen.
- **Deutsch und Englisch**: Die Sprache folgt der Browsersprache; der Sprachknopf in der Kopfzeile schaltet ohne Neuladen um und merkt sich die Wahl. Daneben steht ein GitHub-Link zum Original-Projekt [AirCard-iOS](https://github.com/Mak5er/AirCard-iOS).
## Aufbau
**`web/`** – Vite + TypeScript ohne Framework. Frontend-Module in `src/`:
- `card/` – Geometrie, Zuschnitt, Canvas-Rendering (keine DOM-Abhängigkeiten, einzeln testbar)
- `editor/` – UI (Vorlagen, Vorschau, Download, Speichern)
- `logos/` – Community-Logos: Lesen aus Appwrite, Auswahl mit Herzen und Infozeile (Melden, Löschen, Admin), Hochladen mit Aufbereitung im Browser (Zuschnitt, Verkleinern, PNG)
- `community/` – Galerie: Lesen, Veröffentlichen, Melden, Aufrufe des API-Dienstes
- `account/` – Appwrite-Client, Auth, Kartenverwaltung
- `pages/` – Routen (`/`, `/karten`, `/passwort-neu` via History-API)
- `i18n/` – Deutsch und Englisch: `t('Deutscher Text')` und `msg('…')`, Wörterbuch Deutsch → Englisch in `en.ts` (ein Test verlangt für jeden Text einen Eintrag)
- `pages/` – Routen (`/`, `/gestalten`, `/karten`, `/community/<id>`, `/passwort-neu` via History-API)
- `shell/` – Kopfzeile (GitHub-Link, Sprachknopf, Hell/Dunkel), Fußzeile, Spenden-Banner
**`deploy/`** – Webhook-Router (Port 8087), Build-Skript (`deploy.sh`), nginx-Config, Traefik-Route für Appwrite.
@@ -36,8 +47,9 @@ Im Frontend (`web/`):
```sh
cd web
npx vitest run # Unit-Tests (card/*, editor/*, account/*, pages/*)
npx tsc --noEmit # TypeScript-Prüfung
npx vitest run # Unit-Tests (alle Module unter src/)
npx tsc --noEmit # TypeScript-Prüfung, auch der Tests
npm run build # Produktions-Build; die Prüfung davor nutzt tsconfig.build.json (ohne Tests)
```
Im Repo-Wurzelverzeichnis:
@@ -45,12 +57,15 @@ Im Repo-Wurzelverzeichnis:
```sh
node --test deploy/webhook-server.test.js # Webhook-Server (deploy.sh wird dort durch einen Stub ersetzt)
node --test deploy/deploy.test.js # deploy.sh (echtes Skript, lokaler git-Remote, npm-Stub)
node --test "deploy/api/*.test.js" # API-Dienst (Glob in Anführungszeichen)
node --test web/scripts/appwrite-setup.test.mjs # Appwrite-Schema-Skript
```
## Deploy
**Automatisch:** Push auf `main` → Gitea-Webhook → Container `webklar-aircard-build` führt `npm ci` und `vite build` aus → Ergebnis nach `out/` → nginx `webklar-aircard` liefert.
**Automatisch:** Push auf `main` → Gitea-Webhook → Container `webklar-aircard-build` führt `npm ci` und `npm run build` (Typprüfung ohne Tests, dann `vite build`) aus → Ergebnis nach `out/` → nginx `webklar-aircard` liefert.
**Reihenfolge, wenn eine Änderung auch Schema und API braucht** (wie die Community-Logos): erst das Schema anlegen, dann die API-Dateien ersetzen und neu starten, zuletzt `main` pushen. Die Schritte stehen in [deploy/README.md](deploy/README.md), Abschnitt „Reihenfolge bei Änderungen an Schema, API und Seite“.
**Manuell:**
```sh
@@ -62,7 +77,7 @@ Starten nur mit:
docker compose up -d aircard-build aircard aircard-api
```
`aircard-api` ist der kleine API-Dienst der Community-Galerie (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`).
`aircard-api` ist der kleine API-Dienst der Community-Galerie und der Community-Logos (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`).
Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404.
@@ -70,7 +85,7 @@ Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` ger
Projekt „AirCard“ auf `appwrite.webklar.com`:
- Projekt-ID (öffentlich, von Appwrite generiert): `6ac2130a00289343d6d5`. Sie steht in `deploy/traefik/aircard.yml`, in `/home/webklar/apps/.env` (`AIRCARD_APPWRITE_PROJECT_ID`) und als Standard in `web/src/account/appwrite.ts`.
- Datenbank: `aircard`, Tabelle: `cards`, Bucket: `card-images` (das Skript unten legt sie an und prüft sie)
- Datenbank: `aircard`; Tabellen: `cards` (private Karten), `gallery` und `reports` (Community-Galerie, Meldungen), `logos`, `logo_likes` und `logo_reports` (Community-Logos, Herzen, Meldungen); Buckets: `card-images`, `gallery-images` und `logo-images` (das Skript unten legt sie an und prüft sie)
- Console-Einstellungen laut `docs/appwrite-console.md` (Soll-Stand, nicht maschinell geprüft; `--check` prüft nur das Schema): Auth mit E-Mail + Passwort, Sitzungsdauer 30 Tage, max. 5 Sitzungen pro Nutzer, Nutzerlimit 2000, Web-Plattformen `aircard-ios.org` und `localhost`. Schritte 3 und 4 dort (Auth-Härtung, Dienste abschalten) sind optional und am Live-Projekt unbestätigt.
**Schema prüfen:**
+20 -5
View File
@@ -10,7 +10,7 @@ Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebau
| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` an Appwrite |
| `deploy/api/appwrite.js`, `app.js`, `server.js` | `aircard-ios.org/api/` (nur diese drei Dateien) | API-Dienst `aircard-api` der Community-Galerie, siehe [api/README.md](api/README.md) |
| `deploy/api/appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js` | `aircard-ios.org/api/` (nur diese fünf Dateien) | API-Dienst `aircard-api` der Community-Galerie und der Community-Logos, siehe [api/README.md](api/README.md) |
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build`, `aircard` und `aircard-api` |
Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
@@ -25,7 +25,7 @@ mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.or
cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
cp $SRC/api/appwrite.js $SRC/api/app.js $SRC/api/server.js $APP/aircard-ios.org/api/
cp $SRC/api/appwrite.js $SRC/api/common.js $SRC/api/app.js $SRC/api/logos.js $SRC/api/server.js $APP/aircard-ios.org/api/
# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
@@ -55,14 +55,29 @@ Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.e
## API-Dienst `aircard-api`
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit). Endpunkte, Statuscodes und Betrieb stehen in [api/README.md](api/README.md).
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie und der Community-Logos mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit; bei den Logos Hochladen mit höchstens 30 Logos je Konto, Herzen, Melden, Löschen, Admin-Sichtbarkeit). Endpunkte, Statuscodes, Fehlercodes und Betrieb stehen in [api/README.md](api/README.md).
- **Route**: Traefik-Label am Dienst, Router `aircard-gallery-api` (``Host(`aircard-ios.org`) && PathPrefix(`/api`)``, Priorität 200, Port 8088). Der Name ist absichtlich nicht `aircard-api`: so heißt schon die Appwrite-Route (`/v1`) in `traefik/dynamic/aircard.yml`.
- **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`. Läuft der Dienst nicht, fehlt auch sein Traefik-Router: nginx antwortet dann auf `/api/*` mit `503` und `{"error":"Der Dienst ist gerade nicht erreichbar."}` (Content-Type `application/json`), nie mit der App.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen (alle fünf: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`; `app.js` lädt `common.js` und `logos.js`, ohne sie startet der Dienst nicht), dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
- **Logos brauchen das Schema**: Die Logo-Endpunkte schreiben in die Tabellen `logos`, `logo_likes`, `logo_reports` und den Bucket `logo-images`. Das Schema-Skript `web/scripts/appwrite-setup.mjs` legt sie an (zuerst mit `--check` ansehen, was fehlt, siehe [../README.md](../README.md)); das gehört vor den Neustart des API-Dienstes mit den neuen Dateien. Ohne die Tabellen antworten die Logo-Endpunkte mit `500` „Interner Fehler.“, die Karten-Endpunkte laufen weiter. Die ganze Reihenfolge steht im nächsten Abschnitt.
- **Log-Grenze**: Der Docker-Daemon des Servers hat keine eigenen Log-Optionen, deshalb steht im Service `logging: json-file` mit `max-size: 5m` und `max-file: 2` (höchstens 10 MB). Der Dienst selbst schreibt kein Zugriffsprotokoll und höchstens 120 Zeilen pro Minute.
- **Kein Port nach außen**: der Dienst vertraut dem Header `X-Real-Ip` (setzt Traefik) für Rate-Limit und Melder-Hash, deshalb nie `ports:` ergänzen. Im Docker-Netz `webklar-network` erreichen ihn andere Container trotzdem und könnten `X-Real-Ip` setzen: akzeptiertes Risiko, siehe [api/README.md](api/README.md) (Client-IP). Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
## Reihenfolge bei Änderungen an Schema, API und Seite
Bringt eine Änderung neue Tabellen, neue Endpunkte und eine Seite mit, die beides braucht (wie die Community-Logos), gilt immer diese Reihenfolge. Die Seite geht zuletzt live: sonst ruft sie Endpunkte auf, die es noch nicht gibt, und ein Push von `main` baut sie sofort.
1. **Schema**: `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` zeigt, was fehlt (`FEHLT: …`). Ohne `--check` legt das Skript es an. Danach noch einmal mit `--check`: die Ausgabe muss mit `OK: Schema vollständig` enden (Exit-Code 0). Für die Logos sind das die Tabellen `logos`, `logo_likes`, `logo_reports` und der Bucket `logo-images`.
2. **API**: alle fünf Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`) mit `cp` nach `aircard-ios.org/api/` kopieren, dann `cd /home/webklar/apps && docker compose restart aircard-api`. `docker logs webklar-aircard-api` zeigt `listening on :8088`. Prüfen, dass die Logo-Endpunkte da sind: ein `POST` auf `/api/logos/herz` ohne JWT muss mit `401` und `"code":"unauthorized"` antworten (der Header `Content-Type: application/json` gehört dazu, sonst kommt `415`):
```sh
curl -s -i -X POST -H 'Content-Type: application/json' -d '{}' https://aircard-ios.org/api/logos/herz
```
Antwortet er mit `404`, läuft noch die alte API (Dateien oder Neustart prüfen). Das Schema prüft dieser Aufruf nicht, denn die Anmeldung kommt vor jedem Zugriff auf Appwrite: dafür ist Schritt 1 da.
3. **Seite**: erst jetzt `main` pushen (Gitea-Webhook, siehe unten). Der Build führt `npm ci` und `npm run build` aus: Typprüfung ohne Testdateien (`web/tsconfig.build.json`), dann `vite build`.
## Manuell deployen
```sh
@@ -103,7 +118,7 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
- `deploy/api/*.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
- `deploy/api/*.js` ändern (die fünf Dateien des Dienstes, nicht die Tests und nicht `fake-appwrite.js`): ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
+37 -10
View File
@@ -1,19 +1,21 @@
# aircard-api
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit. Die Website liest die Galerie direkt aus Appwrite; nur das Schreiben läuft hierüber.
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie und die Community-Logos von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit. Die Website liest Galerie und Logos direkt aus Appwrite; nur das Schreiben läuft hierüber.
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11. Deployment: [../README.md](../README.md).
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11 (Karten) und `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` §6, §8, §9 (Logos). Deployment: [../README.md](../README.md).
## Dateien
| Datei | Aufgabe |
|---|---|
| `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden |
| `app.js` | Routen, Validierung, Rechte-Strings, Rate-Limit, Logik (`createApp`) |
| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`) |
| `app.js` | HTTP-Anbindung (Routing, Body, Anmeldung, Rate-Limit, Log, Fehlerantworten) und die Karten-Endpunkte (`createApp`) |
| `logos.js` | die fünf Logo-Endpunkte (`createLogoRoutes`); bekommt Anmeldung, Sperre je Schlüssel, Rate-Limit und Log von `app.js` |
| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße |
| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`, auch `countRows`) |
| `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) |
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js`, `app.js` und `appwrite.js`.
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die fünf Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js` und `server.js`.
## Umgebung
@@ -29,7 +31,7 @@ Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess en
## Endpunkte
Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>"}`. Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`).
Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>", "code": "<Schlüssel>"}` (Codes siehe unten). Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`).
| Endpunkt | Auth | Antworten |
|---|---|---|
@@ -37,9 +39,29 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>"}`.
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `POST /api/logos/hochladen` `{name, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (= Datei-ID) · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), kein PNG („Das Logo muss eine PNG-Datei sein.“), größer als 1 MiB („Das Logo ist größer als 1 MB.“), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“) · `409` „Diese Datei wird schon für ein Logo verwendet.“ · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“ |
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | `200 {herz, likeCount}` · `401` · `400` ID ungültig oder `herz` kein Boolean · `404` fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“) |
| `POST /api/logos/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`; dasselbe Limit wie `/api/melden`) |
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `GET /api/gesundheit` | keine | `200 {ok:true}` |
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“. Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“.
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (`code` `unavailable`). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ (`code` `internal`). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“.
### Fehlercodes
Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel in `MSG` (`common.js`), dessen Text genau dem `error` entspricht. Die Texte in `MSG` sind paarweise verschieden (ein Test prüft das), der Code ist also eindeutig. Dynamische Prüftexte ohne festen Schlüssel („Der Titel ist zu lang (höchstens 60 Zeichen).“, „Ungültige Bild-ID.“, „Bitte gib höchstens 5 Tags an.“, „Der Name ist zu lang (höchstens 40 Zeichen).“, …) haben bei Status `400` den `code` `badRequest`, sonst `error` (kommt derzeit nicht vor). Der `code` ist die stabile Schnittstelle für die Clients (z. B. um Meldungen zu übersetzen); `MSG` ist dafür ein schlichtes Objekt aus Texten, das `common.js` unverändert exportiert.
| Status | `code` · Text |
|---|---|
| `400` | `badRequest` „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · `badCardId` „Ungültige Karten-ID.“ · `badLogoId` „Ungültige Logo-ID.“ · `badReason` „Bitte wähle einen gültigen Grund.“ · `badVisibility` „Ungültige Angabe zur Sichtbarkeit.“ · `badHeart` „Ungültige Angabe zum Herz.“ · `badMono` „Ungültige Angabe zu „einfarbig“.“ · `logoNameMissing` „Bitte gib einen Namen an.“ · `fileMissing` „Bild nicht gefunden.“ · `fileType` „Das Bild muss eine PNG- oder WebP-Datei sein.“ · `fileSize` „Das Bild ist größer als 5 MB.“ · `imageFormat` „Das Bild hat nicht das Kartenformat.“ · `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).“ |
| `401` | `unauthorized` „Bitte melde dich an.“ |
| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ |
| `404` | `notFound` „Nicht gefunden.“ · `cardGone` „Diese Karte ist nicht (mehr) verfügbar.“ · `logoGone` „Dieses Logo ist nicht (mehr) verfügbar.“ |
| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ |
| `413` `415` | `tooLarge` „Die Anfrage ist zu groß.“ · `jsonOnly` „Nur application/json wird unterstützt.“ |
| `429` | `tooManyPublications` „Du hast schon 20 Karten veröffentlicht. …“ · `tooManyLogos` „Du hast schon 30 Logos hochgeladen. …“ · `tooManyReports` „Zu viele Meldungen – bitte später nochmal.“ |
| `500` `502` | `internal` „Interner Fehler.“ · `unavailable` „Der Dienst ist gerade nicht erreichbar.“ |
### Regeln im Einzelnen
@@ -48,12 +70,17 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server
- **Quellkarte**: `sourceCardId` muss eine Zeile der Tabelle `cards` sein, deren `$permissions` `read("user:<ich>")` enthalten, sonst `403` „Diese Karte gehört nicht zu deinem Konto.“ (auch wenn es die Zeile nicht gibt).
- **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`.
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files/<id>/view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:<ich>")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:<ich>")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card <id>` im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. Die Zeilen-ID des Logos ist die Datei-ID: so kann eine Datei nie zwei Logos tragen.
- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:<id>`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:<ich>")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:<ich>")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo <id>` im Log). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet `400` `logoFormat` bzw. `fileMissing`; Log: `logo <id> removed after upload check (<code>)`, bleibt etwas stehen `rollback failed for logo <id>`. Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (`502`, Zeile zurückgenommen, die Datei bekommt `delete("user:<ich>")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest.
- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(<logoId>|<userId>)` (erste 32 Hex), Recht `read("user:<id>")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:<id>`). Herzen werden nicht geloggt.
- **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo|<logoId>|<reporterHash>)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:<owner>")` und `read("label:admin")`).
- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben).
- **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
- **Melden**: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`).
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion, fehlgeschlagenes Zurücknehmen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:<owner>")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:<owner>")`); Herzen tragen `read("user:<id>")` des Kontos.
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen ebenso (Datei, Herzen, Meldungen, Zeile). Bleibt etwas hängen (502), heilt der nächste Versuch.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen; keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
## Tests
+47 -254
View File
@@ -2,7 +2,9 @@
/**
* Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
* Community-Galerie und die Community-Logos laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js; dieses Modul gibt ihnen
* Anmeldung, Sperre je Schlüssel, das (gemeinsame) Rate-Limit und das Log.
*
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
@@ -14,21 +16,50 @@
* 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
* POST /api/logos/hochladen · /api/logos/herz · /api/logos/melden · /api/logos/loeschen · /api/admin/logo-sichtbarkeit
* siehe logos.js
* GET /api/gesundheit {ok:true}
* alles andere 404; Body höchstens 8 KB (413), nur application/json (415)
*
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
* Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write.
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>}.
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle).
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>} (siehe common.js).
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx; code `unavailable`) bzw. 500 (alle anderen, z. B. falscher Key oder
* fehlende Tabelle; code `internal`).
* Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen
* (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute.
*/
const crypto = require('node:crypto');
const net = require('node:net');
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_PUBLICATIONS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const { createLogoRoutes } = require('./logos.js');
const TABLE_GALLERY = 'gallery';
const TABLE_REPORTS = 'reports';
@@ -38,13 +69,8 @@ const BUCKET = 'gallery-images';
const MAX_BODY_BYTES = 8 * 1024;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
const HIDE_AT_REPORTS = 3;
const REPORT_LIST_LIMIT = 100;
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const MAX_PUBLICATIONS = 20;
const REPORT_DELETE_ROUNDS = 50;
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
const IMAGE_HEAD_BYTES = 64;
/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */
const IMAGE_WIDTH = 1536;
const IMAGE_HEIGHT = 969;
@@ -57,90 +83,11 @@ const RATE_SWEEP_MS = 60 * 1000;
const RATE_MAX_KEYS = 50_000;
const LOG_BUDGET_PER_MINUTE = 120;
const LOG_WINDOW_MS = 60 * 1000;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
const MSG = {
unauthorized: 'Bitte melde dich an.',
notFound: 'Nicht gefunden.',
cardGone: 'Diese Karte ist nicht (mehr) verfügbar.',
forbidden: 'Dazu hast du keine Berechtigung.',
adminOnly: 'Nur für Admins.',
foreignFile: 'Diese Datei gehört dir nicht.',
fileMissing: 'Bild nicht gefunden.',
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
imageFormat: 'Das Bild hat nicht das Kartenformat.',
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.',
badReason: 'Bitte wähle einen gültigen Grund.',
badVisibility: 'Ungültige Angabe zur Sichtbarkeit.',
tooLarge: 'Die Anfrage ist zu groß.',
jsonOnly: 'Nur application/json wird unterstützt.',
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
internal: 'Interner Fehler.',
};
// ---- Reine Helfer ----------------------------------------------------------------------------------
function isId(value) {
return typeof value === 'string' && ID_PATTERN.test(value);
}
function assertOwner(owner) {
if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID');
return owner;
}
/** Sichtbare Galerie-Zeile: lesen darf jeder. */
function visibleRowPermissions() {
return ['read("any")'];
}
/** Ausgeblendete Zeile: nur Autor und Admins. */
function hiddenRowPermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
/**
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
* Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`.
*/
function uploadFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
/**
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
* löscht die Dateien serverseitig mit dem Key.
*/
function visibleFilePermissions() {
return ['read("any")'];
}
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function hash32(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
}
/** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */
function galleryRowId(ownerId, sourceCardId) {
return hash32(`${ownerId}|${sourceCardId}`);
@@ -151,128 +98,12 @@ function reportRowId(galleryId, hash) {
return hash32(`${galleryId}|${hash}`);
}
function expandIpv6(address) {
let text = address;
const lastColon = text.lastIndexOf(':');
const tail = text.slice(lastColon + 1);
if (tail.includes('.')) {
const o = tail.split('.').map(Number);
text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const [head, rest] = text.split('::');
const headGroups = head === '' ? [] : head.split(':');
const restGroups = rest === undefined || rest === '' ? [] : rest.split(':');
const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups];
return groups.map((group) => Number.parseInt(group, 16));
}
/**
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/
function parseIp(ip) {
if (typeof ip !== 'string') return { kind: 'none' };
let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
if (zone !== -1) text = text.slice(0, zone);
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1];
if (net.isIPv4(text)) return { kind: 'v4', text };
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
}
return { kind: 'v6', groups };
}
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
}
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
/**
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
*/
function normalizeIp(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
return parsed.text;
}
/**
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden eine Karte nicht aus.
*/
function reporterNetwork(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
return parsed.text;
}
function firstHeaderValue(req, name) {
const value = req.headers && req.headers[name];
const text = Array.isArray(value) ? value[0] : value;
if (typeof text !== 'string') return '';
return text.split(',')[0].trim();
}
/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */
function clientIp(req) {
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
}
function hmacHex(salt, text) {
return crypto.createHmac('sha256', salt).update(text).digest('hex');
}
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
function reporterHash(salt, ip) {
return hmacHex(salt, reporterNetwork(ip));
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */
function safeToken(value, fallback = 'unknown') {
const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60);
return text === '' ? fallback : text;
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
* @returns {{width: number, height: number} | null}
*/
function readImageSize(bytes) {
if (!Buffer.isBuffer(bytes)) return null;
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
const kind = bytes.toString('latin1', 12, 16);
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
}
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
const bits = bytes.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
}
if (kind === 'VP8X' && bytes.length >= 30) {
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
}
}
return null;
}
const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT;
const isCardPreviewSize = (size) =>
size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX;
@@ -357,14 +188,6 @@ function buildSearchText({ title = '', issuer = '', tags = [] }) {
// ---- HTTP-Hilfen ------------------------------------------------------------------------------------
class HttpError extends Error {
constructor(status, message, headers = {}) {
super(message);
this.status = status;
this.headers = headers;
}
}
function sendJson(res, status, payload, headers = {}) {
const body = JSON.stringify(payload);
res.writeHead(status, {
@@ -416,17 +239,6 @@ function readRawBody(req) {
});
}
function parseJsonObject(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new HttpError(400, MSG.badRequest);
}
if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest);
return parsed;
}
/** Je Schlüssel nacheinander abarbeiten (z. B. je Karten-ID), damit Zähler und Rechte nie durcheinanderlaufen. */
function createKeyedLock() {
const tails = new Map();
@@ -448,10 +260,6 @@ function createKeyedLock() {
};
}
function isDuplicate(err) {
return err instanceof AppwriteError && err.status === 409;
}
// ---- Die App ---------------------------------------------------------------------------------------
/**
@@ -466,7 +274,7 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
if (!appwrite || typeof appwrite !== 'object') throw new TypeError('appwrite fehlt');
if (typeof salt !== 'string' || salt === '') throw new TypeError('salt fehlt');
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten- und Logo-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein
// Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen.
const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`);
let logWindowStart = 0;
@@ -489,7 +297,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
}
const withLock = createKeyedLock();
// ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`)
// ---- Rate-Limit je Anschluss (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports` und `logo_reports`).
// Ein Zähler für Karten- und Logo-Meldungen: logos.js bekommt `rateLimit` von hier.
const hits = new Map();
let lastSweep = 0;
@@ -533,27 +342,6 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
}
// ---- Dateien und Zeilen
/**
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und
* Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten.
*/
function checkFileExists(file) {
if (!file) throw new HttpError(400, MSG.fileMissing);
if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien
* hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
*/
function checkFileOwnership(file, userId) {
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
}
function checkFileContent(file) {
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize);
@@ -637,8 +425,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId),
]);
checkFileExists(image);
checkFileExists(preview);
checkFileExists(image, BUCKET);
checkFileExists(preview, BUCKET);
// Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`owner:${user.id}`, async () => {
@@ -782,6 +570,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
['POST /api/zurueckziehen', { name: 'zurueckziehen', json: true, run: withdraw }],
['POST /api/melden', { name: 'melden', json: true, run: reportCard }],
['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }],
// Logos (logos.js): bekommen Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log von hier.
...createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }),
]);
async function handle(req, res) {
@@ -807,9 +597,11 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
} catch (err) {
let headers = {};
let message = MSG.internal;
let code = 'internal';
if (err instanceof HttpError) {
status = err.status;
message = err.message;
code = err.code;
headers = err.headers;
} else if (err instanceof AppwriteError) {
// Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere
@@ -817,12 +609,13 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
const unavailable = err.status === 0 || err.status === 429 || err.status >= 500;
status = unavailable ? 502 : 500;
message = unavailable ? MSG.unavailable : MSG.internal;
code = unavailable ? 'unavailable' : 'internal';
say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`);
} else {
status = 500;
say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`);
}
if (!res.headersSent) sendJson(res, status, { error: message }, headers);
if (!res.headersSent) sendJson(res, status, { error: message, code }, headers);
}
}
+171
View File
@@ -9,6 +9,7 @@ const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { MSG, HttpError, codeFor } = require('./common.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const {
createApp,
@@ -1569,3 +1570,173 @@ test('createApp verlangt Appwrite-Objekt und Salz', () => {
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
assert.throws(() => createApp({ appwrite: {} }));
});
// ---- Fehlercodes: jede Fehlerantwort ist {error, code} -------------------------------------------------
/** Erwartet Status und die Antwort {error: <Text des MSG-Schlüssels>, code: <Schlüssel>} (sonst nichts). */
function expectError(res, status, code, label = code) {
assert.equal(res.status, status, `${label}: Status`);
assert.deepEqual(res.json, { error: MSG[code], code }, label);
}
test('MSG: alle Texte sind paarweise verschieden (der Text bestimmt den Code), die Logo-Texte stehen wörtlich fest', () => {
const texts = Object.values(MSG);
assert.ok(texts.length > 0);
assert.equal(new Set(texts).size, texts.length, 'jeder Text gehört zu genau einem Schlüssel');
assert.ok(texts.every((text) => typeof text === 'string' && text !== ''));
const logoKeys = ['logoGone', 'badLogoId', 'logoNameMissing', 'logoFileType', 'logoFileSize', 'logoFormat', 'logoTaken', 'tooManyLogos', 'badHeart', 'badMono'];
assert.deepEqual(Object.fromEntries(logoKeys.map((key) => [key, MSG[key]])), {
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“.',
});
// die bisherigen Texte bleiben unverändert (Auszug)
assert.equal(MSG.cardGone, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(MSG.forbidden, 'Dazu hast du keine Berechtigung.');
assert.equal(MSG.tooManyReports, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(MSG.tooManyPublications, TOO_MANY_CARDS);
assert.equal(MSG.unavailable, 'Der Dienst ist gerade nicht erreichbar.');
assert.equal(MSG.internal, 'Interner Fehler.');
});
test('codeFor und HttpError: Code = Schlüssel des Textes, sonst badRequest (400) bzw. error', () => {
assert.equal(codeFor(404, MSG.cardGone), 'cardGone');
assert.equal(codeFor(404, MSG.logoGone), 'logoGone');
assert.equal(codeFor(429, MSG.tooManyLogos), 'tooManyLogos');
assert.equal(codeFor(500, MSG.internal), 'internal');
assert.equal(codeFor(400, 'Ungültige Bild-ID.'), 'badRequest', 'dynamischer Prüftext, Status 400');
assert.equal(codeFor(400, MSG.badRequest), 'badRequest');
assert.equal(codeFor(403, 'irgendein anderer Text'), 'error', 'dynamischer Text, anderer Status');
assert.equal(codeFor(409, undefined), 'error');
const withHeaders = new HttpError(429, MSG.tooManyReports, { 'Retry-After': '5' });
assert.ok(withHeaders instanceof Error);
assert.deepEqual([withHeaders.status, withHeaders.message, withHeaders.code, withHeaders.headers], [429, MSG.tooManyReports, 'tooManyReports', { 'Retry-After': '5' }]);
assert.deepEqual(new HttpError(404, MSG.notFound).headers, {});
assert.equal(new HttpError(400, 'Der Titel ist ungültig.').code, 'badRequest');
assert.equal(new HttpError(403, 'Der Titel ist ungültig.').code, 'error');
assert.equal(new HttpError(409, 'x', {}, 'eigener').code, 'eigener', 'ein ausdrücklicher Code gewinnt');
});
test('Fehlerantworten: Anfrage-Fehler (404, 413, 415, 400, 401) haben {error, code}', async () => {
const { call, post, report, fake } = await setup();
seedCard(fake, { id: 'card-9' });
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
expectError(await call('GET', '/api/gibts-nicht'), 404, 'notFound', 'unbekannte Route');
expectError(await call('GET', '/api/melden'), 404, 'notFound', 'falsche Methode');
expectError(await call('POST', '/api/melden', { raw: '{}', contentType: 'text/plain' }), 415, 'jsonOnly');
expectError(await call('POST', '/api/melden', { raw: big }), 413, 'tooLarge');
expectError(await call('POST', '/api/melden', { raw: '{kaputt' }), 400, 'badRequest', 'kaputtes JSON');
expectError(await call('POST', '/api/melden', { raw: '[]' }), 400, 'badRequest', 'Array statt Objekt');
expectError(await report('../x', '203.0.1.9'), 400, 'badCardId');
expectError(await report('card-9', '203.0.1.9', 'gibt-es-nicht'), 400, 'badReason');
expectError(await post('/api/veroeffentlichen', publishBody()), 401, 'unauthorized', 'veröffentlichen ohne JWT');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, 'xxx.yyy.zzz'), 401, 'unauthorized', 'zurückziehen mit falschem JWT');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }), 401, 'unauthorized', 'Admin ohne JWT');
});
test('Fehlerantworten: /api/melden mit unbekannter ID ist genau {error, code} aus der Spec', async () => {
const { report } = await setup();
const res = await report('gibts-nicht', '203.0.1.9');
assert.equal(res.status, 404);
assert.deepEqual(res.json, { error: 'Diese Karte ist nicht (mehr) verfügbar.', code: 'cardGone' });
});
test('Fehlerantworten: Logik-Fehler der Karten-Endpunkte tragen den MSG-Schlüssel, dynamische Prüftexte badRequest', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'card-9', owner: OTHER });
seedUploads(fake);
expectError(await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME), 404, 'cardGone');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME), 403, 'forbidden');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ME), 403, 'adminOnly');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: 'ja' }, JWT_ADMIN), 400, 'badVisibility');
expectError(await post('/api/admin/sichtbarkeit', { id: '../x', sichtbar: true }, JWT_ADMIN), 400, 'badCardId');
expectError(await post('/api/veroeffentlichen', publishBody({ sourceCardId: '../x' }), JWT_ME), 400, 'badCardId');
// Prüftexte mit Zahlen oder Feldnamen haben keinen festen Schlüssel: code badRequest, Text bleibt der genaue
for (const [overrides, text] of [
[{ title: '' }, 'Bitte gib einen Titel an.'],
[{ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).'],
[{ imageFileId: '../x' }, 'Ungültige Bild-ID.'],
[{ tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, 'Bitte gib höchstens 5 Tags an.'],
[{ previewFileId: 'img1' }, 'Bild und Vorschau müssen verschiedene Dateien sein.'],
]) {
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
assert.equal(res.status, 400, text);
assert.deepEqual(res.json, { error: text, code: 'badRequest' }, text);
}
});
test('Fehlerantworten: Datei-, Besitz- und Limit-Fehler beim Veröffentlichen tragen ihren Schlüssel', async () => {
const { post, report, fake } = await setup();
const attempt = (n, { image = {}, preview = {}, card = `card${n}` } = {}, overrides = {}) => {
fake.addFile({ id: `img${n}`, owner: ME, ...image });
fake.addFile({ id: `prev${n}`, owner: ME, mimeType: 'image/webp', ...preview });
if (card) fake.addCard(card, ME);
return post('/api/veroeffentlichen', publishBody({ imageFileId: `img${n}`, previewFileId: `prev${n}`, sourceCardId: `card${n}`, ...overrides }), JWT_ME);
};
expectError(await attempt(1, { image: { owner: OTHER } }), 403, 'foreignFile');
expectError(await attempt(2, { image: { bucket: 'card-images' } }), 403, 'foreignFile', 'falscher Bucket');
expectError(await attempt(3, {}, { previewFileId: 'ghost' }), 400, 'fileMissing');
expectError(await attempt(4, { image: { mimeType: 'image/jpeg' } }), 400, 'fileType');
expectError(await attempt(5, { image: { size: MIB5 + 1 } }), 400, 'fileSize');
expectError(await attempt(6, { image: { content: pngHeader(100, 100) } }), 400, 'imageFormat');
expectError(await attempt(7, { image: { permissions: ['delete("user:user-me")'] } }), 403, 'filesTaken');
expectError(await attempt(8, { card: null }), 403, 'notYourCard');
assert.equal((await attempt(9)).status, 201);
expectError(await attempt(10, {}, { sourceCardId: 'card9' }), 409, 'alreadyPublished');
seedPublications(fake, ME, 19); // mit der Karte oben sind es 20
expectError(await attempt(11), 429, 'tooManyPublications');
// Rate-Limit der Meldungen (die Karte `card9` ist sichtbar veröffentlicht)
const id = galleryRowId(ME, 'card9');
for (let i = 0; i < 10; i += 1) assert.equal((await report(id, '203.0.50.9')).status, 200);
const limited = await report(id, '203.0.50.9');
expectError(limited, 429, 'tooManyReports');
assert.equal(limited.headers['retry-after'], '3600');
});
test('Fehlerantworten: Appwrite-Fehler haben code unavailable (502) bzw. internal (500), unerwartete Fehler internal', async () => {
const { report, fake } = await setup();
seedCard(fake);
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
fake.failNext(isRowRead, { status: 500 });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '5xx');
fake.failNext(isRowRead, { status: 429, type: 'general_rate_limit_exceeded' });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '429');
fake.failNext(isRowRead, { status: 404, type: 'table_not_found' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'fehlende Tabelle');
fake.failNext(isRowRead, { status: 401, type: 'general_unauthorized_scope' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'falscher Key');
const broken = {
accountFromJwt: async () => {
throw new Error('kaputt GEHEIM');
},
};
const app = createApp({ appwrite: broken, salt: SALT, log: () => {} });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const res = await request(server.address().port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } });
expectError(res, 500, 'internal', 'unerwarteter Fehler');
assert.ok(!res.text.includes('GEHEIM'));
});
+17 -2
View File
@@ -7,6 +7,8 @@
* getRow(table, id) Zeile oder null (404 row_not_found)
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter);
* eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles [])
* countRows(table, queries) Anzahl aller Treffer (`total` einer Abfrage mit limit(1), ohne eigenes limit
* in `queries`); eine Antwort ohne gültiges `total` ist ein Fehler
* createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
@@ -74,6 +76,10 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
const base = endpoint.replace(/\/+$/, '');
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
const rowsUrl = (table, queries) => {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
return `${rowsPath(table)}${search === '' ? '' : `?${search}`}`;
};
/** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
async function readHead(response, limit) {
@@ -145,14 +151,23 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
},
async listRows(table, queries = []) {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
const result = await send('GET', `${rowsPath(table)}${search === '' ? '' : `?${search}`}`);
const result = await send('GET', rowsUrl(table, queries));
if (!result.body || !Array.isArray(result.body.rows)) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return result.body.rows;
},
/** Zählt die Treffer einer Abfrage über `total` (Appwrite zählt bis 5000): es wird höchstens eine Zeile übertragen. */
async countRows(table, queries = []) {
const result = await send('GET', rowsUrl(table, [...queries, queryLimit(1)]));
const total = result.body && result.body.total;
if (!Number.isInteger(total) || total < 0) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return total;
},
async createRow(table, rowId, data, permissions) {
const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } });
return result.body;
+44 -2
View File
@@ -129,6 +129,47 @@ test('listRows blättert mit cursorAfter', async () => {
assert.deepEqual(last.map((row) => row.$id), ['e']);
});
test('countRows: liefert das total der Abfrage (nicht die Länge der Seite) und schickt limit(1) mit', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd']) {
await appwrite.createRow('reports', id, { galleryId: id === 'd' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['g1'])]), 3);
const call = lastCall(fake);
assert.equal(call.method, 'GET');
assert.equal(call.path, '/v1/tablesdb/aircard/tables/reports/rows');
assert.deepEqual(call.search.split('&').map((part) => decodeURIComponent(part)), [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[1]}',
]);
assert.equal(call.headers['x-appwrite-key'], fake.key);
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['nix'])]), 0);
assert.equal(await appwrite.countRows('reports'), 4, 'ohne Abfragen zählt es die ganze Tabelle');
});
test('countRows: fehlendes oder ungültiges total ist ein AppwriteError (502, invalid_response), Appwrite-Fehler werden weitergereicht', async () => {
for (const payload of [{ rows: [] }, { total: '3', rows: [] }, { total: null, rows: [] }, { total: -1, rows: [] }, { total: 1.5, rows: [] }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.countRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const zero = createAppwrite({ endpoint: 'http://appwrite.invalid/v1', project: 'p', key: 'k', fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }) });
assert.equal(await zero.countRows('reports', []), 0, 'total 0 ist eine gültige Zahl');
const { fake, appwrite } = await setup();
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(() => appwrite.countRows('reports', []), (err) => err instanceof AppwriteError && err.status === 500);
await assert.rejects(() => appwrite.countRows('keine-tabelle', []), (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'table_not_found');
});
test('Dateien: lesen, Rechte setzen, löschen', async () => {
const { fake, appwrite } = await setup();
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
@@ -335,11 +376,12 @@ test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner We
key: 'k',
fetchImpl: async (url, init) => {
seen.push(init.redirect);
return new Response(JSON.stringify({ rows: [], $id: 'x' }), { status: 200 });
return new Response(JSON.stringify({ total: 0, rows: [], $id: 'x' }), { status: 200 });
},
});
await appwrite.getRow('gallery', 'x');
await appwrite.listRows('gallery', []);
await appwrite.countRows('gallery', []);
await appwrite.createRow('gallery', 'x', {}, []);
await appwrite.updateRow('gallery', 'x', {});
await appwrite.deleteRow('gallery', 'x');
@@ -347,7 +389,7 @@ test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner We
await appwrite.updateFilePermissions('gallery-images', 'x', []);
await appwrite.deleteFile('gallery-images', 'x');
await appwrite.accountFromJwt('a.b.c');
assert.equal(seen.length, 9);
assert.equal(seen.length, 10);
assert.ok(seen.every((value) => value === 'error'));
});
+335
View File
@@ -0,0 +1,335 @@
'use strict';
/**
* Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js) und die Logos (logos.js): Fehlertexte und
* -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße. Nur reine Funktionen und Konstanten, kein Zustand.
*
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>}. Texte ohne festen Schlüssel (dynamische
* Prüftexte wie „Der Titel ist zu lang (höchstens 60 Zeichen).“) haben bei Status 400 den code `badRequest`, sonst `error`.
*/
const crypto = require('node:crypto');
const net = require('node:net');
const { AppwriteError } = require('./appwrite.js');
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const MAX_PUBLICATIONS = 20;
/** Höchstens so viele Logos je Konto (ausgeblendete zählen mit); Löschen gibt einen Platz frei. */
const MAX_LOGOS = 30;
/** Ab so vielen verschiedenen Meldern wird eine Karte bzw. ein Logo ausgeblendet. */
const HIDE_AT_REPORTS = 3;
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
const IMAGE_HEAD_BYTES = 64;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
/**
* Alle Fehlertexte der API, Schlüssel = `code` der Fehlerantwort. Die Texte sind paarweise verschieden (ein Test prüft das):
* `codeFor` findet den Schlüssel über den Text. Die Clients übersetzen Meldungen über den Schlüssel; darum ist MSG ein
* schlichtes Objekt aus Texten, das dieses Modul unverändert exportiert.
*/
const MSG = {
unauthorized: 'Bitte melde dich an.',
notFound: 'Nicht gefunden.',
cardGone: 'Diese Karte ist nicht (mehr) verfügbar.',
forbidden: 'Dazu hast du keine Berechtigung.',
adminOnly: 'Nur für Admins.',
foreignFile: 'Diese Datei gehört dir nicht.',
fileMissing: 'Bild nicht gefunden.',
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
imageFormat: 'Das Bild hat nicht das Kartenformat.',
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.',
badReason: 'Bitte wähle einen gültigen Grund.',
badVisibility: 'Ungültige Angabe zur Sichtbarkeit.',
// Logos
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 ${MAX_LOGOS} Logos hochgeladen. Lösche eins, um ein neues hochzuladen.`,
badHeart: 'Ungültige Angabe zum Herz.',
badMono: 'Ungültige Angabe zu „einfarbig“.',
tooLarge: 'Die Anfrage ist zu groß.',
jsonOnly: 'Nur application/json wird unterstützt.',
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
internal: 'Interner Fehler.',
};
// ---- Fehler ----------------------------------------------------------------------------------------
/** Text -> Schlüssel (die Texte in MSG sind paarweise verschieden, sonst wäre der Code nicht eindeutig). */
const CODE_BY_TEXT = new Map(Object.entries(MSG).map(([key, text]) => [text, key]));
/**
* Der `code` einer Fehlerantwort: der MSG-Schlüssel mit genau diesem Text, sonst `badRequest` bei Status 400 und
* `error` bei jedem anderen Status.
*/
function codeFor(status, message) {
return CODE_BY_TEXT.get(message) || (status === 400 ? 'badRequest' : 'error');
}
class HttpError extends Error {
constructor(status, message, headers = {}, code = codeFor(status, message)) {
super(message);
this.status = status;
this.headers = headers;
this.code = code;
}
}
// ---- Reine Helfer ----------------------------------------------------------------------------------
function isId(value) {
return typeof value === 'string' && ID_PATTERN.test(value);
}
function assertOwner(owner) {
if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID');
return owner;
}
/** Sichtbare Zeile (Karte oder Logo): lesen darf jeder. */
function visibleRowPermissions() {
return ['read("any")'];
}
/** Ausgeblendete Zeile: nur Autor und Admins. */
function hiddenRowPermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
/** Herz (Zeile in `logo_likes`): lesen darf nur das Konto selbst. */
function likeRowPermissions(userId) {
assertOwner(userId);
return [`read("user:${userId}")`];
}
/**
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
* Fehlversuche auf). Nur so kommen Dateien in `publish` (Karten) und `uploadLogo` (Logos); die API setzt sie danach auf
* `visibleFilePermissions`.
*/
function uploadFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
/**
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
* löscht die Dateien serverseitig mit dem Key.
*/
function visibleFilePermissions() {
return ['read("any")'];
}
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function hash32(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
}
function expandIpv6(address) {
let text = address;
const lastColon = text.lastIndexOf(':');
const tail = text.slice(lastColon + 1);
if (tail.includes('.')) {
const o = tail.split('.').map(Number);
text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const [head, rest] = text.split('::');
const headGroups = head === '' ? [] : head.split(':');
const restGroups = rest === undefined || rest === '' ? [] : rest.split(':');
const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups];
return groups.map((group) => Number.parseInt(group, 16));
}
/**
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/
function parseIp(ip) {
if (typeof ip !== 'string') return { kind: 'none' };
let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
if (zone !== -1) text = text.slice(0, zone);
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1];
if (net.isIPv4(text)) return { kind: 'v4', text };
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
}
return { kind: 'v6', groups };
}
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
}
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
/**
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
*/
function normalizeIp(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
return parsed.text;
}
/**
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden keine Karte und kein Logo aus.
*/
function reporterNetwork(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
return parsed.text;
}
function firstHeaderValue(req, name) {
const value = req.headers && req.headers[name];
const text = Array.isArray(value) ? value[0] : value;
if (typeof text !== 'string') return '';
return text.split(',')[0].trim();
}
/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */
function clientIp(req) {
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
}
function hmacHex(salt, text) {
return crypto.createHmac('sha256', salt).update(text).digest('hex');
}
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
function reporterHash(salt, ip) {
return hmacHex(salt, reporterNetwork(ip));
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
* @returns {{width: number, height: number} | null}
*/
function readImageSize(bytes) {
if (!Buffer.isBuffer(bytes)) return null;
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
const kind = bytes.toString('latin1', 12, 16);
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
}
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
const bits = bytes.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
}
if (kind === 'VP8X' && bytes.length >= 30) {
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
}
}
return null;
}
function parseJsonObject(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new HttpError(400, MSG.badRequest);
}
if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest);
return parsed;
}
function isDuplicate(err) {
return err instanceof AppwriteError && err.status === 409;
}
// ---- Dateiprüfungen (Karten und Logos) -----------------------------------------------------------------
/**
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im erwarteten Bucket. Metadaten wie Typ und
* Größe prüft der Aufrufer erst nach `checkFileOwnership`, damit fremde Dateien nichts darüber verraten.
*/
function checkFileExists(file, bucket) {
if (!file) throw new HttpError(400, MSG.fileMissing);
if (file.bucketId !== bucket) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Dateien schon
* verwendeter Zeilen hat der Aufrufer vorher als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
*/
function checkFileOwnership(file, userId) {
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
// Frisch hochgeladene Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
}
module.exports = {
MAX_PUBLICATIONS,
MAX_LOGOS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
ID_PATTERN,
CONTROL_CHARACTERS,
MSG,
codeFor,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
};
+69 -13
View File
@@ -9,17 +9,22 @@
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
*
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an).
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen
* `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` und die Buckets `gallery-images`, `card-images`, `logo-images`.
*
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der
* echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen.
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`:
* die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`).
* Zeilen werden gegen die Spaltenlängen der echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im
* Test auffallen.
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
* Testhilfen: failNext/hangNext/delayNext (Aufruf scheitert, hängt, wird verzögert) und swapContentAfterPermissions
* (Dateiinhalt ändert sich nach dem nächsten Rechte-Update).
*/
const http = require('node:http');
const BUCKETS = ['gallery-images', 'card-images'];
const BUCKETS = ['gallery-images', 'card-images', 'logo-images'];
/** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */
function pngHeader(width, height) {
@@ -80,15 +85,49 @@ const COLUMNS = {
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
// Spalten wie in web/scripts/appwrite-setup.mjs (Community-Logos)
logos: {
name: { size: 40, required: true },
displayName: { size: 30 },
fileId: { size: 36, required: true },
ownerId: { size: 36, required: true },
mono: { type: 'boolean' },
likeCount: { type: 'integer', min: 0 },
hidden: { type: 'boolean' },
reportCount: { type: 'integer', min: 0 },
},
logo_likes: {
logoId: { size: 36, required: true },
userId: { size: 36, required: true },
},
logo_reports: {
logoId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
};
/** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */
const DEFAULTS = {
gallery: { hidden: false, reportCount: 0 },
logos: { mono: false, likeCount: 0, hidden: false, reportCount: 0 },
};
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
const tables = { gallery: new Map(), reports: new Map(), cards: new Map() };
const tables = {
gallery: new Map(),
reports: new Map(),
cards: new Map(),
logos: new Map(),
logo_likes: new Map(),
logo_reports: new Map(),
};
const files = new Map();
const contents = new Map();
const users = new Map();
const calls = [];
const injections = [];
const swaps = new Map();
let sequence = 0;
function addUser(jwt, { id, labels = [] }) {
@@ -144,13 +183,22 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
injections.push({ predicate, delayMs });
}
/**
* Nach dem nächsten Rechte-Update (PUT) der Datei `id` hat sie den Inhalt `content` (einmalig). So lässt sich eine Datei
* nachstellen, die zwischen der ersten Prüfung und dem Festlegen der Rechte ausgetauscht wurde: der Anfang ist beim ersten
* Lesen gültig und danach ein anderer.
*/
function swapContentAfterPermissions(id, content) {
swaps.set(id, content);
}
function validateData(table, data, { partial }) {
const columns = COLUMNS[table];
for (const [name, value] of Object.entries(data)) {
const column = columns[name];
if (!column) return `Unknown attribute: ${name}`;
if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`;
if (column.type === 'integer' && !Number.isInteger(value)) return `Invalid ${name}`;
if (column.type === 'integer' && (!Number.isInteger(value) || (column.min !== undefined && value < column.min))) return `Invalid ${name}`;
if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`;
if (column.size !== undefined) {
if (typeof value !== 'string') return `Invalid ${name}`;
@@ -231,15 +279,14 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
const data = body.data || {};
const problem = validateData(table, data, { partial: false });
if (problem) return error(400, 'document_invalid_structure', problem);
const defaults = table === 'gallery' ? { hidden: false, reportCount: 0 } : {};
const row = addRow(table, body.rowId, { ...defaults, ...data }, body.permissions || []);
const row = addRow(table, body.rowId, { ...DEFAULTS[table], ...data }, body.permissions || []);
return reply(201, row);
}
if (segments.length === 6 && req.method === 'GET') {
const rows = runQueries([...store.values()], search);
if (rows === null) return error(400, 'general_query_invalid');
return reply(200, { total: rows.length, rows });
const found = runQueries([...store.values()], search);
if (found === null) return error(400, 'general_query_invalid');
return reply(200, found);
}
if (segments.length === 7) {
@@ -288,6 +335,10 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
const body = call.body || {};
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
file.$permissions = body.permissions;
if (swaps.has(segments[5])) {
contents.set(segments[5], swaps.get(segments[5]));
swaps.delete(segments[5]);
}
return reply(200, file);
}
}
@@ -313,6 +364,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
failNext,
hangNext,
delayNext,
swapContentAfterPermissions,
row: (table, id) => tables[table].get(id) || null,
rows: (table) => [...tables[table].values()],
file: (id) => files.get(id) || null,
@@ -339,7 +391,10 @@ function safeParse(text) {
}
}
/** Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. */
/**
* Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage.
* Liefert {total, rows}: `total` zählt alle Treffer der equal-Abfragen, `rows` ist die Seite nach cursorAfter und limit.
*/
function runQueries(rows, search) {
let result = rows;
let limit = 25;
@@ -364,12 +419,13 @@ function runQueries(rows, search) {
return null;
}
}
const total = result.length;
if (cursor !== null) {
const index = result.findIndex((row) => row.$id === cursor);
if (index === -1) return null;
result = result.slice(index + 1);
}
return result.slice(0, limit);
return { total, rows: result.slice(0, limit) };
}
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
+401
View File
@@ -0,0 +1,401 @@
'use strict';
/**
* Endpunkte der Community-Logos im API-Dienst `aircard-api` (Node 22, nur Bordmittel). Sie folgen den Karten-Endpunkten
* in app.js: gleiche Reihenfolge der Prüfungen, gleiche Rücknahme beim Hochladen, gleiche Melde- und Ausblend-Logik.
* app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit.
*
* POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId}
* 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
* meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID)
* Nach dem Festlegen der Datei auf read("any") wird ihr Anfang noch einmal geprüft (der Hochlader kann sie dann nicht
* mehr ändern): fällt sie durch oder fehlt sie, werden Datei und Zeile gelöscht und die Antwort ist 400.
* POST /api/logos/herz Nutzer-JWT {id, herz}
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
*
* Tabellen `logos` (Zeilen-ID = Datei-ID, damit eine Datei nie zwei Logos tragen kann), `logo_likes`, `logo_reports`,
* Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows), nie hoch-
* oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo.
* Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt.
*/
const { queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_LOGOS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const TABLE_LOGOS = 'logos';
const TABLE_LIKES = 'logo_likes';
const TABLE_REPORTS = 'logo_reports';
const BUCKET = 'logo-images';
const MAX_LOGO_BYTES = 1024 * 1024;
/** Maße des fertigen Logos (wie die Verarbeitung im Editor): 8 × 8 bis 720 × 250 Pixel. */
const LOGO_MIN_SIDE = 8;
const LOGO_MAX_WIDTH = 720;
const LOGO_MAX_HEIGHT = 250;
/** Herzen und Meldungen eines Logos werden in Runden zu 100 gelöscht, höchstens 50 Runden. */
const ROW_BATCH = 100;
const ROW_ROUNDS = 50;
const LOGO_TEXT_FIELDS = [
{ key: 'name', label: 'Der Name', max: 40, required: true },
{ key: 'displayName', label: 'Der Anzeigename', max: 30, required: false },
];
// ---- Reine Helfer ----------------------------------------------------------------------------------
/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
function logoLikeId(logoId, userId) {
return hash32(`${logoId}|${userId}`);
}
/** Feste Zeilen-ID je Logo und Melder (`logo|` trennt sie von den Karten-Meldungen): eine Meldung pro Melder und Logo. */
function logoReportId(logoId, reporter) {
return hash32(`logo|${logoId}|${reporter}`);
}
function readLogoText(raw, field) {
if (raw === undefined || raw === null) return field.required ? { message: MSG.logoNameMissing } : { value: '' };
if (typeof raw !== 'string') return { message: `${field.label} ist ungültig.` };
const value = raw.trim();
if (value === '' && field.required) return { message: MSG.logoNameMissing };
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.label} enthält ungültige Zeichen.` };
if (value.length > field.max) return { message: `${field.label} ist zu lang (höchstens ${field.max} Zeichen).` };
return { value };
}
/**
* Prüft den Hochladen-Body: Name 1-40, Anzeigename ≤ 30 (getrimmt, ohne Steuer- und Bidi-Zeichen), `mono` fehlt = false
* (sonst muss es ein Boolean sein), `fileId` nach ID_PATTERN.
* @returns {{ok: true, data: {name: string, displayName: string, mono: boolean, fileId: string}} | {ok: false, message: string}}
*/
function validateLogoUpload(body) {
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
const data = {};
for (const field of LOGO_TEXT_FIELDS) {
const result = readLogoText(body[field.key], field);
if (result.message) return { ok: false, message: result.message };
data[field.key] = result.value;
}
if (body.mono !== undefined && typeof body.mono !== 'boolean') return { ok: false, message: MSG.badMono };
data.mono = body.mono === true;
if (!isId(body.fileId)) return { ok: false, message: 'Ungültige Datei-ID.' };
data.fileId = body.fileId;
return { ok: true, data };
}
/** Typ und Größe laut Appwrite (erst nach der Besitzprüfung, damit fremde Dateien nichts darüber verraten). */
function checkLogoContent(file) {
if (file.mimeType !== 'image/png') throw new HttpError(400, MSG.logoFileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_LOGO_BYTES) throw new HttpError(400, MSG.logoFileSize);
}
function isLogoSize(head) {
// readImageSize kennt auch WebP: ein Logo muss einen PNG-Kopf haben, nicht nur den Dateityp laut Appwrite
if (head.toString('latin1', 1, 4) !== 'PNG') return false;
const size = readImageSize(head);
return (
size !== null &&
size.width >= LOGO_MIN_SIDE &&
size.height >= LOGO_MIN_SIDE &&
size.width <= LOGO_MAX_WIDTH &&
size.height <= LOGO_MAX_HEIGHT
);
}
// ---- Die Routen ------------------------------------------------------------------------------------
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @param {string} options.salt Salz für die Melder-Hashes
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
* @param {(key: string) => {allowed: boolean, retryAfter?: number}} options.rateLimit das Rate-Limit der Meldungen (gemeinsam mit den Karten)
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
*/
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) {
const withLogoLock = (id, fn) => withLock(`logo:${id}`, fn);
/** Setzt die Rechte der Logo-Datei; null heißt: die Datei gibt es nicht (mehr). */
function setFilePermissions(row, permissions) {
return isId(row.fileId) ? appwrite.updateFilePermissions(BUCKET, row.fileId, permissions) : Promise.resolve(null);
}
/** Datei zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
async function hideLogo(id, row, extra = {}) {
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
}
/** Löscht alle Zeilen der Tabelle zu diesem Logo, in Runden (die Zeile des Logos selbst bleibt der Anker für eine Wiederholung). */
async function deleteRowsOf(table, logoId) {
for (let round = 0; round < ROW_ROUNDS; round += 1) {
const rows = await appwrite.listRows(table, [queryEqual('logoId', [logoId]), queryLimit(ROW_BATCH)]);
if (rows.length === 0) return;
for (const row of rows) await appwrite.deleteRow(table, row.$id);
}
}
/** reportCount = Anzahl Meldungszeilen des Logos (neu gezählt); ab 3 wird ausgeblendet. */
async function syncReports(id, row) {
const count = await appwrite.countRows(TABLE_REPORTS, [queryEqual('logoId', [id])]);
if (count >= HIDE_AT_REPORTS) {
await hideLogo(id, row, { reportCount: count });
say(`logo ${id} hidden automatically (${count} reports)`);
} else if (row.reportCount !== count) {
await appwrite.updateRow(TABLE_LOGOS, id, { reportCount: count });
}
}
/**
* Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250.
* @returns {Promise<HttpError | null>} das Urteil (400 fileMissing bzw. logoFormat), null = in Ordnung; Appwrite-Fehler werfen
*/
async function logoSizeProblem(fileId) {
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
if (head === null) return new HttpError(400, MSG.fileMissing);
return isLogoSize(head) ? null : new HttpError(400, MSG.logoFormat);
}
async function checkLogoSize(fileId) {
const problem = await logoSizeProblem(fileId);
if (problem !== null) throw problem;
}
/**
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem
* Server-Key löschen, dann die Zeile. Beides wird versucht, auch wenn das erste scheitert: die Zeile macht das Logo für
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id>` im Log.
*/
async function removeRejectedLogo(id, problem) {
let clean = true;
await appwrite.deleteFile(BUCKET, id).catch(() => {
clean = false;
});
await appwrite.deleteRow(TABLE_LOGOS, id).catch(() => {
clean = false;
});
say(clean ? `logo ${id} removed after upload check (${problem.code})` : `rollback failed for logo ${id}`);
}
async function uploadLogo({ req, raw }) {
const user = await requireUser(req);
const validation = validateLogoUpload(parseJsonObject(raw));
if (!validation.ok) throw new HttpError(400, validation.message);
const data = validation.data;
const file = await appwrite.getFile(BUCKET, data.fileId);
checkFileExists(file, BUCKET);
// Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`logo-owner:${user.id}`, async () => {
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht.
const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]);
if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
// Zeilen-ID = Datei-ID: dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung).
if (rows.some((row) => row.$id === data.fileId)) throw new HttpError(409, MSG.logoTaken);
checkFileOwnership(file, user.id);
checkLogoContent(file);
await checkLogoSize(data.fileId);
try {
await appwrite.createRow(
TABLE_LOGOS,
data.fileId,
{
name: data.name,
displayName: data.displayName,
fileId: data.fileId,
ownerId: user.id,
mono: data.mono,
likeCount: 0,
hidden: false,
reportCount: 0,
},
visibleRowPermissions(),
);
} catch (err) {
// Eine Zeile mit dieser ID gehört schon jemandem (z. B. der liegengebliebene Anker eines halb gelöschten Logos).
if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
throw err;
}
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
let problem = null;
try {
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
problem = await logoSizeProblem(data.fileId);
} catch (err) {
let rolledBack = true;
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
rolledBack = false;
say(`rollback failed for logo ${data.fileId}`);
});
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf).
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
throw err;
}
if (problem !== null) {
await removeRejectedLogo(data.fileId, problem);
throw problem;
}
say(`logo ${data.fileId} uploaded`);
return { status: 201, body: { id: data.fileId } };
});
}
async function setHeart({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.herz !== 'boolean') throw new HttpError(400, MSG.badHeart);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
const likeId = logoLikeId(id, user.id);
if (body.herz) {
try {
await appwrite.createRow(TABLE_LIKES, likeId, { logoId: id, userId: user.id }, likeRowPermissions(user.id));
} catch (err) {
if (!isDuplicate(err)) throw err; // schon da: kein Fehler
}
} else {
await appwrite.deleteRow(TABLE_LIKES, likeId); // schon weg (false): kein Fehler
}
// Immer neu zählen statt hoch- oder runterzählen: das heilt einen früher steckengebliebenen Zählerstand.
const likeCount = await appwrite.countRows(TABLE_LIKES, [queryEqual('logoId', [id])]);
if (row.likeCount !== likeCount) await appwrite.updateRow(TABLE_LOGOS, id, { likeCount });
return { status: 200, body: { herz: body.herz, likeCount } };
});
}
async function reportLogo({ req, raw }) {
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt je Anschluss und gilt für Karten und Logos zusammen.
const ip = clientIp(req);
const reporter = reporterHash(salt, ip);
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
let isNew = true;
try {
await appwrite.createRow(TABLE_REPORTS, logoReportId(id, reporter), { logoId: id, reason: body.reason, reporterHash: reporter }, []);
} catch (err) {
if (!isDuplicate(err)) throw err;
isNew = false;
}
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
await syncReports(id, row);
if (isNew) say(`logo ${id} reported (${body.reason})`);
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
});
}
async function deleteLogo({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
// Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
await deleteRowsOf(TABLE_LIKES, id);
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.deleteRow(TABLE_LOGOS, id);
say(`logo ${id} deleted${row.ownerId === user.id ? '' : ' by admin'}`);
return { status: 200, body: { ok: true } };
});
}
async function setVisibility({ req, raw }) {
const user = await requireUser(req);
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
if (body.sichtbar) {
// Datei, dann Meldungen, Zeile zuletzt: sichtbar wird das Logo erst, wenn alles andere steht.
await setFilePermissions(row, visibleFilePermissions());
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
say(`logo ${id} made visible by admin`);
} else {
await hideLogo(id, row);
say(`logo ${id} hidden by admin`);
}
return { status: 200, body: { ok: true } };
});
}
return [
['POST /api/logos/hochladen', { name: 'logos/hochladen', json: true, run: uploadLogo }],
['POST /api/logos/herz', { name: 'logos/herz', json: true, run: setHeart }],
['POST /api/logos/melden', { name: 'logos/melden', json: true, run: reportLogo }],
['POST /api/logos/loeschen', { name: 'logos/loeschen', json: true, run: deleteLogo }],
['POST /api/admin/logo-sichtbarkeit', { name: 'admin/logo-sichtbarkeit', json: true, run: setVisibility }],
];
}
module.exports = { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload };
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -2,7 +2,7 @@
Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console>
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports` mit Spalten und Indizes, Buckets `card-images` und `gallery-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` mit Spalten und Indizes, Buckets `card-images`, `gallery-images` und `logo-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
**Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein.
@@ -58,7 +58,7 @@ Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
4. **Create** klicken.
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
Die Indizes der Tabellen `gallery` und `reports` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
Die Indizes der Tabellen `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
## 6. Key auf dem Server eintragen
@@ -77,7 +77,7 @@ Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
## 7. Community
Für die Community-Galerie (Tabellen `gallery` und `reports`, Bucket `gallery-images`, API-Dienst `aircard-api`) sind drei Dinge in der Console nötig. Das Schema dazu legt dasselbe Skript an (`node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs`, vorher mit `--check` ansehen, was fehlt).
Für die Community-Galerie und die Community-Logos (Tabellen `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports`, Buckets `gallery-images` und `logo-images`, API-Dienst `aircard-api`) sind drei Dinge in der Console nötig. Das Schema dazu legt dasselbe Skript an (`node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs`, vorher mit `--check` ansehen, was fehlt).
### 7.1 JWT muss an sein
@@ -85,7 +85,7 @@ JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten. A
### 7.2 Label `admin` am eigenen Konto
Das Label `admin` macht dich zum Admin der Galerie: Die Website zeigt dir dann Ein-/Ausblenden-Knöpfe und den Galerie-Filter „Gemeldet“/„Ausgeblendet“.
Das Label `admin` macht dich zum Admin der Galerie und der Community-Logos: Die Website zeigt dir dann Ein-/Ausblenden-Knöpfe und die Filter „Gemeldet“/„Ausgeblendet“ (in der Galerie und im Logo-Bereich des Editors).
1. Linke Leiste → **Auth** → **Users** → dein Konto öffnen.
2. Bei **Labels** den Wert `admin` eintragen → **Update**.
@@ -100,7 +100,7 @@ Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und
- `documents.read` und `documents.write`
- `files.read` und `files.write`
Die Console beschriftet die Zeilen-Scopes mit `documents.*`; das sind dieselben wie die `rows.read`/`rows.write` der Appwrite-API (Tabellen heißen dort Zeilen). Einen Eintrag `rows.*` gibt es in der Console nicht.
Die Console beschriftet die Zeilen-Scopes mit `documents.*`; das sind dieselben wie die `rows.read`/`rows.write` der Appwrite-API (Tabellen heißen dort Zeilen). Einen Eintrag `rows.*` gibt es in der Console nicht. Dieselben vier Scopes genügen für die Logo-Endpunkte (Tabellen `logos`, `logo_likes`, `logo_reports`, Bucket `logo-images`).
4. **Create** klicken und auf der Seite des Keys den **Secret**-Wert kopieren.
5. Auf dem Server den Wert verdeckt in die `.env` schreiben. Der Befehl fragt den Wert ohne Anzeige ab (`read -rs`), schreibt bei leerer Eingabe nichts, beginnt mit einem Zeilenumbruch (so klebt die Zeile nie an einer letzten Zeile ohne Umbruch) und zeigt den Key nirgends an:
@@ -112,4 +112,4 @@ Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und
## 8. Bescheid geben
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt“.
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images`, `logo-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt“.
@@ -0,0 +1,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:<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: ID `hash32("<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` (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 <noreply@anthropic.com>`.
- 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**: 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` (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 `<title>` 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</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ätzlich `lang` setzen (gleiche Regel wie `initialLang`, Kommentar verweist darauf); `<footer id="app-footer"></footer>` leer (Text kommt aus `mountFooter`).
- 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-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:<userId>` beim Hochladen, `logo:<id>` 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:<ich>")`; 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:<id>")']` 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<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'` 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<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; `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(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 + 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(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`: `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: Text lesen, Seitenverhältnis mit `svgAspect` aus `card/marks.ts` (viewBox), `width`/`height` aus `svgRasterSize` ins Wurzelelement schreiben (wie `withIntrinsicSize` in `marks.ts`), dann über `Image` aus 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')`, `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<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 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 – <Text>“ (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 `<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 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<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 <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf „<Zahl>“ (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 `<img>`, 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<boolean>, reportDialog(opts): Promise<ReportOutcome>
```
- [ ] **Step 1: Tests schreiben:**
- Infozeile für das gewählte Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ 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: „<n> 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, `<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.
@@ -0,0 +1,275 @@
# AirCard-IOS – Community-Logos, Englisch und GitHub-Link – Design
Stand: 2026-10-04 · Status: Entwurf im Chat von Kenso freigegeben („ja“), Spec + Plan + Umsetzung ohne weitere Rückfrage · baut auf `2026-10-04-aircard-website-design.md` und `2026-10-04-aircard-community-design.md` auf (live, main 8fb28eb)
## 1. Ziel und Rahmen
AirCard-IOS soll in die internationale Community der App [AirCard-iOS](https://github.com/Mak5er/AirCard-iOS), ohne dass Kenso fremde Marken-Logos selbst anbietet. Dafür:
1. **Keine eingebauten Marken-Logos mehr.** Stattdessen laden Nutzer Logos hoch (Community-Logos), alle können sie benutzen, Herzen zeigen die beliebtesten.
2. **Die Seite gibt es auf Deutsch und Englisch.**
3. **GitHub-Link zum Original-Repo** der App, daneben der Sprachknopf.
| Punkt | Entscheidung (Kenso) |
|---|---|
| Eingebaute Logos | Alle 18 Netzwerk-Logos (Visa ×4, Mastercard ×5 inkl. „Klassisch“, UnionPay ×4, American Express ×3, JCB, Discover) und das Apple-Logo entfallen, auch ihre SVG-Dateien. |
| „Klassik kann so bleiben“ | Gemeint ist die **Vorlage „Klassisch“** (Chip, Kontaktlos-Zeichen, Nummer, PLATINUM). Sie bleibt unverändert. |
| Logo oben links | Bleibt: AirCard-IOS Symbol, AirCard-IOS Text, Kein Logo (ohne Apple). |
| Platz der Community-Logos | **Unten rechts**, wo bisher das Kartennetzwerk saß. |
| Hochladen | Nur angemeldet; sofort öffentlich; Melden, Auto-Ausblenden ab 3 Meldern, Admin. |
| Herzen | **Nur angemeldet**, eins pro Konto und Logo, wieder entfernbar; Sortierung „Beliebt“ zuerst. |
| Sprache | **Automatisch** nach Browsersprache, Umschalter merkt sich die Wahl. |
| GitHub | Link `https://github.com/Mak5er/AirCard-iOS` in der Kopfzeile, Sprachknopf direkt daneben. |
Live-Stand beim Schreiben: 0 veröffentlichte Community-Karten, 1 gespeicherte Testkarte. Es gibt keinen Altbestand mit Marken-Logos, der aufgeräumt werden müsste.
## 2. Karte und Konfiguration
- `CardConfig`: Das Feld `network` entfällt. Neu ist `logo: string | null`, die ID eines Community-Logos (Muster `ID_PATTERN`). `brand` ist nur noch `'aircards-symbol' | 'aircards-text' | 'none'`. `v` bleibt `1`.
- `parseConfig`: Ein altes `network` wird ignoriert. Ein fehlendes oder ungültiges `logo` ergibt `null`. `brand: 'apple'` und andere Unbekannte ergeben den Standard (`aircards-symbol`). `serializeConfig` schreibt `{v, template, logo, details, brand, crop}`.
- `DEFAULT_CONFIG.logo = null`, eine neue Karte hat also kein Logo unten rechts.
- In den Vorlagen-Layouts heißt `network` jetzt `logo`: „Nur Bild“ ohne, „Minimalistisch“, „Klassisch“ und „Signature“ mit Logo.
- Das Logo sitzt wie bisher das Netzwerk-Logo: rechte Kante 1480, Unterkante 913, höchstens 360 px breit und 125 px hoch (Seitenverhältnis des Bildes bleibt).
- **Einfarbige Logos** (`mono = true`) werden beim Zeichnen in der Details-Farbe gefüllt (Hell `#FFFFFF`, Dunkel `#111111`). Die Form kommt aus dem Alphakanal des PNG. Mehrfarbige Logos erscheinen unverändert.
- **Gelöscht** werden 19 SVGs in `web/src/assets/marks/` (alle außer `aircards-symbol.svg` und `aircards-text.svg`) sowie alles zu Netzwerk-Marken in `marks.ts` und `config.ts`.
- **Fehlt ein Logo** (gelöscht oder ausgeblendet), zeichnet der Editor die Karte ohne Logo und zeigt den Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“. Die Auswahl bleibt, bis der Nutzer ein anderes Logo wählt. Beim Veröffentlichen aus „Meine Karten“ führt ein fehlendes Logo zum Abbruch mit „Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.“.
## 3. Editor: Bereich „Logo unten rechts“
Die rechte Spalte „Individuell gestalten“ zeigt von oben nach unten: **Logo unten rechts**, Details (Hell/Dunkel), **Logo oben links** (bisher „Logo“).
Der Bereich „Logo unten rechts“ besteht aus:
- **Kopf:** Überschrift „Logo unten rechts“, Plakette „Community“ und der Knopf „+ Hochladen“. Darunter ein kleiner Satz: „Logos werden von der Community hochgeladen.“
- **Sortierung:** Umschalter „Beliebt“ (Standard: meiste Herzen, bei Gleichstand neueste zuerst) und „Neu“ (neueste zuerst). Admins sehen zusätzlich „Gemeldet“ und „Ausgeblendet“.
- **Raster** (Desktop 2 Spalten):
- Erste Kachel „Kein Logo“.
- Jede Logo-Kachel zeigt das Logo so, wie es auf der Karte erscheint. Einfarbige Logos stehen wie die bisherigen Kacheln in Weiß auf dunklem Grund.
- Darunter folgen der Name (eine Zeile, gekürzt) und „von <Anzeigename>“ bzw. „von Anonym“ (klein, gedämpft).
- Der Herz-Knopf „♥ <Zahl>“ ist ein eigener Knopf, nicht in den Auswahlknopf verschachtelt. `aria-pressed` zeigt, ob ich das Logo mit einem Herz markiert habe.
- Ein Klick auf die Kachel setzt `config.logo`. Karte und Vorlagen-Miniaturen zeigen das Logo sofort.
- Ist das gewählte Logo nicht in der geladenen Liste (z. B. nach dem Öffnen einer gespeicherten Karte), steht es als zweite Kachel direkt nach „Kein Logo“.
- 24 Logos je Seite, darunter „Mehr laden“ (Cursor).
- **Infozeile zum gewählten Logo** unter dem Raster: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und der Knopf „Melden“.
- Wer das Logo hochgeladen hat, sieht zusätzlich „Löschen“ (mit Rückfrage).
- Admins sehen „<n> Meldungen“ mit den Gründen sowie „Ausblenden“/„Einblenden“ und „Löschen“.
- **Leer:** „Noch keine Community-Logos – lade das erste hoch.“
- **Ladefehler:** „Die Logos konnten nicht geladen werden.“ mit „Nochmal versuchen“.
- **Herz ohne Anmeldung:** Ein Klick öffnet den Login-Dialog, danach wird das Herz gesetzt. Das Herz schaltet sofort um (optimistisch). Scheitert die Anfrage, springt es zurück, und die Infozeile zeigt die Fehlermeldung.
- **Handy (unter 900 px):** Das Raster nutzt die volle Breite (`auto-fill`, mindestens 140 px je Kachel).
## 4. Logo hochladen
Der Dialog „Logo hochladen“ (eigenes `<dialog>` wie die bisherigen Dialoge, alle Texte als Text, nie als HTML) enthält:
- **Datei:** PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.
- **Vorschau:** das fertig verarbeitete Logo, einmal auf dunklem und einmal auf hellem Grund. Bei „Einfarbig“ ist es in Weiß bzw. Schwarz gefüllt.
- **Name:** Pflicht, höchstens 40 Zeichen.
- **Anzeigename:** optional, höchstens 30 Zeichen. Leer bedeutet „Anonym“, die E-Mail erscheint nie. Vorbelegt mit dem zuletzt benutzten Namen aus `localStorage` (`aircard.displayName`; Zugriffe in try/catch). Der Veröffentlichen-Dialog liest und schreibt denselben Schlüssel.
- **Häkchen „Einfarbig – passt sich Hell/Dunkel an“:** nur wählbar, wenn das Logo transparente Bereiche hat.
- **Pflicht-Häkchen:** „Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.“
- **Knöpfe:** „Hochladen“ und „Abbrechen“.
**Verarbeitung im Browser** (der Server prüft trotzdem):
1. Datei dekodieren. SVG wird als Bild geladen, also ohne Skripte. Hat ein SVG keine eigene Größe, gilt 1000 × 1000.
2. Transparente Ränder abschneiden (Alpha ≤ 8 gilt als leer).
3. Auf höchstens 720 × 250 px verkleinern, nie vergrößern. SVG wird direkt in der Zielgröße gerastert.
4. Als PNG ausgeben.
5. Abbruch mit „Das Logo ist leer.“, wenn nichts Sichtbares bleibt, und mit „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“ unter 8 × 8 px.
**Ablauf:**
1. Das PNG landet im Bucket `logo-images` mit den Rechten `read("any")` und `delete("user:<ich>")`.
2. `POST /api/logos/hochladen` wird aufgerufen.
3. Scheitert der Aufruf eindeutig, löscht der Client die Datei wieder (gleiche Regel wie beim Veröffentlichen). Bleibt der Ausgang offen, bleibt die Datei liegen.
4. Danach ist das Logo für alle sichtbar, wird gewählt (`config.logo`) und steht als erste Logo-Kachel.
Pro Konto sind höchstens **30 Logos** erlaubt, ausgeblendete zählen mit. Darüber hinaus erscheint: „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“
## 5. Herzen
- Die Tabelle `logo_likes` hat eine Zeile je Konto und Logo (feste Zeilen-ID aus Logo und Nutzer). Der Zähler `likeCount` in `logos` wird nur von der API gesetzt, und zwar immer als Anzahl der Zeilen (neu gezählt, nicht hoch- oder runtergezählt).
- Der Client lädt den Herz-Zustand für die angezeigten Logos mit `equal('userId', me)` und `equal('logoId', [ids])`. Beim An- oder Abmelden wird er neu geladen bzw. geleert.
- Für ausgeblendete oder gelöschte Logos gibt es kein Herz (404 „Dieses Logo ist nicht (mehr) verfügbar.“).
## 6. Melden, Löschen, Admin
- **Melden** funktioniert wie bei Karten (gleicher Dialog, Text „dieses Logo“).
- Gründe für Logos: „Unangemessen“, „Urheberrecht/Marke“, „Spam“, „Sonstiges“.
- Ein Melder ist ein IP-Netz (IPv4 /24, IPv6 /48), gespeichert nur als Hash.
- Rate-Limit: 10 Meldungen pro Stunde je Anschluss, gemeinsam mit den Karten-Meldungen.
- Ab **3** verschiedenen Meldern wird das Logo ausgeblendet. Zeile und Datei sind dann nur noch für den Hochlader und Admins lesbar.
- **Löschen** (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile. Gespeicherte Karten zeigen das Logo danach nicht mehr (Hinweis siehe § 2). Bereits veröffentlichte Kartenbilder bleiben unverändert, weil das Logo im Bild steckt.
- **Admin** (Label `admin`):
- Filter „Gemeldet“ (`reportCount > 0`) und „Ausgeblendet“.
- Ein- und Ausblenden. Einblenden löscht die Meldungen und setzt `reportCount` auf 0.
- Löschen.
## 7. Sprache und Kopfzeile
**Kopfzeile, rechte Seite:** GitHub · Sprache · ☕ Unterstützen · Hell/Dunkel · Menü.
- **GitHub:** GitHub-Zeichen und der Text „GitHub“.
- Ziel ist `https://github.com/Mak5er/AirCard-iOS` mit `target="_blank" rel="noopener noreferrer"`. Der Title lautet „AirCard-iOS auf GitHub“ bzw. „AirCard-iOS on GitHub“.
- Zwischen 600 und 1179 px zeigt der Link nur das Zeichen (wie der Spenden-Knopf).
- Unter 600 px steht der Link im aufgeklappten Menü statt in der Kopfzeile.
- **Sprachknopf:** Globus-Zeichen plus das Kürzel der Zielsprache, also „EN“ auf der deutschen und „DE“ auf der englischen Seite.
- `aria-label` und `title` stehen in der Zielsprache („Switch to English“ bzw. „Auf Deutsch umschalten“), dazu ein passendes `lang` am Knopf.
- Der Knopf bleibt bei jeder Breite in der Kopfzeile.
**Startsprache:** zuerst `localStorage` `aircard.lang` (`de` oder `en`). Sonst Deutsch, wenn die erste bevorzugte Browsersprache (`navigator.languages[0]`, ersatzweise `navigator.language`) mit `de` beginnt, andernfalls Englisch. `<html lang>` folgt der Sprache und wird wie das Theme vor dem ersten Paint per Inline-Skript gesetzt.
**Umschalten** geht ohne Neuladen:
- Kopfzeile, Spenden-Banner, Konto-Menü, Fußzeile und die aktuelle Seite werden neu aufgebaut. Der Editor-Entwurf bleibt erhalten, weil er im Store liegt.
- `document.title` wird neu gesetzt.
- Der Appwrite-Client bekommt die Sprache (`client.setLocale`), damit Passwort-Mails in dieser Sprache kommen.
**Übersetzt** wird alles Sichtbare: Texte, `aria-label`, Platzhalter, `title`, Seitentitel, Fehlermeldungen (Client, Appwrite, API) und Datumsangaben (Deutsch `04.10.2026`, Englisch `Oct 4, 2026`).
**Nicht übersetzt** werden:
- Nutzerinhalte: Titel, Herausgeber, Tags, Logo-Namen, Anzeigenamen.
- Routen: Sie bleiben `/gestalten`, `/karten`, `/community/<id>`, `/passwort-neu`.
- Download-Dateinamen, README und Dokumentation.
- Die Werte der Melde-Gründe in der API.
**Technik (gettext-Stil):**
- `t('Deutscher Text', { name })` übersetzt beim Aufruf. Platzhalter stehen als `{name}` im Text.
- `msg('…')` markiert Texte, die vorab in Konstanten stehen. Sie gehen erst beim Anzeigen durch `t()`.
- Das Wörterbuch `web/src/i18n/en.ts` ordnet Deutsch → Englisch zu. Fehlt ein Eintrag, bleibt der deutsche Text.
- Ein Test sammelt alle Literale aus `t()`/`msg()` in `web/src/**` (ohne Tests). Er prüft, dass jedes eine englische Übersetzung hat, dass `en.ts` keine ungenutzten Einträge enthält und dass kein `t()` mit `${…}` aufgerufen wird.
- In Tests ist die Sprache Deutsch (Vitest-Setup).
- Englisch: kurz, freundlich, US-Schreibweise. „AirCard-IOS“ bleibt der Name der Seite, „AirCard-iOS“ der Name der App.
**Fehler der API:** Antworten tragen zusätzlich `code`, den Schlüssel aus `MSG`, z. B. `{"error":"…","code":"alreadyPublished"}`. Der Client übersetzt über den Code. Ohne bekannten Code zeigt er auf Deutsch den Servertext und auf Englisch eine allgemeine Meldung je Status (400 „Invalid request.“, 403 „You are not allowed to do that.“, 404 „Not found.“, sonst „Something went wrong. Please try again.“).
**Statisches `index.html`:** `<title>AirCard-IOS – Card designs for Apple Wallet</title>` und `<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG.">`, damit Link-Vorschauen englisch sind. Der Router setzt danach den Titel in der gewählten Sprache.
**Fußzeile:**
- Deutsch: „Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.“
- Englisch: „Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.“
## 8. Daten (Appwrite, Projekt `6ac2130a00289343d6d5`, Datenbank `aircard`, Setup-Skript erweitert)
**Tabelle `logos`** (rowSecurity an, **keine** Tabellenrechte für Clients):
| Spalte | Typ | Inhalt |
|---|---|---|
| `name` | string 40, Pflicht | Name des Logos |
| `displayName` | string 30 | Anzeigename oder leer |
| `fileId` | string 36, Pflicht | PNG im Bucket `logo-images` |
| `ownerId` | string 36, Pflicht | Nutzer-ID des Hochladers |
| `mono` | boolean, Standard false | einfarbig |
| `likeCount` | integer, Standard 0, min 0 | Anzahl Herzen |
| `hidden` | boolean, Standard false | ausgeblendet |
| `reportCount` | integer, Standard 0, min 0 | Anzahl verschiedener Melder |
Indizes: `owner` (key, `ownerId`), `hidden` (key, `hidden`), `likes` (key, `likeCount`).
Zeilen-ID = Datei-ID. Damit kann eine Datei nie zwei Logos tragen, und ein zweiter Versuch ergibt 409.
Zeilenrechte (setzt nur die API): sichtbar `['read("any")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")']`.
**Tabelle `logo_likes`** (rowSecurity an, keine Tabellenrechte):
- Spalten: `logoId` string 36 Pflicht, `userId` string 36 Pflicht.
- Zeilen-ID = `hash32("<logoId>|<userId>")`.
- Zeilenrecht `['read("user:<userId>")']`.
- Indizes: `logo` (key, `logoId`), `user_logo` (key, `userId`, `logoId`).
**Tabelle `logo_reports`** (rowSecurity aus, Tabellenrecht `read("label:admin")`, Schreiben nur API):
- Spalten: `logoId` string 36 Pflicht, `reason` enum (unangemessen, kartendaten, urheberrecht, spam, sonstiges) Pflicht, `reporterHash` string 64 Pflicht.
- Zeilen-ID = `hash32("logo|<logoId>|<reporterHash>")`.
- Index `logo` (key, `logoId`).
**Bucket `logo-images`:**
- Bucket-Recht `create("users")`, fileSecurity an, höchstens 1 MiB, Endung nur `png`, ohne Kompression und ohne Virenscan.
- Dateirechte beim Hochladen: `read("any")`, `delete("user:<owner>")`.
- Nach dem Anlegen setzt die API genau `read("any")`. Der Hochlader verliert damit `delete`, sonst könnte er das Bild unter derselben ID tauschen.
- Ausgeblendet: `read("user:<owner>")`, `read("label:admin")`.
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`. 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 |
|---|---|---|
| `POST /api/logos/hochladen` `{name, displayName?, mono, fileId}` | Nutzer-JWT | Prüft Felder; die Datei existiert, liegt in `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist PNG, höchstens 1 MiB, Maße zwischen 8 × 8 und 720 × 250 (aus dem PNG-Kopf); höchstens 30 Logos je Konto. Legt die Zeile an (sichtbar), setzt die Datei auf `read("any")` und nimmt die Zeile zurück, wenn das scheitert. → `201 {id}`; 409 bei schon verwendeter Datei; 429 bei Limit. |
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | 404 bei fehlendem oder ausgeblendetem Logo. `herz: true` legt die Like-Zeile an (409 = schon da, kein Fehler), `false` löscht sie (404 = schon weg, kein Fehler). Danach `likeCount = countRows`. → `200 {herz, likeCount}` |
| `POST /api/logos/melden` `{id, reason}` | keine | Wie `/api/melden`: Rate-Limit (gemeinsam), Melder-Hash, Duplikat → `200 {schonGemeldet:true}`, `reportCount` = Zeilen, ab 3 ausblenden. 404 bei fehlendem oder ausgeblendetem Logo. |
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader **oder** Admin) | Löscht Datei, Herzen, Meldungen und zuletzt die Zeile. 403 bei fremdem Logo, 404 bei fehlendem. → `200 {ok:true}` |
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | Wie `/api/admin/sichtbarkeit` für Logos (Datei- und Zeilenrechte, `hidden`; Einblenden löscht Meldungen, `reportCount = 0`). |
Alle Fehlerantworten (auch die bestehenden) haben die Form `{error: <deutscher Text>, code: <MSG-Schlüssel>}`. Dynamische Prüftexte ohne festen Schlüssel bekommen `code: 'badRequest'`.
Body höchstens 8 KB, nur `application/json`, sonst unverändert wie in der Community-Spec § 8.
## 10. Was Kenso tun muss
Nichts Pflicht. Der vorhandene Provisioning-Key (`AIRCARD_APPWRITE_API_KEY`) legt das Schema an. Der API-Key `aircard-api` hat die nötigen Scopes (Zeilen und Dateien). Fürs Testen der Admin-Knöpfe braucht Kensos Konto weiterhin das Label `admin` (steht noch aus der Community-Runde offen).
## 11. Fehlerbehandlung
| Fall | Meldung |
|---|---|
| Datei falscher Typ / zu groß / zu viele Pixel | „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“ / „Die Datei ist größer als 5 MB.“ / „Das Bild ist zu groß (max. 40 Megapixel).“ |
| Datei nicht lesbar | „Das Logo konnte nicht gelesen werden.“ |
| Logo leer / zu klein | siehe § 4 |
| Name fehlt / zu lang | „Bitte gib einen Namen an.“ / „Der Name ist zu lang (höchstens 40 Zeichen).“ |
| Häkchen fehlt | „Bitte bestätige, dass du das Logo teilen darfst.“ |
| Limit 30 | siehe § 4 |
| Hochladen gescheitert | „Hochladen fehlgeschlagen – <Grund>“, Datei wird bei eindeutigem Fehler gelöscht |
| Herz/Melden für weg/ausgeblendet | „Dieses Logo ist nicht (mehr) verfügbar.“ |
| Melden doppelt / Rate-Limit | „Danke, du hast dieses Logo schon gemeldet.“ / „Zu viele Meldungen – bitte später nochmal.“ |
| API nicht erreichbar | „Der Dienst ist gerade nicht erreichbar.“ |
| Nicht angemeldet (Hochladen, Herz) | Login-Dialog (`withLogin`/`requireLogin`), Abbruch → nichts passiert |
Alle Texte gibt es auf Deutsch und Englisch.
## 12. Tests
- **Vitest:**
- i18n: Startsprache, Umschalten, Platzhalter, Abdeckung aller `t()`/`msg()`-Texte, Datum.
- Konfiguration: alte Konfiguration mit `network`/`apple`, `logo`.
- Renderer: Logo-Box, einfarbig, ohne Logo.
- Logo-Verarbeitung: Rand-Zuschnitt, Skalierung, leer, zu klein, Transparenz-Erkennung.
- Logo-Abfragen: Beliebt/Neu/Admin-Filter, Cursor, eigene Herzen.
- Hochladen-Ablauf mit Fakes: Aufräumen nur bei eindeutigem Fehler.
- Logo-Bereich: Kacheln, Auswahl, Herz optimistisch und Rücksprung, Login-Pflicht, angeheftete Auswahl, Infozeile je Rolle.
- Kopfzeile: GitHub-Link, Sprachknopf.
- Bestehende Tests bleiben grün (Sprache im Test = Deutsch).
- **node:test (`aircard-api`):**
- Hochladen: fremde Datei 403, falsches Format/Maße 400, Limit 429, doppelt 409, Rechte nachher genau `read("any")`, Rücknahme bei Fehler.
- Herz: an/aus idempotent, Zähler = Zeilen, ausgeblendet 404.
- Melden: Dedup, gemeinsames Rate-Limit, ab 3 ausgeblendet.
- Löschen: Hochlader/Admin/fremd.
- Admin-Sichtbarkeit.
- `code` in allen Fehlerantworten.
- **Setup-Skript:** `--check` deckt `logos`, `logo_likes`, `logo_reports` und `logo-images` inkl. Indizes ab.
- **Live (aircard-ios.org):**
- Hochladen (PNG transparent, einfarbig; SVG; JPG ohne Transparenz). Das Logo ist für Gäste sichtbar und sitzt auf der Karte; Download-PNG enthält es.
- Herz an/aus (Zahl, Sortierung).
- Melden über 3 Netze → ausgeblendet; Gast sieht es nicht mehr.
- Löschen.
- Sprachwechsel mit offenem Editor-Entwurf (bleibt erhalten).
- Englisch nach Browsersprache; GitHub-Link.
- Breiten 1440 und 390 px, Hell/Dunkel.
## 13. Nicht im Umfang
Logo-Suche, Community-Logos oben links, Herzen für Community-Karten, Zähler „wie oft benutzt“, Logos bearbeiten (nur löschen und neu hochladen), englische Routen, weitere Sprachen, Mail an Kenso bei Meldungen, SVG-Speicherung (gespeichert wird immer PNG).
## 14. Risiken
- **Marken über Nutzer:** Nutzer können Visa & Co. selbst hochladen. Dagegen stehen das Pflicht-Häkchen, der Melde-Grund „Urheberrecht/Marke“, das Auto-Ausblenden und der Admin. Kenso haftet ab Kenntnis, sollte also gemeldete Logos regelmäßig ansehen. Ohne Kontaktadresse/Impressum fehlt ein Beschwerdeweg außerhalb von „Melden“ (Kenso-Entscheidung aus der Website-Runde, bekannt).
- **Herzen:** Konten sind kostenlos, viele Konten ergeben viele Herzen (akzeptiert). Auth-Limits in der Console bleiben Kensos offener Schritt.
- **Zählung:** `countRows` nutzt `total` von Appwrite (zählt bis 5000). Das reicht.
- **Übersetzungslücken:** Der Abdeckungstest fängt fehlende Einträge. Server-Prüftexte ohne Code erscheinen auf Englisch als allgemeine Meldung.
+19 -7
View File
@@ -1,17 +1,19 @@
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>
<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." />
<meta name="theme-color" content="#0F1828" />
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
<script>
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
// In einer sofort aufgerufenen Funktion, damit keine globale Variable `theme` entsteht.
// Hell/Dunkel und Sprache (<html lang>) vor dem ersten Paint setzen (kein Aufblitzen); dieselben Regeln wie
// initialTheme() in src/shell/theme.ts und initialLang() in src/i18n/i18n.ts.
// In einer sofort aufgerufenen Funktion, damit keine globalen Variablen `theme` und `lang` entstehen.
(function () {
try {
var theme = null;
@@ -23,6 +25,18 @@
}
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
try {
var lang = null;
try {
lang = localStorage.getItem('aircard.lang');
} catch (e) {}
if (lang !== 'de' && lang !== 'en') {
var browser = window.navigator;
var first = (browser.languages && browser.languages[0]) || browser.language || '';
lang = /^de/i.test(first) ? 'de' : 'en';
}
document.documentElement.setAttribute('lang', lang);
} catch (e) {}
})();
</script>
</head>
@@ -33,9 +47,7 @@
<div id="account-slot"></div>
</header>
<main id="app-root"></main>
<footer id="app-footer">
<p>Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.</p>
</footer>
<footer id="app-footer"></footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit -p tsconfig.build.json && vite build",
"test": "vitest run",
"preview": "vite preview"
},
+63 -3
View File
@@ -3,8 +3,9 @@
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und
* Indizes) und die Buckets `card-images` und `gallery-images` an.
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und
* `logo_reports` (TablesDB, mit Spalten und Indizes) und die Buckets
* `card-images`, `gallery-images` und `logo-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
*
* Aufruf (vom Repo-Wurzelverzeichnis):
@@ -40,7 +41,7 @@ const str = (key, size, required) => ({ key, type: 'string', size, required });
export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' },
// Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes).
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports (je Tabelle: Spalten, dann Indizes).
tables: [
{
id: 'cards',
@@ -88,6 +89,54 @@ export const DESIRED = {
],
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
},
{
// Community-Logos. Wie `gallery`: Zeilenrechte setzt nur die API, Clients dürfen nichts auf Tabellenebene.
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'] },
],
},
{
// Ein Herz je Konto und Logo: Zeilen-ID hash32("<logoId>|<userId>"), lesen darf nur das Konto selbst; geschrieben wird nur über die API.
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'] },
// Mehrspalten-Index: die Reihenfolge der Spalten zählt (und wird verglichen).
{ key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
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'] }],
},
],
buckets: [
{
@@ -110,6 +159,17 @@ export const DESIRED = {
compression: 'none',
antivirus: false,
},
{
// Logo-PNG (höchstens 1 MiB); die Dateirechte setzt die API je Datei.
id: 'logo-images',
name: 'Logo Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 1048576,
allowedFileExtensions: ['png'],
compression: 'none',
antivirus: false,
},
],
};
+252 -17
View File
@@ -236,6 +236,16 @@ async function run(fake, opts = {}) {
const startsWith = (lines, prefix) => lines.filter((l) => l.startsWith(prefix));
/** Stellt einen älteren Schema-Stand nach: entfernt die Tabellen (samt Spalten und Indizes) und Buckets aus dem Fake. */
function dropFromFake(fake, { tables = [], buckets = [] }) {
for (const map of [fake.state.tables, fake.state.columns, fake.state.indexes]) {
for (const id of [...map.keys()]) {
if (tables.some((t) => id === `aircard/${t}` || id.startsWith(`aircard/${t}/`))) map.delete(id);
}
}
for (const id of buckets) fake.state.buckets.delete(id);
}
function runCli(args, env) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [SCRIPT, ...args], {
@@ -260,10 +270,10 @@ describe('DESIRED', () => {
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
const str = (key, size, required) => ({ key, type: 'string', size, required });
test('Reihenfolge: cards zuerst, dann gallery, reports; Buckets card-images, gallery-images', () => {
test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => {
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports']);
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images']);
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']);
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-images']);
});
test('cards entspricht dem bisherigen Schema (unverändert)', () => {
@@ -336,6 +346,66 @@ describe('DESIRED', () => {
});
});
test('logos entspricht Logo-Spec §8', () => {
const t = table('logos');
assert.equal(t.name, 'Logos');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.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 },
]);
assert.deepEqual(t.indexes, [
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
{ key: 'likes', type: 'key', columns: ['likeCount'] },
]);
});
test('logo_likes entspricht Logo-Spec §8 (Mehrspalten-Index user_logo: userId, logoId)', () => {
const t = table('logo_likes');
assert.equal(t.name, 'Logo Likes');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [str('logoId', 36, true), str('userId', 36, true)]);
assert.deepEqual(t.indexes, [
{ key: 'logo', type: 'key', columns: ['logoId'] },
{ key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] },
]);
});
test('logo_reports entspricht Logo-Spec §8', () => {
const t = table('logo_reports');
assert.equal(t.name, 'Logo Reports');
assert.deepEqual(t.permissions, ['read("label:admin")']);
assert.equal(t.rowSecurity, false);
assert.deepEqual(t.columns, [
str('logoId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
]);
assert.deepEqual(t.indexes, [{ key: 'logo', type: 'key', columns: ['logoId'] }]);
});
test('logo-images entspricht Logo-Spec §8', () => {
assert.deepEqual(bucket('logo-images'), {
id: 'logo-images',
name: 'Logo Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 1048576,
allowedFileExtensions: ['png'],
compression: 'none',
antivirus: false,
});
});
test('Fulltext-Indizes decken genau eine Spalte ab, alle Index-Spalten existieren', () => {
for (const t of DESIRED.tables) {
const keys = t.columns.map((c) => c.key);
@@ -538,6 +608,18 @@ describe('compareIndex', () => {
const { columns, ...none } = ok();
assert.deepEqual(compareIndex(none, desired), ['columns ist [], erwartet [searchText]']);
});
test('Mehrspalten-Index (logo_likes.user_logo): die Reihenfolge der Spalten zählt', () => {
const userLogo = DESIRED.tables.find((t) => t.id === 'logo_likes').indexes.find((i) => i.key === 'user_logo');
const okMulti = { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'], lengths: [], orders: [], status: 'available' };
assert.deepEqual(compareIndex(okMulti, userLogo), []);
assert.deepEqual(compareIndex({ ...okMulti, columns: ['logoId', 'userId'] }, userLogo), [
'columns ist [logoId, userId], erwartet [userId, logoId]',
]);
assert.deepEqual(compareIndex({ ...okMulti, columns: ['userId'] }, userLogo), [
'columns ist [userId], erwartet [userId, logoId]',
]);
});
});
describe('compareBucket', () => {
@@ -588,6 +670,21 @@ describe('compareBucket', () => {
'allowedFileExtensions ist [jpg, png, webp], erwartet [png, webp]',
]);
});
test('logo-images: nur png, höchstens 1 MiB', () => {
const logo = DESIRED.buckets[2];
const okLogo = {
$id: 'logo-images', name: 'Logo Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 1048576, allowedFileExtensions: ['png'], compression: 'none', antivirus: false,
};
assert.deepEqual(compareBucket(okLogo, logo), []);
assert.deepEqual(compareBucket({ ...okLogo, allowedFileExtensions: ['png', 'webp'] }, logo), [
'allowedFileExtensions ist [png, webp], erwartet [png]',
]);
assert.deepEqual(compareBucket({ ...okLogo, maximumFileSize: 5242880 }, logo), [
'maximumFileSize ist 5242880, erwartet 1048576',
]);
});
});
// ---------------------------------------------------------------------------
@@ -622,10 +719,35 @@ describe('main gegen Fake-Server', () => {
'Spalte reports.reason',
'Spalte reports.reporterHash',
'Index reports.gallery',
'Tabelle logos',
'Spalte logos.name',
'Spalte logos.displayName',
'Spalte logos.fileId',
'Spalte logos.ownerId',
'Spalte logos.mono',
'Spalte logos.likeCount',
'Spalte logos.hidden',
'Spalte logos.reportCount',
'Index logos.owner',
'Index logos.hidden',
'Index logos.likes',
'Tabelle logo_likes',
'Spalte logo_likes.logoId',
'Spalte logo_likes.userId',
'Index logo_likes.logo',
'Index logo_likes.user_logo',
'Tabelle logo_reports',
'Spalte logo_reports.logoId',
'Spalte logo_reports.reason',
'Spalte logo_reports.reporterHash',
'Index logo_reports.logo',
'Bucket card-images',
'Bucket gallery-images',
'Bucket logo-images',
];
const NEW_OBJECTS = EXPECTED_OBJECTS.filter((o) => /gallery|reports/.test(o));
// Erweiterung um die Galerie (gallery, reports, gallery-images) bzw. um die Logos (logos, logo_likes, logo_reports, logo-images)
const GALLERY_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) (gallery|reports)(\.|$)|^Bucket gallery-images$/.test(o));
const LOGO_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) logo(s|_likes|_reports)(\.|$)|^Bucket logo-images$/.test(o));
const T = '/v1/tablesdb/aircard/tables';
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
@@ -658,6 +780,31 @@ describe('main gegen Fake-Server', () => {
}],
str('reports', 'reporterHash', 64, true),
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
[T, { tableId: 'logos', name: 'Logos', permissions: [], rowSecurity: true }],
str('logos', 'name', 40, true),
str('logos', 'displayName', 30, false),
str('logos', 'fileId', 36, true),
str('logos', 'ownerId', 36, true),
[`${T}/logos/columns/boolean`, { key: 'mono', required: false, default: false }],
[`${T}/logos/columns/integer`, { key: 'likeCount', required: false, default: 0, min: 0 }],
[`${T}/logos/columns/boolean`, { key: 'hidden', required: false, default: false }],
[`${T}/logos/columns/integer`, { key: 'reportCount', required: false, default: 0, min: 0 }],
[`${T}/logos/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/logos/indexes`, { key: 'hidden', type: 'key', columns: ['hidden'] }],
[`${T}/logos/indexes`, { key: 'likes', type: 'key', columns: ['likeCount'] }],
[T, { tableId: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true }],
str('logo_likes', 'logoId', 36, true),
str('logo_likes', 'userId', 36, true),
[`${T}/logo_likes/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }],
// Mehrspalten-Index: `columns` geht als Liste in dieser Reihenfolge an Appwrite
[`${T}/logo_likes/indexes`, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }],
[T, { tableId: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false }],
str('logo_reports', 'logoId', 36, true),
[`${T}/logo_reports/columns/enum`, {
key: 'reason', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true,
}],
str('logo_reports', 'reporterHash', 64, true),
[`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
@@ -668,16 +815,22 @@ describe('main gegen Fake-Server', () => {
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'],
compression: 'none', antivirus: false,
}],
['/v1/storage/buckets', {
bucketId: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 1048576, allowedFileExtensions: ['png'],
compression: 'none', antivirus: false,
}],
];
const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? '');
test('--check auf leerem Projekt: Exit 1, alle 28 Objekte FEHLT, nichts angelegt', async () => {
test('--check auf leerem Projekt: Exit 1, alle 51 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.equal(EXPECTED_OBJECTS.length, 28);
assert.equal(EXPECTED_OBJECTS.length, 51);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(lines, 'OK').length, 0);
assert.ok(lines.includes('UNVOLLSTÄNDIG: 28 fehlend.'), lines.join('\n'));
assert.ok(lines.includes('UNVOLLSTÄNDIG: 51 fehlend.'), lines.join('\n'));
assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0);
});
@@ -706,13 +859,16 @@ describe('main gegen Fake-Server', () => {
assert.ok(!first.lines.join('\n').includes(KEY));
// Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available)
assert.equal(fake.state.columns.size, 18);
assert.equal(fake.state.indexes.size, 4);
assert.equal(fake.state.columns.size, 31);
assert.equal(fake.state.indexes.size, 10);
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`);
// Indizes wurden erst angelegt, nachdem ALLE Spalten der Tabelle available waren
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), ['gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery']);
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), [
'gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery',
'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo',
]);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
// Zweite Prüfung
@@ -732,24 +888,21 @@ describe('main gegen Fake-Server', () => {
});
});
test('Bestehendes Schema (nur cards + card-images) wird ergänzt, Bestehendes nicht angefasst', async () => {
test('Bestehendes Schema ohne gallery, reports, gallery-images wird ergänzt, Bestehendes nicht angefasst', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Zustand vor der Erweiterung nachstellen
for (const map of [fake.state.tables, fake.state.columns, fake.state.indexes]) {
for (const id of [...map.keys()]) if (/^aircard\/(gallery|reports)(\/|$)/.test(id)) map.delete(id);
}
fake.state.buckets.delete('gallery-images');
dropFromFake(fake, { tables: ['gallery', 'reports'], buckets: ['gallery-images'] });
const requestsBefore = fake.state.requests.length;
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), NEW_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.deepEqual(startsWith(check.lines, 'FEHLT'), GALLERY_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(fake.posts().length, EXPECTED_POSTS.length);
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), NEW_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), GALLERY_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.ok(apply.lines.includes('OK: Tabelle cards'));
assert.ok(apply.lines.includes('OK: Bucket card-images'));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
@@ -760,6 +913,49 @@ describe('main gegen Fake-Server', () => {
});
});
test('Bestehendes Schema ohne die Logo-Objekte wird um sie ergänzt, cards/gallery/reports und die Buckets bleiben unberührt', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Stand vor den Logos nachstellen: cards, gallery, reports, card-images, gallery-images
dropFromFake(fake, { tables: ['logos', 'logo_likes', 'logo_reports'], buckets: ['logo-images'] });
const kept = EXPECTED_OBJECTS.filter((o) => !LOGO_OBJECTS.includes(o));
assert.equal(LOGO_OBJECTS.length, 23);
assert.equal(kept.length, 28);
const requestsBefore = fake.state.requests.length;
const postsBefore = fake.posts().length;
// --check: jedes fehlende neue Objekt als FEHLT, sonst nichts auffällig, nichts angelegt
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), LOGO_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(check.lines, 'ABWEICHUNG').length, 0);
assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
assert.ok(check.lines.includes('UNVOLLSTÄNDIG: 23 fehlend.'), check.lines.join('\n'));
assert.equal(fake.posts().length, postsBefore);
// Anlegen: genau die Logo-Objekte, samt Mehrspalten-Index, in der Reihenfolge von DESIRED
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), LOGO_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.deepEqual(startsWith(apply.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(isLogoPost));
// Mehrspalten-Index: `columns` geht als Liste in dieser Reihenfolge an Appwrite
const userLogo = newPosts.find((r) => r.body.key === 'user_logo');
assert.equal(userLogo.path, `${T}/logo_likes/indexes`);
assert.deepEqual(userLogo.body.columns, ['userId', 'logoId']);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
// Alles Bestehende wurde nur gelesen
assert.equal(fake.otherWrites().length, 0);
assert.equal(fake.state.columns.size, 31);
assert.equal(fake.state.indexes.size, 10);
// danach ist das Schema vollständig
const again = await run(fake, { check: true });
assert.equal(again.code, 0, again.lines.join('\n'));
});
});
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
@@ -887,6 +1083,41 @@ describe('main gegen Fake-Server', () => {
});
});
test('Abweichende Logo-Objekte werden gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.tables.get('aircard/logos').$permissions = ['read("any")'];
fake.state.tables.get('aircard/logo_reports').rowSecurity = true;
fake.state.columns.get('aircard/logos/name').size = 80;
fake.state.columns.get('aircard/logos/mono').default = true;
fake.state.columns.get('aircard/logos/likeCount').default = 1;
fake.state.columns.get('aircard/logo_likes/userId').required = false;
fake.state.indexes.get('aircard/logo_likes/user_logo').columns = ['logoId', 'userId'];
fake.state.buckets.get('logo-images').maximumFileSize = 5242880;
fake.state.buckets.get('logo-images').allowedFileExtensions = ['png', 'webp'];
const requestsBefore = fake.state.requests.length;
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: logos.$permissions ist [read("any")], erwartet []',
'ABWEICHUNG: logos.name.size ist 80, erwartet 40',
'ABWEICHUNG: logos.mono.default ist true, erwartet false',
'ABWEICHUNG: logos.likeCount.default ist 1, erwartet 0',
'ABWEICHUNG: logo_likes.userId.required ist false, erwartet true',
'ABWEICHUNG: logo_likes.user_logo.columns ist [logoId, userId], erwartet [userId, logoId]',
'ABWEICHUNG: logo_reports.rowSecurity ist true, erwartet false',
'ABWEICHUNG: logo-images.maximumFileSize ist 5242880, erwartet 1048576',
'ABWEICHUNG: logo-images.allowedFileExtensions ist [png, webp], erwartet [png]',
]);
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
assert.ok(!lines.includes('OK: Schema vollständig'));
}
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
@@ -1069,6 +1300,10 @@ describe('CLI', () => {
assert.match(stdout, /FEHLT: Index gallery\.search_text/);
assert.match(stdout, /FEHLT: Tabelle reports/);
assert.match(stdout, /FEHLT: Bucket gallery-images/);
assert.match(stdout, /FEHLT: Tabelle logos/);
assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/);
assert.match(stdout, /FEHLT: Tabelle logo_reports/);
assert.match(stdout, /FEHLT: Bucket logo-images/);
assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0);
});
+8 -6
View File
@@ -1,7 +1,9 @@
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
// baut main.ts das Menü neu auf.
import './account.css';
import { el } from '../editor/dom';
import { t } from '../i18n/i18n';
import { currentUser, logout, onAuthChange } from './auth';
import { errorMessage } from './errors';
import { requireLogin } from './login-dialog';
@@ -26,7 +28,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
}
const user = currentUser();
if (!user) {
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden');
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Anmelden'));
signIn.addEventListener('click', () => void requireLogin('login'));
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
return;
@@ -38,18 +40,18 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
{
type: 'button',
class: 'account-avatar',
'aria-label': 'Konto-Menü',
'aria-label': t('Konto-Menü'),
'aria-expanded': 'false',
'aria-controls': 'account-dropdown',
},
initial,
);
const email = el('span', { class: 'account-email', title: user.email }, user.email);
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten');
const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden');
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
const error = el('span', { class: 'account-error', role: 'alert' });
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, avatar, dropdown);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
let isOpen = false;
function setOpen(open: boolean): void {
+6 -1
View File
@@ -7,7 +7,8 @@ export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID
/**
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie.
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie,
* `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien).
*/
export const IDS = {
database: 'aircard',
@@ -16,6 +17,10 @@ export const IDS = {
gallery: 'gallery',
reports: 'reports',
galleryBucket: 'gallery-images',
logos: 'logos',
logoLikes: 'logo_likes',
logoReports: 'logo_reports',
logoBucket: 'logo-images',
} as const;
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
+3 -2
View File
@@ -1,6 +1,7 @@
// Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
import { ID } from 'appwrite';
import { t } from '../i18n/i18n';
import { account } from './appwrite';
import { errorType, isUnauthorized } from './errors';
@@ -132,9 +133,9 @@ export function onLogout(fn: () => void): () => void {
};
}
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer (in der Sprache der Seite). */
export function validatePassword(pw: string): string | null {
return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX
? null
: `Das Passwort muss ${PASSWORD_MIN} bis ${PASSWORD_MAX} Zeichen lang sein.`;
: t('Das Passwort muss {min} bis {max} Zeichen lang sein.', { min: PASSWORD_MIN, max: PASSWORD_MAX });
}
+2 -2
View File
@@ -350,7 +350,7 @@ describe('save: bestehende Karte', () => {
it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => {
const fake = fakeApi([existing()]);
const c = config({ network: 'jcb' });
const c = config({ logo: 'abc123' });
const saved = await service(fake).save(
newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }),
);
@@ -370,7 +370,7 @@ describe('save: bestehende Karte', () => {
expect(saved.id).toBe('row1');
expect(saved.imageFileId).toBe('oldImage');
expect(saved.previewFileId).toBe('file1');
expect(saved.config.network).toBe('jcb');
expect(saved.config.logo).toBe('abc123');
});
it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => {
+7 -6
View File
@@ -1,8 +1,9 @@
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
import { parseConfig, serializeConfig, type CardConfig } from '../card/config';
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
import type { SavedCardRef } from '../editor/state';
import { msg, t } from '../i18n/i18n';
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
import { currentUserId } from './auth';
import { errorType } from './errors';
@@ -44,16 +45,16 @@ export interface SaveInput {
previous: SavedCardRef | null;
}
export const DEFAULT_CARD_NAME = 'Meine Karte';
export const DEFAULT_CARD_NAME = msg('Meine Karte');
const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
export { ID_PATTERN };
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */
export function normalizeName(name: string): string {
const collapsed = name.replace(/\s+/g, ' ').trim();
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
return cut || DEFAULT_CARD_NAME;
return cut || t(DEFAULT_CARD_NAME);
}
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DISPLAY_NAME_KEY, rememberDisplayName, rememberedDisplayName } from './display-name';
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
describe('gemerkter Anzeigename (localStorage)', () => {
it('der Schlüssel heißt aircard.displayName', () => {
expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
});
it('ohne gespeicherten Namen ist er leer', () => {
expect(rememberedDisplayName()).toBe('');
});
it('liest den gespeicherten Namen', () => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
expect(rememberedDisplayName()).toBe('Kenso');
});
it('speichert den Namen getrimmt unter dem Schlüssel und überschreibt den alten', () => {
rememberDisplayName(' Kenso ');
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
rememberDisplayName('Zoë');
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Zoë');
expect(rememberedDisplayName()).toBe('Zoë');
});
it.each([[''], [' ']])('ein leerer Name (%j) entfernt den Schlüssel („Anonym“)', (empty) => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
rememberDisplayName(empty);
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
expect(rememberedDisplayName()).toBe('');
});
it('gesperrter Speicher beim Lesen: leer, kein Fehler', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
expect(rememberedDisplayName()).toBe('');
});
it('gesperrter Speicher beim Schreiben und beim Entfernen: kein Fehler', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
expect(() => rememberDisplayName('Kenso')).not.toThrow();
expect(() => rememberDisplayName('')).not.toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
// Der zuletzt benutzte Anzeigename (Spec § 4): Der Veröffentlichen-Dialog und der Dialog „Logo hochladen“ lesen und schreiben
// denselben Schlüssel in localStorage. Jeder Zugriff steht in try/catch: ist der Speicher gesperrt (z. B. privates Fenster),
// bleibt das Feld leer und der Name wird nicht gemerkt, ohne dass etwas scheitert.
export const DISPLAY_NAME_KEY = 'aircard.displayName';
/** Der gemerkte Anzeigename; leer, wenn keiner gemerkt ist oder der Speicher nicht lesbar ist. */
export function rememberedDisplayName(): string {
try {
return localStorage.getItem(DISPLAY_NAME_KEY) ?? '';
} catch {
return '';
}
}
/** Merkt den Anzeigenamen (getrimmt); ein leerer Name („Anonym“) entfernt den gemerkten. */
export function rememberDisplayName(name: string): void {
const trimmed = name.trim();
try {
if (trimmed === '') localStorage.removeItem(DISPLAY_NAME_KEY);
else localStorage.setItem(DISPLAY_NAME_KEY, trimmed);
} catch {
// Gesperrt oder voll: der Name wird dann nur nicht gemerkt.
}
}
+25 -23
View File
@@ -1,11 +1,13 @@
// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar.
// Fehlermeldungen für Appwrite-Fehler in der Sprache der Seite: Die deutschen Texte sind die Schlüssel für t() (i18n/en.ts).
// Rein und ohne Appwrite-Import, damit gut testbar.
// Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`.
import { msg, t } from '../i18n/i18n';
export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load';
const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.';
const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.';
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
const LINK_INVALID = msg('Der Link ist ungültig oder abgelaufen.');
const WRONG_CREDENTIALS = msg('E-Mail oder Passwort ist falsch.');
const GENERIC = msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
function field(err: unknown, key: 'code' | 'type' | 'message'): unknown {
return typeof err === 'object' && err !== null ? (err as Record<string, unknown>)[key] : undefined;
@@ -40,46 +42,46 @@ export function isInvalidLinkError(err: unknown): boolean {
}
function passwordMessage(err: unknown, ctx: ErrorContext): string {
if (ctx === 'login') return WRONG_CREDENTIALS;
if (ctx === 'login') return t(WRONG_CREDENTIALS);
const message = field(err, 'message');
if (typeof message === 'string' && message.includes('commonly used')) {
return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.';
return t('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.');
}
return 'Das Passwort muss 8 bis 256 Zeichen lang sein.';
return t('Das Passwort muss 8 bis 256 Zeichen lang sein.');
}
/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
/** Meldung für Nutzer in der Sprache der Seite: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
export function errorMessage(err: unknown, ctx: ErrorContext): string {
switch (errorType(err)) {
case 'user_invalid_credentials':
return WRONG_CREDENTIALS;
return t(WRONG_CREDENTIALS);
case 'user_already_exists':
return 'Diese E-Mail ist schon registriert. Melde dich an.';
return t('Diese E-Mail ist schon registriert. Melde dich an.');
case 'user_invalid_token':
return LINK_INVALID;
return t(LINK_INVALID);
case 'user_not_found':
if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.';
if (ctx === 'reset') return LINK_INVALID;
if (ctx === 'recover') return t('Zu dieser E-Mail gibt es kein Konto.');
if (ctx === 'reset') return t(LINK_INVALID);
break;
case 'general_argument_invalid': {
const param = invalidParam(err);
if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.';
if (param === 'email') return t('Bitte gib eine gültige E-Mail-Adresse ein.');
if (param === 'password') return passwordMessage(err, ctx);
if (param === 'userId' || param === 'secret') return LINK_INVALID;
if (param === 'userId' || param === 'secret') return t(LINK_INVALID);
break;
}
case 'row_not_found':
return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.';
return t('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.');
case 'storage_file_not_found':
return 'Das Bild dieser Karte fehlt.';
return t('Das Bild dieser Karte fehlt.');
case 'general_unknown_origin':
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
return t('Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.');
case 'user_session_already_exists':
return 'Du bist schon angemeldet. Bitte lade die Seite neu.';
return t('Du bist schon angemeldet. Bitte lade die Seite neu.');
case 'user_count_exceeded':
return 'Zurzeit sind keine neuen Konten möglich.';
return t('Zurzeit sind keine neuen Konten möglich.');
}
if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.';
if (err instanceof TypeError) return 'Keine Verbindung zum Server.';
return GENERIC;
if (field(err, 'code') === 429) return t('Zu viele Versuche – bitte kurz warten.');
if (err instanceof TypeError) return t('Keine Verbindung zum Server.');
return t(GENERIC);
}
+30 -25
View File
@@ -1,19 +1,20 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
import './account.css';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors';
export type LoginView = 'login' | 'register' | 'forgot';
const TITLES: Record<LoginView, string> = {
login: 'Anmelden',
register: 'Registrieren',
forgot: 'Passwort vergessen',
login: msg('Anmelden'),
register: msg('Registrieren'),
forgot: msg('Passwort vergessen'),
};
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
@@ -37,7 +38,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const closeButton = el(
'button',
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
{ type: 'button', class: 'auth-close', 'aria-label': t('Schließen') },
el('span', { 'aria-hidden': 'true' }, '×'),
);
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
@@ -135,7 +136,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
'aria-controls': 'auth-panel',
tabindex: selected ? 0 : -1,
},
TITLES[tabView],
t(TITLES[tabView]),
);
tab.addEventListener('click', () => select(tabView));
tab.addEventListener('keydown', (event) => {
@@ -145,7 +146,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
});
return tab;
});
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': t('Konto') }, ...tabs);
}
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
@@ -196,10 +197,10 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
if (forView === 'login') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'current-password');
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'current-password');
const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit(t('Anmelden')), el('p', { class: 'auth-aside' }, forgot));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -214,20 +215,22 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
}
if (forView === 'register') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
const address = email.input.value.trim();
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
// Der Fehler liegt am Feld, das ihn verursacht; verglichen wird nie mit dem (übersetzten) Text.
const mismatch = password.input.value !== repeat.input.value;
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
if (problem) {
showError(panel, problem);
(problem === MISMATCH ? repeat : password).input.focus();
(mismatch ? repeat : password).input.focus();
return;
}
void run(
@@ -239,16 +242,16 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
},
(err) => {
if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton('Anmelden', () => showView('login'));
return linkButton(t('Anmelden'), () => showView('login'));
},
);
});
return panel;
}
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login'));
form.append(email.label, errorEl, okEl, submit(t('Link senden')), el('p', { class: 'auth-aside' }, back));
const panel: Panel = { form, first: email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -256,7 +259,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const address = email.input.value.trim();
void run(panel, 'recover', async () => {
await requestPasswordReset(address);
showOk(panel, RESET_SENT);
showOk(panel, t(RESET_SENT));
});
});
return panel;
@@ -268,7 +271,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim();
view = next;
title.textContent = TITLES[next];
title.textContent = t(TITLES[next]);
const panel = buildPanel(next);
if (next === 'forgot') {
content.replaceChildren(panel.form);
@@ -288,6 +291,8 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
pending = new Promise<boolean>((resolve) => {
settle = resolve;
});
// Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben.
closeButton.setAttribute('aria-label', t('Schließen'));
showView(initial);
try {
dialog.showModal();
+3 -2
View File
@@ -1,10 +1,11 @@
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an.
import { msg } from '../i18n/i18n';
import { getCurrentUser } from './auth';
import { errorType, isUnauthorized } from './errors';
import { requireLogin } from './login-dialog';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert. */
export const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert; angezeigt wird er mit `t(LOGIN_NEEDED)`. */
export const LOGIN_NEEDED = msg('Bitte melde dich an, um fortzufahren.');
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
export class LoginCancelled extends Error {
-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="-66 -67.24 1132.01 1132.01" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-width="36" d="M-48-49.24h1096v1096H-48z"/><path fill="currentColor" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 814.11 999.91" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2m-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3"/></svg>

Before

Width:  |  Height:  |  Size: 655 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 -0.49 220.58 37.13" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.125 0 0 -.125 -.2539 59.38386)"><path d="M145.605 261.297c-17.113-15.453-39.343-22.195-74.535-22.195H56.453v184.644H71.07c35.192 0 56.547-6.297 74.535-22.582 18.836-16.773 30.168-42.762 30.168-69.512 0-26.804-11.332-53.589-30.168-70.355M81.981 471.055H2.03V191.832h79.53c42.289 0 72.824 9.977 99.629 32.234 31.851 26.36 50.687 66.09 50.687 107.184 0 82.402-61.566 139.805-149.894 139.805m174.956-279.223h54.465v279.223h-54.465zm187.64 172.074c-32.683 12.094-42.281 20.071-42.281 35.157 0 17.585 17.098 30.949 40.574 30.949 16.32 0 29.731-6.703 43.922-22.614l28.496 37.317c-23.418 20.484-51.437 30.957-82.051 30.957-49.402 0-87.086-34.309-87.086-80.008 0-38.469 17.547-58.164 68.7-76.574 21.32-7.52 32.171-12.531 37.648-15.902 10.883-7.106 16.332-17.165 16.332-28.891 0-22.625-17.988-39.387-42.289-39.387-25.977 0-46.902 12.992-59.441 37.242l-35.188-33.879c25.094-36.835 55.231-53.16 96.672-53.16 56.602 0 96.305 37.633 96.305 91.688 0 44.363-18.356 64.445-80.313 87.105m97.496-32.656c0-82.074 64.446-145.715 147.383-145.715 23.445 0 43.523 4.61 68.285 16.274v64.113c-21.773-21.793-41.058-30.582-65.754-30.582-54.851 0-93.785 39.769-93.785 96.312 0 53.61 40.164 95.899 91.254 95.899 25.977 0 45.641-9.27 68.285-31.418v64.082c-23.906 12.129-43.566 17.152-67 17.152-82.515 0-148.668-64.929-148.668-146.117m647.516-47.75-74.46 187.555h-59.5l118.49-286.387h29.31l120.63 286.387h-59.04zm159.1-91.668h154.46v47.27h-100.04v75.371h96.35v47.297h-96.35v61.976h100.04v47.309h-154.46zm260.84 150.672h-15.91v84.57h16.77c33.92 0 52.36-14.211 52.36-41.386 0-28.071-18.44-43.184-53.22-43.184m109.27 46.117c0 52.274-36.01 82.434-98.82 82.434h-80.76V191.832h54.4V304h7.1l75.39-112.168h66.98l-87.9 117.633c41.02 8.34 63.61 36.402 63.61 79.156m24.44 68.59h-.99v6.414h1.05c2.9 0 4.4-1.043 4.4-3.156 0-2.172-1.52-3.258-4.46-3.258m10.32 3.348c0 4.894-3.37 7.57-9.29 7.57h-7.88v-24.543h5.86v9.519l6.87-9.519h7.15l-8.07 10.121c3.45.93 5.36 3.445 5.36 6.852" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><path d="M1745.37 438.332c-9.38 0-17.05 7.805-17.05 17.543 0 9.773 7.57 17.574 17.05 17.574 9.33 0 16.94-7.98 16.94-17.574 0-9.656-7.61-17.543-16.94-17.543m.08 38.969c-11.92 0-21.38-9.512-21.38-21.395 0-11.875 9.57-21.398 21.38-21.398 11.61 0 21.12 9.625 21.12 21.398 0 11.715-9.51 21.395-21.12 21.395" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><defs><linearGradient id="discover-g" x1=".3" x2=".7" y1="1" y2="0"><stop offset="0" stop-color="#ffb000"/><stop offset="1" stop-color="#e5600f"/></linearGradient></defs><circle cx="924.03" cy="330.5" r="148.5" fill="url(#discover-g)"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="1 1 298.01 229.52" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-17.94377 -13.02567)scale(1.821516)"><path d="M174 108.3c0 14-11.4 25.4-25.4 25.4H10.4V33.1c0-14 11.4-25.4 25.4-25.4H174z" style="fill:#fff"/><linearGradient id="jcb-g1_" x1="117.386" x2="157.33" y1="81.399" y2="81.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M129 82.5h10.5c.3 0 1-.1 1.3-.1 2-.4 3.7-2.2 3.7-4.7 0-2.4-1.7-4.2-3.7-4.7-.3-.1-.9-.1-1.3-.1H129z" style="fill:url(#jcb-g1_)"/><linearGradient id="jcb-g2_" x1="117.384" x2="157.332" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M138.3 16.2c-10 0-18.2 8.1-18.2 18.2v18.9h25.7c.6 0 1.3 0 1.8.1 5.8.3 10.1 3.3 10.1 8.5 0 4.1-2.9 7.6-8.3 8.3v.2c5.9.4 10.4 3.7 10.4 8.8 0 5.5-5 9.1-11.6 9.1H120v37h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g2_)"/><linearGradient id="jcb-g3_" x1="117.385" x2="157.331" y1="68.399" y2="68.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M143.2 63.1c0-2.4-1.7-4-3.7-4.3-.2 0-.7-.1-1-.1H129v8.8h9.5c.3 0 .9 0 1-.1 2-.3 3.7-1.9 3.7-4.3" style="fill:url(#jcb-g3_)"/><linearGradient id="jcb-g4_" x1="27.959" x2="68.522" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#1f286f"/><stop offset=".475" style="stop-color:#004e94"/><stop offset=".826" style="stop-color:#0066b1"/><stop offset="1" style="stop-color:#006fbc"/></linearGradient><path d="M37.7 16.2c-10 0-18.2 8.1-18.2 18.2v44.9c5.1 2.5 10.4 4.1 15.7 4.1 6.3 0 9.7-3.8 9.7-9V53.2h15.6v21.1c0 8.2-5.1 14.9-22.4 14.9-10.5 0-18.7-2.3-18.7-2.3v38.3h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g4_)"/><linearGradient id="jcb-g5_" x1="72.46" x2="111.855" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#6c2c2f"/><stop offset=".173" style="stop-color:#882730"/><stop offset=".573" style="stop-color:#be1833"/><stop offset=".859" style="stop-color:#dc0436"/><stop offset="1" style="stop-color:#e60039"/></linearGradient><path d="M88 16.2c-10 0-18.2 8.1-18.2 18.2v23.8c4.6-3.9 12.6-6.4 25.5-5.8 6.9.3 14.3 2.2 14.3 2.2v7.7c-3.7-1.9-8.1-3.6-13.8-4-9.8-.7-15.7 4.1-15.7 12.5 0 8.5 5.9 13.3 15.7 12.5 5.7-.4 10.1-2.2 13.8-4V87s-7.3 1.9-14.3 2.2c-12.9.6-20.9-1.9-25.5-5.8v42h26.7c10 0 18.2-8.1 18.2-18.2v-91z" style="fill:url(#jcb-g5_)"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="1.97 1.87 296.07 177.27" xmlns="http://www.w3.org/2000/svg"><path d="M298.032 90.5c.014 48.936-39.646 88.614-88.582 88.627-48.937.012-88.614-39.646-88.627-88.582V90.5c-.013-48.935 39.647-88.615 88.581-88.628 48.937-.013 88.615 39.647 88.628 88.583z" style="fill:#f90"/><path d="M90.001 1.895C41.355 2.204 1.967 41.781 1.967 90.5c0 48.909 39.695 88.604 88.605 88.604 22.955 0 43.879-8.748 59.624-23.086l-.007-.004h.019a89.4 89.4 0 0 0 8.995-9.488H141.05a87 87 0 0 1-6.606-9.127h31.308a89 89 0 0 0 5.158-9.488h-41.635a87 87 0 0 1-3.703-9.309h49.045a88.4 88.4 0 0 0 4.56-28.103c0-6.512-.706-12.861-2.042-18.974h-54.164a88 88 0 0 1 2.528-9.308h49.063a89 89 0 0 0-3.82-9.487H129.27a85 85 0 0 1 5.084-9.307h31.285a89 89 0 0 0-6.877-9.488h-17.443a82 82 0 0 1 8.889-8.95c-15.746-14.34-36.676-23.09-59.636-23.09z" style="fill:#c00"/><path d="M289.143 136.82q.723.001 1.409.371.688.37 1.07 1.062c.256.456.384.937.384 1.435q0 .739-.379 1.424-.377.683-1.061 1.063-.678.374-1.424.375c-.746.001-.972-.126-1.426-.375a2.7 2.7 0 0 1-1.063-1.063 2.9 2.9 0 0 1-.377-1.424c0-.498.127-.979.384-1.435a2.66 2.66 0 0 1 1.071-1.062 3 3 0 0 1 1.412-.371m0 .475q-.602 0-1.176.311c-.38.207-.677.5-.891.888a2.4 2.4 0 0 0-.325 1.194q0 .62.315 1.188.321.566.888.885.57.316 1.188.315.62 0 1.189-.315.567-.319.884-.885.313-.568.313-1.188 0-.623-.321-1.194a2.2 2.2 0 0 0-.894-.888 2.4 2.4 0 0 0-1.17-.311m-1.256 3.975v-3.082h1.062q.54-.001.784.088a.76.76 0 0 1 .388.297.81.81 0 0 1-.098 1.039.95.95 0 0 1-.639.281.9.9 0 0 1 .264.164q.186.18.455.61l.375.603h-.606l-.272-.485q-.322-.572-.521-.718-.137-.104-.397-.105h-.293v1.311h-.5m.496-1.738h.604q.431 0 .588-.129a.42.42 0 0 0 .159-.342.4.4 0 0 0-.075-.244.47.47 0 0 0-.213-.162q-.134-.054-.497-.055h-.564v.932" style="fill:#fcb340"/><path d="m133.719 99.926 1.18-8.02c-.645 0-1.593.279-2.431.279-3.284 0-3.694-1.755-3.436-3.037l3.236-16.13h4.992l1.029-9.103h-4.705l.958-5.516H124.7c-.208.208-5.568 31.022-5.568 34.776 0 5.555 3.118 8.027 7.516 7.988 3.442-.029 6.125-.982 7.071-1.237m2.987-15.288c0 13.332 8.799 16.499 16.297 16.499 6.921 0 10.55-1.604 10.55-1.604l1.662-9.1s-5.848 2.378-10.601 2.378c-10.131 0-8.355-7.554-8.355-7.554l19.463.059s1.239-6.111 1.239-8.602c0-6.217-3.387-13.849-13.745-13.849-9.486.002-16.51 10.223-16.51 21.773m16.546-13.325c5.324 0 4.342 5.984 4.342 6.469H147.12c0-.62.989-6.469 6.132-6.469m59.738 28.61 1.689-10.284s-4.632 2.321-7.807 2.321c-6.693 0-9.378-5.11-9.378-10.601 0-11.137 5.758-17.265 12.168-17.265 4.808 0 8.665 2.699 8.665 2.699l1.54-9.993s-4.554-3.289-9.456-3.308c-14.745-.058-23.182 10.208-23.182 27.955 0 11.763 6.248 19.768 17.506 19.768 3.183 0 8.255-1.292 8.255-1.292M81.83 63.012c-6.469 0-11.427 2.079-11.427 2.079l-1.37 8.127s4.093-1.663 10.281-1.663c3.513 0 6.083.395 6.083 3.25 0 1.734-.314 2.374-.314 2.374s-2.772-.231-4.056-.231c-9.21 0-16.729 3.482-16.729 13.98 0 8.273 5.623 10.17 9.108 10.17 6.657 0 9.292-4.203 9.444-4.215l-.077 3.488h8.307l3.706-25.98c0-11.025-9.616-11.379-12.956-11.379m1.438 21.096c.181 1.586-.41 9.086-6.092 9.086-2.93 0-3.691-2.24-3.691-3.562 0-2.584 1.403-5.683 8.315-5.683 1.61 0 1.197.116 1.468.159m20.347 16.798c2.125 0 14.272.541 14.272-11.994 0-11.721-11.244-9.404-11.244-14.114 0-2.342 1.833-3.08 5.184-3.08 1.329 0 6.447.423 6.447.423l1.189-8.33c0 .001-3.312-.741-8.704-.741-6.979 0-14.063 2.786-14.063 12.318 0 10.802 11.812 9.717 11.812 14.267 0 3.037-3.3 3.287-5.844 3.287-4.401 0-8.363-1.511-8.377-1.438l-1.259 8.245c.229.07 2.674 1.157 10.587 1.157m187.192-45.451-1.705 12.709s-3.553-4.905-9.112-4.905c-10.459 0-15.849 10.423-15.849 22.396 0 7.73 3.844 15.307 11.699 15.307 5.651 0 8.784-3.941 8.784-3.941l-.415 3.365h9.178l7.207-44.862zm-4.052 24.701c0 4.983-2.468 11.64-7.581 11.64-3.396 0-4.988-2.851-4.988-7.324 0-7.315 3.285-12.14 7.432-12.14 3.394 0 5.137 2.33 5.137 7.824M30.749 100.423l5.743-33.87.844 33.87h6.499l12.125-33.87-5.371 33.87h9.658l7.437-44.922-15.342-.117-9.126 27.504-.25-27.387h-14.06l-7.544 44.922z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M176.101 100.487c2.746-15.615 3.724-27.947 11.732-25.393 1.15-6.044 3.891-11.3 5.143-13.858 0 0-.396-.589-2.871-.589-4.225 0-9.866 8.574-9.866 8.574l.843-5.301h-8.786l-5.884 36.566h9.689z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M-612.55 63.012c-6.472 0-11.43 2.079-11.43 2.079l-1.369 8.127s4.095-1.663 10.28-1.663c3.514 0 6.083.395 6.083 3.25 0 1.734-.313 2.374-.313 2.374s-2.771-.231-4.055-.231c-9.211 0-16.729 3.482-16.729 13.98 0 8.273 5.622 10.17 9.107 10.17 6.655 0 9.292-4.203 9.443-4.215l-.078 3.488h8.309l3.705-25.98c.001-11.025-9.615-11.379-12.953-11.379m1.436 21.096c.18 1.586-.411 9.086-6.092 9.086-2.932 0-3.692-2.24-3.692-3.562 0-2.584 1.402-5.683 8.315-5.683 1.611 0 1.199.116 1.469.159" style="fill:#006" transform="translate(831.55605 15.9939)"/><path d="M255.266 100.487c1.508-11.488 4.299-27.616 11.731-25.393 1.149-6.044.041-6.028-2.433-6.028-4.228 0-5.164.154-5.164.154l.844-5.301h-8.785l-5.884 36.567h9.691z" style="fill:#006" Line truncated

Before

Width:  |  Height:  |  Size: 8.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><path fill="#eb001b" d="M308 0a309 309 0 1 0 2 0z"/><path fill="#f79e1b" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#ff5f00" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>

Before

Width:  |  Height:  |  Size: 253 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-text-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-text-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="#8c8c8c" d="M364 66.1h270.4v485.8H364z"/><path fill="url(#mc-silver-text-g1)" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="url(#mc-silver-text-g2)" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#mc-silver-g1)" d="M308 0a309 309 0 1 0 2 0z"/><path fill="url(#mc-silver-g2)" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#8c8c8c" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>

Before

Width:  |  Height:  |  Size: 686 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><path fill="#ff5a00" d="M364 66.1h270.4v485.8H364z"/><path fill="#eb001b" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="#f79e1b" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated

Before

Width:  |  Height:  |  Size: 7.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 51.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 54.65 66.613 52.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated

Before

Width:  |  Height:  |  Size: 5.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="up-silver-g1" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#f8f8f8"/><stop offset="1" stop-color="#bdbdbd"/></linearGradient><linearGradient id="up-silver-g2" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#d6d6d6"/><stop offset="1" stop-color="#8a8a8a"/></linearGradient><linearGradient id="up-silver-g3" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#ebebeb"/><stop offset="1" stop-color="#a4a4a4"/></linearGradient><mask id="up-silver-m" width="280" height="180" x="-10" y="-10" maskUnits="userSpaceOnUse"><path fill="#fff" d="M-10-10h280v180H-10z"/><path d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.353c-4.148.802-4.246.93-8.076 1.421l-.299.267c-.042.128Line truncated

Before

Width:  |  Height:  |  Size: 8.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="-9.6 -9.6 274.74 179.05" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.Line truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 825 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1434cb" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 820 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1a1f71" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 820 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="visa-silver-g" x1=".15" x2=".85" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#visa-silver-g)" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

+63 -11
View File
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { describe, expect, it } from 'vitest';
import indexHtml from '../index.html?raw';
import { initialLang } from './i18n/i18n';
import { initialTheme } from './shell/theme';
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
@@ -16,7 +17,11 @@ function pngInfo(buf: Buffer) {
describe('Name und Kopfbereich', () => {
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
expect(indexHtml).toContain('<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>');
// Statisch englisch, damit Link-Vorschauen englisch sind; der Router setzt danach den Titel in der gewählten Sprache.
expect(indexHtml).toContain('<title>AirCard-IOS – Card designs for Apple Wallet</title>');
expect(indexHtml).toContain(
'<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG." />',
);
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
});
@@ -33,10 +38,9 @@ describe('Name und Kopfbereich', () => {
);
});
it('Fußzeilen-Hinweis bleibt unverändert', () => {
expect(indexHtml).toContain(
'Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.',
);
it('Fußzeile ist leer: ihren Text setzt mountFooter in der gewählten Sprache', () => {
expect(indexHtml).toContain('<footer id="app-footer"></footer>');
expect(indexHtml).not.toContain('Inoffizielles Hobbyprojekt');
});
});
@@ -50,20 +54,33 @@ describe('Icon-Dateien', () => {
});
});
describe('Inline-Skript für Hell/Dunkel in index.html', () => {
describe('Inline-Skript für Hell/Dunkel und Sprache in index.html', () => {
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
/** Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat. */
function run(stored: string | null, prefersDark: boolean, storageThrows = false) {
/** Was der Browser zur Sprache hergibt: der gespeicherte Wert von `aircard.lang`, `navigator.languages` und `navigator.language`. */
interface BrowserLang {
stored?: string | null;
languages?: readonly string[];
language?: string;
}
/**
* Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat.
* `stored` ist der Wert von `aircard.theme`; die Sprache steht in `browser`.
*/
function run(stored: string | null, prefersDark: boolean, storageThrows = false, browser: BrowserLang = {}) {
const attributes: Record<string, string> = {};
const sandbox: Record<string, unknown> = {
localStorage: {
getItem: () => {
getItem: (key: string) => {
if (storageThrows) throw new Error('gesperrt');
return stored;
return key === 'aircard.lang' ? (browser.stored ?? null) : stored;
},
},
window: { matchMedia: () => ({ matches: prefersDark }) },
window: {
matchMedia: () => ({ matches: prefersDark }),
navigator: { languages: browser.languages, language: browser.language },
},
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
};
runInNewContext(script, sandbox);
@@ -79,6 +96,7 @@ describe('Inline-Skript für Hell/Dunkel in index.html', () => {
const { sandbox } = run('dark', false);
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
expect('theme' in sandbox).toBe(false);
expect('lang' in sandbox).toBe(false);
});
it.each([
@@ -99,4 +117,38 @@ describe('Inline-Skript für Hell/Dunkel in index.html', () => {
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
expect(run(null, false, true).attributes['data-theme']).toBe('light');
});
it.each([
[null, ['de-DE', 'en']],
[null, ['en-US', 'de']],
['en', ['de-DE']],
['de', ['en-US']],
['xx', ['de-AT']],
['xx', ['fr-FR']],
['', ['de']],
[null, ['DE']],
[null, ['de_CH']],
[null, []],
] as const)('setzt <html lang> nach derselben Regel wie initialLang(): gespeichert %j, Browsersprachen %j', (stored, languages) => {
const { attributes } = run(null, true, false, { stored, languages });
expect(attributes.lang).toBe(initialLang(stored, languages));
});
it('ohne navigator.languages gilt navigator.language (wie currentLang), ohne beides Englisch', () => {
expect(run(null, true, false, { language: 'de-CH' }).attributes.lang).toBe('de');
expect(run(null, true, false, { language: 'en-GB' }).attributes.lang).toBe('en');
expect(run(null, true, false, { languages: [], language: 'de' }).attributes.lang).toBe('de');
expect(run(null, true, false, {}).attributes.lang).toBe('en');
});
it('gesperrter localStorage: es gilt die Browsersprache', () => {
expect(run(null, true, true, { stored: 'en', languages: ['de-DE'] }).attributes.lang).toBe('de');
expect(run(null, true, true, { stored: 'de', languages: ['en-US'] }).attributes.lang).toBe('en');
});
it('die gespeicherte Sprache hat mit dem Theme nichts zu tun (und umgekehrt)', () => {
const { attributes } = run('light', false, false, { stored: 'en', languages: ['de'] });
expect(attributes['data-theme']).toBe('light');
expect(attributes.lang).toBe('en');
});
});
+49 -22
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
DEFAULT_CONFIG,
DEFAULT_CROP,
NETWORK_IDS,
ID_PATTERN,
TONE_COLOR,
parseConfig,
serializeConfig,
@@ -12,9 +12,9 @@ import {
const custom: CardConfig = {
v: 1,
template: 'classic',
network: 'jcb',
logo: 'abc123',
details: 'dark',
brand: 'apple',
brand: 'aircards-text',
crop: { x: -120.5, y: 33, zoom: 2.5 },
};
@@ -23,7 +23,7 @@ describe('Standardwerte', () => {
expect(DEFAULT_CONFIG).toEqual({
v: 1,
template: 'signature',
network: 'visa-auto',
logo: null,
details: 'light',
brand: 'aircards-symbol',
crop: { x: 0, y: 0, zoom: 1 },
@@ -36,16 +36,13 @@ describe('Standardwerte', () => {
expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP);
});
it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => {
expect(NETWORK_IDS.length).toBe(18);
expect([...NETWORK_IDS]).toEqual([
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
'up-color', 'up-silver', 'up-white-frame', 'up-english',
'amex-blue', 'amex-outline', 'amex-select',
'jcb', 'discover',
]);
expect(new Set(NETWORK_IDS).size).toBe(18);
it('eine neue Karte hat kein Logo unten rechts', () => {
expect(DEFAULT_CONFIG.logo).toBeNull();
});
it('ID_PATTERN: Appwrite-IDs mit höchstens 36 Zeichen und ohne Sonderzeichen am Anfang', () => {
for (const ok of ['abc123', 'A', '0', 'a.b_c-d', 'x'.repeat(36)]) expect(ID_PATTERN.test(ok), ok).toBe(true);
for (const bad of ['', 'kaputt id', '-abc', '_abc', '.abc', 'ä', 'a/b', 'x'.repeat(37)]) expect(ID_PATTERN.test(bad), bad).toBe(false);
});
it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => {
@@ -72,9 +69,31 @@ describe('parseConfig / serializeConfig', () => {
}
});
it('unbekanntes Netzwerk setzt nur dieses Feld auf den Standard', () => {
const raw = JSON.stringify({ ...custom, network: 'paypal' });
expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network });
it('eine alte Konfiguration mit network und Apple-Logo: network entfällt, Logo oben links fällt auf den Standard', () => {
const raw = '{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}';
expect(parseConfig(raw)).toEqual({
v: 1,
template: 'classic',
logo: null,
details: 'dark',
brand: 'aircards-symbol',
crop: { x: 0, y: 0, zoom: 1 },
});
// andere unbekannte Marken ebenso
expect(parseConfig('{"brand":"visa"}').brand).toBe('aircards-symbol');
});
it('logo: eine gültige ID bleibt, alles andere ergibt null', () => {
expect(parseConfig('{"logo":"abc123"}').logo).toBe('abc123');
expect(parseConfig(JSON.stringify({ logo: 'x'.repeat(36) })).logo).toBe('x'.repeat(36));
for (const bad of ['kaputt id', '', '-abc', 'x'.repeat(37), 42, true, null, ['abc'], { id: 'abc' }]) {
expect(parseConfig(JSON.stringify({ logo: bad })).logo, JSON.stringify(bad)).toBeNull();
}
expect(parseConfig('{}').logo).toBeNull();
});
it('das gewählte Logo bleibt nur dem Feld logo zugeordnet: ein altes network wird nie zum Logo', () => {
expect(parseConfig('{"network":"abc123"}').logo).toBeNull();
});
it('jedes Feld wird einzeln validiert', () => {
@@ -88,11 +107,11 @@ describe('parseConfig / serializeConfig', () => {
});
it('Namen aus Object.prototype gelten nicht als gültige Werte', () => {
const raw = JSON.stringify({ ...custom, network: 'constructor', template: '__proto__', brand: 'toString' });
const raw = JSON.stringify({ ...custom, template: '__proto__', details: 'constructor', brand: 'toString' });
expect(parseConfig(raw)).toEqual({
...custom,
network: DEFAULT_CONFIG.network,
template: DEFAULT_CONFIG.template,
details: DEFAULT_CONFIG.details,
brand: DEFAULT_CONFIG.brand,
});
});
@@ -137,21 +156,29 @@ describe('parseConfig / serializeConfig', () => {
const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } });
const c = parseConfig(raw);
expect(c).toEqual(custom);
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'network', 'template', 'v']);
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'logo', 'template', 'v']);
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
});
it('serializeConfig schreibt nur bekannte Felder', () => {
const s = serializeConfig({ ...custom, evil: 1 } as CardConfig);
const s = serializeConfig({ ...custom, evil: 1, network: 'jcb' } as CardConfig);
expect(s).not.toContain('evil');
expect(s).not.toContain('network');
expect(JSON.parse(s)).toEqual(custom);
});
it('serializeConfig schreibt genau {v, template, logo, details, brand, crop} in dieser Reihenfolge', () => {
expect(Object.keys(JSON.parse(serializeConfig(custom)))).toEqual(['v', 'template', 'logo', 'details', 'brand', 'crop']);
expect(serializeConfig(DEFAULT_CONFIG)).toBe(
'{"v":1,"template":"signature","logo":null,"details":"light","brand":"aircards-symbol","crop":{"x":0,"y":0,"zoom":1}}',
);
});
it('serializeConfig bleibt unter 2000 Zeichen', () => {
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
const worst: CardConfig = {
...custom,
network: 'mc-silver-text',
logo: 'x'.repeat(36),
brand: 'aircards-symbol',
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
};
+19 -14
View File
@@ -3,21 +3,17 @@
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
const TONES = ['light', 'dark'] as const;
const BRANDS = ['aircards-symbol', 'aircards-text', 'apple', 'none'] as const;
const NETWORKS = [
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
'up-color', 'up-silver', 'up-white-frame', 'up-english',
'amex-blue', 'amex-outline', 'amex-select',
'jcb', 'discover',
] as const;
const BRANDS = ['aircards-symbol', 'aircards-text', 'none'] as const;
export type TemplateId = (typeof TEMPLATES)[number];
export type DetailsTone = (typeof TONES)[number];
export type BrandId = (typeof BRANDS)[number];
export type NetworkId = (typeof NETWORKS)[number];
export const NETWORK_IDS: readonly NetworkId[] = NETWORKS;
/**
* Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. Steht hier, damit die reine
* Konfiguration die ID des Community-Logos prüfen kann, ohne Appwrite zu laden (account/cards.ts reicht sie weiter).
*/
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Zoom-Grenzen relativ zum Cover-Maßstab. */
export const MIN_ZOOM = 1;
@@ -33,8 +29,10 @@ export interface Crop {
export interface CardConfig {
v: 1;
template: TemplateId;
network: NetworkId;
/** ID des Community-Logos unten rechts; null = keins. */
logo: string | null;
details: DetailsTone;
/** Das Logo oben links (AirCard-IOS Symbol oder Text, oder keins). */
brand: BrandId;
crop: Crop;
}
@@ -44,7 +42,7 @@ export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
export const DEFAULT_CONFIG: CardConfig = {
v: 1,
template: 'signature',
network: 'visa-auto',
logo: null,
details: 'light',
brand: 'aircards-symbol',
crop: { ...DEFAULT_CROP },
@@ -61,6 +59,11 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
: fallback;
}
/** Die ID eines Community-Logos; alles, was keine Appwrite-ID sein kann, ergibt null (kein Logo). */
function parseLogo(value: unknown): string | null {
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
}
function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
@@ -82,6 +85,8 @@ function parseCrop(value: unknown): Crop {
/**
* Liest eine gespeicherte Konfiguration. Jedes Feld wird einzeln geprüft, Unbekanntes oder
* Kaputtes fällt auf den Standard dieses Feldes zurück. Liefert immer ein neues Objekt.
* Ältere Karten kennen noch das Feld `network` (Kartennetzwerk) und das Apple-Logo: `network` wird ignoriert,
* `brand: 'apple'` fällt auf den Standard.
*/
export function parseConfig(json: string | null | undefined): CardConfig {
let raw: unknown = null;
@@ -96,7 +101,7 @@ export function parseConfig(json: string | null | undefined): CardConfig {
return {
v: 1,
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template),
network: pick(r.network, NETWORKS, DEFAULT_CONFIG.network),
logo: parseLogo(r.logo),
details: pick(r.details, TONES, DEFAULT_CONFIG.details),
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
crop: parseCrop(r.crop),
@@ -108,7 +113,7 @@ export function serializeConfig(c: CardConfig): string {
return JSON.stringify({
v: 1,
template: c.template,
network: c.network,
logo: c.logo,
details: c.details,
brand: c.brand,
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
+40 -27
View File
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
import {
CARD_H,
CARD_W,
LOGO_MAX_H,
MARGIN,
PREVIEW_RADIUS,
WALLET_KEEP_CLEAR,
fitBrand,
fitNetwork,
fitLogo,
layoutFor,
type Box,
} from './geometry';
@@ -41,36 +42,48 @@ describe('Konstanten', () => {
});
});
describe('fitNetwork', () => {
it('Standardbreite 330 × 109 sitzt unten rechts auf dem Rand', () => {
const b = fitNetwork(330 / 109, 109);
expect(b.x).toBeCloseTo(1150, 1);
expect(b.y).toBeCloseTo(804, 1);
expect(b.w).toBeCloseTo(330, 1);
expect(b.h).toBeCloseTo(109, 1);
describe('fitLogo', () => {
it('LOGO_MAX_H ist 125', () => {
expect(LOGO_MAX_H).toBe(125);
});
it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => {
expect(fitLogo(2, 125)).toEqual({ x: 1230, y: 788, w: 250, h: 125 });
});
it('ohne maxH gilt LOGO_MAX_H', () => {
expect(fitLogo(2)).toEqual(fitLogo(2, LOGO_MAX_H));
expect(fitLogo(1)).toEqual({ x: 1355, y: 788, w: 125, h: 125 });
});
it('sehr breite Logos werden auf 360 begrenzt', () => {
const b = fitNetwork(6, 109);
const b = fitLogo(10, 125);
expect(b.w).toBeCloseTo(360, 1);
expect(b.h).toBeCloseTo(60, 1);
expect(b.h).toBeCloseTo(36, 1);
expect(b.x + b.w).toBeCloseTo(1480, 1);
expect(b.y + b.h).toBeCloseTo(913, 1);
});
it('reine Symbole dürfen bis 125 px hoch sein', () => {
const b = fitNetwork(1, 125);
expect(b.w).toBeCloseTo(125, 1);
it('hohe Logos werden auf die Höhe begrenzt', () => {
const b = fitLogo(0.5, 125);
expect(b.h).toBeCloseTo(125, 1);
expect(b.w).toBeCloseTo(62.5, 1);
expect(b.x + b.w).toBeCloseTo(1480, 1);
expect(b.y + b.h).toBeCloseTo(913, 1);
});
it('maxH ist einstellbar', () => {
const b = fitLogo(1, 100);
expect(b.w).toBeCloseTo(100, 1);
expect(b.h).toBeCloseTo(100, 1);
expect(b.y + b.h).toBeCloseTo(913, 1);
});
it('Seitenverhältnis bleibt erhalten', () => {
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) {
const b = fitNetwork(aspect, 109);
const b = fitLogo(aspect);
expect(b.w / b.h).toBeCloseTo(aspect, 1);
expect(b.h).toBeLessThanOrEqual(109 + 1e-9);
expect(b.h).toBeLessThanOrEqual(LOGO_MAX_H + 1e-9);
expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
}
});
@@ -105,23 +118,23 @@ describe('fitBrand', () => {
});
describe('layoutFor', () => {
it('image: kein Netzwerk, keine Marke, keine optionalen Felder', () => {
it('image: kein Logo, keine Marke, keine optionalen Felder', () => {
const l = layoutFor('image');
expect(l.network).toBe(false);
expect(l.logo).toBe(false);
expect(l.brand).toBe(false);
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
});
it('minimal: nur Netzwerk', () => {
it('minimal: nur Logo', () => {
const l = layoutFor('minimal');
expect(l.network).toBe(true);
expect(l.logo).toBe(true);
expect(l.brand).toBe(false);
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
});
it('classic: alles', () => {
const l = layoutFor('classic');
expect(l.network).toBe(true);
expect(l.logo).toBe(true);
expect(l.brand).toBe(true);
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
expect(l.chip).toEqual({
@@ -141,9 +154,9 @@ describe('layoutFor', () => {
});
});
it('signature: Netzwerk, Marke und PLATINUM links', () => {
it('signature: Logo, Marke und PLATINUM links', () => {
const l = layoutFor('signature');
expect(l.network).toBe(true);
expect(l.logo).toBe(true);
expect(l.brand).toBe(true);
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
expect(l.chip).toBeUndefined();
@@ -193,9 +206,9 @@ describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (
if (l.contactless) boxes.contactless = l.contactless.box;
if (l.number) boxes.number = NUMBER_BOX;
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
if (l.network) {
boxes.networkSymbol = fitNetwork(1, 125);
boxes.networkWide = fitNetwork(6, 109);
if (l.logo) {
boxes.logoSquare = fitLogo(1);
boxes.logoWide = fitLogo(10);
}
if (l.brand) boxes.brand = fitBrand(3.6);
+17 -13
View File
@@ -20,19 +20,21 @@ export interface Box {
/** Unten links blendet Wallet die letzten vier Ziffern ein, hier darf nichts liegen. */
export const WALLET_KEEP_CLEAR: Box = { x: 0, y: 779, w: 620, h: 190 };
const NETWORK_MAX_W = 360;
const NETWORK_RIGHT = CARD_W - MARGIN; // 1480
const NETWORK_BOTTOM = CARD_H - MARGIN; // 913
/** Höhe, bis zu der ein Community-Logo unten rechts auf der Karte wachsen darf. */
export const LOGO_MAX_H = 125;
const LOGO_MAX_W = 360;
const LOGO_RIGHT = CARD_W - MARGIN; // 1480
const LOGO_BOTTOM = CARD_H - MARGIN; // 913
const BRAND_MAX_W = 360;
/** Netzwerk-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
export function fitNetwork(aspect: number, maxH: number): Box {
const w = Math.min(NETWORK_MAX_W, maxH * aspect);
/** Community-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
export function fitLogo(aspect: number, maxH = LOGO_MAX_H): Box {
const w = Math.min(LOGO_MAX_W, maxH * aspect);
const h = w / aspect;
return { x: NETWORK_RIGHT - w, y: NETWORK_BOTTOM - h, w, h };
return { x: LOGO_RIGHT - w, y: LOGO_BOTTOM - h, w, h };
}
/** AirCard-/Apple-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
/** AirCard-IOS-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
export function fitBrand(aspect: number, maxH = 100): Box {
const w = Math.min(BRAND_MAX_W, maxH * aspect);
const h = w / aspect;
@@ -40,7 +42,9 @@ export function fitBrand(aspect: number, maxH = 100): Box {
}
export interface Layout {
network: boolean;
/** Community-Logo unten rechts. */
logo: boolean;
/** AirCard-IOS-Logo oben links. */
brand: boolean;
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
chip?: {
@@ -55,10 +59,10 @@ export interface Layout {
}
const LAYOUTS: Record<TemplateId, Layout> = {
image: { network: false, brand: false },
minimal: { network: true, brand: false },
image: { logo: false, brand: false },
minimal: { logo: true, brand: false },
classic: {
network: true,
logo: true,
brand: true,
platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' },
chip: {
@@ -80,7 +84,7 @@ const LAYOUTS: Record<TemplateId, Layout> = {
},
},
signature: {
network: true,
logo: true,
brand: true,
platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' },
},
+55 -107
View File
@@ -1,62 +1,39 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { NETWORK_IDS } from './config';
import favicon from '../../public/favicon.svg?raw';
import {
BRAND_MARKS,
NETWORK_MARKS,
getBrandMark,
getNetworkMark,
loadMarkImage,
svgAspect,
tintSvg,
} from './marks';
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg, viewBoxAspect } from './marks';
const NETWORK_LABELS = [
'Automatisch', 'Blau', 'Marineblau', 'Metallisches Silber',
'Farbe', 'Metallisches Silber', 'Wortmarke', 'Silber + Text', 'Klassisch',
'Farbe', 'Metallisches Silber', 'Weißer Rahmen', 'Englisch',
'Blau', 'Umriss', 'Select',
'JCB', 'Discover',
];
const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
const ALL = [...NETWORK_MARKS, ...BRAND_MARKS];
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple'];
describe('Registers', () => {
it('NETWORK_MARKS folgt NETWORK_IDS', () => {
expect(NETWORK_MARKS.map((m) => m.id)).toEqual([...NETWORK_IDS]);
});
it('Labels stehen im Wortlaut der Vorlage in Reihenfolge', () => {
expect(NETWORK_MARKS.map((m) => m.label)).toEqual(NETWORK_LABELS);
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text', 'Apple']);
});
it('BRAND_MARKS: Logo-Gruppe, feste Reihenfolge', () => {
expect(BRAND_MARKS.map((m) => m.id)).toEqual(['aircards-symbol', 'aircards-text', 'apple']);
describe('Register', () => {
it('BRAND_MARKS: nur die zwei AirCard-IOS-Marken, in fester Reihenfolge, Logo-Gruppe', () => {
expect(BRAND_MARKS.map((m) => m.id)).toEqual(BRAND_IDS);
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
});
it('Gruppen der Netzwerke', () => {
const groups = NETWORK_MARKS.map((m) => m.group);
expect(groups).toEqual([
'Visa', 'Visa', 'Visa', 'Visa',
'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard',
'UnionPay', 'UnionPay', 'UnionPay', 'UnionPay',
'American Express', 'American Express', 'American Express',
'JCB', 'Discover',
]);
it('Labels stehen im Wortlaut der Vorlage', () => {
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']);
});
it('getNetworkMark / getBrandMark finden den Eintrag', () => {
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id);
expect(getBrandMark('apple').id).toBe('apple');
it('getBrandMark findet den Eintrag', () => {
for (const id of BRAND_IDS) expect(getBrandMark(id as 'aircards-symbol').id).toBe(id);
expect(getBrandMark('aircards-text').label).toBe('AirCard-IOS · Text');
});
it('keine Netzwerk- und keine Apple-Marken mehr im Register', () => {
const registry = BRAND_MARKS.map((m) => `${m.id} ${m.group} ${m.label}`).join('\n');
expect(registry).not.toMatch(/visa|mastercard|unionpay|american express|jcb|discover|apple/i);
});
it('assets/marks enthält nur noch die zwei AirCard-IOS-Dateien (keine fremden Marken-Logos)', () => {
const files = readdirSync(join(import.meta.dirname, '../assets/marks')).sort();
expect(files).toEqual(['aircards-symbol.svg', 'aircards-text.svg']);
});
});
describe('SVG-Inhalt', () => {
it.each(ALL.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
expect(m.svg.trimStart().startsWith('<svg')).toBe(true);
expect(m.svg).toMatch(/viewBox="[^"]+"/);
expect(m.svg).not.toContain('<image');
@@ -67,7 +44,7 @@ describe('SVG-Inhalt', () => {
});
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
for (const m of ALL) {
for (const m of BRAND_MARKS) {
const root = /^<svg\b[^>]*>/.exec(m.svg.trimStart())![0];
expect(root, m.id).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(root, m.id).not.toMatch(/\s(width|height)=/);
@@ -84,24 +61,24 @@ describe('SVG-Inhalt', () => {
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
});
it('svgAspect: Visa ist ein breites Wortlogo', () => {
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1);
it('viewBoxAspect: Breite durch Höhe aus dem Wert einer viewBox (Leerraum oder Kommas), sonst null', () => {
expect(viewBoxAspect('0 0 300 100')).toBeCloseTo(3, 5);
expect(viewBoxAspect(' 10,20,200,100 ')).toBeCloseTo(2, 5);
expect(viewBoxAspect('0 0 100 100')).toBe(1);
for (const unusable of ['', 'none', '0 0 0 10', '0 0 10 0', '0 0 -10 5', '0 0 10', '0 0 1 2 3', '0 0 a b', '0 0 Infinity 5']) {
expect(viewBoxAspect(unusable), JSON.stringify(unusable)).toBeNull();
}
});
it('Einfärbbar genau bei den fünf Tint-Marken', () => {
const tinted = ALL.filter((m) => m.tint).map((m) => m.id).sort();
expect(tinted).toEqual([...TINT_IDS].sort());
});
it('Tint-Marken enthalten currentColor, alle anderen nicht', () => {
for (const m of ALL) {
expect(m.svg.includes('currentColor'), m.id).toBe(m.tint);
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
for (const m of BRAND_MARKS) {
expect(m.tint, m.id).toBe(true);
expect(m.svg.includes('currentColor'), m.id).toBe(true);
}
});
it('tintSvg ersetzt jedes currentColor', () => {
for (const id of TINT_IDS) {
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
for (const m of BRAND_MARKS) {
const dark = tintSvg(m.svg, 'dark');
expect(dark).toContain('#111111');
expect(dark).not.toContain('currentColor');
@@ -111,14 +88,14 @@ describe('SVG-Inhalt', () => {
}
});
it('tintSvg lässt Marken ohne currentColor unverändert', () => {
const m = getNetworkMark('jcb');
expect(tintSvg(m.svg, 'dark')).toBe(m.svg);
it('tintSvg lässt ein SVG ohne currentColor unverändert', () => {
const svg = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
expect(tintSvg(svg, 'dark')).toBe(svg);
});
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
const seen = new Map<string, string>();
for (const m of ALL) {
for (const m of BRAND_MARKS) {
for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
const id = hit[1];
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false);
@@ -129,36 +106,15 @@ describe('SVG-Inhalt', () => {
});
describe('Verweise innerhalb der Dateien', () => {
it.each(ALL.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
const defined = new Set([...m.svg.matchAll(/\bid="([^"]+)"/g)].map((h) => h[1]));
const refs = [...m.svg.matchAll(/url\(\s*['"]?#([^)'"\s]+)/g)].map((h) => h[1]);
for (const ref of refs) expect(defined.has(ref), `${ref} fehlt in ${m.id}`).toBe(true);
});
it('die Silber-/Verlaufs-Marken verwenden überhaupt Verweise', () => {
for (const id of ['visa-silver', 'mc-silver', 'mc-silver-text', 'up-silver', 'jcb', 'discover']) {
expect(getNetworkMark(id as never).svg, id).toMatch(/url\(#/);
}
});
});
describe('maxH', () => {
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
it('109 für Visa, Mastercard-Wortmarken, UnionPay English, Discover', () => {
for (const id of ['visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', 'mc-wordmark', 'mc-silver-text', 'up-english', 'discover']) {
expect(h(id), id).toBe(109);
}
});
it('125 für Symbol-Logos', () => {
for (const id of ['mc-color', 'mc-silver', 'mc-classic', 'up-color', 'up-silver', 'up-white-frame', 'amex-blue', 'amex-outline', 'jcb']) {
expect(h(id), id).toBe(125);
}
});
it('90 für Amex Select, 100 für alle Marken', () => {
expect(h('amex-select')).toBe(90);
it('100 für beide Marken', () => {
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true);
});
});
@@ -290,11 +246,11 @@ describe('loadMarkImage', () => {
it('gleiches (id, tone) liefert dieselbe Promise und lädt nur einmal, anderer Ton eine andere', async () => {
const m = await fresh();
const visa = m.getNetworkMark('visa-auto');
const a = m.loadMarkImage(visa, 'dark');
const b = m.loadMarkImage(visa, 'dark');
const c = m.loadMarkImage(visa, 'light');
const d = m.loadMarkImage(m.getNetworkMark('jcb'), 'dark');
const symbol = m.getBrandMark('aircards-symbol');
const a = m.loadMarkImage(symbol, 'dark');
const b = m.loadMarkImage(symbol, 'dark');
const c = m.loadMarkImage(symbol, 'light');
const d = m.loadMarkImage(m.getBrandMark('aircards-text'), 'dark');
expect(b).toBe(a);
expect(c).not.toBe(a);
expect(d).not.toBe(a);
@@ -302,15 +258,15 @@ describe('loadMarkImage', () => {
expect(imgs[0]).toBeInstanceOf(FakeImage);
expect(blobs).toHaveLength(3);
// nach dem Laden bleibt der Eintrag im Cache
expect(m.loadMarkImage(visa, 'dark')).toBe(a);
expect(m.loadMarkImage(symbol, 'dark')).toBe(a);
expect(blobs).toHaveLength(3);
});
it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => {
const m = await fresh();
const visa = m.getNetworkMark('visa-auto');
await m.loadMarkImage(visa, 'dark');
await m.loadMarkImage(visa, 'light');
const symbol = m.getBrandMark('aircards-symbol');
await m.loadMarkImage(symbol, 'dark');
await m.loadMarkImage(symbol, 'light');
const [dark, light] = await Promise.all(blobs.map((b) => b.text()));
expect(blobs[0].type).toBe('image/svg+xml');
expect(dark).toContain('#111111');
@@ -320,33 +276,25 @@ describe('loadMarkImage', () => {
const root = /^<svg\b[^>]*>/.exec(dark)![0];
const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]);
const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]);
expect(h).toBe(visa.maxH * 4);
expect(w / h).toBeCloseTo(m.svgAspect(visa.svg), 1);
expect(h).toBe(symbol.maxH * 4);
expect(w / h).toBeCloseTo(m.svgAspect(symbol.svg), 1);
// Bild bekommt die Blob-URL
expect(env.images[0].src).toBe('blob:fake/1');
expect(env.images[1].src).toBe('blob:fake/2');
});
it('Marken ohne currentColor bleiben im Blob unverändert (bis auf die Größe)', async () => {
const m = await fresh();
const jcb = m.getNetworkMark('jcb');
await m.loadMarkImage(jcb, 'dark');
const text = await blobs[0].text();
expect(text.replace(/^<svg width="[\d.]+" height="[\d.]+"/, '<svg')).toBe(jcb.svg);
});
it('gibt die Blob-URL nach dem Laden frei', async () => {
const m = await fresh();
await m.loadMarkImage(m.getNetworkMark('visa-blue'), 'light');
await m.loadMarkImage(m.getBrandMark('aircards-text'), 'light');
expect(revoked).toEqual(['blob:fake/1']);
});
it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => {
const m = await fresh();
const def = m.getNetworkMark('visa-auto');
const def = m.getBrandMark('aircards-symbol');
env.mode = 'error';
const first = m.loadMarkImage(def, 'dark');
await expect(first).rejects.toThrow(/visa-auto/);
await expect(first).rejects.toThrow(/aircards-symbol/);
expect(revoked).toEqual(['blob:fake/1']);
env.mode = 'load';
+22 -60
View File
@@ -1,35 +1,21 @@
// Logo-Register: SVG-Dateien der Netzwerke und Marken, Einfärben und Laden als Bild für den Canvas.
// Logo-Register: SVG-Dateien der AirCard-IOS-Marken (Logo oben links), Einfärben und Laden als Bild für den Canvas.
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
// Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke.
import { TONE_COLOR, type BrandId, type DetailsTone, type NetworkId, NETWORK_IDS } from './config';
import { msg } from '../i18n/i18n';
import { TONE_COLOR, type BrandId, type DetailsTone } from './config';
import visaAuto from '../assets/marks/visa-auto.svg?raw';
import visaBlue from '../assets/marks/visa-blue.svg?raw';
import visaNavy from '../assets/marks/visa-navy.svg?raw';
import visaSilver from '../assets/marks/visa-silver.svg?raw';
import mcColor from '../assets/marks/mc-color.svg?raw';
import mcSilver from '../assets/marks/mc-silver.svg?raw';
import mcWordmark from '../assets/marks/mc-wordmark.svg?raw';
import mcSilverText from '../assets/marks/mc-silver-text.svg?raw';
import mcClassic from '../assets/marks/mc-classic.svg?raw';
import upColor from '../assets/marks/up-color.svg?raw';
import upSilver from '../assets/marks/up-silver.svg?raw';
import upWhiteFrame from '../assets/marks/up-white-frame.svg?raw';
import upEnglish from '../assets/marks/up-english.svg?raw';
import amexBlue from '../assets/marks/amex-blue.svg?raw';
import amexOutline from '../assets/marks/amex-outline.svg?raw';
import amexSelect from '../assets/marks/amex-select.svg?raw';
import jcb from '../assets/marks/jcb.svg?raw';
import discover from '../assets/marks/discover.svg?raw';
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
import aircardsText from '../assets/marks/aircards-text.svg?raw';
import apple from '../assets/marks/apple.svg?raw';
export type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo';
export type MarkGroup = 'Logo';
export interface MarkDef {
id: NetworkId | Exclude<BrandId, 'none'>;
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */
id: Exclude<BrandId, 'none'>;
/**
* Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“).
* Beide sind deutsch markiert (`msg`) und werden erst beim Anzeigen mit `t()` übersetzt.
*/
group: MarkGroup;
label: string;
svg: string;
@@ -39,56 +25,32 @@ export interface MarkDef {
maxH: number;
}
type NetworkEntry = Omit<MarkDef, 'id'>;
type MarkEntry = Omit<MarkDef, 'id'>;
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = {
'visa-auto': { group: 'Visa', label: 'Automatisch', svg: visaAuto, tint: true, maxH: 109 },
'visa-blue': { group: 'Visa', label: 'Blau', svg: visaBlue, tint: false, maxH: 109 },
'visa-navy': { group: 'Visa', label: 'Marineblau', svg: visaNavy, tint: false, maxH: 109 },
'visa-silver': { group: 'Visa', label: 'Metallisches Silber', svg: visaSilver, tint: false, maxH: 109 },
'mc-color': { group: 'Mastercard', label: 'Farbe', svg: mcColor, tint: false, maxH: 125 },
'mc-silver': { group: 'Mastercard', label: 'Metallisches Silber', svg: mcSilver, tint: false, maxH: 125 },
'mc-wordmark': { group: 'Mastercard', label: 'Wortmarke', svg: mcWordmark, tint: false, maxH: 109 },
'mc-silver-text': { group: 'Mastercard', label: 'Silber + Text', svg: mcSilverText, tint: false, maxH: 109 },
'mc-classic': { group: 'Mastercard', label: 'Klassisch', svg: mcClassic, tint: false, maxH: 125 },
'up-color': { group: 'UnionPay', label: 'Farbe', svg: upColor, tint: false, maxH: 125 },
'up-silver': { group: 'UnionPay', label: 'Metallisches Silber', svg: upSilver, tint: false, maxH: 125 },
'up-white-frame': { group: 'UnionPay', label: 'Weißer Rahmen', svg: upWhiteFrame, tint: false, maxH: 125 },
'up-english': { group: 'UnionPay', label: 'Englisch', svg: upEnglish, tint: false, maxH: 109 },
'amex-blue': { group: 'American Express', label: 'Blau', svg: amexBlue, tint: false, maxH: 125 },
'amex-outline': { group: 'American Express', label: 'Umriss', svg: amexOutline, tint: true, maxH: 125 },
'amex-select': { group: 'American Express', label: 'Select', svg: amexSelect, tint: false, maxH: 90 },
jcb: { group: 'JCB', label: 'JCB', svg: jcb, tint: false, maxH: 125 },
discover: { group: 'Discover', label: 'Discover', svg: discover, tint: false, maxH: 109 },
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
'aircards-symbol': { group: msg('Logo'), label: msg('AirCard-IOS · Symbol'), svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: msg('Logo'), label: msg('AirCard-IOS · Text'), svg: aircardsText, tint: true, maxH: 100 },
};
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, NetworkEntry> = {
'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: 'Logo', label: 'AirCard-IOS · Text', svg: aircardsText, tint: true, maxH: 100 },
apple: { group: 'Logo', label: 'Apple', svg: apple, tint: true, maxH: 100 },
};
export const NETWORK_MARKS: readonly MarkDef[] = NETWORK_IDS.map((id) => ({ id, ...NETWORK_TABLE[id] }));
export const BRAND_MARKS: readonly MarkDef[] = (
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
).map((id) => ({ id, ...BRAND_TABLE[id] }));
export function getNetworkMark(id: NetworkId): MarkDef {
return NETWORK_MARKS.find((m) => m.id === id) ?? NETWORK_MARKS[0];
}
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
}
/** Breite durch Höhe aus dem Wert einer viewBox („x y Breite Höhe“, mit Leerraum oder Kommas); null, wenn er nicht brauchbar ist. */
export function viewBoxAspect(value: string): number | null {
const parts = value.trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : null;
}
/** Breite durch Höhe aus der viewBox; 1, wenn keine brauchbare viewBox da ist. */
export function svgAspect(svg: string): number {
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
if (!hit) return 1;
const parts = hit[1].trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 1;
return (hit && viewBoxAspect(hit[1])) ?? 1;
}
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
+98 -32
View File
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadLogoAsset } from '../logos/logo-asset';
import { DEFAULT_CONFIG, type CardConfig } from './config';
import { fitLogo } from './geometry';
import { loadMarkImage } from './marks';
import {
drawCard,
@@ -9,6 +11,7 @@ import {
type CardAssets,
type CardImage,
type Ctx,
type LogoAsset,
} from './render';
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
@@ -22,7 +25,6 @@ vi.mock('./marks', () => {
maxH,
});
return {
getNetworkMark: (id: string) => mark(id, '0 0 330 109', 109),
getBrandMark: (id: string) => mark(id, '0 0 100 100', 100),
svgAspect: (svg: string) => {
const p = /viewBox="([^"]+)"/.exec(svg)![1].split(' ').map(Number);
@@ -32,6 +34,9 @@ vi.mock('./marks', () => {
};
});
// Der Standard-Lader der Logos (Appwrite, Dekodierung) kommt in diesen Tests nie zum Einsatz.
vi.mock('../logos/logo-asset', () => ({ loadLogoAsset: vi.fn() }));
interface Call {
name: string;
args: unknown[];
@@ -99,13 +104,15 @@ function fakeCtx() {
return { ctx: ctx as unknown as Ctx, calls, strokePath };
}
const NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
const LOGO_SOURCE = { tag: 'logo' } as unknown as CanvasImageSource;
const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
/** Ein Community-Logo mit Seitenverhältnis 2 : 1 (die Größe in Pixeln spielt für den Platz auf der Karte keine Rolle). */
const LOGO: LogoAsset = { source: LOGO_SOURCE, w: 200, h: 100 };
const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
image: null,
network: NETWORK,
logo: LOGO,
brand: BRAND,
...over,
});
@@ -187,18 +194,42 @@ describe('renderCard: Grund und Bild', () => {
});
describe('renderCard: Vorlagen', () => {
it('minimal: nur das Netzwerk-Logo unten rechts', () => {
it('minimal: nur das Community-Logo unten rechts (Seitenverhältnis 2 : 1 → 250 × 125, rechte Kante 1480, Unterkante 913)', () => {
const calls = render(cfg({ template: 'minimal' }), assets());
expect(named(calls, 'drawImage')).toHaveLength(1);
const [src, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
expect(src).toBe(NETWORK);
expect(x).toBeCloseTo(1150, 1);
expect(y).toBeCloseTo(804, 1);
expect(w).toBeCloseTo(330, 1);
expect(h).toBeCloseTo(109, 1);
const [src, x, y, w, h] = drawsOf(calls, LOGO_SOURCE)[0].args as number[];
expect(src).toBe(LOGO_SOURCE);
expect([x, y, w, h]).toEqual([1230, 788, 250, 125]);
expect(named(calls, 'fillText')).toHaveLength(0);
});
it('das Logo sitzt dort, wo fitLogo es hinlegt (Seitenverhältnis aus w und h des Logos)', () => {
const wide: LogoAsset = { source: LOGO_SOURCE, w: 720, h: 72 };
const [src, x, y, w, h] = drawsOf(render(cfg({ template: 'classic' }), assets({ logo: wide })), LOGO_SOURCE)[0].args as number[];
const box = fitLogo(10);
expect(src).toBe(LOGO_SOURCE);
expect([x, y, w, h]).toEqual([box.x, box.y, box.w, box.h]);
expect(w).toBe(360);
});
it('Vorlage image: auch mit Logo wird es nicht gezeichnet', () => {
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(0);
expect(named(calls, 'drawImage')).toHaveLength(1);
});
it('ohne Logo (assets.logo null) wird nichts unten rechts gezeichnet, auch wenn die Konfiguration eines wählt', () => {
for (const template of ['minimal', 'classic', 'signature'] as const) {
const calls = render(cfg({ template, logo: 'abc123' }), assets({ logo: null }));
expect(drawsOf(calls, LOGO_SOURCE), template).toHaveLength(0);
}
});
it('das Logo hängt nur an den Bildern, nicht an config.logo (die Bilder gehören zur Konfiguration, mit der sie geladen wurden)', () => {
const calls = render(cfg({ template: 'minimal', logo: null }), assets());
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
});
it('classic dunkel: Text in #111111, PLATINUM endet bei x 1480, Ziffern und Punkte stimmen', () => {
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
@@ -238,11 +269,10 @@ describe('renderCard: Vorlagen', () => {
expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
});
it('classic: Brand-Logo links oben, Netzwerk-Logo unten rechts', () => {
it('classic: Brand-Logo links oben, Community-Logo unten rechts', () => {
const calls = render(cfg({ template: 'classic' }), assets());
expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]);
const [, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
expect([x, y, w, h].map((v) => Math.round(v))).toEqual([1150, 804, 330, 109]);
expect(drawsOf(calls, LOGO_SOURCE)[0].args).toEqual([LOGO_SOURCE, 1230, 788, 250, 125]);
});
it('classic: Kontaktlos-Bögen in Details-Farbe mit runden Enden', () => {
@@ -256,7 +286,7 @@ describe('renderCard: Vorlagen', () => {
it('signature, Brand none: kein Brand-drawImage, PLATINUM beginnt bei x 60', () => {
const calls = render(cfg({ template: 'signature', brand: 'none' }), assets());
expect(drawsOf(calls, BRAND)).toHaveLength(0);
expect(drawsOf(calls, NETWORK)).toHaveLength(1);
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
const plat = letters(calls);
expect(plat[0].args).toEqual(['P', 60, 226]);
expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
@@ -273,7 +303,7 @@ describe('renderCard: Vorlagen', () => {
});
it('fehlende Logo-Bilder werden übersprungen', () => {
const calls = render(cfg({ template: 'classic' }), assets({ network: null, brand: null }));
const calls = render(cfg({ template: 'classic' }), assets({ logo: null, brand: null }));
expect(named(calls, 'drawImage')).toHaveLength(0);
expect(letters(calls)).toHaveLength(8);
});
@@ -319,7 +349,7 @@ describe('renderCard: Chip', () => {
});
describe('renderCard: Reihenfolge', () => {
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Netzwerk', () => {
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Logo', () => {
const calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
const order = [
@@ -330,7 +360,7 @@ describe('renderCard: Reihenfolge', () => {
at((c) => c.name === 'arc'),
at((c) => c.name === 'fillText' && c.args[0] === 'P'),
at((c) => c.name === 'drawImage' && c.args[0] === BRAND),
at((c) => c.name === 'drawImage' && c.args[0] === NETWORK),
at((c) => c.name === 'drawImage' && c.args[0] === LOGO_SOURCE),
];
expect(order.every((i) => i >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
@@ -386,14 +416,17 @@ describe('drawCard', () => {
});
describe('loadCardAssets', () => {
const NET_IMG = { tag: 'net-img' };
const BRAND_IMG = { tag: 'brand-img' };
const fontsLoad = vi.fn();
const mocked = vi.mocked(loadMarkImage);
const defaultLoader = vi.mocked(loadLogoAsset);
const loadLogo = vi.fn<(id: string, tone: 'light' | 'dark') => Promise<LogoAsset | null>>();
beforeEach(() => {
fontsLoad.mockReset().mockResolvedValue([]);
mocked.mockReset().mockImplementation(async (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
mocked.mockReset().mockImplementation(async () => BRAND_IMG as unknown as HTMLImageElement);
defaultLoader.mockReset().mockResolvedValue(LOGO);
loadLogo.mockReset().mockResolvedValue(LOGO);
Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
});
@@ -401,35 +434,68 @@ describe('loadCardAssets', () => {
Reflect.deleteProperty(document, 'fonts');
});
it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
it('lädt Community-Logo und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
const image = photo(100, 100);
const result = await loadCardAssets(cfg({ network: 'jcb', brand: 'apple', details: 'dark', template: 'image' }), image);
const result = await loadCardAssets(cfg({ logo: 'abc123', brand: 'aircards-text', details: 'dark', template: 'image' }), image, loadLogo);
expect(result).toEqual({ image, logo: LOGO, brand: BRAND_IMG, logoMissing: false });
expect(result.image).toBe(image);
expect(result.network).toBe(NET_IMG);
expect(result.brand).toBe(BRAND_IMG);
expect(mocked).toHaveBeenCalledTimes(2);
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['jcb', 'dark'], ['apple', 'dark']]);
expect(loadLogo).toHaveBeenCalledTimes(1);
expect(loadLogo).toHaveBeenCalledWith('abc123', 'dark');
expect(mocked).toHaveBeenCalledTimes(1);
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['aircards-text', 'dark']]);
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
});
it('ohne Logo (logo null): kein Lader-Aufruf, kein fehlendes Logo', async () => {
const result = await loadCardAssets(cfg({ logo: null }), null, loadLogo);
expect(loadLogo).not.toHaveBeenCalled();
expect(result.logo).toBeNull();
expect(result.logoMissing).toBe(false);
});
it('eine leere logo-Angabe zählt wie kein Logo', async () => {
const result = await loadCardAssets(cfg({ logo: '' }), null, loadLogo);
expect(loadLogo).not.toHaveBeenCalled();
expect(result.logoMissing).toBe(false);
});
it('der Lader liefert null (gelöscht, ausgeblendet): logo null und logoMissing true, Brand-Logo wird trotzdem geladen', async () => {
loadLogo.mockResolvedValue(null);
const result = await loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo);
expect(result.logo).toBeNull();
expect(result.logoMissing).toBe(true);
expect(result.brand).toBe(BRAND_IMG);
});
it('der Lader wirft: loadCardAssets wirft (ein echter Ladefehler ist kein fehlendes Logo)', async () => {
loadLogo.mockRejectedValue(new Error('offline'));
await expect(loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo)).rejects.toThrow('offline');
});
it('ohne dritten Parameter lädt loadLogoAsset', async () => {
const result = await loadCardAssets(cfg({ logo: 'abc123', details: 'light' }), null);
expect(defaultLoader).toHaveBeenCalledWith('abc123', 'light');
expect(result.logo).toBe(LOGO);
});
it('Brand none: kein Brand-Logo laden', async () => {
const result = await loadCardAssets(cfg({ brand: 'none' }), null);
const result = await loadCardAssets(cfg({ brand: 'none' }), null, loadLogo);
expect(result.image).toBeNull();
expect(result.brand).toBeNull();
expect(mocked).toHaveBeenCalledTimes(1);
expect(mocked).not.toHaveBeenCalled();
});
it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
fontsLoad.mockRejectedValue(new Error('offline'));
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
Reflect.deleteProperty(document, 'fonts');
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
});
it('Logo-Fehler wird weitergereicht', async () => {
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: jcb'));
await expect(loadCardAssets(cfg({ network: 'jcb' }), null)).rejects.toThrow(/Logo/);
it('Marken-Fehler wird weitergereicht', async () => {
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: aircards-symbol'));
await expect(loadCardAssets(cfg(), null, loadLogo)).rejects.toThrow(/Logo/);
});
});
+40 -15
View File
@@ -1,18 +1,34 @@
// Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext.
// Vorschau, Vorlagen-Miniaturen, PNG-Download und Vorschaubild beim Speichern benutzen alle drawCard.
import { msg } from '../i18n/i18n';
import { loadLogoAsset } from '../logos/logo-asset';
import { TONE_COLOR, type CardConfig, type DetailsTone } from './config';
import { clampCrop, imageRect } from './crop';
import { CARD_H, CARD_W, fitBrand, fitNetwork, layoutFor, type Layout } from './geometry';
import { getBrandMark, getNetworkMark, loadMarkImage, svgAspect } from './marks';
import { CARD_H, CARD_W, fitBrand, fitLogo, layoutFor, type Layout } from './geometry';
import { getBrandMark, loadMarkImage, svgAspect } from './marks';
export type CardImage = { source: CanvasImageSource; w: number; h: number };
/** Ein Community-Logo, fertig zum Zeichnen (einfarbige schon in der Details-Farbe); w/h = Pixel der Quelle, daraus folgt das Seitenverhältnis. */
export interface LogoAsset {
source: CanvasImageSource;
w: number;
h: number;
}
export interface CardAssets {
image: CardImage | null;
network: CanvasImageSource | null;
/** Das Community-Logo unten rechts; null = keins oder nicht verfügbar. */
logo: LogoAsset | null;
/** Das AirCard-IOS-Logo oben links. */
brand: CanvasImageSource | null;
}
/** Was `loadCardAssets` liefert: dazu die Auskunft, ob die Karte ein Logo wählt, das es nicht (mehr) gibt. */
export interface LoadedAssets extends CardAssets {
logoMissing: boolean;
}
/** Der kleine Ausschnitt von CanvasRenderingContext2D, den der Renderer braucht (leicht zu faken). */
export type Ctx = Pick<
CanvasRenderingContext2D,
@@ -112,7 +128,7 @@ function drawPlatinum(ctx: Ctx, p: NonNullable<Layout['platinum']>, color: strin
});
}
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk. */
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Marke oben links, Logo unten rechts. */
export function renderCard(
ctx: Ctx,
config: CardConfig,
@@ -150,10 +166,10 @@ export function renderCard(
ctx.drawImage(assets.brand, b.x, b.y, b.w, b.h);
}
if (layout.network && assets.network) {
const mark = getNetworkMark(config.network);
const n = fitNetwork(svgAspect(mark.svg), mark.maxH);
ctx.drawImage(assets.network, n.x, n.y, n.w, n.h);
// Gezeichnet wird, was die Bilder mitbringen (sie gehören zu der Konfiguration, mit der sie geladen wurden).
if (layout.logo && assets.logo) {
const l = fitLogo(assets.logo.w / assets.logo.h);
ctx.drawImage(assets.logo.source, l.x, l.y, l.w, l.h);
}
}
@@ -165,7 +181,8 @@ export function drawCard(
): void {
// Die Überladungen beider Canvas-Typen lassen sich als Union nicht aufrufen; zur Laufzeit gleich.
const ctx = (canvas as HTMLCanvasElement).getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
// Die Meldung geht deutsch markiert in den Fehler; der Editor zeigt sie mit `t(error.message)`.
if (!ctx) throw new Error(msg('Canvas wird in diesem Browser nicht unterstützt.'));
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(canvas.width / CARD_W, canvas.height / CARD_H);
// Fotos werden stark verkleinert; die Standardqualität (low) zeigt dabei Treppenstufen.
@@ -173,17 +190,25 @@ export function drawCard(
renderCard(ctx, config, assets);
}
/** Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch. */
export async function loadCardAssets(config: CardConfig, image: CardImage | null): Promise<CardAssets> {
/**
* Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch.
* Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet), ist `logo` null und `logoMissing` true; die Karte
* lässt sich dann ohne Logo zeichnen. Ein echter Ladefehler (Netz, Server) wirft dagegen.
*/
export async function loadCardAssets(
config: CardConfig,
image: CardImage | null,
loadLogo: (id: string, tone: DetailsTone) => Promise<LogoAsset | null> = loadLogoAsset,
): Promise<LoadedAssets> {
// Ohne geladene Schrift misst und zeichnet der Canvas mit der Ersatzschrift.
const fontReady = Promise.resolve(typeof document !== 'undefined' ? document.fonts?.load(FONT_LOAD) : undefined)
.catch(() => undefined);
const [network, brand] = await Promise.all([
loadMarkImage(getNetworkMark(config.network), config.details),
const [logo, brand] = await Promise.all([
config.logo ? loadLogo(config.logo, config.details) : null,
config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details),
fontReady,
]);
return { image, network, brand };
return { image, logo, brand, logoMissing: Boolean(config.logo) && logo === null };
}
/** Rendert die Karte offscreen in der gewünschten Breite (Höhe nach 1536 : 969) und liefert die Bilddatei. */
@@ -198,7 +223,7 @@ export async function exportCard(
drawCard(canvas, config, assets);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Das Bild konnte nicht erzeugt werden.'))),
(blob) => (blob ? resolve(blob) : reject(new Error(msg('Das Bild konnte nicht erzeugt werden.')))),
opts.type,
opts.quality,
);
+263 -2
View File
@@ -25,10 +25,18 @@ type Api = typeof import('./api');
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
const MINUTE = 60_000;
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. Das gilt auch für i18n: die frische Instanz
// beginnt mit der Browsersprache (im Test Englisch) und wird wie im Test-Setup auf Deutsch gestellt.
async function freshApi(): Promise<Api> {
vi.resetModules();
return import('./api');
const api = await import('./api');
(await import('../i18n/i18n')).setLang('de', { persist: false });
return api;
}
/** Stellt die Sprache der frischen Instanz um (dieselbe, die `freshApi` geladen hat). */
async function setLanguage(lang: 'de' | 'en'): Promise<void> {
(await import('../i18n/i18n')).setLang(lang, { persist: false });
}
const json = (status: number, body: unknown) =>
@@ -593,3 +601,256 @@ describe('withdrawCard, reportCard, setVisibility', () => {
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
});
});
describe('serverCode: der Schlüssel der Fehlerantwort', () => {
const fail = (api: Api) => api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
it('ApiError trägt ohne Angabe serverCode null; das Feld lässt sich setzen, ohne code und definitive zu ändern', async () => {
const api = await freshApi();
expect(new api.ApiError(400, 'x').serverCode).toBeNull();
expect(new api.ApiError(400, 'x', undefined, null).serverCode).toBeNull();
const err = new api.ApiError(409, 'x', undefined, 'alreadyPublished');
expect(err).toMatchObject({ code: 409, status: 409, message: 'x', definitive: true, serverCode: 'alreadyPublished' });
expect(new api.ApiError(429, 'x', true, 'tooManyPublications')).toMatchObject({ definitive: true, serverCode: 'tooManyPublications' });
});
it('wird aus {error, code} der Antwort gelesen; code bleibt der Status, message der deutsche Text', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' }));
const err = await fail(api);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 403, status: 403, message: 'Nur für Admins.', serverCode: 'adminOnly' });
});
it('ohne code in der Antwort (älterer Dienst, Proxy) bleibt serverCode null', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
expect(await fail(api)).toMatchObject({ code: 403, message: 'Nur für Admins.', serverCode: null });
});
it.each([[42], [''], [null], [true], [['adminOnly']], [{ a: 1 }]])('ein code, der kein nichtleerer Text ist (%j), zählt nicht', async (code) => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code }));
expect(await fail(api)).toMatchObject({ message: 'Nur für Admins.', serverCode: null });
});
it('der Dienst sendet Dienst-Fehler mit ihrem Schlüssel: 429, 404, 409', async () => {
const api = await freshApi();
for (const [status, error, code] of [
[429, 'Zu viele Meldungen – bitte später nochmal.', 'tooManyReports'],
[404, 'Dieses Logo ist nicht (mehr) verfügbar.', 'logoGone'],
[409, 'Diese Datei wird schon für ein Logo verwendet.', 'logoTaken'],
] as const) {
fetchMock.mockResolvedValueOnce(json(status, { error, code }));
expect(await fail(api)).toMatchObject({ code: status, message: error, serverCode: code });
}
});
it('Netzfehler und Zeitlimit: unavailable', async () => {
const api = await freshApi();
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')));
fetchMock.mockImplementation(async (_url, init) => {
throw init.signal?.reason;
});
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
});
it.each([502, 503, 504])('Status %i vom Proxy (ohne code im Body, auch mit fremdem code): unavailable', async (status) => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas' }));
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas', code: 'internal' }));
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
});
it('Fehlerseite ohne JSON, HTML mit Status 200 und JSON, das kein Objekt ist: unavailable', async () => {
const api = await freshApi();
for (const response of [
new Response('<html>404 page not found</html>', { status: 404 }),
new Response('<!doctype html><title>AirCard</title>', { status: 200 }),
new Response('"ok"', { status: 200 }),
]) {
fetchMock.mockResolvedValueOnce(response);
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
}
});
it('eine abgebrochene Antwort (res.text() scheitert): unavailable', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response);
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
});
it('JSON-Fehler ohne Text und ohne code: generic', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(500, {}));
expect(await fail(api)).toMatchObject({ code: 500, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'generic' });
});
it('JSON-Fehler ohne Text, aber mit code: der code des Dienstes', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(404, { code: 'logoGone' }));
expect(await fail(api)).toMatchObject({ code: 404, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'logoGone' });
});
it('createJWT mit user_auth_method_unsupported: jwtUnsupported (und eindeutig)', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 501, serverCode: 'jwtUnsupported', definitive: true });
});
it('der zweite Versuch nach einem 401 liefert den Schlüssel der zweiten Antwort', async () => {
const api = await freshApi();
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.', serverCode: 'unauthorized' });
});
it('unavailableError(): 503, Ausgang offen, Schlüssel unavailable', async () => {
const api = await freshApi();
const err = api.unavailableError();
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable', definitive: false });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
describe('beim Veröffentlichen', () => {
const input = {
title: 'Meine Karte',
issuer: 'Beispielbank',
tags: ['visa'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'card1',
};
it('403 „Dateien belegt“: der Dienst sendet filesTaken, publishCard macht daraus 409 alreadyPublished', async () => {
const api = await freshApi();
const text = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
fetchMock.mockImplementation(async () => json(403, { error: text, code: 'filesTaken' })); // je Aufruf eine neue Response
expect(await api.apiPost('/api/veroeffentlichen', input, { auth: true }).catch((e: unknown) => e)).toMatchObject({
code: 403,
message: text,
serverCode: 'filesTaken',
});
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.', serverCode: 'alreadyPublished' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('409 vom Dienst: alreadyPublished', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.', code: 'alreadyPublished' }));
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
});
it('429 „schon 20 Karten“: tooManyPublications, eindeutig', async () => {
const api = await freshApi();
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyPublications' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyPublications', definitive: true });
});
it('ohne die Schlüssel (Dienst von vorher) klappt die Umdeutung weiter über den deutschen Text', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
fetchMock.mockResolvedValueOnce(
json(429, { error: 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.' }),
);
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 429, serverCode: 'tooManyPublications', definitive: true });
});
it('eine Antwort ohne id: unavailable, Ausgang offen', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, {}));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 503, serverCode: 'unavailable', definitive: false });
});
it('ein anderes 403 behält seinen Schlüssel', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.', code: 'foreignFile' }));
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 403, serverCode: 'foreignFile' });
});
});
});
type ApiErr = InstanceType<Api['ApiError']>;
/** Der ApiError, mit dem `promise` scheitert. */
async function rejection(promise: Promise<unknown>): Promise<ApiErr> {
try {
await promise;
} catch (err) {
return err as ApiErr;
}
throw new Error('Die Anfrage ist nicht gescheitert.');
}
describe('apiErrorText: die Fehlermeldung zum Anzeigen', () => {
it('Deutsch: der Text des Dienstes, auch bei Fehlern ohne bekannten Schlüssel', async () => {
const api = await freshApi();
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
'Diese Karte ist schon veröffentlicht.',
);
expect(api.apiErrorText(new api.ApiError(400, 'Der Titel ist zu lang (höchstens 60 Zeichen).', undefined, 'badRequest'))).toBe(
'Der Titel ist zu lang (höchstens 60 Zeichen).',
);
expect(api.apiErrorText(new api.ApiError(418, 'Ich bin eine Teekanne.'))).toBe('Ich bin eine Teekanne.');
});
it('Englisch: übersetzt über den Schlüssel', async () => {
const api = await freshApi();
await setLanguage('en');
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
'This card is already published.',
);
expect(api.apiErrorText(new api.ApiError(403, 'Nur für Admins.', undefined, 'adminOnly'))).toBe('Admins only.');
});
it('Englisch: Fehler, die der Client selbst erzeugt (Netz, JWT, Veröffentlichen), sind übersetzt', async () => {
const api = await freshApi();
await setLanguage('en');
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const network = await rejection(api.apiPost('/api/x', {}, { auth: false }));
expect(api.apiErrorText(network)).toBe('The service is currently unavailable.');
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const jwt = await rejection(api.apiPost('/api/x', {}, { auth: true }));
expect(api.apiErrorText(jwt)).toBe('Token sign-in is turned off on the server – please let the site owner know.');
fetchMock.mockResolvedValue(json(500, {}));
const generic = await rejection(api.apiPost('/api/x', {}, { auth: false }));
expect(api.apiErrorText(generic)).toBe('Something went wrong. Please try again.');
});
it('Englisch: unbekannter oder fehlender Schlüssel, allgemeine Meldung je Status (die ApiError.code)', async () => {
const api = await freshApi();
await setLanguage('en');
expect(api.apiErrorText(new api.ApiError(400, 'Das gibt es nur auf Deutsch.'))).toBe('Invalid request.');
expect(api.apiErrorText(new api.ApiError(403, 'Das gibt es nur auf Deutsch.', undefined, 'neuerCode'))).toBe(
'You are not allowed to do that.',
);
expect(api.apiErrorText(new api.ApiError(404, 'Das gibt es nur auf Deutsch.'))).toBe('Not found.');
expect(api.apiErrorText(new api.ApiError(500, 'Das gibt es nur auf Deutsch.'))).toBe('Something went wrong. Please try again.');
});
it('übergibt Schlüssel, Status und Text an apiMessage', async () => {
const api = await freshApi();
const { apiMessage } = await import('../i18n/api-messages');
await setLanguage('en');
for (const err of [
new api.ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', undefined, 'logoGone'),
new api.ApiError(400, 'Der Name ist ungültig.', undefined, 'badRequest'),
new api.ApiError(403, 'Text', undefined, null),
]) {
expect(api.apiErrorText(err)).toBe(apiMessage(err.serverCode, err.code, err.message));
}
});
});
+47 -18
View File
@@ -4,6 +4,7 @@
import { account } from '../account/appwrite';
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
import { errorType } from '../account/errors';
import { API_MESSAGES, apiMessage } from '../i18n/api-messages';
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
@@ -17,14 +18,16 @@ export interface PublishInput {
sourceCardId: string;
}
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.';
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
// Die deutschen Texte stehen einmal in i18n/api-messages.ts (dort mit MSG des Dienstes abgeglichen); `ApiError.message` bleibt
// Deutsch, damit der Vergleich mit ihnen weiter geht. Angezeigt wird über `apiErrorText`.
const UNAVAILABLE = API_MESSAGES.unavailable;
const GENERIC = API_MESSAGES.generic;
const JWT_UNSUPPORTED = API_MESSAGES.jwtUnsupported;
const ALREADY_PUBLISHED = API_MESSAGES.alreadyPublished;
/** Wortlaut der API (deploy/api/common.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
const FILES_TAKEN = API_MESSAGES.filesTaken;
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
const TOO_MANY_PUBLICATIONS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
const TOO_MANY_PUBLICATIONS = API_MESSAGES.tooManyPublications;
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
const JWT_TTL_MS = 10 * 60 * 1000;
@@ -33,6 +36,9 @@ const REQUEST_TIMEOUT_MS = 15_000;
/**
* Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext.
* `serverCode` ist der Schlüssel der Fehlerantwort (`code` in `{error, code}`, siehe MSG in deploy/api/common.js; Fehler, die
* der Client selbst erzeugt, tragen die passenden Schlüssel `unavailable`, `generic`, `jwtUnsupported`, …), null ohne Schlüssel.
* Übersetzt wird der Text nach `serverCode` (`apiErrorText`), nie nach `message`.
* `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt.
* Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was
* keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die
@@ -41,12 +47,14 @@ const REQUEST_TIMEOUT_MS = 15_000;
export class ApiError extends Error {
readonly code: number;
readonly definitive: boolean;
readonly serverCode: string | null;
constructor(code: number, message: string, definitive?: boolean) {
constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null) {
super(message);
this.name = 'ApiError';
this.code = code;
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
this.serverCode = serverCode ?? null;
}
get status(): number {
@@ -54,6 +62,16 @@ export class ApiError extends Error {
}
}
/** Der Dienst gab keine verwertbare Antwort: 503, Ausgang offen (die Anfrage kann trotzdem gewirkt haben). */
export function unavailableError(): ApiError {
return new ApiError(503, UNAVAILABLE, false, 'unavailable');
}
/** Der Text eines Fehlers zum Anzeigen, in der Sprache der Seite (bekannter `serverCode` übersetzt, siehe `apiMessage`). */
export function apiErrorText(err: ApiError): string {
return apiMessage(err.serverCode, err.code, err.message);
}
/**
* Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes,
* oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`).
@@ -96,7 +114,7 @@ async function mintJwt(userId: string | null, started: number): Promise<string>
if (errorType(err) === 'user_auth_method_unsupported') {
const code = (err as { code?: unknown }).code;
// Es ging keine Anfrage an den API-Dienst raus: eindeutig.
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true);
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true, 'jwtUnsupported');
}
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
}
@@ -134,6 +152,12 @@ function serverMessage(body: Json): string | null {
return null;
}
/** Der Schlüssel einer Fehlerantwort (`code` neben `error`), sonst null. */
function serverCodeOf(body: Json): string | null {
const value = body.code;
return typeof value === 'string' && value !== '' ? value : null;
}
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (jwt) headers['X-Aircard-Jwt'] = jwt;
@@ -152,18 +176,23 @@ async function send(path: string, body: unknown, jwt: string | null): Promise<Js
text = await res.text();
} catch {
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
throw new ApiError(503, UNAVAILABLE);
throw unavailableError();
}
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200).
if (res.status === 502 || res.status === 503 || res.status === 504) throw new ApiError(503, UNAVAILABLE);
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). Sein Body
// trägt keinen `code`; darum wird schon der Status (vor dem Lesen des Bodys) zu `unavailable`.
if (res.status === 502 || res.status === 503 || res.status === 504) throw unavailableError();
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new ApiError(503, UNAVAILABLE);
throw unavailableError();
}
if (!isRecord(data)) throw unavailableError();
if (!res.ok) {
const message = serverMessage(data);
// Ohne Text und ohne Schlüssel: der allgemeine Fehler.
throw new ApiError(res.status, message ?? GENERIC, undefined, serverCodeOf(data) ?? (message === null ? 'generic' : null));
}
if (!isRecord(data)) throw new ApiError(503, UNAVAILABLE);
if (!res.ok) throw new ApiError(res.status, serverMessage(data) ?? GENERIC);
return data;
}
@@ -200,14 +229,14 @@ export async function publishCard(input: PublishInput): Promise<{ id: string }>
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
} catch (err) {
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
throw new ApiError(409, ALREADY_PUBLISHED);
throw new ApiError(409, ALREADY_PUBLISHED, undefined, 'alreadyPublished');
}
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true);
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true, 'tooManyPublications');
}
throw err;
}
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
if (typeof res.id !== 'string' || res.id === '') throw unavailableError();
return { id: res.id };
}
+22
View File
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
import type { CheckedImage, GalleryCard } from './gallery';
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
@@ -204,3 +206,23 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben
});
});
describe('mountGalleryGrid auf Englisch', () => {
it('Tag-Titel, „No preview“ und die Status-Marken der Admin-Ansicht; Nutzerinhalte bleiben unverändert', async () => {
setLang('en', { persist: false });
const load = vi.fn(async (c: GalleryCard) => {
if (c.id === 'g1') throw new Error('404');
return image();
});
grid = mountGalleryGrid(host, { load, onTag: () => {}, showStatus: true });
grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 })]);
await flush();
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('No preview');
expect(tiles()[0].querySelector('.gallery-tag')?.getAttribute('title')).toBe('Show cards with this tag');
const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent));
expect(badges).toEqual([['Hidden', '3 reports'], ['1 report']]);
expect(tiles()[0].querySelector('.gallery-title')?.textContent).toBe('Karte 1');
expect(tiles()[0].querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank');
expect(germanLeft(host)).toEqual([]);
});
});
+5 -4
View File
@@ -1,8 +1,9 @@
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked)
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML; übersetzt wird beim Aufbau.
import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
import { el } from '../editor/dom';
import { t } from '../i18n/i18n';
import { reportsLabel } from '../pages/format';
import type { CheckedImage, GalleryCard } from './gallery';
@@ -37,7 +38,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
let active = 0;
const showEmpty = (thumb: HTMLElement) =>
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, t('Keine Vorschau')));
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
try {
@@ -89,7 +90,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
if (tags.length > 0) {
const row = el('div', { class: 'gallery-tags' });
for (const tag of tags) {
const button = el('button', { type: 'button', class: 'gallery-tag', title: 'Karten mit diesem Tag zeigen' }, tag);
const button = el('button', { type: 'button', class: 'gallery-tag', title: t('Karten mit diesem Tag zeigen') }, tag);
button.addEventListener('click', () => opts.onTag(tag));
row.append(button);
}
@@ -98,7 +99,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
if (opts.showStatus && (card.hidden || card.reportCount > 0)) {
const badges = el('div', { class: 'gallery-badges' });
if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, 'Ausgeblendet'));
if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, t('Ausgeblendet')));
if (card.reportCount > 0) badges.append(el('span', { class: 'gallery-badge' }, reportsLabel(card.reportCount)));
tile.append(badges);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { AppwriteException, Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite';
import { msg } from '../i18n/i18n';
export interface GalleryCard {
id: string;
@@ -174,7 +175,7 @@ export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'previe
/** Obergrenze für Breite × Höhe eines Galerie-Bildes: die Karte hat 1536 × 969 (rund 1,5 Megapixel). */
export const MAX_GALLERY_PIXELS = 4_000_000;
const IMAGE_UNUSABLE = 'Das Bild konnte nicht geladen werden.';
const IMAGE_UNUSABLE = msg('Das Bild konnte nicht geladen werden.');
/** Ein geprüftes Galerie-Bild; `url` ist eine Object-URL auf `blob`, die der Aufrufer mit `URL.revokeObjectURL` freigibt. */
export interface CheckedImage {
+213 -1
View File
@@ -4,12 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { DISPLAY_NAME_KEY } from '../account/display-name';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
import { ApiError } from './api';
import { NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import {
CONSENT_TEXT,
publishDialog,
publishFailureText,
validatePublishForm,
type PublishFormValues,
type PublishOutcome,
@@ -47,10 +51,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<P
beforeEach(() => {
submit.mockReset();
document.body.innerHTML = '';
localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt
});
afterEach(() => {
document.body.innerHTML = '';
localStorage.clear();
vi.restoreAllMocks();
});
describe('Veröffentlichen-Dialog: Felder', () => {
@@ -382,6 +389,100 @@ describe('Veröffentlichen-Dialog: Senden', () => {
});
});
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
void open();
fill();
send();
await settle();
expect(errorText()).toBe(
'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
expect(errorText()).toContain(LOGO_MISSING_PUBLISH);
expect(errorText()).not.toContain('schiefgelaufen');
});
it('publishFailureText reicht ihn ebenso durch', () => {
expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`);
});
});
describe('Veröffentlichen-Dialog: gemerkter Anzeigename', () => {
it('der Anzeigename ist mit dem zuletzt benutzten Namen (aircard.displayName) vorbelegt, die anderen Felder bleiben leer', () => {
expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
localStorage.setItem('aircard.displayName', 'Kenso');
void open();
expect(input('displayName').value).toBe('Kenso');
for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
expect(input('displayName').placeholder).toBe('Anonym');
});
it('gesperrter Speicher: das Feld bleibt leer, der Dialog öffnet sich trotzdem', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
void open();
expect(dialogEl()?.open).toBe(true);
expect(input('displayName').value).toBe('');
});
it('nach dem Veröffentlichen wird der (getrimmte) Anzeigename gemerkt', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ displayName: ' Kenso ' });
send();
await expect(outcome).resolves.toBe('published');
expect(localStorage.getItem('aircard.displayName')).toBe('Kenso');
});
it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
localStorage.setItem('aircard.displayName', 'Kenso');
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ displayName: '' });
send();
await outcome;
expect(localStorage.getItem('aircard.displayName')).toBeNull();
});
it('bei einem Fehler wird nichts gemerkt, auch nicht bei „schon veröffentlicht“', async () => {
localStorage.setItem('aircard.displayName', 'Alt');
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
const outcome = open();
fill({ displayName: 'Neu' });
send();
await settle();
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
submit.mockRejectedValueOnce(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
send();
await settle();
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
buttonByText('Schließen')!.click();
await expect(outcome).resolves.toBe('already');
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
});
it('beim Abbrechen und beim Schließen durch das Signal wird nichts gemerkt', async () => {
const controller = new AbortController();
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open({ signal: controller.signal });
fill({ displayName: 'Kenso' });
send();
controller.abort();
await outcome;
finish({ id: 'g1' });
await settle();
expect(localStorage.getItem('aircard.displayName')).toBeNull();
void open();
fill({ displayName: 'Kenso' });
buttonByText('Abbrechen')!.click();
expect(localStorage.getItem('aircard.displayName')).toBeNull();
});
});
describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open();
@@ -457,3 +558,114 @@ describe('Veröffentlichen-Dialog: Schließen', () => {
expect(document.activeElement).toBe(opener);
});
});
describe('Veröffentlichen-Dialog auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
it('Überschrift, Einleitung, Felder, Hinweise, Einwilligung und Knöpfe', () => {
void open({ cardName: 'Urlaub' });
const dialog = dialogEl()!;
expect(dialog.querySelector('h2')!.textContent).toBe('Publish card');
expect(dialog.querySelector('.report-text')!.textContent).toBe('“Urlaub” will be shown publicly in the community gallery, including its image.');
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
'Title (required)',
'Issuer / bank (optional)',
'Tags (optional)',
'Display name (optional)',
]);
expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.');
expect(dialog.querySelector('#publish-displayName-hint')!.textContent).toBe('Leave empty for “Anonymous”. Your email address is never shown.');
expect(input('displayName').placeholder).toBe('Anonymous');
expect(consent().closest('label')!.textContent).toBe('The image is mine or I’m allowed to use it, and it contains no real card data.');
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Publish')).toBeTruthy();
expect(germanLeft(dialog)).toEqual([]);
});
it('ohne Kartennamen eine allgemeine Einleitung; die Konstante bleibt der deutsche Schlüssel', () => {
void open();
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('The card will be shown publicly in the community gallery, including its image.');
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
});
it('die Prüfung vor dem Senden meldet in der Sprache der Seite', () => {
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
const problem = (values: Partial<PublishFormValues>) => validatePublishForm({ ...ok, ...values })?.message;
expect(problem({ title: ' ' })).toBe('Please enter a title.');
expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.');
expect(problem({ title: 'x'.repeat(61) })).toBe('The title is too long (at most 60 characters).');
expect(problem({ issuer: 'Bank\u200f' })).toBe('Issuer / bank contains invalid characters.');
expect(problem({ issuer: 'x'.repeat(41) })).toBe('Issuer / bank is too long (at most 40 characters).');
expect(problem({ tags: 'visa, gol\u200bd' })).toBe('Tags contain invalid characters.');
expect(problem({ tags: `visa, ${'x'.repeat(21)}` })).toBe('A tag can be at most 20 characters long.');
expect(problem({ tags: 'a, b, c, d, e, f' })).toBe('Please enter at most 5 tags.');
expect(problem({ displayName: 'Ken\u2060so' })).toBe('The display name contains invalid characters.');
expect(problem({ displayName: 'x'.repeat(31) })).toBe('The display name is too long (at most 30 characters).');
expect(problem({ consent: false })).toBe('Please confirm the statement about the image.');
});
it('Laufzeit, Fehler des Dienstes (über ihren Code), Rendern, Netz und abgebrochene Anmeldung erscheinen auf Englisch', async () => {
let fail!: (e: Error) => void;
submit.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
void open();
fill();
send();
expect(statusText()).toBe('Publishing …');
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable'));
await settle();
expect(errorText()).toBe('Publishing failed – The service is currently unavailable.');
expect(statusText()).toBe('');
// ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status, nicht den deutschen Servertext
submit.mockRejectedValueOnce(new ApiError(403, 'Irgendein Servertext.'));
send();
await settle();
expect(errorText()).toBe('Publishing failed – You are not allowed to do that.');
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
send();
await settle();
expect(errorText()).toBe('Publishing failed – The card image is too large for the gallery (max. 5 MB).');
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
send();
await settle();
expect(errorText()).toBe('Publishing failed – This card’s logo is no longer available. Open the card in the editor and choose another one.');
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
send();
await settle();
expect(errorText()).toBe('Publishing failed – Can’t reach the server.');
submit.mockRejectedValueOnce(new LoginCancelled());
send();
await settle();
expect(errorText()).toBe('Please sign in to continue.');
expect(germanLeft(dialogEl()!)).toEqual([]);
});
it('„Publishing failed – This card is already published.“: ein ApiError mit dem Code alreadyPublished', () => {
const err = new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished');
expect(publishFailureText(err)).toBe('Publishing failed – This card is already published.');
// in der anderen Sprache derselbe Fehler mit dem deutschen Text
setLang('de', { persist: false });
expect(publishFailureText(err)).toBe('Veröffentlichen fehlgeschlagen – Diese Karte ist schon veröffentlicht.');
});
it('ein Fehler mit dem Code alreadyPublished, aber anderem Status, zeigt ihn im Dialog mit dem Vorsatz', async () => {
submit.mockRejectedValueOnce(new ApiError(400, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'));
void open();
fill();
send();
await settle();
expect(errorText()).toBe('Publishing failed – This card is already published.');
});
it('409: „This card is already published.“ und „Close“', async () => {
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'));
const outcome = open();
fill();
send();
await settle();
expect(statusText()).toBe('This card is already published.');
buttonByText('Close')!.click();
await expect(outcome).resolves.toBe('already');
});
});
+58 -43
View File
@@ -1,32 +1,35 @@
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { ApiError, isAlreadyPublished } from './api';
import { NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, isAlreadyPublished } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
export const CONSENT_TEXT = msg('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
/** Wortlaut aus Spec § 11. */
export const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
const FAILED_PREFIX = 'Veröffentlichen fehlgeschlagen – ';
const RUNNING = 'Wird veröffentlicht …';
export const ALREADY_PUBLISHED = msg('Diese Karte ist schon veröffentlicht.');
const RUNNING = msg('Wird veröffentlicht …');
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60;
const ISSUER_MAX = 40;
const DISPLAY_NAME_MAX = 30;
/** Auch für den Anzeigenamen im Dialog „Logo hochladen“ (logos/logo-dialog.ts). */
export const DISPLAY_NAME_MAX = 30;
const TAGS_MAX = 5;
const TAG_MAX = 20;
/**
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
* Auch für Name und Anzeigename im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
*/
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
export interface PublishFormValues extends PublishForm {
consent: boolean;
@@ -34,47 +37,57 @@ export interface PublishFormValues extends PublishForm {
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
/** Das erste Problem in Formularreihenfolge (mit deutschem Text) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
/** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
const title = values.title.trim();
if (title === '') return { field: 'title', message: 'Bitte gib einen Titel ein.' };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: 'Der Titel enthält ungültige Zeichen.' };
if (title.length > TITLE_MAX) return { field: 'title', message: `Der Titel ist zu lang (höchstens ${TITLE_MAX} Zeichen).` };
if (title === '') return { field: 'title', message: t('Bitte gib einen Titel ein.') };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: t('Der Titel enthält ungültige Zeichen.') };
if (title.length > TITLE_MAX) {
return { field: 'title', message: t('Der Titel ist zu lang (höchstens {max} Zeichen).', { max: TITLE_MAX }) };
}
const issuer = values.issuer.trim();
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: 'Herausgeber / Bank enthält ungültige Zeichen.' };
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: t('Herausgeber / Bank enthält ungültige Zeichen.') };
if (issuer.length > ISSUER_MAX) {
return { field: 'issuer', message: `Herausgeber / Bank ist zu lang (höchstens ${ISSUER_MAX} Zeichen).` };
return { field: 'issuer', message: t('Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).', { max: ISSUER_MAX }) };
}
const tags = normalizeTags(values.tags);
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: 'Tags enthalten ungültige Zeichen.' };
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: t('Tags enthalten ungültige Zeichen.') };
if (tags.some((tag) => tag.length > TAG_MAX)) {
return { field: 'tags', message: `Ein Tag darf höchstens ${TAG_MAX} Zeichen lang sein.` };
return { field: 'tags', message: t('Ein Tag darf höchstens {max} Zeichen lang sein.', { max: TAG_MAX }) };
}
if (tags.length > TAGS_MAX) return { field: 'tags', message: `Bitte gib höchstens ${TAGS_MAX} Tags an.` };
if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) };
const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) {
return { field: 'displayName', message: 'Der Anzeigename enthält ungültige Zeichen.' };
return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') };
}
if (displayName.length > DISPLAY_NAME_MAX) {
return { field: 'displayName', message: `Der Anzeigename ist zu lang (höchstens ${DISPLAY_NAME_MAX} Zeichen).` };
return {
field: 'displayName',
message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }),
};
}
if (!values.consent) return { field: 'consent', message: 'Bitte bestätige die Erklärung zum Bild.' };
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
return null;
}
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set([TOO_LARGE, NOT_RENDERED, READ_MESSAGE, PIXELS_MESSAGE]);
/** Meldungen aus dem Rendern und dem Bildlesen tragen den deutschen Text (msg); angezeigt werden sie mit t(). Alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
/**
* Der Text zu einem fehlgeschlagenen Veröffentlichen in der Sprache der Seite (Spec § 11: „Veröffentlichen fehlgeschlagen – …“).
* Fehler des Dienstes kommen über ihren Code (`apiErrorText`).
*/
export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return FAILED_PREFIX + err.message;
if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) return FAILED_PREFIX + err.message;
return FAILED_PREFIX + errorMessage(err, 'save');
if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
let reason: string;
if (err instanceof ApiError) reason = apiErrorText(err);
else if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) reason = t(err.message);
else reason = errorMessage(err, 'save');
return t('Veröffentlichen fehlgeschlagen – {reason}', { reason });
}
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
@@ -111,7 +124,8 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
const titleInput = text('title', { 'aria-required': 'true' });
const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
const nameInput = text('displayName', { placeholder: 'Anonym', 'aria-describedby': 'publish-displayName-hint' });
const nameInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'publish-displayName-hint' });
nameInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Dialog „Logo hochladen“
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
title: titleInput,
@@ -134,30 +148,30 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
'p',
{ class: 'report-text' },
opts.cardName
? `„${opts.cardName}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.`
: 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.',
? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName })
: t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'),
);
const fields = el(
'div',
{ class: 'publish-fields' },
field('Titel (Pflicht)', titleInput),
field('Herausgeber / Bank (optional)', issuerInput),
field('Tags (optional)', tagsInput, 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.'),
field('Anzeigename (optional)', nameInput, 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, CONSENT_TEXT)),
field(t('Titel (Pflicht)'), titleInput),
field(t('Herausgeber / Bank (optional)'), issuerInput),
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Veröffentlichen');
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit deutschen Texten statt der des Browsers.
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, 'Karte veröffentlichen'), form),
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, t('Karte veröffentlichen')), form),
);
const finish = () => {
@@ -187,7 +201,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
for (const control of Object.values(inputs)) control.disabled = value;
send.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? RUNNING : '';
statusEl.textContent = value ? t(RUNNING) : '';
}
function clearInvalid(): void {
@@ -197,10 +211,10 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
function showAlready(): void {
outcome = 'already';
errorEl.textContent = '';
statusEl.textContent = ALREADY_PUBLISHED;
statusEl.textContent = t(ALREADY_PUBLISHED);
intro.remove();
fields.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
@@ -240,6 +254,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
return;
}
if (closed) return;
rememberDisplayName(values.displayName);
outcome = 'published';
finish();
}
+232 -3
View File
@@ -1,8 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config';
import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
import type { SavedCard } from '../account/cards';
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
const mocks = vi.hoisted(() => ({
createJWT: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
getLogo: vi.fn(),
loadLogoBlob: vi.fn(),
}));
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
@@ -14,9 +21,27 @@ vi.mock('../account/appwrite', () => ({
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
}));
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
vi.mock('../editor/upload', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/upload')>()),
decodeImage: mocks.decodeImage,
}));
import { forgetLogo, loadLogoAsset } from '../logos/logo-asset';
import type { CommunityLogo } from '../logos/logos';
import { ApiError, publishCard } from './api';
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish';
import {
LOGO_MISSING_PUBLISH,
PNG_MAX_BYTES,
assertLogoAvailable,
normalizeTags,
publishSavedCard,
renderForGallery,
type PublishDeps,
} from './publish';
const card: SavedCard = {
id: 'card1',
@@ -242,6 +267,210 @@ describe('publishSavedCard', () => {
});
});
describe('Logo der Karte beim Veröffentlichen', () => {
const photo = { source: {}, w: 3000, h: 2000 };
const source = new Blob(['src'], { type: 'image/jpeg' });
const withLogo = (logo: string | null, template: TemplateId = DEFAULT_CONFIG.template): SavedCard => ({
...card,
config: { ...DEFAULT_CONFIG, template, logo },
});
/** Die Vorlagen, die das Community-Logo zeichnen (alle außer „Nur Bild“). */
const DRAWING = ['minimal', 'classic', 'signature'] as const;
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
beforeEach(() => {
mocks.decodeImage.mockReset().mockResolvedValue(photo);
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
mocks.exportCard
.mockReset()
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
});
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
const { t, setLang } = await import('../i18n/i18n');
expect(LOGO_MISSING_PUBLISH).toBe(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
try {
setLang('en', { persist: false });
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
} finally {
setLang('de', { persist: false });
}
});
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
const out = await renderForGallery(withLogo('abc123'), source);
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
});
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
const out = await renderForGallery(withLogo(null), source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
expect(out.png.type).toBe('image/png');
});
it.each(DRAWING)(
'fehlendes Logo (gelöscht oder ausgeblendet), Vorlage %s zeichnet es: Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert',
async (template) => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
await expect(renderForGallery(withLogo('gone', template), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.exportCard).not.toHaveBeenCalled();
},
);
it('fehlendes Logo bei „Nur Bild“ (image): die Vorlage zeichnet es nie, es wird ganz normal gerendert', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const out = await renderForGallery(withLogo('gone', 'image'), source);
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
});
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
const failure = new TypeError('Failed to fetch');
mocks.loadCardAssets.mockRejectedValue(failure);
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
});
it('publishSavedCard: classic mit fehlendem Logo – nichts wird hochgeladen, nichts gelöscht, die API nicht gerufen', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone', 'classic'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(fake.names()).toEqual(['userId', 'loadImage']);
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('publishSavedCard: „Nur Bild“ (image) mit fehlendem Logo – wird veröffentlicht, als wäre nichts', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone', 'image'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
expect(fake.of('deleteFile')).toHaveLength(0);
});
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
});
});
describe('Zwischenspeicher der Logos beim Veröffentlichen (ein ausgeblendetes Logo darf nicht in die Galerie)', () => {
const photo = { source: {}, w: 3000, h: 2000 };
const source = new Blob(['src'], { type: 'image/jpeg' });
const logoRow = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'zw1',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'zw1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const withLogo = (id: string, template: TemplateId = 'classic'): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, template, logo: id } });
beforeEach(() => {
mocks.decodeImage.mockReset().mockResolvedValue(photo);
mocks.exportCard
.mockReset()
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
mocks.getLogo.mockReset().mockResolvedValue(logoRow());
mocks.loadLogoBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }));
// Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter.
mocks.loadCardAssets.mockReset().mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, image: unknown) => {
const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null;
return { image, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null };
});
vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 }));
});
afterEach(() => {
forgetLogo('zw1');
vi.unstubAllGlobals();
});
it('ein Logo, das nach dem ersten Laden (Editor, Zwischenspeicher) ausgeblendet wurde, wird neu geprüft: Abbruch, nichts wird hochgeladen', async () => {
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann hat ein Admin oder die Melder es ausgeblendet
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('zw1'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.exportCard).not.toHaveBeenCalled();
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('ein Logo, das nach dem ersten Laden gelöscht wurde, ebenso', async () => {
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
mocks.getLogo.mockResolvedValue(null);
await expect(renderForGallery(withLogo('zw1'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.exportCard).not.toHaveBeenCalled();
});
it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei gerendert (kein Zwischenspeicher-Treffer)', async () => {
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
const out = await renderForGallery(withLogo('zw1'), source);
expect(out.png.type).toBe('image/png');
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } });
});
it('beide Tonarten des Logos werden vergessen, nicht nur die der Karte', async () => {
await loadLogoAsset('zw1', 'light');
await loadLogoAsset('zw1', 'dark');
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
await expect(renderForGallery({ ...withLogo('zw1'), config: { ...withLogo('zw1').config, details: 'dark' } }, source)).rejects.toThrow(
LOGO_MISSING_PUBLISH,
);
await expect(loadLogoAsset('zw1', 'light')).resolves.toBeNull(); // auch das helle Bild ist weg: es wird neu geprüft
});
it('eine Karte ohne Logo vergisst und holt nichts', async () => {
const out = await renderForGallery({ ...card, config: { ...DEFAULT_CONFIG, logo: null } }, source);
expect(out.png.type).toBe('image/png');
expect(mocks.getLogo).not.toHaveBeenCalled();
});
it('„Nur Bild“ (image) zeichnet nie ein Logo: auch ein inzwischen ausgeblendetes Logo hindert das Veröffentlichen nicht', async () => {
await loadLogoAsset('zw1', 'light');
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
const out = await renderForGallery(withLogo('zw1', 'image'), source);
expect(out.png.type).toBe('image/png');
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
});
});
describe('assertLogoAvailable', () => {
const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' });
const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing });
it.each(['minimal', 'classic', 'signature'] as const)('Logo fehlt und Vorlage %s zeichnet es: wirft LOGO_MISSING_PUBLISH', (template) => {
expect(() => assertLogoAvailable(config(template), assets(true))).toThrow(LOGO_MISSING_PUBLISH);
});
it('Logo fehlt, aber „Nur Bild“ zeichnet nie ein Logo: wirft nicht', () => {
expect(() => assertLogoAvailable(config('image'), assets(true))).not.toThrow();
});
it.each(['image', 'minimal', 'classic', 'signature'] as const)('Logo ist da (oder keins gewählt): Vorlage %s wirft nicht', (template) => {
expect(() => assertLogoAvailable(config(template), assets(false))).not.toThrow();
});
});
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+28 -5
View File
@@ -4,16 +4,24 @@
import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import type { CardConfig } from '../card/config';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH, layoutFor } from '../card/geometry';
import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets } from '../card/render';
import { exportCard, loadCardAssets, type LoadedAssets } from '../card/render';
import { decodeImage } from '../editor/upload';
import { msg } from '../i18n/i18n';
import { forgetLogo } from '../logos/logo-asset';
import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880;
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
// Die Meldungen dieser Datei gehen deutsch markiert in den Fehler; der Veröffentlichen-Dialog zeigt sie mit t().
export const TOO_LARGE = msg('Das Kartenbild ist zu groß für die Galerie (max. 5 MB).');
export const NOT_RENDERED = msg('Das Kartenbild konnte nicht erzeugt werden.');
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: ohne dieses Logo wird sie weder veröffentlicht noch heruntergeladen. */
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.',
);
const GALLERY_TYPES = ['image/png', 'image/webp'];
export interface PublishForm {
@@ -47,10 +55,25 @@ export function normalizeTags(input: string): string[] {
return tags;
}
async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
/**
* Prüft nach dem Laden der Bilder einer gespeicherten Karte, ob ihr beim Rendern etwas fehlt (Veröffentlichen und Herunterladen
* aus „Meine Karten“): Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet) und zeichnet die Vorlage es, wirft
* das LOGO_MISSING_PUBLISH. So wird nie eine andere Karte veröffentlicht oder heruntergeladen, als der Nutzer sie gestaltet hat.
* Bei „Nur Bild“ wird ein Logo nie gezeichnet: dort fehlt nichts, und es wird ganz normal gerendert.
*/
export function assertLogoAvailable(config: CardConfig, assets: LoadedAssets): void {
if (assets.logoMissing && layoutFor(config.template).logo) throw new Error(LOGO_MISSING_PUBLISH);
}
export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
const image = await decodeImage(imageBlob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
// Zeile und Bild des Logos neu vom Server prüfen: der Zwischenspeicher lebt so lange wie die Seite, ein inzwischen ausgeblendetes oder
// gelöschtes Logo käme sonst aus ihm in ein öffentliches Kartenbild.
if (config.logo) forgetLogo(config.logo);
const assets = await loadCardAssets(config, image);
// Lieber abbrechen, als eine Karte ohne ihr Logo zu veröffentlichen.
assertLogoAvailable(config, assets);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
return { png, preview };
+157 -1
View File
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { setLang } from '../i18n/i18n';
import { ApiError, type ReportReason } from './api';
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
@@ -27,7 +28,7 @@ const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function open(opts: { cardTitle?: string; signal?: AbortSignal } = {}): Promise<ReportOutcome> {
function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise<ReportOutcome> {
return reportDialog({ send, ...opts });
}
@@ -239,3 +240,158 @@ describe('Melden-Dialog', () => {
expect(dialogEl()).toBeNull();
});
});
describe('Melden-Dialog für Logos (subject: „logo“)', () => {
const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent);
it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => {
void open({ subject: 'logo' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']);
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']);
expect(radios().some((r) => r.checked)).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Melden')).toBeTruthy();
});
it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => {
expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => {
void open({ subject: 'logo', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
});
it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => {
send.mockResolvedValue({ schonGemeldet: false });
void open({ subject: 'logo' });
choose(value);
submit();
await settle();
expect(send).toHaveBeenCalledExactlyOnceWith(value);
});
it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => {
void open({ subject: 'logo' });
submit();
await settle();
expect(errorText()).toBe('Bitte wähle einen Grund.');
expect(send).not.toHaveBeenCalled();
});
it('neue Meldung: derselbe Dank, „reported“', async () => {
send.mockResolvedValue({ schonGemeldet: false });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
});
it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => {
send.mockResolvedValue({ schonGemeldet: true });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Melden')!.disabled).toBe(false);
});
it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => {
void open({ subject: 'card', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?');
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
});
describe('Melden-Dialog auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => {
void open({ cardTitle: 'Holiday' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?');
expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Real card data',
'Copyright/trademark',
'Spam',
'Other',
]);
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Report')).toBeTruthy();
// ohne Titel
dialogEl()!.remove();
void open();
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?');
});
it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => {
send.mockResolvedValueOnce({ schonGemeldet: true });
const result = open({ subject: 'logo' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Copyright/trademark',
'Spam',
'Other',
]);
submit(); // ohne Grund
await settle();
expect(errorText()).toBe('Please choose a reason.');
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this logo.');
buttonByText('Close')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Dank und Duplikat bei Karten', async () => {
send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true });
const first = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks for your report.');
buttonByText('Close')!.click();
await first;
const second = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this card.');
buttonByText('Close')!.click();
await second;
});
it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('This logo is no longer available.');
});
});
+49 -28
View File
@@ -1,35 +1,45 @@
// Melden-Dialog der Detailseite: Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
// Melden-Dialog für Karten (Detailseite) und Logos (Editor): Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { errorMessage } from '../account/errors';
import { el } from '../editor/dom';
import { ApiError, type ReportReason } from './api';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, type ReportReason } from './api';
/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */
export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [
{ value: 'unangemessen', label: 'Unangemessen' },
{ value: 'kartendaten', label: 'Echte Kartendaten' },
{ value: 'urheberrecht', label: 'Urheberrecht/Marke' },
{ value: 'spam', label: 'Spam' },
{ value: 'sonstiges', label: 'Sonstiges' },
{ value: 'unangemessen', label: msg('Unangemessen') },
{ value: 'kartendaten', label: msg('Echte Kartendaten') },
{ value: 'urheberrecht', label: msg('Urheberrecht/Marke') },
{ value: 'spam', label: msg('Spam') },
{ value: 'sonstiges', label: msg('Sonstiges') },
];
export const REPORT_THANKS = 'Danke für deine Meldung.';
/** Wortlaut aus Spec § 11. */
export const REPORT_DUPLICATE = 'Danke, du hast diese Karte schon gemeldet.';
const NO_REASON = 'Bitte wähle einen Grund.';
/** Ein Logo hat keine Kartendaten: für Logos entfällt „Echte Kartendaten“ (Spec § 6). */
const LOGO_REASONS = REPORT_REASONS.filter((reason) => reason.value !== 'kartendaten');
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
export const REPORT_THANKS = msg('Danke für deine Meldung.');
/** Wortlaut aus Spec § 11. */
export const REPORT_DUPLICATE = msg('Danke, du hast diese Karte schon gemeldet.');
export const LOGO_REPORT_DUPLICATE = msg('Danke, du hast dieses Logo schon gemeldet.');
const NO_REASON = msg('Bitte wähle einen Grund.');
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte (das Logo) schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled';
/** Was gemeldet wird: eine Karte (Standard) oder ein Logo. */
export type ReportSubject = 'card' | 'logo';
export interface ReportDialogOptions {
/** Sendet die Meldung (`reportCard` der API). */
/** Sendet die Meldung (`reportCard` bzw. `reportLogo` der API). */
send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
/** Titel der Karte für die Überschrift des Textes (als Text). */
/** Titel der Karte für die Überschrift des Textes (als Text); für Logos ohne Wirkung (dort heißt es „dieses Logo“). */
cardTitle?: string;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
/** Karte (Standard) oder Logo: Überschrift, Einleitung, Dank bei Duplikat und die Gründe (ohne „Echte Kartendaten“) folgen daraus. */
subject?: ReportSubject;
}
/**
@@ -44,32 +54,43 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const logo = opts.subject === 'logo';
const choices = logo ? LOGO_REASONS : REPORT_REASONS;
let outcome: ReportOutcome = 'cancelled';
let closed = false;
let busy = false;
const radios = REPORT_REASONS.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
const radios = choices.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
const reasons = el(
'fieldset',
{ class: 'report-reasons' },
el('legend', { class: 'report-legend' }, 'Grund der Meldung'),
...REPORT_REASONS.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], label)),
el('legend', { class: 'report-legend' }, t('Grund der Meldung')),
...choices.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], t(label))),
);
const intro = el(
'p',
{ class: 'report-text' },
opts.cardTitle ? `Warum möchtest du „${opts.cardTitle}“ melden?` : 'Warum möchtest du diese Karte melden?',
logo
? t('Warum möchtest du dieses Logo melden?')
: opts.cardTitle
? t('Warum möchtest du „{title}“ melden?', { title: opts.cardTitle })
: t('Warum möchtest du diese Karte melden?'),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Melden');
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Melden'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog', 'aria-labelledby': 'report-title' },
el('div', { class: 'report-card' }, el('h2', { id: 'report-title', class: 'report-title' }, 'Karte melden'), form),
el(
'div',
{ class: 'report-card' },
el('h2', { id: 'report-title', class: 'report-title' }, logo ? t('Logo melden') : t('Karte melden')),
form,
),
);
const finish = () => {
@@ -92,20 +113,20 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
function showResult(duplicate: boolean): void {
outcome = duplicate ? 'duplicate' : 'reported';
errorEl.textContent = '';
statusEl.textContent = duplicate ? REPORT_DUPLICATE : REPORT_THANKS;
statusEl.textContent = t(duplicate ? (logo ? LOGO_REPORT_DUPLICATE : REPORT_DUPLICATE) : REPORT_THANKS);
intro.remove();
reasons.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
}
async function submit(): Promise<void> {
const chosen = REPORT_REASONS.find((_, i) => radios[i].checked);
const chosen = choices.find((_, i) => radios[i].checked);
if (!chosen) {
statusEl.textContent = '';
errorEl.textContent = NO_REASON;
errorEl.textContent = t(NO_REASON);
return;
}
setBusy(true);
@@ -117,8 +138,8 @@ export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>
showResult(res.schonGemeldet);
} catch (err) {
if (closed) return;
// Der Dienst liefert deutsche Texte (auch für 429); alles andere (Netz, JWT) übersetzt errorMessage.
errorEl.textContent = err instanceof ApiError ? err.message : errorMessage(err, 'load');
// Texte des Dienstes (auch für 429) kommen über ihren Schlüssel in der Sprache der Seite; alles andere (Netz, JWT) über errorMessage.
errorEl.textContent = err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
setBusy(false);
}
}
+3
View File
@@ -5,3 +5,6 @@
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
*/
export const DONATE_URL: string = 'https://buymeacoffee.com/aircard';
/** Original-Repo der App AirCard-iOS auf GitHub; Ziel des GitHub-Links in der Kopfzeile. */
export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS';
+2 -1
View File
@@ -1,6 +1,7 @@
// Bestätigungsdialog als eigenes <dialog> (kein window.confirm): Löschen einer Karte, Verwerfen von Änderungen.
import '../account/account.css';
import './confirm-dialog.css';
import { t } from '../i18n/i18n';
import { el } from './dom';
export interface ConfirmOptions {
@@ -20,7 +21,7 @@ export function confirmDialog(options: ConfirmOptions, signal?: AbortSignal): Pr
resolve(false);
return;
}
const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? 'Abbrechen');
const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? t('Abbrechen'));
const confirm = el('button', { type: 'button', class: 'auth-btn auth-btn-danger' }, options.confirmLabel);
const dialog = el(
'dialog',
+6
View File
@@ -32,6 +32,12 @@ const ICONS = {
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
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"/>',
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"/>',
} as const;
export type IconName = keyof typeof ICONS;
+4 -3
View File
@@ -1,8 +1,9 @@
// PNG-Download: Dateiname, Größenhinweis und das Anstoßen des Browser-Downloads.
import { msg, t } from '../i18n/i18n';
/** Wallet nimmt eventuell nur Bilder unter 4 MB. */
export const PNG_WARN_BYTES = 4_000_000;
export const PNG_SIZE_WARNING = 'Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.';
export const PNG_SIZE_WARNING = msg('Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.');
const MAX_SLUG_LENGTH = 40;
@@ -29,9 +30,9 @@ export function downloadFileName(cardName: string, date: Date): string {
return `aircard-${slugify(cardName) || isoDay(date)}.png`;
}
/** Hinweistext, wenn das PNG über der Wallet-Grenze liegt, sonst null. */
/** Hinweistext (in der Sprache der Seite), wenn das PNG über der Wallet-Grenze liegt, sonst null. */
export function pngSizeWarning(size: number): string | null {
return size > PNG_WARN_BYTES ? PNG_SIZE_WARNING : null;
return size > PNG_WARN_BYTES ? t(PNG_SIZE_WARNING) : null;
}
// Manche Browser starten den Download erst nach dem Klick; sofort freigeben würde ihn abbrechen.
+664 -7
View File
@@ -1,9 +1,10 @@
// Speichern und Öffnen im Editor (Karten-Dienst, Anmeldung und Renderer sind ersetzt).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config';
import type { CardImage } from '../card/render';
import { PNG_SIZE_WARNING } from './download';
import { createStore, initialEditorState, type EditorStore } from './state';
const mocks = vi.hoisted(() => ({
@@ -15,8 +16,13 @@ const mocks = vi.hoisted(() => ({
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
drawCard: vi.fn(),
saveBlobAs: vi.fn(),
getGalleryCard: vi.fn(),
loadGalleryImage: vi.fn(),
createLogoPicker: vi.fn(),
getLogo: vi.fn(),
loadLogoBlob: vi.fn(),
logoutListeners: new Set<() => void>(),
}));
@@ -35,19 +41,29 @@ vi.mock('../account/cards', () => ({
cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }),
}));
vi.mock('../card/render', () => ({
drawCard: vi.fn(),
drawCard: mocks.drawCard,
loadCardAssets: mocks.loadCardAssets,
exportCard: mocks.exportCard,
}));
// Der Browser-Download selbst (Object-URL, Anker) gehört nicht in diese Tests.
vi.mock('./download', async (importOriginal) => ({
...(await importOriginal<typeof import('./download')>()),
saveBlobAs: mocks.saveBlobAs,
}));
vi.mock('../community/gallery', () => ({
getGalleryCard: mocks.getGalleryCard,
loadGalleryImage: mocks.loadGalleryImage,
}));
// Der Bereich „Logo unten rechts“ hat seine eigenen Tests (logos/logo-picker.test.ts); hier zählt nur die Verdrahtung.
vi.mock('../logos/logo-picker', () => ({ createLogoPicker: mocks.createLogoPicker }));
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
vi.mock('./upload', async (importOriginal) => ({
...(await importOriginal<typeof import('./upload')>()),
decodeImage: mocks.decodeImage,
}));
import { forgetLogo, loadLogoAsset } from '../logos/logo-asset';
import { mountEditor } from './editor-page';
const user: User = { $id: 'u1', email: 'a@b.de' };
@@ -67,6 +83,9 @@ const savedCard = (patch: Partial<SavedCard> = {}): SavedCard => ({
let root: HTMLElement;
let store: EditorStore;
let unmount: (() => void) | null;
/** Der Attrappen-Picker und die Funktionen, die der Editor ihm mitgegeben hat. */
let picker: { el: HTMLElement; select: Mock; selected: Mock; dispose: Mock };
let pickerHandlers: { onSelect(id: string | null): void };
beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset();
@@ -79,8 +98,13 @@ beforeEach(() => {
store = createStore(initialEditorState());
mocks.getCurrentUser.mockResolvedValue(user);
mocks.requireLogin.mockResolvedValue(true);
mocks.loadCardAssets.mockResolvedValue({ image: null, network: {}, brand: {} });
mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' }));
picker = { el: document.createElement('div'), select: vi.fn(), selected: vi.fn(() => null), dispose: vi.fn() };
mocks.createLogoPicker.mockImplementation((handlers: typeof pickerHandlers) => {
pickerHandlers = handlers;
return picker;
});
unmount = null;
});
@@ -110,7 +134,7 @@ function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage }
describe('Speichern', () => {
it('speichert eine neue Karte mit Schnappschuss, Vorschau und Nutzer-Prüfung', async () => {
const { blob } = withImage();
store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, network: 'jcb' } });
store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, logo: 'abc123' } });
const result = savedCard({ id: 'neu', name: 'Mein Urlaub' });
mocks.save.mockResolvedValue(result);
mount();
@@ -122,14 +146,14 @@ describe('Speichern', () => {
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
expect(mocks.getCurrentUser).toHaveBeenCalled();
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ network: 'jcb' }), expect.anything(), {
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), expect.anything(), {
width: 480,
type: 'image/webp',
quality: 0.85,
});
const input = mocks.save.mock.calls[0][0];
expect(input).toMatchObject({ name: 'Mein Urlaub', image: blob, imageChanged: true, previous: null });
expect(input.config.network).toBe('jcb');
expect(input.config.logo).toBe('abc123');
expect(input.preview.type).toBe('image/webp');
const s = store.get();
@@ -982,6 +1006,639 @@ describe('Speichern: Schnappschuss beim Klick', () => {
});
describe('Community-Logo auf der Karte', () => {
const LOGO_GONE = 'Das gewählte Logo ist nicht mehr verfügbar.';
const MISSING = { image: null, logo: null, brand: {}, logoMissing: true };
const FOUND = { image: null, logo: { source: {}, w: 200, h: 100 }, brand: {}, logoMissing: false };
const withLogo = (logo: string | null, patch: Record<string, unknown> = {}) => ({ ...store.get().config, logo, ...patch });
/** Das Logo „gone“ gibt es nicht mehr, alle anderen schon. */
const goneLoader = async (config: { logo: string | null }) => (config.logo === 'gone' ? MISSING : FOUND);
const downloadButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Herunterladen') as HTMLButtonElement;
beforeEach(() => {
// Hier wird wirklich gezeichnet: requestAnimationFrame läuft (asynchron) ab.
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0) as unknown as number);
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
});
it('die Bilder werden mit dem gewählten Logo geladen (loadCardAssets bekommt die Konfiguration samt logo)', async () => {
store.set({ config: withLogo('abc123', { details: 'dark' }) });
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalled());
const [config, image] = mocks.loadCardAssets.mock.calls[0];
expect(config).toMatchObject({ logo: 'abc123', details: 'dark' });
expect(image).toBeNull();
expect(warningText()).toBe('');
expect(errorText()).toBe('');
});
it('Logo fehlt: Warnung „Das gewählte Logo ist nicht mehr verfügbar.“, kein Fehler, die Karte wird trotzdem gezeichnet', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
withImage();
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
expect(errorText()).toBe('');
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
// Vorschau (Canvas „card-canvas“) und die vier Vorlagen-Miniaturen: alle ohne Logo, mit unveränderter Auswahl
const calls = mocks.drawCard.mock.calls;
expect(calls.some(([canvas]) => (canvas as HTMLElement).classList.contains('card-canvas'))).toBe(true);
expect(new Set(calls.map(([canvas]) => canvas)).size).toBe(5);
for (const [, config, assets] of calls) {
expect(config.logo).toBe('gone');
expect(assets.logo).toBeNull();
}
// die Auswahl bleibt im Zustand
expect(store.get().config.logo).toBe('gone');
});
it('die Warnung erscheint auch ohne Bild (die Miniaturen zeigen die Karte)', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
// ohne Bild keine Vorschau, aber die vier Vorlagen-Miniaturen
await vi.waitFor(() => expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(4));
expect(mocks.drawCard.mock.calls.some(([canvas]) => (canvas as HTMLElement).classList.contains('card-canvas'))).toBe(false);
});
it('Wechsel auf ein vorhandenes Logo: die Warnung verschwindet', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ config: withLogo('da') });
await vi.waitFor(() => expect(warningText()).toBe(''));
expect(errorText()).toBe('');
// und zurück auf das fehlende Logo: der Hinweis kommt wieder
store.set({ config: withLogo('gone') });
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
});
it('Wechsel auf „Kein Logo“: die Warnung verschwindet', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ config: withLogo(null) });
await vi.waitFor(() => expect(warningText()).toBe(''));
});
it('Hell/Dunkel bei fehlendem Logo: der Hinweis bleibt stehen', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ config: withLogo('gone', { details: 'dark' }) });
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
await new Promise((r) => setTimeout(r, 0));
expect(warningText()).toBe(LOGO_GONE);
});
it('ein fehlendes Logo wird nicht immer wieder geladen (kein Fehler-Loop)', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
withImage();
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
store.set({ cardName: 'Andere Karte' });
store.set({ config: withLogo('gone', { template: 'classic' }) });
store.set({ config: withLogo('gone', { template: 'classic', crop: { x: 10, y: 5, zoom: 2 } }) });
await new Promise((r) => setTimeout(r, 10));
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1);
expect(warningText()).toBe(LOGO_GONE);
});
it('kein „Die Logos konnten nicht geladen werden.“ bei fehlendem Logo, nur bei einem echten Ladefehler', async () => {
mocks.loadCardAssets.mockResolvedValue(MISSING);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
expect(errorText()).toBe('');
unmount?.();
mocks.loadCardAssets.mockRejectedValue(new Error('offline'));
store.set({ config: withLogo('anderes') });
mount();
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
expect(warningText()).toBe('');
// kein Wiederholungs-Loop: derselbe Schlüssel wird nicht noch einmal versucht
const attempts = mocks.loadCardAssets.mock.calls.length;
store.set({ cardName: 'x' });
await new Promise((r) => setTimeout(r, 10));
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(attempts);
});
it('ein einmal gescheiterter Ladevorgang blockiert das Logo nicht für immer: A scheitert, B lädt, A wird erneut gewählt → A wird neu geladen und gezeichnet', async () => {
let attemptsForA = 0;
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => {
if (config.logo === 'A' && ++attemptsForA === 1) throw new TypeError('Failed to fetch');
return FOUND;
});
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
// Die Karte steht trotzdem da, nur ohne das Logo (Näheres siehe „Ladefehler eines Logos“ unten).
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
expect(mocks.drawCard.mock.calls.every(([, config, assets]) => config.logo === 'A' && assets.logo === null)).toBe(true);
mocks.drawCard.mockClear();
store.set({ config: withLogo('B') });
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'B')).toBe(true);
mocks.drawCard.mockClear();
store.set({ config: withLogo('A') });
await vi.waitFor(() => expect(attemptsForA).toBe(2));
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true);
});
describe('Ladefehler eines Logos (Netz, Server): die Karte steht trotzdem da, eine erneute Wahl versucht es noch einmal', () => {
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
const BRAND = { tag: 'marke' };
/**
* Wie der echte Lader: ohne gewähltes Logo kommt nur die Marke (sie ist Teil der Seite), mit Logo auch dieses. Das Logo „A“
* scheitert bei den ersten `failures` Versuchen. Liefert die Zahl der bisherigen Versuche für „A“.
*/
function flaky(failures: number): () => number {
let attempts = 0;
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => {
if (config.logo === 'A' && ++attempts <= failures) throw new TypeError('Failed to fetch');
return { image: null, logo: config.logo ? FOUND.logo : null, brand: BRAND, logoMissing: false };
});
return () => attempts;
}
/** Die Logos, mit denen zuletzt gezeichnet wurde (Vorschau und Miniaturen zeichnen alle gleich). */
const lastLogo = () => mocks.drawCard.mock.calls.at(-1)?.[2].logo;
it('Vorschau und Miniaturen werden ohne Logo gezeichnet (mit der Marke), der Fehler und die Auswahl bleiben', async () => {
flaky(Infinity);
withImage();
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
// die Vorschau und die vier Vorlagen-Miniaturen, alle mit der gewählten Konfiguration, aber ohne Logo
expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(5);
for (const [, config, assets] of mocks.drawCard.mock.calls) {
expect(config.logo).toBe('A');
expect(assets).toMatchObject({ logo: null, brand: BRAND });
}
expect(errorText()).toBe(LOGO_ERROR);
expect(warningText()).toBe(''); // das Logo gibt es ja, es lässt sich nur gerade nicht laden
expect(store.get().config.logo).toBe('A');
});
it('ein Klick auf das schon gewählte Logo versucht es noch einmal: lädt es, wird es gezeichnet und der Fehler verschwindet', async () => {
const attempts = flaky(1);
withImage();
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
expect(lastLogo()).toBeNull();
mocks.drawCard.mockClear();
pickerHandlers.onSelect('A'); // der Klick auf die gewählte Kachel (der Picker meldet jeden Klick)
await vi.waitFor(() => expect(attempts()).toBe(2));
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
expect(errorText()).toBe('');
expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true);
});
it('scheitert auch die Wiederholung, bleibt es bei Fehler und Karte ohne Logo; es gibt keinen Wiederholungs-Loop, jede Wahl versucht es genau einmal', async () => {
const attempts = flaky(Infinity);
withImage();
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
pickerHandlers.onSelect('A');
await vi.waitFor(() => expect(attempts()).toBe(2));
await new Promise((r) => setTimeout(r, 10));
expect(attempts()).toBe(2);
expect(errorText()).toBe(LOGO_ERROR);
expect(lastLogo()).toBeNull();
store.set({ cardName: 'Andere Karte' }); // eine andere Änderung versucht es nicht erneut
await new Promise((r) => setTimeout(r, 10));
expect(attempts()).toBe(2);
pickerHandlers.onSelect('A');
await vi.waitFor(() => expect(attempts()).toBe(3));
});
it('lädt auch die Marke nicht, wird die Karte trotzdem gezeichnet (ohne Logo und Marke) und der Fehler steht da', async () => {
mocks.loadCardAssets.mockRejectedValue(new TypeError('Failed to fetch'));
withImage();
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
for (const [, config, assets] of mocks.drawCard.mock.calls) {
expect(config.logo).toBe('A');
expect(assets).toMatchObject({ logo: null, brand: null });
}
expect(errorText()).toBe(LOGO_ERROR);
});
it('ein anderes Logo nach dem Fehler wird nie mit den Bildern der gescheiterten Auswahl gezeichnet, sondern erst mit den eigenen', async () => {
flaky(Infinity);
withImage();
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
mocks.drawCard.mockClear();
store.set({ config: withLogo('B') });
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
expect(mocks.drawCard.mock.calls.every(([, config, assets]) => config.logo === 'B' && assets.logo === FOUND.logo)).toBe(true);
});
it('ein Klick auf das gewählte Logo, das geladen ist, lädt nichts neu', async () => {
mocks.loadCardAssets.mockResolvedValue(FOUND);
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
pickerHandlers.onSelect('A');
await new Promise((r) => setTimeout(r, 10));
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1);
});
it('ein Klick auf ein anderes Logo ändert nichts an der Sperre des gescheiterten: es lädt das neue, ohne das alte zu wiederholen', async () => {
const attempts = flaky(Infinity);
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
pickerHandlers.onSelect('B');
await vi.waitFor(() => expect(errorText()).toBe(''));
expect(attempts()).toBe(1);
});
});
describe('Fehler „Die Logos konnten nicht geladen werden.“ und das gemeinsame Fehlerfeld', () => {
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
/** Logo „A“ lässt sich nicht laden, alle anderen schon. */
const failA = async (config: { logo: string | null }) => {
if (config.logo === 'A') throw new TypeError('Failed to fetch');
return FOUND;
};
it('der Fehler gehört dem Logo-Zustand: lädt danach ein anderes Logo, verschwindet er', async () => {
mocks.loadCardAssets.mockImplementation(failA);
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo('B') });
await vi.waitFor(() => expect(errorText()).toBe(''));
// und auch „Kein Logo“ ist ein erfolgreicher Ladevorgang
store.set({ config: withLogo('A') });
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo(null) });
await vi.waitFor(() => expect(errorText()).toBe(''));
});
it('ein anderer Fehler im Feld bleibt stehen, auch wenn danach ein Logo lädt', async () => {
mocks.loadCardAssets.mockImplementation(failA);
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
// Ein anderer Fehler ersetzt den Logo-Fehler im Feld …
saveButton().disabled = false;
saveButton().click();
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
// … und gehört nicht dem Logo-Zustand: ein erfolgreicher Ladevorgang löscht ihn nicht.
store.set({ config: withLogo('B') });
// (Nach dem Fehler für „A“ lädt der Editor einmal die Marke allein, um die Karte ohne Logo zu zeichnen: darum nicht nach der Zahl der Aufrufe fragen.)
await vi.waitFor(() => expect(mocks.loadCardAssets.mock.calls.some(([config]) => config.logo === 'B')).toBe(true));
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
});
it('ein erfolgreicher Ladevorgang ohne Logo-Fehler lässt einen fremden Fehler ebenfalls stehen', async () => {
store.set({ config: withLogo('A') });
mocks.loadCardAssets.mockResolvedValue(FOUND);
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
saveButton().disabled = false;
saveButton().click();
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
store.set({ config: withLogo('B') });
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
});
it('derselbe Text aus einem gescheiterten Download gehört dem Download und bleibt stehen', async () => {
withImage();
store.set({ config: withLogo('da') });
mocks.loadCardAssets.mockResolvedValueOnce(FOUND).mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue(FOUND);
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
downloadButton().click();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo('db') });
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(3));
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe(LOGO_ERROR);
});
it('eine späte Antwort für einen überholten Schlüssel löscht nichts', async () => {
let finishB!: (v: typeof FOUND) => void;
mocks.loadCardAssets.mockImplementation((config: { logo: string | null }) => {
if (config.logo === 'A') return Promise.reject(new TypeError('Failed to fetch'));
if (config.logo === 'B') return new Promise((resolve) => (finishB = resolve as (v: typeof FOUND) => void));
return Promise.resolve(FOUND);
});
store.set({ config: withLogo('A') });
mount();
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
store.set({ config: withLogo('B') }); // lädt noch …
store.set({ config: withLogo('A') }); // … da wird A wieder gewählt und scheitert erneut
finishB(FOUND);
await new Promise((r) => setTimeout(r, 10));
expect(errorText()).toBe(LOGO_ERROR);
});
});
describe('Download und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => {
const row = (patch: Record<string, unknown> = {}) => ({
id: 'zw3',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'zw3',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
/** Das Logo des letzten Zeichnens: null = ohne Logo gezeichnet, undefined = noch nichts gezeichnet. */
const lastDrawnLogo = () => mocks.drawCard.mock.calls.at(-1)?.[2].logo;
const drawnWithLogo = () => expect(lastDrawnLogo()).toMatchObject({ w: 200, h: 100 });
beforeEach(() => {
mocks.getLogo.mockResolvedValue(row());
mocks.loadLogoBlob.mockResolvedValue(new Blob(['logo'], { type: 'image/png' }));
// Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter.
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, img: unknown) => {
const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null;
return { image: img, logo, brand: {}, logoMissing: Boolean(config.logo) && logo === null };
});
vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 }));
});
afterEach(() => forgetLogo('zw3'));
it('ein Logo, das nach dem Laden der Vorschau ausgeblendet wurde, wird neu geprüft: das PNG entsteht ohne Logo, der Hinweis erscheint, die Vorschau folgt', async () => {
withImage();
store.set({ config: withLogo('zw3') });
mount();
await vi.waitFor(drawnWithLogo); // die Vorschau zeigt das Logo
expect(warningText()).toBe('');
mocks.getLogo.mockResolvedValue(row({ hidden: true })); // jetzt wird es ausgeblendet
mocks.drawCard.mockClear();
downloadButton().click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][1].logo).toBeNull(); // das PNG: ohne Logo, wie die Vorschau nun auch
expect(errorText()).toBe('');
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE)); // derselbe Hinweis wie bei jedem fehlenden Logo
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
expect(lastDrawnLogo()).toBeNull();
expect(store.get().config.logo).toBe('zw3'); // die Auswahl bleibt
});
it('der Hinweis steht zusammen mit dem Größenhinweis des Downloads', async () => {
mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' }));
withImage();
store.set({ config: withLogo('zw3') });
mount();
await vi.waitFor(drawnWithLogo);
mocks.getLogo.mockResolvedValue(row({ hidden: true }));
downloadButton().click();
await vi.waitFor(() => expect(warningText()).toBe(`${PNG_SIZE_WARNING} ${LOGO_GONE}`));
});
it('ein Logo, das inzwischen wieder da ist (eingeblendet), wird ebenso übernommen: Hinweis weg, PNG und Vorschau mit Logo', async () => {
mocks.getLogo.mockResolvedValue(row({ hidden: true }));
withImage();
store.set({ config: withLogo('zw3') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
mocks.getLogo.mockResolvedValue(row());
mocks.drawCard.mockClear();
downloadButton().click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
expect(mocks.exportCard.mock.calls[0][1].logo).toMatchObject({ w: 200, h: 100 });
await vi.waitFor(() => expect(warningText()).toBe(''));
await vi.waitFor(drawnWithLogo);
});
it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei heruntergeladen, ohne Hinweis', async () => {
withImage();
store.set({ config: withLogo('zw3') });
mount();
await vi.waitFor(drawnWithLogo);
mocks.drawCard.mockClear();
downloadButton().click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][1].logo).toMatchObject({ w: 200, h: 100 });
expect(warningText()).toBe('');
expect(mocks.drawCard).not.toHaveBeenCalled(); // nichts hat sich geändert: die Vorschau wird nicht neu gezeichnet
});
it('ohne gewähltes Logo wird nichts vergessen oder geholt', async () => {
withImage();
mount();
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
downloadButton().click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
expect(mocks.getLogo).not.toHaveBeenCalled();
});
it('hat sich die Auswahl während des Downloads geändert, gilt dessen Ergebnis nicht für die neue Auswahl: kein Hinweis zu „Kein Logo“', async () => {
withImage();
store.set({ config: withLogo('zw3') });
mount();
await vi.waitFor(drawnWithLogo);
let answer!: (logo: unknown) => void;
mocks.getLogo.mockReturnValue(new Promise((resolve) => (answer = resolve))); // die erneute Prüfung des Downloads hängt noch
downloadButton().click();
store.set({ config: withLogo(null) }); // inzwischen: „Kein Logo“
mocks.drawCard.mockClear();
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); // der Editor zeichnet schon die neue Auswahl
answer(row({ hidden: true })); // erst jetzt kommt die Antwort: das alte Logo ist ausgeblendet
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 10));
expect(warningText()).toBe('');
});
});
it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } }));
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mocks.loadCardAssets.mockImplementation(goneLoader);
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
expect(errorText()).toBe('');
expect(store.get().config.logo).toBe('gone');
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
downloadButton().click();
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'gone' }), expect.objectContaining({ logo: null }), {
width: 1536,
type: 'image/png',
});
expect(errorText()).toBe('');
// Der Hinweis bleibt: das Logo fehlt weiterhin.
expect(warningText()).toBe(LOGO_GONE);
});
it('Download: scheitert das Laden der Logos (Netz), steht „Die Logos konnten nicht geladen werden.“ statt der rohen Fehlermeldung', async () => {
withImage();
store.set({ config: withLogo('da') });
mocks.loadCardAssets.mockResolvedValueOnce(FOUND).mockRejectedValue(new TypeError('Failed to fetch'));
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
downloadButton().click();
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
expect(mocks.exportCard).not.toHaveBeenCalled();
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
expect(downloadButton().disabled).toBe(false);
});
it('der Logo-Hinweis und andere Hinweise stehen zusammen und löschen sich nicht gegenseitig', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gone' } }));
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mocks.loadCardAssets.mockImplementation(goneLoader);
mount();
// Bilddatei fehlt UND das Logo fehlt: beide Hinweise
await vi.waitFor(() => expect(warningText()).toBe(`Das Bild dieser Karte fehlt. ${LOGO_GONE}`));
// Ein anderes Logo: nur der Logo-Hinweis geht, der Bild-Hinweis bleibt.
store.set({ config: withLogo('da') });
await vi.waitFor(() => expect(warningText()).toBe('Das Bild dieser Karte fehlt.'));
});
it('der Größenhinweis des Downloads bleibt, wenn der Logo-Hinweis verschwindet (und umgekehrt)', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' }));
withImage();
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
downloadButton().click();
await vi.waitFor(() => expect(warningText()).toBe(`${PNG_SIZE_WARNING} ${LOGO_GONE}`));
store.set({ config: withLogo('da') });
await vi.waitFor(() => expect(warningText()).toBe(PNG_SIZE_WARNING));
});
it('Neue Karte: der Hinweis zum Logo der alten Karte verschwindet', async () => {
mocks.loadCardAssets.mockImplementation(goneLoader);
store.set({
config: withLogo('gone'),
saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
savedConfig: withLogo('gone'),
cardName: 'Urlaub',
});
mount();
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
root.querySelector<HTMLButtonElement>('.bound-row button')!.click();
await vi.waitFor(() => expect(store.get().saved).toBeNull());
await vi.waitFor(() => expect(warningText()).toBe(''));
});
it('Englisch: der Hinweis erscheint übersetzt', async () => {
const { setLang } = await import('../i18n/i18n');
setLang('en', { persist: false });
mocks.loadCardAssets.mockResolvedValue(MISSING);
store.set({ config: withLogo('gone') });
mount();
await vi.waitFor(() => expect(warningText()).toBe('The selected logo is no longer available.'));
});
});
describe('Logo-Auswahl („Logo unten rechts“)', () => {
it('der Picker wird einmal erzeugt und sitzt im Platz oben in der Optionsspalte (logoSlot)', () => {
mount();
expect(mocks.createLogoPicker).toHaveBeenCalledTimes(1);
const slot = root.querySelector('.editor-logo-slot')!;
expect(slot.contains(picker.el)).toBe(true);
expect(slot.parentElement!.classList.contains('editor-options')).toBe(true);
});
it('eine Wahl im Picker setzt config.logo, „Kein Logo“ setzt es zurück; der Rest der Konfiguration bleibt', () => {
store.set({ config: { ...store.get().config, template: 'classic', details: 'dark' } });
mount();
pickerHandlers.onSelect('abc123');
expect(store.get().config).toMatchObject({ logo: 'abc123', template: 'classic', details: 'dark' });
pickerHandlers.onSelect(null);
expect(store.get().config).toMatchObject({ logo: null, template: 'classic', details: 'dark' });
});
it('die Wahl lädt die Karten-Bilder mit dem neuen Logo (der Editor zeichnet sie neu)', async () => {
mount();
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
pickerHandlers.onSelect('abc123');
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
expect(mocks.loadCardAssets.mock.calls[1][0]).toMatchObject({ logo: 'abc123' });
});
it('sync: der Picker bekommt config.logo beim Aufbau und bei jeder Änderung', () => {
store.set({ config: { ...store.get().config, logo: 'abc123' } });
mount();
expect(picker.select).toHaveBeenLastCalledWith('abc123');
store.set({ config: { ...store.get().config, logo: 'def456' } });
expect(picker.select).toHaveBeenLastCalledWith('def456');
store.set({ config: { ...store.get().config, logo: null } });
expect(picker.select).toHaveBeenLastCalledWith(null);
});
it('eine geöffnete gespeicherte Karte: der Picker bekommt ihr Logo', async () => {
history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gespeichert' } }));
mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image());
mount();
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
expect(picker.select).toHaveBeenLastCalledWith('gespeichert');
});
it('„Neue Karte“ setzt den Editor zurück: der Picker bekommt wieder null', () => {
store.set({
config: { ...store.get().config, logo: 'abc123' },
saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
savedConfig: { ...store.get().config, logo: 'abc123' },
cardName: 'Urlaub',
});
mount();
expect(picker.select).toHaveBeenLastCalledWith('abc123');
root.querySelector<HTMLButtonElement>('.bound-row button')!.click();
expect(picker.select).toHaveBeenLastCalledWith(null);
});
it('die Seite zu verlassen räumt den Picker genau einmal auf (dispose)', () => {
mount();
expect(picker.dispose).not.toHaveBeenCalled();
unmount!();
unmount = null;
expect(picker.dispose).toHaveBeenCalledTimes(1);
});
});
describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`);
const galleryCard = (patch: Record<string, unknown> = {}) => ({
@@ -1029,7 +1686,7 @@ describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
it('eine zuvor geöffnete gespeicherte Karte wird entkoppelt: Speichern legt eine neue Karte an', async () => {
openTemplate('g1');
const { blob } = available();
const config = { ...store.get().config, template: 'classic' as const, network: 'jcb' as const };
const config = { ...store.get().config, template: 'classic' as const, logo: 'abc123' };
store.set({
image: image(),
imageBlob: png(),
+119 -40
View File
@@ -5,11 +5,14 @@ import { getCurrentUser, onLogout } from '../account/auth';
import { errorMessage, errorType } from '../account/errors';
import { requireLogin } from '../account/login-dialog';
import { LoginCancelled, withLogin } from '../account/with-login';
import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type NetworkId, type TemplateId } from '../card/config';
import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type TemplateId } from '../card/config';
import { clampCrop, panBy, zoomTo } from '../card/crop';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage, type LoadedAssets } from '../card/render';
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { msg, t } from '../i18n/i18n';
import { LOGO_MISSING, forgetLogo } from '../logos/logo-asset';
import { createLogoPicker } from '../logos/logo-picker';
import { dropCardParam, dropTemplateParam } from './card-url';
import { confirmDialog } from './confirm-dialog';
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
@@ -21,19 +24,21 @@ import { initialEditorState, setImage, type EditorState, type EditorStore, type
import { createTemplatesPanel } from './templates-panel';
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage, validateImageFile } from './upload';
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
const NO_IMAGE = 'Bitte zuerst ein Bild wählen.';
const LOADING_CARD = 'Karte wird geladen …';
const LOADING_TEMPLATE = 'Vorlage wird geladen …';
const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.';
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.';
// Deutsch markierte Texte: Fehler werden mit ihnen geworfen (`new Error(LOGO_ERROR)`) und erst beim Anzeigen mit `t()` übersetzt.
const LOGO_ERROR = msg('Die Logos konnten nicht geladen werden.');
const EXPORT_ERROR = msg('Das PNG konnte nicht erzeugt werden.');
const NO_IMAGE = msg('Bitte zuerst ein Bild wählen.');
const LOADING_CARD = msg('Karte wird geladen …');
const LOADING_TEMPLATE = msg('Vorlage wird geladen …');
const TEMPLATE_UNAVAILABLE = msg('Diese Vorlage ist nicht (mehr) verfügbar.');
const LOGIN_TO_OPEN = msg('Melde dich an, um diese Karte zu öffnen.');
const SAVED_FLASH_MS = 2000;
type Logos = Pick<CardAssets, 'network' | 'brand'>;
/** Die geladenen Logo-Bilder; `missing`: die Karte wählt ein Community-Logo, das es nicht (mehr) gibt. */
type Logos = Pick<CardAssets, 'logo' | 'brand'> & { missing: boolean };
/** Alles, was Logo-Bilder bestimmt: wechselt der Schlüssel, müssen sie neu geladen werden. */
const logoKey = (c: CardConfig) => `${c.network}|${c.details}|${c.brand}`;
const logoKey = (c: CardConfig) => `${c.logo ?? ''}|${c.details}|${c.brand}`;
/** Der Teil einer gespeicherten Karte, den der Editor-Zustand festhält (ohne Konfiguration). */
function toRef(card: SavedCardRef): SavedCardRef {
@@ -65,10 +70,18 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const templates = createTemplatesPanel((template: TemplateId) => patchConfig({ template }));
const options = createOptionsPanel({
onNetwork: (network: NetworkId) => patchConfig({ network }),
onDetails: (details: DetailsTone) => patchConfig({ details }),
onBrand: (brand: BrandId) => patchConfig({ brand }),
});
// „Logo unten rechts“: die Wahl setzt config.logo, markiert wird sie über sync (picker.select). Der Picker meldet auch den Klick auf das
// schon gewählte Logo: scheiterte dessen Laden, hebt das die Sperre auf und ensureLogos versucht es noch einmal.
const logoPicker = createLogoPicker({
onSelect(logo) {
if (logo === store.get().config.logo) failedKey = '';
patchConfig({ logo });
},
});
options.logoSlot.append(logoPicker.el);
let exporting = false;
@@ -88,7 +101,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onSave() {
if (store.get().saving) return;
if (!store.get().imageBlob) {
preview.setError(NO_IMAGE);
preview.setError(t(NO_IMAGE));
return;
}
// Der Schnappschuss gilt für alles, was dieser Klick auslöst – auch für die Wiederholung nach einem 401.
@@ -100,7 +113,30 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onNewCard: () => void startNewCard(),
});
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten');
// Das Fehlerfeld der Vorschau teilen sich alle Fehler des Editors. LOGO_ERROR (ensureLogos) darf verschwinden, sobald ein Logo
// doch lädt, aber nur, solange er noch dort steht, wo ensureLogos ihn hingeschrieben hat: jeder andere Aufruf von setError
// (ein anderer Text, auch derselbe aus einem Download, oder das Leeren) nimmt ihm dieses Recht.
let logoErrorShown = false;
const setPreviewError = preview.setError;
preview.setError = (message) => {
logoErrorShown = false;
setPreviewError(message);
};
// Der Hinweis unter der Vorschau: ein allgemeiner (Bild fehlt, PNG zu groß …) und der zum fehlenden Community-Logo (er gilt,
// solange die geladenen Logos `missing` sind, siehe ensureLogos). Beide teilen sich das Feld, werden aber getrennt gehalten,
// damit das Ende des einen den anderen nicht löscht.
let generalWarning: string | null = null;
function showWarning(): void {
const parts = [generalWarning, logos?.missing ? t(LOGO_MISSING) : null].filter((part): part is string => !!part);
preview.setWarning(parts.length > 0 ? parts.join(' ') : null);
}
function setWarning(message: string | null): void {
generalWarning = message;
showWarning();
}
const heading = el('h1', { class: 'sr-only' }, t('Kartenmotiv gestalten'));
const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el);
root.replaceChildren(heading, page);
@@ -124,7 +160,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
setImage(store, image, file);
} catch (error) {
if (disposed || token !== fileToken) return;
preview.setError(error instanceof Error ? error.message : 'Das Bild konnte nicht gelesen werden.');
preview.setError(error instanceof Error ? t(error.message) : t(READ_MESSAGE));
}
}
attachFileDrop(page, (file) => void handleFile(file), (active) => preview.setDragging(active && !store.get().saving));
@@ -136,20 +172,38 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
exporting = true;
sync(store.get());
preview.setError(null);
preview.setWarning(null);
setWarning(null);
try {
const assets = await loadCardAssets(s.config, s.image);
// Zeile und Bild des Logos neu vom Server prüfen: ein inzwischen ausgeblendetes oder gelöschtes Logo käme sonst aus dem
// Zwischenspeicher (er lebt so lange wie die Seite) ins PNG.
if (s.config.logo) forgetLogo(s.config.logo);
// Die Logos kommen aus dem Netz: scheitert das, soll der Nutzer „Logos konnten nicht geladen werden“ lesen, nicht „Failed to fetch“.
const assets = await loadCardAssets(s.config, s.image).catch(() => {
throw new Error(LOGO_ERROR);
});
followDownload(s.config, assets);
const blob = await exportCard(s.config, assets, { width: CARD_W, type: 'image/png' });
saveBlobAs(blob, downloadFileName(s.cardName, new Date()));
preview.setWarning(pngSizeWarning(blob.size));
setWarning(pngSizeWarning(blob.size));
} catch (error) {
preview.setError(error instanceof Error && error.message ? error.message : EXPORT_ERROR);
preview.setError(error instanceof Error && error.message ? t(error.message) : t(EXPORT_ERROR));
} finally {
exporting = false;
if (!disposed) sync(store.get());
}
}
/**
* Der Download hat Zeile und Bild des Logos frisch geholt (siehe download). Hat sich dabei geändert, ob es das Logo gibt (es wurde
* ausgeblendet oder gelöscht, oder ist wieder da), folgen Hinweis und Vorschau: das PNG und die Vorschau zeigen dasselbe.
*/
function followDownload(config: CardConfig, assets: LoadedAssets): void {
if (!logos || logos.key !== logoKey(config) || logos.missing === assets.logoMissing) return;
logos = { ...logos, logo: assets.logo, missing: assets.logoMissing };
showWarning();
schedule();
}
// Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts).
let savedFlash = false;
let flashTimer: ReturnType<typeof setTimeout> | undefined;
@@ -217,8 +271,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const stale = () => disposed || token !== loadToken;
const service = cardsService();
preview.setError(null);
preview.setWarning(null);
preview.setNotice(LOADING_CARD);
setWarning(null);
preview.setNotice(t(LOADING_CARD));
try {
// Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden.
if (!(await getCurrentUser())) {
@@ -262,11 +316,11 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
savedConfig: config,
cardName: card.name,
});
preview.setWarning(missingImage);
setWarning(missingImage);
} catch (err) {
if (stale()) return;
if (err instanceof LoginCancelled) {
preview.setWarning(LOGIN_TO_OPEN);
setWarning(t(LOGIN_TO_OPEN));
} else if (errorType(err) === 'row_not_found') {
// Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie.
const s = store.get();
@@ -274,7 +328,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
preview.setError(errorMessage(err, 'load'));
} else {
const imageProblem = err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE);
preview.setError(imageProblem ? err.message : errorMessage(err, 'load'));
preview.setError(imageProblem ? t(err.message) : errorMessage(err, 'load'));
}
} finally {
if (!stale()) preview.setNotice(null);
@@ -287,8 +341,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const token = ++loadToken;
const stale = () => disposed || token !== loadToken;
preview.setError(null);
preview.setWarning(null);
preview.setNotice(LOADING_TEMPLATE);
setWarning(null);
preview.setNotice(t(LOADING_TEMPLATE));
try {
// Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404).
const card = await getGalleryCard(galleryId);
@@ -321,7 +375,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
dropTemplateParam();
} catch {
if (stale()) return;
preview.setError(TEMPLATE_UNAVAILABLE);
preview.setError(t(TEMPLATE_UNAVAILABLE));
} finally {
if (!stale()) preview.setNotice(null);
}
@@ -334,9 +388,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
try {
return await confirmDialog(
{
title: 'Ungespeicherte Änderungen verwerfen?',
text: 'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.',
confirmLabel: 'Verwerfen',
title: t('Ungespeicherte Änderungen verwerfen?'),
text: t('Alles, was du seit dem letzten Speichern geändert hast, geht verloren.'),
confirmLabel: t('Verwerfen'),
},
disposal.signal,
);
@@ -363,37 +417,60 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
cancelCardLoad(); // eine noch ladende Karte soll nicht in den leeren Editor springen
fileToken += 1; // … und ein noch dekodiertes Bild auch nicht
preview.setError(null);
preview.setWarning(null);
setWarning(null);
store.set(initialEditorState());
dropCardParam();
}
// Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert).
let logos: (Logos & { key: string }) | null = null;
// Logos und Schrift: werden nur bei Wechsel von Community-Logo, Details oder Marke geladen (Logos sind zwischengespeichert).
// Fehlt das gewählte Community-Logo, wird die Karte ohne es gezeichnet und der Hinweis gezeigt, solange das so bleibt. Ein echter
// Ladefehler (Netz, Server) zeigt dagegen LOGO_ERROR; die Karte wird auch dann gezeichnet, ebenfalls ohne Logo (`failed`, siehe
// drawWithoutLogo), damit Vorschau und Miniaturen nicht leer bleiben. Derselbe Schlüssel wird nicht im Kreis neu versucht: erst eine
// andere Wahl (Logo, Details, Marke) oder ein erneuter Klick auf das gewählte Logo (siehe onSelect) hebt die Sperre auf.
let logos: (Logos & { key: string; failed?: boolean }) | null = null;
let pendingKey = '';
let failedKey = '';
let logoToken = 0;
function ensureLogos(config: CardConfig): void {
const key = logoKey(config);
if (logos?.key === key || pendingKey === key || failedKey === key) return;
if ((logos?.key === key && !logos.failed) || pendingKey === key || failedKey === key) return;
failedKey = '';
pendingKey = key;
const token = ++logoToken;
loadCardAssets(config, null).then(
(assets) => {
if (disposed || token !== logoToken) return;
pendingKey = '';
logos = { network: assets.network, brand: assets.brand, key };
logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key };
if (logoErrorShown) preview.setError(null); // der Ladefehler dieses Zustands ist erledigt, alle anderen Fehler bleiben
showWarning();
schedule();
},
() => {
if (disposed || token !== logoToken) return;
pendingKey = '';
failedKey = key;
preview.setError(LOGO_ERROR);
preview.setError(t(LOGO_ERROR));
logoErrorShown = true;
drawWithoutLogo(config, key, token);
},
);
}
/**
* Nach einem Ladefehler: die Karte ohne Logo zeichnen. Geladen wird nur die Marke (sie gehört zur Seite und lädt fast immer);
* scheitert auch das, fehlt sie ebenfalls. Gilt nur, solange noch dieser Ladevorgang der jüngste ist.
*/
function drawWithoutLogo(config: CardConfig, key: string, token: number): void {
if (logos?.key === key) return; // eine Wiederholung für denselben Schlüssel scheiterte erneut: die Karte steht schon so da
const adopt = (brand: Logos['brand']) => {
if (disposed || token !== logoToken) return;
logos = { logo: null, brand, missing: false, key, failed: true };
schedule();
};
loadCardAssets({ ...config, logo: null }, null).then((assets) => adopt(assets.brand), () => adopt(null));
}
// Zeichnen: höchstens einmal je Animationsbild, egal wie oft sich der Zustand ändert.
let frame = 0;
function schedule(): void {
@@ -406,14 +483,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
function draw(): void {
const s = store.get();
// Mit Logos eines anderen Netzwerks würde das Seitenverhältnis nicht passen; lieber auf die neuen warten.
// Mit den Logos einer anderen Auswahl würde das Seitenverhältnis nicht passen; lieber auf die neuen warten.
if (!logos || logos.key !== logoKey(s.config)) return;
const assets: CardAssets = { image: s.image, network: logos.network, brand: logos.brand };
const assets: CardAssets = { image: s.image, logo: logos.logo, brand: logos.brand };
try {
if (s.image) drawCard(preview.canvas, s.config, assets);
templates.draw(s.config, assets);
} catch (error) {
preview.setError(error instanceof Error ? error.message : 'Die Vorschau konnte nicht gezeichnet werden.');
preview.setError(error instanceof Error ? t(error.message) : t('Die Vorschau konnte nicht gezeichnet werden.'));
}
}
@@ -421,6 +498,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
function sync(s: EditorState): void {
templates.select(s.config.template);
options.select(s.config);
logoPicker.select(s.config.logo);
preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, boundName: s.saved?.name ?? null, exporting });
}
@@ -428,7 +506,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
// Der Größenhinweis gehört zum letzten Download; ein anderes Bild macht ihn hinfällig.
if (s.image !== lastImage) {
lastImage = s.image;
preview.setWarning(null);
setWarning(null);
}
sync(s);
ensureLogos(s.config);
@@ -460,6 +538,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
clearTimeout(flashTimer);
offLogout();
unsubscribe();
logoPicker.dispose();
if (frame) cancelAnimationFrame(frame);
frame = 0;
};
+5 -4
View File
@@ -73,11 +73,12 @@
line-height: 1.2;
}
.panel-title + .panel-subtitle {
.panel-title + .panel-subtitle,
.editor-logo-slot:empty + .panel-subtitle {
margin-top: 0;
}
/* Kacheln (Vorlagen, Netzwerke, Logos) */
/* Kacheln (Vorlagen, Logos) */
.tile {
appearance: none;
@@ -154,7 +155,7 @@
display: block;
}
/* Kartennetzwerk und Logo */
/* Logo oben links */
.mark-grid {
display: grid;
@@ -503,7 +504,7 @@
font-size: 1.1rem;
}
/* Ab 1024 px: schmale Optionsspalte → zwei statt drei Netzwerk-Kacheln pro Reihe, damit Beschriftungen nicht mitten im Wort brechen */
/* Ab 1024 px: schmale Optionsspalte → zwei statt drei Logo-Kacheln pro Reihe, damit Beschriftungen nicht mitten im Wort brechen */
@media (min-width: 1024px) {
.mark-grid {
+101
View File
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG, type CardConfig } from '../card/config';
import { setLang } from '../i18n/i18n';
import { createOptionsPanel, type OptionsPanel } from './options-panel';
const handlers = { onDetails: vi.fn(), onBrand: vi.fn() };
let panel: OptionsPanel;
const cfg = (patch: Partial<CardConfig> = {}): CardConfig => ({ ...DEFAULT_CONFIG, crop: { ...DEFAULT_CONFIG.crop }, ...patch });
const headings = () => [...panel.el.querySelectorAll('h2, h3')].map((h) => h.textContent);
const tiles = () => [...panel.el.querySelectorAll<HTMLButtonElement>('.mark-tile')];
const tile = (label: string) => tiles().find((b) => b.getAttribute('aria-label') === label)!;
beforeEach(() => {
handlers.onDetails.mockReset();
handlers.onBrand.mockReset();
panel = createOptionsPanel(handlers);
});
describe('Optionen: Logo oben links', () => {
it('keine Netzwerk-Kacheln mehr: nur drei Kacheln (Symbol, Text, Kein Logo), keine Überschrift „Kartennetzwerk“', () => {
expect(headings()).not.toContain('Kartennetzwerk');
expect(tiles().map((b) => b.getAttribute('aria-label'))).toEqual([
'Logo – AirCard-IOS · Symbol',
'Logo – AirCard-IOS · Text',
'Kein Logo',
]);
});
it('kein Apple und keine fremden Marken in den Kacheln', () => {
const text = panel.el.textContent! + tiles().map((b) => b.getAttribute('title')).join(' ');
expect(text).not.toMatch(/apple|visa|mastercard|unionpay|american express|jcb|discover/i);
});
it('die Überschrift lautet „Logo oben links“ und beschriftet die Kachelgruppe', () => {
const heading = [...panel.el.querySelectorAll('h3')].find((h) => h.textContent === 'Logo oben links')!;
expect(heading).toBeDefined();
const group = panel.el.querySelector(`[aria-labelledby="${heading.id}"]`)!;
expect(group.getAttribute('role')).toBe('group');
expect([...group.querySelectorAll('.mark-tile')]).toEqual(tiles());
});
it('Reihenfolge der Überschriften: Individuell gestalten, Details, Logo oben links', () => {
expect(headings()).toEqual(['Individuell gestalten', 'Details', 'Logo oben links']);
});
it('die Überschrift „Logo oben links“ wird übersetzt', () => {
setLang('en', { persist: false });
panel = createOptionsPanel(handlers);
expect(headings()).toContain('Top-left logo');
expect(headings()).not.toContain('Logo oben links');
});
it('Klick auf eine Kachel meldet die Marke (oder none)', () => {
tile('Logo – AirCard-IOS · Symbol').click();
tile('Logo – AirCard-IOS · Text').click();
tile('Kein Logo').click();
expect(handlers.onBrand.mock.calls).toEqual([['aircards-symbol'], ['aircards-text'], ['none']]);
});
it('select markiert die gewählte Marke mit aria-pressed', () => {
const pressed = () => tiles().filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.getAttribute('aria-label'));
panel.select(cfg({ brand: 'aircards-text' }));
expect(pressed()).toEqual(['Logo – AirCard-IOS · Text']);
panel.select(cfg({ brand: 'none' }));
expect(pressed()).toEqual(['Kein Logo']);
panel.select(cfg({ brand: 'aircards-symbol' }));
expect(pressed()).toEqual(['Logo – AirCard-IOS · Symbol']);
});
});
describe('Optionen: Details und Platz für das Community-Logo', () => {
it('Details Hell/Dunkel: Auswahl und Meldung', () => {
const inputs = [...panel.el.querySelectorAll<HTMLInputElement>('input[name="card-details"]')];
expect(inputs.map((i) => i.value)).toEqual(['light', 'dark']);
panel.select(cfg({ details: 'dark' }));
expect(inputs.map((i) => i.checked)).toEqual([false, true]);
inputs[0].checked = true;
inputs[0].dispatchEvent(new Event('change'));
expect(handlers.onDetails).toHaveBeenCalledWith('light');
});
it('logoSlot: ein leerer Platz ganz oben, direkt unter „Individuell gestalten“, vor den Details', () => {
expect(panel.logoSlot).toBeInstanceOf(HTMLElement);
expect(panel.logoSlot.childElementCount).toBe(0);
expect(panel.el.contains(panel.logoSlot)).toBe(true);
const children = [...panel.el.children];
const slot = children.indexOf(panel.logoSlot);
const details = children.findIndex((c) => c.textContent === 'Details');
expect(children[0].textContent).toBe('Individuell gestalten');
expect(slot).toBe(1);
expect(slot).toBeLessThan(details);
});
it('der Platz bleibt beim Auswählen unberührt (Task 8 füllt ihn)', () => {
const before = panel.logoSlot;
panel.select(cfg({ logo: 'abc123' }));
expect(panel.logoSlot).toBe(before);
expect(panel.logoSlot.childElementCount).toBe(0);
});
});
+30 -30
View File
@@ -1,10 +1,11 @@
// Rechte Spalte „Individuell gestalten“: Kartennetzwerk, Details (Hell/Dunkel) und Logo.
import type { BrandId, CardConfig, DetailsTone, NetworkId } from '../card/config';
import { BRAND_MARKS, NETWORK_MARKS, tintSvg, type MarkDef } from '../card/marks';
// Rechte Spalte „Individuell gestalten“: oben der Platz für das Community-Logo (unten rechts auf der Karte),
// dann Details (Hell/Dunkel) und das Logo oben links.
import type { BrandId, CardConfig, DetailsTone } from '../card/config';
import { BRAND_MARKS, tintSvg, type MarkDef } from '../card/marks';
import { msg, t } from '../i18n/i18n';
import { el, icon } from './dom';
export interface OptionsHandlers {
onNetwork(id: NetworkId): void;
onDetails(tone: DetailsTone): void;
onBrand(id: BrandId): void;
}
@@ -12,11 +13,13 @@ export interface OptionsHandlers {
export interface OptionsPanel {
el: HTMLElement;
select(config: CardConfig): void;
/** Leerer Platz oben in der Spalte, für den Bereich „Logo unten rechts“ (der Logo-Auswahl). */
logoSlot: HTMLElement;
}
const TONES: readonly { id: DetailsTone; label: string }[] = [
{ id: 'light', label: 'Hell' },
{ id: 'dark', label: 'Dunkel' },
{ id: 'light', label: msg('Hell') },
{ id: 'dark', label: msg('Dunkel') },
];
/** Logo der Kachel als Bild: einfarbige Logos in Weiß (currentColor hätte im <img> die Farbe Schwarz). */
@@ -25,12 +28,12 @@ function markSrc(def: MarkDef): string {
}
function markTile(def: MarkDef, onClick: () => void) {
const name = `${def.group} – ${def.label}`;
const name = `${t(def.group)} – ${t(def.label)}`;
const button = el(
'button',
{ type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': name, title: name },
el('img', { class: 'mark-img', src: markSrc(def), alt: '', draggable: 'false' }),
el('span', { class: 'mark-label' }, def.label),
el('span', { class: 'mark-label' }, t(def.label)),
);
button.addEventListener('click', onClick);
return button;
@@ -41,56 +44,53 @@ function heading(id: string, text: string, level: 'h2' | 'h3') {
}
export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
const networkTiles = NETWORK_MARKS.map((def) => ({
id: def.id as NetworkId,
button: markTile(def, () => handlers.onNetwork(def.id as NetworkId)),
}));
const brandTiles: { id: BrandId; button: HTMLButtonElement }[] = BRAND_MARKS.map((def) => ({
id: def.id as BrandId,
button: markTile(def, () => handlers.onBrand(def.id as BrandId)),
}));
const noLogo = t('Kein Logo');
const none = el(
'button',
{ type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': 'Kein Logo', title: 'Kein Logo' },
{ type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': noLogo, title: noLogo },
icon('ban', 'mark-none'),
el('span', { class: 'mark-label' }, 'Kein Logo'),
el('span', { class: 'mark-label' }, noLogo),
);
none.addEventListener('click', () => handlers.onBrand('none'));
brandTiles.push({ id: 'none', button: none });
const toneInputs = TONES.map((t) => {
const input = el('input', { type: 'radio', name: 'card-details', value: t.id, class: 'pill-input' });
const toneInputs = TONES.map((tone) => {
const input = el('input', { type: 'radio', name: 'card-details', value: tone.id, class: 'pill-input' });
input.addEventListener('change', () => {
if (input.checked) handlers.onDetails(t.id);
if (input.checked) handlers.onDetails(tone.id);
});
const label = el(
'label',
{ class: 'pill' },
input,
el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${t.id}` }), t.label),
el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${tone.id}` }), t(tone.label)),
);
return { id: t.id, input, label };
return { id: tone.id, input, label };
});
const logoSlot = el('div', { class: 'editor-logo-slot' });
const section = el(
'section',
{ class: 'editor-side editor-options' },
heading('editor-options-heading', 'Individuell gestalten', 'h2'),
heading('editor-network-heading', 'Kartennetzwerk', 'h3'),
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-network-heading' }, ...networkTiles.map((t) => t.button)),
heading('editor-details-heading', 'Details', 'h3'),
el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((t) => t.label)),
heading('editor-brand-heading', 'Logo', 'h3'),
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((t) => t.button)),
heading('editor-options-heading', t('Individuell gestalten'), 'h2'),
logoSlot,
heading('editor-details-heading', t('Details'), 'h3'),
el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((input) => input.label)),
heading('editor-brand-heading', t('Logo oben links'), 'h3'),
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((tile) => tile.button)),
);
return {
el: section,
logoSlot,
select(config) {
for (const t of networkTiles) t.button.setAttribute('aria-pressed', String(t.id === config.network));
for (const t of brandTiles) t.button.setAttribute('aria-pressed', String(t.id === config.brand));
for (const t of toneInputs) t.input.checked = t.id === config.details;
for (const tile of brandTiles) tile.button.setAttribute('aria-pressed', String(tile.id === config.brand));
for (const tone of toneInputs) tone.input.checked = tone.id === config.details;
},
};
}
+30 -26
View File
@@ -1,6 +1,7 @@
// Mittlere Spalte: Ablagefläche bzw. Kartenvorschau (Verschieben, Zoom) und die Knopfleiste.
import { MAX_ZOOM, MIN_ZOOM } from '../card/config';
import { CARD_H, CARD_W } from '../card/geometry';
import { msg, t } from '../i18n/i18n';
import { el, icon } from './dom';
import { ACCEPTED_TYPES } from './upload';
@@ -32,15 +33,14 @@ export interface Preview {
setDragging(active: boolean): void;
setError(message: string | null): void;
setWarning(message: string | null): void;
/** Neutraler Hinweis, z. B. „Karte wird geladen …“. */
/** Neutraler Hinweis, z. B. „Karte wird geladen …“ (der Text kommt schon in der Sprache der Seite an). */
setNotice(message: string | null): void;
}
const NO_IMAGE_HINT = 'Erst ein Bild wählen';
const SAVE_LABEL = 'Speichern';
const SAVING_LABEL = 'Speichert …';
const SAVED_LABEL = 'Gespeichert';
const BOUND_PREFIX = 'Bearbeitet: ';
const NO_IMAGE_HINT = msg('Erst ein Bild wählen');
const SAVE_LABEL = msg('Speichern');
const SAVING_LABEL = msg('Speichert …');
const SAVED_LABEL = msg('Gespeichert');
const KEY_STEP = 24;
const KEY_STEP_BIG = 96;
const KEY_ZOOM_STEP = 0.1;
@@ -80,6 +80,9 @@ export function attachFileDrop(
});
}
/** Der Zoom als Prozentzahl, in der Schreibweise der Sprache der Seite („100 %“ / „100%“). */
const percent = (zoom: number): string => t('{percent} %', { percent: Math.round(zoom * 100) });
export function createPreview(handlers: PreviewHandlers): Preview {
let zoom = MIN_ZOOM;
@@ -87,7 +90,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
type: 'file',
accept: ACCEPTED_TYPES.join(','),
hidden: true,
'aria-label': 'Bilddatei wählen',
'aria-label': t('Bilddatei wählen'),
});
const openPicker = () => input.click();
input.addEventListener('change', () => {
@@ -98,14 +101,14 @@ export function createPreview(handlers: PreviewHandlers): Preview {
});
// Ablagefläche (ohne Bild)
const chooseButton = el('button', { type: 'button', class: 'btn btn-primary btn-large' }, 'Bild auswählen');
const chooseButton = el('button', { type: 'button', class: 'btn btn-primary btn-large' }, t('Bild auswählen'));
const dropzone = el(
'div',
{ class: 'dropzone' },
icon('imagePlus', 'dropzone-icon'),
el('p', { class: 'dropzone-title' }, 'Ziehe deine Idee hierher'),
el('p', { class: 'dropzone-title' }, t('Ziehe deine Idee hierher')),
chooseButton,
el('p', { class: 'dropzone-hint' }, 'PNG / JPG / WebP · Max. 5 MiB'),
el('p', { class: 'dropzone-hint' }, t('PNG / JPG / WebP · Max. 5 MiB')),
);
dropzone.addEventListener('click', openPicker);
@@ -116,7 +119,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
height: CARD_H,
tabindex: 0,
role: 'application',
'aria-label': 'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.',
'aria-label': t('Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.'),
});
const cardWrap = el('div', { class: 'card-wrap', hidden: true }, canvas);
@@ -177,7 +180,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
// Bindung an eine gespeicherte Karte: Name und Ausweg „Neue Karte“
const boundLabel = el('span', { class: 'bound-label' });
const newCardButton = el('button', { type: 'button', class: 'btn btn-small' }, 'Neue Karte');
const newCardButton = el('button', { type: 'button', class: 'btn btn-small' }, t('Neue Karte'));
newCardButton.addEventListener('click', () => handlers.onNewCard());
const boundRow = el('div', { class: 'bound-row', hidden: true }, boundLabel, newCardButton);
@@ -191,12 +194,12 @@ export function createPreview(handlers: PreviewHandlers): Preview {
step: 0.01,
value: MIN_ZOOM,
});
const zoomValue = el('output', { class: 'zoom-value', for: 'editor-zoom' }, '100 %');
const zoomValue = el('output', { class: 'zoom-value', for: 'editor-zoom' }, percent(MIN_ZOOM));
zoomInput.addEventListener('input', () => handlers.onZoom(Number(zoomInput.value)));
const zoomRow = el(
'div',
{ class: 'zoom-row', hidden: true },
el('label', { class: 'zoom-label', for: 'editor-zoom' }, 'Zoom'),
el('label', { class: 'zoom-label', for: 'editor-zoom' }, t('Zoom')),
zoomInput,
zoomValue,
);
@@ -207,11 +210,11 @@ export function createPreview(handlers: PreviewHandlers): Preview {
const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' });
// Knopfleiste
const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen');
const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, t('Bild ersetzen'));
replaceButton.addEventListener('click', openPicker);
const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen');
const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, t('Herunterladen'));
downloadButton.addEventListener('click', () => handlers.onDownload());
const saveButton = el('button', { type: 'button', class: 'btn btn-primary btn-save' }, SAVE_LABEL);
const saveButton = el('button', { type: 'button', class: 'btn btn-primary btn-save' }, t(SAVE_LABEL));
saveButton.addEventListener('click', () => handlers.onSave());
const actions = el(
'div',
@@ -220,10 +223,10 @@ export function createPreview(handlers: PreviewHandlers): Preview {
el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton),
);
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), 'Dein Bild bleibt auf deinem Gerät, bis du speicherst.');
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), t('Dein Bild bleibt auf deinem Gerät, bis du speicherst.'));
const inner = el('div', { class: 'preview-inner' }, boundRow, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input);
const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner);
const section = el('section', { class: 'editor-center', 'aria-label': t('Kartenvorschau') }, inner);
function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void {
button.disabled = disabled;
@@ -244,18 +247,19 @@ export function createPreview(handlers: PreviewHandlers): Preview {
cardWrap.hidden = !view.hasImage;
zoomRow.hidden = !view.hasImage;
zoomInput.value = String(view.zoom);
zoomValue.textContent = `${Math.round(view.zoom * 100)} %`;
setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT);
setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT);
saveButton.textContent = view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL;
zoomValue.textContent = percent(view.zoom);
setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : t(NO_IMAGE_HINT));
setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : t(NO_IMAGE_HINT));
saveButton.textContent = t(view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL);
// Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen.
replaceButton.disabled = view.saving;
input.disabled = view.saving;
boundRow.hidden = view.boundName === null;
// Nur als Text, nie als HTML: der Name kommt vom Nutzer.
boundLabel.textContent = view.boundName === null ? '' : BOUND_PREFIX + view.boundName;
if (view.boundName === null) boundLabel.removeAttribute('title');
else boundLabel.setAttribute('title', BOUND_PREFIX + view.boundName);
const bound = view.boundName === null ? null : t('Bearbeitet: {name}', { name: view.boundName });
boundLabel.textContent = bound ?? '';
if (bound === null) boundLabel.removeAttribute('title');
else boundLabel.setAttribute('title', bound);
newCardButton.disabled = view.saving;
downloadButton.setAttribute('aria-busy', String(view.exporting));
saveButton.setAttribute('aria-busy', String(view.saving));
+2 -3
View File
@@ -2,6 +2,7 @@
// Kennt weder DOM noch Appwrite; alles Konkrete kommt über `deps`.
import { getCurrentUser, type User } from '../account/auth';
import { errorMessage, isUnauthorized } from '../account/errors';
import { t } from '../i18n/i18n';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
@@ -16,8 +17,6 @@ export interface SaveDeps {
refreshUser?(): Promise<unknown>;
}
const FAILED = 'Speichern fehlgeschlagen – ';
/** Liefert die Funktion hinter dem Speichern-Knopf; ein laufendes Speichern blockiert jeden weiteren Aufruf. */
export function createSaveAction(deps: SaveDeps): () => Promise<void> {
const refreshUser = deps.refreshUser ?? getCurrentUser;
@@ -50,7 +49,7 @@ export function createSaveAction(deps: SaveDeps): () => Promise<void> {
}
deps.onStatus('saved');
} catch (err) {
deps.onStatus('error', FAILED + errorMessage(err, 'save'));
deps.onStatus('error', t('Speichern fehlgeschlagen – {reason}', { reason: errorMessage(err, 'save') }));
} finally {
running = false;
}
+3 -2
View File
@@ -2,6 +2,7 @@
// Der Store wird einmal in main.ts erzeugt und überlebt Seitenwechsel.
import { DEFAULT_CONFIG, DEFAULT_CROP, type CardConfig } from '../card/config';
import type { CardImage } from '../card/render';
import { t } from '../i18n/i18n';
export interface SavedCardRef {
id: string;
@@ -49,7 +50,7 @@ export function createStore(initial: EditorState) {
export type EditorStore = ReturnType<typeof createStore>;
/** Frischer Anfangszustand; DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */
/** Frischer Anfangszustand (der Standardname der Karte in der Sprache der Seite); DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */
export function initialEditorState(): EditorState {
return {
config: structuredClone(DEFAULT_CONFIG),
@@ -58,7 +59,7 @@ export function initialEditorState(): EditorState {
imageChanged: false,
saved: null,
savedConfig: null,
cardName: 'Meine Karte',
cardName: t('Meine Karte'),
saving: false,
};
}
+14 -13
View File
@@ -1,16 +1,17 @@
// Linke Spalte „Vorlage“: vier Kacheln mit Live-Miniatur der Karte.
import type { CardConfig, TemplateId } from '../card/config';
import { drawCard, type CardAssets } from '../card/render';
import { msg, t } from '../i18n/i18n';
import { el, icon } from './dom';
export const THUMB_W = 384;
export const THUMB_H = 242;
const TEMPLATES: readonly { id: TemplateId; title: string; subtitle: string }[] = [
{ id: 'image', title: 'Nur Bild', subtitle: 'Bild unverändert lassen' },
{ id: 'minimal', title: 'Minimalistisch', subtitle: 'Dein Bild steht im Mittelpunkt' },
{ id: 'classic', title: 'Klassisch', subtitle: 'Die vertrauten Kartendetails' },
{ id: 'signature', title: 'Signature', subtitle: 'Ein bisschen mehr Persönlichkeit' },
{ id: 'image', title: msg('Nur Bild'), subtitle: msg('Bild unverändert lassen') },
{ id: 'minimal', title: msg('Minimalistisch'), subtitle: msg('Dein Bild steht im Mittelpunkt') },
{ id: 'classic', title: msg('Klassisch'), subtitle: msg('Die vertrauten Kartendetails') },
{ id: 'signature', title: msg('Signature'), subtitle: msg('Ein bisschen mehr Persönlichkeit') },
];
export interface TemplatesPanel {
@@ -22,35 +23,35 @@ export interface TemplatesPanel {
}
export function createTemplatesPanel(onSelect: (template: TemplateId) => void): TemplatesPanel {
const tiles = TEMPLATES.map((t) => {
const tiles = TEMPLATES.map((tpl) => {
const canvas = el('canvas', { class: 'template-thumb', width: THUMB_W, height: THUMB_H, 'aria-hidden': 'true' });
const button = el(
'button',
{ type: 'button', class: 'tile template-tile', 'aria-pressed': 'false' },
canvas,
el('span', { class: 'template-title' }, t.title),
el('span', { class: 'template-sub' }, t.subtitle),
el('span', { class: 'template-title' }, t(tpl.title)),
el('span', { class: 'template-sub' }, t(tpl.subtitle)),
icon('check', 'template-check'),
);
button.addEventListener('click', () => onSelect(t.id));
return { id: t.id, canvas, button };
button.addEventListener('click', () => onSelect(tpl.id));
return { id: tpl.id, canvas, button };
});
const heading = el('h2', { id: 'editor-templates-heading', class: 'panel-title' }, 'Vorlage');
const heading = el('h2', { id: 'editor-templates-heading', class: 'panel-title' }, t('Vorlage'));
const list = el(
'div',
{ class: 'template-list', role: 'group', 'aria-labelledby': 'editor-templates-heading' },
...tiles.map((t) => t.button),
...tiles.map((tile) => tile.button),
);
const section = el('section', { class: 'editor-side editor-templates' }, heading, list);
return {
el: section,
select(template) {
for (const t of tiles) t.button.setAttribute('aria-pressed', String(t.id === template));
for (const tile of tiles) tile.button.setAttribute('aria-pressed', String(tile.id === template));
},
draw(config, assets) {
for (const t of tiles) drawCard(t.canvas, { ...config, template: t.id }, assets);
for (const tile of tiles) drawCard(tile.canvas, { ...config, template: tile.id }, assets);
},
};
}
+11 -8
View File
@@ -1,23 +1,26 @@
// Bild-Upload: Datei prüfen und in ein Bild für den Canvas dekodieren. Kein Netzwerk, kein Appwrite.
import type { CardImage } from '../card/render';
import { msg, t } from '../i18n/i18n';
export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
export const MAX_IMAGE_BYTES = 5_242_880;
/** Obergrenze für Breite × Höhe: ein kleines, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten. */
export const MAX_IMAGE_PIXELS = 40_000_000;
export const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.';
export const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.';
export const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.';
export const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).';
// Die Texte sind deutsch markiert: `decodeImage` wirft sie unübersetzt (Aufrufer vergleichen `err.message` mit READ_MESSAGE und
// PIXELS_MESSAGE), angezeigt werden sie mit `t(err.message)`. `validateImageFile` liefert den Text schon übersetzt.
export const TYPE_MESSAGE = msg('Bitte ein PNG-, JPG- oder WebP-Bild wählen.');
export const SIZE_MESSAGE = msg('Das Bild ist größer als 5 MiB.');
export const READ_MESSAGE = msg('Das Bild konnte nicht gelesen werden.');
export const PIXELS_MESSAGE = msg('Das Bild ist zu groß (max. 40 Megapixel).');
export type FileCheck = { ok: true } | { ok: false; message: string };
/** Prüft Typ und Größe, bevor das Bild dekodiert wird. Der Typ hat Vorrang vor der Größe. */
/** Prüft Typ und Größe, bevor das Bild dekodiert wird. Der Typ hat Vorrang vor der Größe. Die Meldung steht in der Sprache der Seite. */
export function validateImageFile(f: { type: string; size: number }): FileCheck {
if (!(ACCEPTED_TYPES as readonly string[]).includes(f.type)) return { ok: false, message: TYPE_MESSAGE };
if (f.size > MAX_IMAGE_BYTES) return { ok: false, message: SIZE_MESSAGE };
if (f.size <= 0) return { ok: false, message: READ_MESSAGE };
if (!(ACCEPTED_TYPES as readonly string[]).includes(f.type)) return { ok: false, message: t(TYPE_MESSAGE) };
if (f.size > MAX_IMAGE_BYTES) return { ok: false, message: t(SIZE_MESSAGE) };
if (f.size <= 0) return { ok: false, message: t(READ_MESSAGE) };
return { ok: true };
}
+124
View File
@@ -0,0 +1,124 @@
import { createRequire } from 'node:module';
import { describe, expect, it } from 'vitest';
import { API_MESSAGES, apiMessage } from './api-messages';
import { EN } from './en';
import { setLang } from './i18n';
// MSG, alle Fehlertexte der API, liegt im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt es von dort.
const { MSG } = createRequire(import.meta.url)('../../../deploy/api/common.js') as { MSG: Record<string, string> };
const GENERIC_EN = 'Something went wrong. Please try again.';
describe('Gleichlauf mit MSG im API-Dienst (deploy/api/common.js)', () => {
it('der Dienst liefert seine Texte (sonst bestünde der Vergleich leer)', () => {
expect(Object.keys(MSG).length).toBeGreaterThan(30);
expect(MSG.alreadyPublished).toBe('Diese Karte ist schon veröffentlicht.');
});
it('jeder Schlüssel von MSG steht mit demselben deutschen Text in API_MESSAGES', () => {
const different = Object.keys(MSG).filter((key) => API_MESSAGES[key] !== MSG[key]);
expect(different).toEqual([]);
});
it('darüber hinaus hat API_MESSAGES nur die Client-Codes generic und jwtUnsupported', () => {
const extra = Object.keys(API_MESSAGES).filter((key) => !Object.hasOwn(MSG, key));
expect(extra.sort()).toEqual(['generic', 'jwtUnsupported']);
});
it('jeder Text hat eine englische Übersetzung, die vom Deutschen abweicht', () => {
const untranslated = Object.values(API_MESSAGES).filter((text) => !Object.hasOwn(EN, text) || EN[text] === text);
expect(untranslated).toEqual([]);
});
});
describe('apiMessage: bekannter Code', () => {
it('Deutsch: der Text des Codes', () => {
expect(apiMessage('alreadyPublished', 409, 'Diese Karte ist schon veröffentlicht.')).toBe(
'Diese Karte ist schon veröffentlicht.',
);
expect(apiMessage('logoGone', 404, 'Dieses Logo ist nicht (mehr) verfügbar.')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
});
it('Englisch: die Übersetzung des Codes', () => {
setLang('en');
expect(apiMessage('alreadyPublished', 409, 'Diese Karte ist schon veröffentlicht.')).toBe('This card is already published.');
expect(apiMessage('logoGone', 404, 'Dieses Logo ist nicht (mehr) verfügbar.')).toBe('This logo is no longer available.');
expect(apiMessage('unavailable', 503, 'Der Dienst ist gerade nicht erreichbar.')).toBe('The service is currently unavailable.');
});
it('jeder Code in beiden Sprachen: deutsch wie im Wörterbuch, englisch laut en.ts', () => {
for (const [code, text] of Object.entries(API_MESSAGES)) {
setLang('de');
expect(apiMessage(code, 400, text)).toBe(text);
setLang('en');
expect(apiMessage(code, 400, text)).toBe(EN[text]);
}
});
it('Englisch: der Code entscheidet, nicht der mitgelieferte Text und nicht der Status', () => {
setLang('en');
expect(apiMessage('logoGone', 500, 'irgendein anderer Text')).toBe('This logo is no longer available.');
expect(apiMessage('logoGone', 404, '')).toBe('This logo is no longer available.');
});
it('die Sprache wird bei jedem Aufruf neu gelesen', () => {
expect(apiMessage('logoGone', 404, 'x')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
setLang('en');
expect(apiMessage('logoGone', 404, 'x')).toBe('This logo is no longer available.');
setLang('de');
expect(apiMessage('logoGone', 404, 'x')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
});
it('die Client-Codes generic und jwtUnsupported werden wie die des Dienstes übersetzt', () => {
setLang('en');
expect(apiMessage('generic', 500, 'x')).toBe(GENERIC_EN);
expect(apiMessage('jwtUnsupported', 501, 'x')).toBe(
'Token sign-in is turned off on the server – please let the site owner know.',
);
});
});
describe('apiMessage: dynamische Prüftexte (Code badRequest)', () => {
const dynamic = 'Der Titel ist zu lang (höchstens 60 Zeichen).';
it('Deutsch: der genauere Servertext statt „Ungültige Anfrage.“', () => {
expect(apiMessage('badRequest', 400, dynamic)).toBe(dynamic);
expect(apiMessage('badRequest', 400, 'Ungültige Anfrage.')).toBe('Ungültige Anfrage.');
});
it('Englisch: der allgemeine Text des Codes (der deutsche Prüftext lässt sich nicht übersetzen)', () => {
setLang('en');
expect(apiMessage('badRequest', 400, dynamic)).toBe('Invalid request.');
expect(apiMessage('badRequest', 400, 'Ungültige Anfrage.')).toBe('Invalid request.');
});
});
describe('apiMessage: unbekannter Code', () => {
it.each([null, 'error', 'neuerCode', ''])('Deutsch (%j): der Servertext', (code) => {
expect(apiMessage(code, 500, 'Der Server sagt etwas.')).toBe('Der Server sagt etwas.');
expect(apiMessage(code, 400, 'Der Server sagt etwas.')).toBe('Der Server sagt etwas.');
});
it.each([
[400, 'Invalid request.'],
[403, 'You are not allowed to do that.'],
[404, 'Not found.'],
[401, GENERIC_EN],
[409, GENERIC_EN],
[429, GENERIC_EN],
[500, GENERIC_EN],
[503, GENERIC_EN],
] as const)('Englisch: Status %i → „%s“', (status, expected) => {
setLang('en');
for (const code of [null, 'error', 'neuerCode']) expect(apiMessage(code, status, 'Der Server sagt etwas.')).toBe(expected);
});
it.each(['constructor', 'toString', '__proto__', 'hasOwnProperty'])(
'Namen aus Object.prototype (%s) sind kein Code',
(code) => {
expect(apiMessage(code, 500, 'Servertext')).toBe('Servertext');
setLang('en');
expect(apiMessage(code, 500, 'Servertext')).toBe(GENERIC_EN);
},
);
});
+69
View File
@@ -0,0 +1,69 @@
// Übersetzbare Fehlertexte der API: Der Dienst `aircard-api` (deploy/api) antwortet auf Fehler mit `{error, code}`. `error` ist der
// deutsche Text, `code` sein Schlüssel in MSG (deploy/api/common.js). Hier steht jeder Text noch einmal unter seinem Schlüssel
// (der Browser lädt den Dienst nicht); api-messages.test.ts lädt MSG und prüft den Gleichlauf. Dazu kommen zwei Client-Codes.
// Übersetzt wird beim Anzeigen (`apiMessage`); `ApiError.message` bleibt der deutsche Text, damit Vergleiche damit weiter gehen.
import { currentLang, msg, t } from './i18n';
export const API_MESSAGES: Readonly<Record<string, string>> = {
// Die Schlüssel und Texte von MSG, in derselben Reihenfolge.
unauthorized: msg('Bitte melde dich an.'),
notFound: msg('Nicht gefunden.'),
cardGone: msg('Diese Karte ist nicht (mehr) verfügbar.'),
forbidden: msg('Dazu hast du keine Berechtigung.'),
adminOnly: msg('Nur für Admins.'),
foreignFile: msg('Diese Datei gehört dir nicht.'),
fileMissing: msg('Bild nicht gefunden.'),
fileType: msg('Das Bild muss eine PNG- oder WebP-Datei sein.'),
fileSize: msg('Das Bild ist größer als 5 MB.'),
alreadyPublished: msg('Diese Karte ist schon veröffentlicht.'),
filesTaken: msg('Diese Bilder sind bereits veröffentlicht oder gesperrt.'),
imageFormat: msg('Das Bild hat nicht das Kartenformat.'),
notYourCard: msg('Diese Karte gehört nicht zu deinem Konto.'),
tooManyPublications: msg('Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'),
tooManyReports: msg('Zu viele Meldungen – bitte später nochmal.'),
badRequest: msg('Ungültige Anfrage.'),
badCardId: msg('Ungültige Karten-ID.'),
badReason: msg('Bitte wähle einen gültigen Grund.'),
badVisibility: msg('Ungültige Angabe zur Sichtbarkeit.'),
logoGone: msg('Dieses Logo ist nicht (mehr) verfügbar.'),
badLogoId: msg('Ungültige Logo-ID.'),
logoNameMissing: msg('Bitte gib einen Namen an.'),
logoFileType: msg('Das Logo muss eine PNG-Datei sein.'),
logoFileSize: msg('Das Logo ist größer als 1 MB.'),
logoFormat: msg('Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).'),
logoTaken: msg('Diese Datei wird schon für ein Logo verwendet.'),
tooManyLogos: msg('Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'),
badHeart: msg('Ungültige Angabe zum Herz.'),
badMono: msg('Ungültige Angabe zu „einfarbig“.'),
tooLarge: msg('Die Anfrage ist zu groß.'),
jsonOnly: msg('Nur application/json wird unterstützt.'),
unavailable: msg('Der Dienst ist gerade nicht erreichbar.'),
internal: msg('Interner Fehler.'),
// Nur im Client (kein Schlüssel im Dienst): allgemeiner Fehler und „Anmeldung per Token ausgeschaltet“.
generic: msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'),
jwtUnsupported: msg('Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.'),
};
/** Die allgemeine Meldung zu einem Status, wenn der Code unbekannt ist (Spec § 7): 400, 403, 404, sonst „Etwas ist schiefgelaufen“. */
function fallbackText(status: number): string {
if (status === 400) return API_MESSAGES.badRequest;
if (status === 403) return API_MESSAGES.forbidden;
if (status === 404) return API_MESSAGES.notFound;
return API_MESSAGES.generic;
}
/**
* Der Text zu einem Fehler des Dienstes (oder einem Fehler, den der Client mit denselben Codes erzeugt) in der Sprache der Seite.
* Ein bekannter Code wird über das Wörterbuch übersetzt. Ein unbekannter oder fehlender Code (älterer Dienst, Proxy) zeigt auf
* Deutsch den Text des Dienstes, auf Englisch eine allgemeine Meldung je Status.
*/
export function apiMessage(serverCode: string | null, status: number, serverText: string): string {
const english = currentLang() === 'en';
if (serverCode !== null && Object.hasOwn(API_MESSAGES, serverCode)) {
// `badRequest` tragen auch die dynamischen Prüftexte des Dienstes („Der Titel ist zu lang (höchstens 60 Zeichen).“):
// Deutsch zeigt dann den genaueren Servertext, Englisch den allgemeinen Text des Codes.
if (!english && serverCode === 'badRequest') return serverText;
return t(API_MESSAGES[serverCode]);
}
return english ? t(fallbackText(status)) : serverText;
}
+528
View File
@@ -0,0 +1,528 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
import { EN } from './en';
const SRC = join(import.meta.dirname, '..');
/** Alle Quelldateien (ohne Tests) unter src/ als [Pfad relativ zu src, Text]. */
function sources(dir = SRC): Array<[string, string]> {
const out: Array<[string, string]> = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) out.push(...sources(path));
else if (/\.ts$/.test(name) && !/\.test\.ts$/.test(name)) out.push([relative(SRC, path), readFileSync(path, 'utf8')]);
}
return out;
}
interface Found {
/** Die deutschen Texte aus `t('…')` und `msg('…')` (Literale in '…', "…" oder `…` ohne ${…}). */
texts: string[];
/** Aufrufe mit einem Template-Literal samt ${…}: der Text steht nicht fest und lässt sich nicht nachschlagen. */
dynamic: string[];
}
/**
* Sammelt die Texte aller freistehenden Aufrufe `t(…)` und `msg(…)`. Gelesen wird der Syntaxbaum, daher zählen
* Kommentare, Zeichenketten und Methodenaufrufe wie `x.t(…)` nicht. Ein Argument, das kein Literal ist
* (etwa eine Konstante `t(TITEL)`), wird nicht gesammelt: seinen Text trägt das `msg('…')` der Konstante.
*/
function findTexts(code: string): Found {
const found: Found = { texts: [], dynamic: [] };
const file = ts.createSourceFile('quelle.ts', code, ts.ScriptTarget.Latest);
const visit = (node: ts.Node): void => {
const isTranslation = ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ['t', 'msg'].includes(node.expression.text);
if (isTranslation) {
const arg = node.arguments[0];
if (arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) found.texts.push(arg.text);
else if (arg && ts.isTemplateExpression(arg)) found.dynamic.push(arg.getText(file));
}
ts.forEachChild(node, visit);
};
visit(file);
return found;
}
/** Die Platzhalter `{name}` eines Textes, ohne Doppelte und sortiert. */
const placeholders = (text: string): string[] => [...new Set(text.match(/\{\w+\}/g) ?? [])].sort();
// Text → Dateien, in denen er steht; dazu alle dynamischen Aufrufe als „Datei: Quelltext“.
const used = new Map<string, string[]>();
const dynamic: string[] = [];
for (const [path, code] of sources()) {
const found = findTexts(code);
for (const text of found.texts) used.set(text, [...(used.get(text) ?? []), path]);
dynamic.push(...found.dynamic.map((call) => `${path}: ${call}`));
}
/**
* Bereiche, die schon vollständig übersetzt sind: Ordner (mit „/“ am Ende) oder einzelne Dateien, relativ zu src/.
* Dort darf kein deutsches Text-Literal mehr außerhalb von t()/msg() stehen; ein weiterer übersetzter Bereich kommt hier dazu.
* Mit Task 11 ist die ganze Oberfläche übersetzt. Nicht in der Liste stehen nur Dateien ohne eigene Texte (config.ts, i18n/i18n.ts,
* Typdateien) und das Wörterbuch (i18n/en.ts: seine Werte sind Englisch).
*/
const TRANSLATED_AREAS = ['account/', 'card/', 'community/', 'editor/', 'logos/', 'pages/', 'shell/', 'main.ts', 'i18n/api-messages.ts'];
/**
* Literale, die wie deutscher Text aussehen, aber nie angezeigt werden. Jede Ausnahme nennt ihren Grund; der Test unten
* verlangt, dass sie noch zutrifft, damit die Liste nicht veraltet.
*/
const ALLOWED_LITERALS: ReadonlyArray<{ file: string; text: string; reason: string }> = [
{
file: 'account/auth.ts',
text: 'Unerwartete Antwort',
reason: 'Meldung eines internen Fehlers (Antwort ohne $id/email); angezeigt wird nur der allgemeine Text aus errorMessage()',
},
{
file: 'account/cards.ts',
text: 'Unerwartete Antwort',
reason: 'Meldung der AppwriteException für eine Datei, die kein Bild ist; errorMessage() liest nur Typ und Code, nie diese Meldung',
},
{
file: 'account/cards.ts',
text: 'Row not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); errorMessage() liest nur den Typ',
},
{
file: 'account/cards.ts',
text: 'Bildtyp nicht erlaubt: ',
reason: 'Programmierfehler-Meldung (nur Blob-Typen aus dem Editor kommen an); angezeigt wird der allgemeine Text aus errorMessage()',
},
{
file: 'account/with-login.ts',
text: 'Anmeldung abgebrochen',
reason: 'Meldung von LoginCancelled; die Aufrufer erkennen den Fehler mit instanceof und zeigen LOGIN_NEEDED',
},
{
file: 'card/marks.ts',
text: 'Logo konnte nicht geladen werden: ',
reason: 'Entwicklermeldung mit interner ID; Nutzer sehen die eigenen Texte der Seiten (im Editor LOGO_ERROR, „Die Logos konnten nicht geladen werden.“)',
},
{
file: 'community/gallery.ts',
text: 'File not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); die Seiten zeigen nur ihre eigenen Texte (Keine Vorschau, IMAGE_ERROR)',
},
{
file: 'logos/logos.ts',
text: 'File not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); die Kachel zeigt bei einem Fehler nur den leeren Platzhalter',
},
{
file: 'logos/logo-asset.ts',
text: 'Das Logo konnte nicht gelesen werden.',
reason: 'Interner Fehler von decodeWithImage; decode() fängt ihn ab und meldet „kein Bild“ (null), er wird nie angezeigt',
},
{
file: 'logos/logo-asset.ts',
text: 'Canvas wird in diesem Browser nicht unterstützt.',
reason: 'Interner Fehler von fillShape; die Seiten zeigen bei einem Ladefehler der Logos ihre eigenen Texte (LOGO_ERROR, allgemeine Meldung von errorMessage())',
},
{
file: 'logos/logo-image.ts',
text: 'Das Logo ist zu groß.',
reason: 'Interner Fehler von checkImage; showLogoImage fängt ihn ab und zeigt den leeren Platzhalter (data-state="unusable")',
},
{
file: 'logos/logo-image.ts',
text: 'Das Logo konnte nicht gelesen werden.',
reason: 'Interner Fehler von checkImage; showLogoImage fängt ihn ab und zeigt den leeren Platzhalter (data-state="unusable")',
},
{
file: 'shell/header.ts',
text: 'Switch to English',
reason: 'Sprachknopf: Beschriftung in der Zielsprache (Spec § 7), unabhängig von der Sprache der Seite – darf deshalb nicht durch t()',
},
{
file: 'shell/header.ts',
text: 'Auf Deutsch umschalten',
reason: 'Sprachknopf: Beschriftung in der Zielsprache (Spec § 7), unabhängig von der Sprache der Seite – darf deshalb nicht durch t()',
},
];
/** Liegt die Datei in einem Bereich, in dem alle Texte schon übersetzt sein müssen? */
const inTranslatedArea = (path: string): boolean =>
TRANSLATED_AREAS.some((area) => path === area || (area.endsWith('/') && path.startsWith(area)));
/**
* Sieht ein Text-Literal wie deutscher Text aus? Ja bei einem Umlaut oder ß, bei mindestens zwei Wörtern („Bitte melde dich an.“;
* ein Wort hat mindestens zwei Buchstaben, Satzzeichen am Ende und eine Klammer davor oder dahinter zählen nicht mit) und bei einem
* einzelnen großgeschriebenen Wort („Hell“, „Speichert …“, „E-Mail“). Ein Wort mit Bindestrich-Teilen („auth-btn“) und CamelCase
* („ArrowLeft“) sind Bezeichner.
*/
function looksGerman(text: string): boolean {
if (/[äöüÄÖÜß]/.test(text)) return true;
if (text.split(/\s+/).filter((word) => /^\(?\p{L}{2,}[.,:;!?…]*\)?[.,:;!?…]*$/u.test(word)).length >= 2) return true;
const single = text.replace(/[\s.,:;!?…]+$/u, '');
return /^\p{Lu}[\p{L}-]*$/u.test(single) && /\p{Ll}/u.test(single) && !/\p{Ll}\p{Lu}/u.test(single);
}
const EQUALITY = [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken];
const SEARCH_METHODS = ['includes', 'startsWith', 'endsWith', 'indexOf'];
/** Steht das Literal dort, wo es übersetzt wird oder nie angezeigt wird (Importpfad, Typ, Eigenschaftsname, Vergleichswert …)? */
function isExemptPosition(node: ts.Node): boolean {
const parent = node.parent;
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent) || ts.isLiteralTypeNode(parent)) return true;
if (ts.isCaseClause(parent) || (ts.isBinaryExpression(parent) && EQUALITY.includes(parent.operatorToken.kind))) return true; // key === 'Escape'
if (ts.isPropertyAssignment(parent)) {
// { 'aria-label': … } (der Name, nicht der Wert), { class: 'card tile' } (CSS-Klassen) und { rel: 'noopener noreferrer' }
return parent.name === node || ['class', 'className', 'rel'].includes(ts.isIdentifier(parent.name) ? parent.name.text : '');
}
// headers['X-Aircard-Jwt'] = …: ein Name in Klammerschreibweise, kein Text
if (ts.isElementAccessExpression(parent)) return parent.argumentExpression === node;
if (!ts.isCallExpression(parent)) return false;
const callee = parent.expression;
if (ts.isIdentifier(callee)) return ['t', 'msg'].includes(callee.text) && parent.arguments[0] === node;
if (!ts.isPropertyAccessExpression(callee)) return false;
// console.warn('…') und Suchen in Daten: liste.includes('Files'), text.startsWith('…')
const isConsole = ts.isIdentifier(callee.expression) && callee.expression.text === 'console';
return isConsole || SEARCH_METHODS.includes(callee.name.text);
}
/**
* Die Literale (Zeichenketten und Teile von Template-Literalen), die wie deutscher Text aussehen und nicht durch t()/msg() gehen.
* Gelesen wird der Syntaxbaum: Kommentare und reguläre Ausdrücke zählen nicht.
*/
function findUntranslated(code: string): string[] {
const out: string[] = [];
const file = ts.createSourceFile('quelle.ts', code, ts.ScriptTarget.Latest, true);
const check = (node: ts.Node, text: string): void => {
if (looksGerman(text) && !isExemptPosition(node)) out.push(text);
};
const visit = (node: ts.Node): void => {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) check(node, node.text);
else if (ts.isTemplateExpression(node)) {
for (const text of [node.head.text, ...node.templateSpans.map((span) => span.literal.text)]) check(node, text);
}
ts.forEachChild(node, visit);
};
visit(file);
return out;
}
// Dateien der übersetzten Bereiche → ihre verdächtigen Literale.
const untranslated: Array<{ file: string; text: string }> = sources()
.filter(([path]) => inTranslatedArea(path))
.flatMap(([path, code]) => findUntranslated(code).map((text) => ({ file: path, text })));
// ---- Konstanten mit msg() dürfen nur über t() angezeigt werden -------------------------------------------------------------------
// Die Heuristik oben sieht deutsche Literale, nicht aber eine Konstante `const TEXT = msg('…')`, die ohne t() im DOM landet:
// ihr Literal steht ja in msg(). Dafür liest dieser Teil, wo eine solche Konstante vorkommt.
const isCall = (node: ts.Node, name: string): node is ts.CallExpression =>
ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name;
/** Die Namen der Konstanten `const NAME = msg('…')` einer Datei, dazu die davon exportierten. */
function msgConstants(file: ts.SourceFile): { all: Set<string>; exported: Set<string> } {
const out = { all: new Set<string>(), exported: new Set<string>() };
const visit = (node: ts.Node): void => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isCall(node.initializer, 'msg')) {
out.all.add(node.name.text);
const statement = node.parent.parent;
if (ts.isVariableStatement(statement) && statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
out.exported.add(node.name.text);
}
}
ts.forEachChild(node, visit);
};
visit(file);
return out;
}
/** Ist der Bezeichner ein Wert-Gebrauch (nicht der Name der Deklaration, einer Eigenschaft oder eines Imports)? */
function isValueUse(node: ts.Identifier): boolean {
const parent = node.parent;
if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isTypeQueryNode(parent)) return false;
if (ts.isPropertyAccessExpression(parent)) return parent.name !== node; // obj.NAME ist nicht die Konstante
if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) || ts.isParameter(parent)) return parent.name !== node;
return true;
}
const FALLBACKS = [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.AmpersandAmpersandToken];
/**
* Wird der Text der Konstante dort nur übersetzt, verglichen oder als Fehlermeldung weitergegeben? Das sind `t(NAME)`,
* `new Error(NAME)`, `===`/`!==`/`case` und eine Liste oder Menge zum Vergleichen (`[NAME, …]`, `new Set([NAME, …])`); Klammern,
* `ok ? A : B`, `??`, `||` und `&&` zählen mit. Alles andere (Argument einer anderen Funktion, Wert einer Eigenschaft, Teil eines
* Templates, Zuweisung …) zeigt den deutschen Text an oder reicht ihn an einen weiter, der es tut. Nicht erkannt wird, was später
* aus einer Liste gelesen und ohne t() angezeigt wird.
*/
function isTranslatedOrCompared(node: ts.Identifier): boolean {
let child: ts.Node = node;
let parent = node.parent;
while (
ts.isParenthesizedExpression(parent) ||
(ts.isConditionalExpression(parent) && parent.condition !== child) ||
(ts.isBinaryExpression(parent) && FALLBACKS.includes(parent.operatorToken.kind))
) {
child = parent;
parent = parent.parent;
}
if (isCall(parent, 't')) return parent.arguments[0] === child;
if (ts.isNewExpression(parent)) return ts.isIdentifier(parent.expression) && parent.expression.text === 'Error';
if (ts.isBinaryExpression(parent)) return EQUALITY.includes(parent.operatorToken.kind);
return ts.isCaseClause(parent) || ts.isArrayLiteralExpression(parent);
}
/**
* Die Stellen („Zeile: Quelltext“), an denen eine msg()-Konstante der Datei nicht durch t() geht. Bekannt sind die Konstanten der
* Datei selbst und die, die sie aus einer anderen Datei importiert (`exportsOf`: Pfad relativ zu src/ → exportierte msg()-Konstanten).
*/
function findRawConstantUses(path: string, code: string, exportsOf: ReadonlyMap<string, ReadonlySet<string>>): string[] {
const file = ts.createSourceFile(path, code, ts.ScriptTarget.Latest, true);
const known = new Set(msgConstants(file).all);
for (const statement of file.statements) {
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
if (!statement.moduleSpecifier.text.startsWith('.')) continue;
const target = `${join(path, '..', statement.moduleSpecifier.text)}.ts`;
const bindings = statement.importClause?.namedBindings;
if (!bindings || !ts.isNamedImports(bindings)) continue;
for (const item of bindings.elements) {
if (exportsOf.get(target)?.has((item.propertyName ?? item.name).text)) known.add(item.name.text);
}
}
const out: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isIdentifier(node) && known.has(node.text) && isValueUse(node) && !isTranslatedOrCompared(node)) {
const line = file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
out.push(`${line}: ${node.parent.getText(file).replace(/\s+/g, ' ').slice(0, 80)}`);
}
ts.forEachChild(node, visit);
};
visit(file);
return out;
}
// Pfad → exportierte msg()-Konstanten, dann je Datei die Stellen, an denen eine davon unübersetzt vorkommt.
const msgExports = new Map(
sources().map(([path, code]) => [path, msgConstants(ts.createSourceFile(path, code, ts.ScriptTarget.Latest, true)).exported] as const),
);
const rawConstantUses: string[] = sources().flatMap(([path, code]) =>
findRawConstantUses(path, code, msgExports).map((use) => `${path}:${use}`),
);
describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => {
it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => {
expect(used.size).toBeGreaterThan(10);
expect(used.has('Gestalten')).toBe(true);
});
it('jeder Text aus t()/msg() hat eine (nicht leere) englische Übersetzung', () => {
const translated = (text: string) => Object.hasOwn(EN, text) && EN[text].trim() !== '';
const missing = [...used].filter(([text]) => !translated(text)).map(([text, files]) => `${files[0]}: ${text}`);
expect(missing).toEqual([]);
});
it('jeder Eintrag in en.ts wird in t()/msg() benutzt', () => {
expect(Object.keys(EN).filter((text) => !used.has(text))).toEqual([]);
});
it('Platzhalter {…} stehen in Text und Übersetzung gleich', () => {
const mismatched = Object.entries(EN)
.filter(([text, english]) => placeholders(text).join() !== placeholders(english).join())
.map(([text]) => text);
expect(mismatched).toEqual([]);
});
it('kein t()/msg() mit Template-Literal und ${…}', () => {
expect(dynamic).toEqual([]);
});
});
describe('Scanner für t() und msg()', () => {
it('findet Literale in \'…\', "…" und `…` ohne ${…}, jeweils das erste Argument', () => {
const code = [
"const a = t('Eins');",
'const b = msg("Zwei");',
'const c = t(`Drei`);',
"const d = t('Vier {n}', { n: 4 });",
"const e = t('Mit \\'Escape\\'');",
].join('\n');
expect(findTexts(code)).toEqual({ texts: ['Eins', 'Zwei', 'Drei', 'Vier {n}', "Mit 'Escape'"], dynamic: [] });
});
it('meldet Template-Literale mit ${…} als dynamisch', () => {
const found = findTexts('const a = t(`Hallo ${name}`); const b = msg(`x${1}`);');
expect(found.texts).toEqual([]);
expect(found.dynamic).toEqual(['`Hallo ${name}`', '`x${1}`']);
});
it('ignoriert Kommentare, Zeichenketten, Methodenaufrufe, ähnliche Namen und Konstanten', () => {
const code = [
"// t('Kommentar')",
"/* msg('Block') */",
"const s = \"t('in Zeichenkette')\";",
"const m = obj.t('Methode');",
"const f = format('Name') + git('x') + t_('y') + $t('z');",
'const k = t(TITEL) + t(LABELS[key]);',
'const leer = t();',
].join('\n');
expect(findTexts(code)).toEqual({ texts: [], dynamic: [] });
});
it('findet verschachtelte Aufrufe', () => {
const code = "el('a', { title: t('Innen') }, t('Außen'), cond ? t('Wenn') : msg('Sonst'));";
expect(findTexts(code).texts).toEqual(['Innen', 'Außen', 'Wenn', 'Sonst']);
});
});
describe('Deutsche Texte außerhalb von t()/msg() (übersetzte Bereiche)', () => {
it('die übersetzten Bereiche gibt es und enthalten Quelldateien (sonst bestünde die Prüfung leer)', () => {
const files = sources().filter(([path]) => inTranslatedArea(path));
expect(files.length).toBeGreaterThan(20);
expect(files.some(([path]) => path === 'pages/reset-page.ts')).toBe(true);
});
it('kein deutsches Text-Literal steht noch außerhalb von t()/msg(), außer den begründeten Ausnahmen', () => {
const allowed = (file: string, text: string) => ALLOWED_LITERALS.some((entry) => entry.file === file && entry.text === text);
const open = untranslated.filter(({ file, text }) => !allowed(file, text)).map(({ file, text }) => `${file}: ${text}`);
expect(open).toEqual([]);
});
it('jede Ausnahme hat einen Grund und trifft noch ein Literal', () => {
for (const entry of ALLOWED_LITERALS) expect(entry.reason.trim(), `${entry.file}: ${entry.text}`).not.toBe('');
const stale = ALLOWED_LITERALS.filter((entry) => !untranslated.some(({ file, text }) => file === entry.file && text === entry.text));
expect(stale.map((entry) => `${entry.file}: ${entry.text}`)).toEqual([]);
});
});
describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
it('findet Umlaute, mindestens zwei Wörter und einzelne großgeschriebene Wörter, auch mit Satzzeichen und in Template-Teilen', () => {
const code = [
"const a = 'Schließen';",
"const b = 'Bitte melde dich an.';",
"const c = 'Speichert …';",
"const d = 'Anmelden';",
'const e = `Das Passwort muss ${min} bis ${max} Zeichen lang sein.`;',
"const f = 'E-Mail';",
"const g = 'Bearbeitet: ';",
'const h = `Nur Bild`;',
"const i = 'Ausgeblendet (gemeldet)';",
].join('\n');
expect(findUntranslated(code)).toEqual([
'Schließen',
'Bitte melde dich an.',
'Speichert …',
'Anmelden',
'Das Passwort muss ',
' Zeichen lang sein.',
'E-Mail',
'Bearbeitet: ',
'Nur Bild',
'Ausgeblendet (gemeldet)',
]);
});
it('ignoriert Texte in t()/msg(), Kommentare, Importpfade, Typen, Eigenschaftsnamen, console, Vergleiche und Suchen', () => {
const code = [
"import './konto-menü.css';",
"export * from './über-uns';",
"type Ansicht = 'Anmelden' | 'Registrieren';",
"// const kommentar = 'Das ist ein Kommentar';",
"const a = t('Schließen') + msg('Bitte melde dich an.') + t('Speichern', { was: 1 });",
"const b = { 'aria-label': 1, 'Zwei Wörter': 2 };",
"console.warn('Das ist nur eine Konsolenmeldung');",
"if (event.key !== 'Escape' && kind === 'Anmelden') x();",
"switch (key) { case 'Schließen': break; }",
"const c = types.includes('Files') && text.startsWith('Das ist ein Satz') && list.indexOf('Zwei Wörter');",
'const d = /[äöüß]/.test(text);',
].join('\n');
expect(findUntranslated(code)).toEqual([]);
});
it('ignoriert technische Zeichenketten: CSS-Klassen, rel, Kopfzeilennamen, Kleinbuchstaben-Wörter, Formate, Bezeichner, Zahlen', () => {
const code = [
"el('button', { type: 'button', class: 'auth-btn auth-btn-small', 'aria-describedby': 'reset-hint reset-error' }, '×');",
"el('div', { class: 'card tile' });",
"const a = 'user_invalid_credentials' + 'image/png' + '#account-slot button' + 'ArrowLeft' + 'LoginCancelled' + 'PLATINUM';",
"const b = '100 %' + '1536 × 969 PNG' + '•••• •••• •••• 8888' + 'M548.75 379.89a98.21 98.21 0 0 1 0 98.21';",
'const c = `aircard-${name}.png` + `data:image/svg+xml;charset=utf-8,${svg}`;',
"const link = { href, target: '_blank', rel: 'noopener noreferrer' };",
"headers['X-Aircard-Jwt'] = jwt;",
].join('\n');
expect(findUntranslated(code)).toEqual([]);
});
it('übersetzt wird erst im Argument von t()/msg(): eine Bedingung davor zählt als offener Text', () => {
expect(findUntranslated("const a = t(ok ? 'Gespeichert' : 'Speichern');")).toEqual(['Gespeichert', 'Speichern']);
});
});
describe('msg()-Konstanten werden nur über t() angezeigt', () => {
it('der Scanner kennt die Konstanten (sonst bestünde die Prüfung leer)', () => {
expect(msgExports.get('account/with-login.ts')).toContain('LOGIN_NEEDED');
expect(msgExports.get('editor/upload.ts')).toContain('READ_MESSAGE');
});
it('keine Konstante mit msg() steht ohne t() im DOM', () => {
expect(rawConstantUses).toEqual([]);
});
});
describe('Scanner für msg()-Konstanten ohne t()', () => {
const none = new Map<string, ReadonlySet<string>>();
it('findet eine Konstante als Argument, Eigenschaftswert, Template-Teil, Rückgabewert und Zuweisung', () => {
const code = [
"const TEXT = msg('Hallo');",
'el(\'p\', {}, TEXT);',
'const a = { title: TEXT };',
'const b = `Fehler: ${TEXT}`;',
'function c() { return TEXT; }',
'node.textContent = ok ? TEXT : TEXT;',
'show(TEXT ?? t(TEXT));',
].join('\n');
expect(findRawConstantUses('quelle.ts', code, none).map((use) => use.split(':')[0])).toEqual(['2', '3', '4', '5', '6', '6', '7']);
});
it('lässt t(NAME), Bedingungen und Rückfälle darin, new Error, Vergleiche, case, Listen und Mengen durch', () => {
const code = [
"const A = msg('Eins');",
"const B = msg('Zwei');",
'const x = t(A) + t(ok ? A : B) + t((A ?? B) || A);',
'const y = t(A, { n: 1 });',
'throw new Error(A);',
'throw new Error(ok ? A : B);',
'if (err.message === A || B !== err.message) f();',
'switch (text) { case A: break; }',
'const known = new Set<string>([A, B]);',
'const list = [A, B];',
].join('\n');
expect(findRawConstantUses('quelle.ts', code, none)).toEqual([]);
});
it('die Bedingung einer Bedingung zählt nicht als Weitergabe des Textes', () => {
const code = ["const A = msg('Eins');", 'const x = t(A ? 1 : 2);'].join('\n');
expect(findRawConstantUses('quelle.ts', code, none).map((use) => use.split(':')[0])).toEqual(['2']);
});
it('ignoriert Deklaration, Eigenschaftsnamen, Importe, Typen und gleich lautende Eigenschaften', () => {
const code = [
"import { A } from './x';",
"export const B = msg('Zwei');",
'const o = { B: 1, C: obj.B };',
'type T = typeof B;',
'export { B };',
].join('\n');
expect(findRawConstantUses('quelle.ts', code, new Map([['x.ts', new Set(['A'])]]))).toEqual([]);
});
it('kennt Konstanten aus anderen Dateien über den Import (auch umbenannt) und nur die exportierten', () => {
const exportsOf = new Map<string, ReadonlySet<string>>([['account/with-login.ts', new Set(['LOGIN_NEEDED'])]]);
const code = [
"import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';",
"import { LOGIN_NEEDED as NEEDED } from '../account/with-login';",
"import { OTHER } from '../account/with-login';",
"import { LOGIN_NEEDED as ELSE } from '../account/anders';",
'const a = LOGIN_NEEDED;',
'const b = NEEDED;',
'const c = OTHER;',
'const d = ELSE;',
'const e = t(LOGIN_NEEDED) + t(NEEDED);',
].join('\n');
expect(findRawConstantUses('pages/seite.ts', code, exportsOf).map((use) => use.split(':')[0])).toEqual(['5', '6']);
});
});
+371
View File
@@ -0,0 +1,371 @@
// Englisches Wörterbuch: deutscher Text (Schlüssel) → Englisch, gelesen von t() in i18n.ts. Fehlt ein Eintrag, bleibt der deutsche Text.
// Jeder Eintrag gehört zu einem t('…') oder msg('…') im Quelltext; coverage.test.ts prüft es in beide Richtungen.
// Englisch: kurz, freundlich, US-Schreibweise. „AirCard-IOS“ ist der Name dieser Seite, „AirCard-iOS“ der Name der App.
export const EN: Readonly<Record<string, string>> = {
// Seitentitel (pages/router.ts)
'AirCard-IOS – Kartendesigns für Apple Wallet': 'AirCard-IOS – Card designs for Apple Wallet',
'Gestalten – AirCard-IOS': 'Create – AirCard-IOS',
'Meine Karten – AirCard-IOS': 'My cards – AirCard-IOS',
'Community – AirCard-IOS': 'Community – AirCard-IOS',
'Passwort neu setzen – AirCard-IOS': 'Set new password – AirCard-IOS',
// Kopfzeile (shell/header.ts)
'Hauptnavigation': 'Main navigation',
'Community': 'Community',
'Gestalten': 'Create',
'Meine Karten': 'My cards',
'Unterstützen': 'Support us',
'Titel, Tags oder Herausgeber': 'Title, tags or issuer',
'Community-Karten durchsuchen': 'Search community cards',
'Suchen': 'Search',
'Hell/Dunkel umschalten': 'Toggle light/dark mode',
'Menü': 'Menu',
'AirCard-iOS auf GitHub': 'AirCard-iOS on GitHub',
// Spenden-Banner (shell/donate-banner.ts)
'Banner schließen': 'Close banner',
'Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.':
'Enjoying AirCard-IOS? Buy us a coffee – it keeps the project running.',
// Fußzeile (shell/footer.ts)
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
// Logo auf der Karte (logos/logo-asset.ts, community/publish.ts, editor/options-panel.ts)
'Das gewählte Logo ist nicht mehr verfügbar.': 'The selected logo is no longer available.',
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.':
'This card’s logo is no longer available. Open the card in the editor and choose another one.',
'Logo oben links': 'Top-left logo',
// Logo auswählen und verarbeiten (logos/prepare-logo.ts)
'Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.': 'Please choose a PNG, JPG, WebP or SVG image.',
'Die Datei ist größer als 5 MB.': 'The file is larger than 5 MB.',
'Das Bild ist zu groß (max. 40 Megapixel).': 'The image is too large (max. 40 megapixels).',
'Das Logo konnte nicht gelesen werden.': 'The logo couldn’t be read.',
'Das Logo ist leer.': 'The logo is empty.',
'Das Logo ist zu klein (mindestens 8 × 8 Pixel).': 'The logo is too small (at least 8 × 8 pixels).',
// Dialog „Logo hochladen“ (logos/logo-dialog.ts). „Bitte gib einen Namen an.“ steht unter den API-Fehlertexten.
'Logo hochladen': 'Upload logo',
'Datei (Pflicht)': 'File (required)',
'PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.': 'PNG, JPG, WebP or SVG, up to 5 MB and 40 megapixels.',
'Vorschau': 'Preview',
'Dunkel': 'Dark',
'Hell': 'Light',
'Logo-Vorschau auf dunklem Grund': 'Logo preview on a dark background',
'Logo-Vorschau auf hellem Grund': 'Logo preview on a light background',
'Einfarbig – passt sich Hell/Dunkel an': 'Single color – adapts to light/dark mode',
'Nur wählbar, wenn das Logo transparente Bereiche hat.': 'Only available if the logo has transparent areas.',
'Name (Pflicht)': 'Name (required)',
'Anzeigename (optional)': 'Display name (optional)',
'Anonym': 'Anonymous',
'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.': 'Leave empty for “Anonymous”. Your email address is never shown.',
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.':
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
'Abbrechen': 'Cancel',
'Hochladen': 'Upload',
'Logo wird verarbeitet …': 'Processing logo …',
'Wird hochgeladen …': 'Uploading …',
'Hochladen fehlgeschlagen – {reason}': 'Upload failed – {reason}',
'Bitte wähle eine Datei.': 'Please choose a file.',
'Der Name enthält ungültige Zeichen.': 'The name contains invalid characters.',
'Der Name ist zu lang (höchstens {max} Zeichen).': 'The name is too long (at most {max} characters).',
'Der Anzeigename enthält ungültige Zeichen.': 'The display name contains invalid characters.',
'Der Anzeigename ist zu lang (höchstens {max} Zeichen).': 'The display name is too long (at most {max} characters).',
'Bitte bestätige, dass du das Logo teilen darfst.': 'Please confirm that you’re allowed to share the logo.',
// Bereich „Logo unten rechts“ im Editor (logos/logo-picker.ts). „Community“, „Hochladen“ und „Anonym“ stehen weiter oben.
'Logo unten rechts': 'Bottom-right logo',
'Logos werden von der Community hochgeladen.': 'Logos are uploaded by the community.',
'Sortierung': 'Sort order',
'Beliebt': 'Popular',
'Neu': 'New',
'Gemeldet': 'Reported',
'Ausgeblendet': 'Hidden',
'Kein Logo': 'No logo',
'von {name}': 'by {name}',
'Herz für „{name}“: {count}': 'Heart for “{name}”: {count}',
'Noch keine Community-Logos – lade das erste hoch.': 'No community logos yet – upload the first one.',
'Keine Logos in dieser Ansicht.': 'No logos in this view.',
'Die Logos konnten nicht geladen werden.': 'The logos couldn’t be loaded.',
'Nochmal versuchen': 'Try again',
'Mehr laden': 'Load more',
// Infozeile zum gewählten Logo (logos/logo-info.ts). „von {name}“, „Anonym“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle,
// der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ weiter oben (Logo auf der Karte).
'Löschen': 'Delete',
'Ausblenden': 'Hide',
'Einblenden': 'Unhide',
'Die Gründe konnten nicht geladen werden.': 'The reasons couldn’t be loaded.',
'Logo „{name}“ löschen?': 'Delete logo “{name}”?',
'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.':
'The logo, its hearts and reports will be deleted permanently. Saved cards will no longer show it. Cards that were already published stay unchanged.',
// Melden-Dialog für Karten und Logos (community/report-dialog.ts). „Abbrechen“ steht weiter oben.
'Melden': 'Report',
'Karte melden': 'Report card',
'Logo melden': 'Report logo',
'Warum möchtest du diese Karte melden?': 'Why do you want to report this card?',
'Warum möchtest du „{title}“ melden?': 'Why do you want to report “{title}”?',
'Warum möchtest du dieses Logo melden?': 'Why do you want to report this logo?',
'Grund der Meldung': 'Reason for the report',
'Unangemessen': 'Inappropriate',
'Echte Kartendaten': 'Real card data',
'Urheberrecht/Marke': 'Copyright/trademark',
'Spam': 'Spam',
'Sonstiges': 'Other',
'Bitte wähle einen Grund.': 'Please choose a reason.',
'Danke für deine Meldung.': 'Thanks for your report.',
'Danke, du hast diese Karte schon gemeldet.': 'Thanks, you’ve already reported this card.',
'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.',
'Schließen': 'Close',
// Konto-Menü, Anmelden-Dialog und Seite „Passwort neu setzen“ (account/account-menu.ts, account/login-dialog.ts, pages/reset-page.ts).
// „Abbrechen“ und „Schließen“ stehen weiter oben.
'Anmelden': 'Sign in',
'Registrieren': 'Sign up',
'Abmelden': 'Sign out',
'Konto': 'Account',
'Konto-Menü': 'Account menu',
'Passwort vergessen': 'Forgot password',
'Passwort vergessen?': 'Forgot password?',
'E-Mail': 'Email',
'Passwort': 'Password',
'Passwort wiederholen': 'Confirm password',
'Mindestens 8 Zeichen.': 'At least 8 characters.',
'Zurück zur Anmeldung': 'Back to sign in',
'Link senden': 'Send link',
'Die Passwörter stimmen nicht überein.': 'The passwords don’t match.',
'Wir haben dir einen Link geschickt. Er gilt eine Stunde.': 'We’ve sent you a link. It’s valid for one hour.',
'Bitte melde dich an, um fortzufahren.': 'Please sign in to continue.',
'Passwort neu setzen': 'Set new password',
'Neues Passwort': 'New password',
'Neues Passwort wiederholen': 'Confirm new password',
'Passwort speichern': 'Save password',
'Neuen Link anfordern': 'Request a new link',
'Passwort geändert. Du kannst dich jetzt anmelden.': 'Password changed. You can now sign in.',
// Fehlermeldungen zu Konto und Karten (account/errors.ts, account/auth.ts)
'Der Link ist ungültig oder abgelaufen.': 'The link is invalid or has expired.',
'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.': 'This password is too common. Please choose a different one.',
'Das Passwort muss 8 bis 256 Zeichen lang sein.': 'The password must be 8 to 256 characters long.',
'Das Passwort muss {min} bis {max} Zeichen lang sein.': 'The password must be {min} to {max} characters long.',
'Diese E-Mail ist schon registriert. Melde dich an.': 'This email is already registered. Please sign in.',
'Zu dieser E-Mail gibt es kein Konto.': 'There is no account for this email.',
'Bitte gib eine gültige E-Mail-Adresse ein.': 'Please enter a valid email address.',
'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.':
'This card no longer exists or doesn’t belong to your account.',
'Das Bild dieser Karte fehlt.': 'This card’s image is missing.',
'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.':
'This site isn’t enabled on the server. Please contact the site owner.',
'Du bist schon angemeldet. Bitte lade die Seite neu.': 'You’re already signed in. Please reload the page.',
'Zurzeit sind keine neuen Konten möglich.': 'New accounts can’t be created right now.',
'Zu viele Versuche – bitte kurz warten.': 'Too many attempts – please wait a moment.',
'Keine Verbindung zum Server.': 'Can’t reach the server.',
'Meine Karte': 'My card',
// Editor, Spalte „Vorlage“ (editor/templates-panel.ts)
'Vorlage': 'Template',
'Nur Bild': 'Image only',
'Bild unverändert lassen': 'Keep the image as is',
'Minimalistisch': 'Minimalist',
'Dein Bild steht im Mittelpunkt': 'Your image takes center stage',
'Klassisch': 'Classic',
'Die vertrauten Kartendetails': 'The familiar card details',
'Signature': 'Signature',
'Ein bisschen mehr Persönlichkeit': 'A little more personality',
// Editor, Spalte „Individuell gestalten“ (editor/options-panel.ts, card/marks.ts). „Hell“, „Dunkel“ und „Kein Logo“ stehen weiter oben.
'Individuell gestalten': 'Customize',
'Details': 'Details',
'Logo': 'Logo',
'AirCard-IOS · Symbol': 'AirCard-IOS · Symbol',
'AirCard-IOS · Text': 'AirCard-IOS · Text',
// Editor, Vorschau und Knöpfe (editor/editor-page.ts, editor/preview.ts)
'Kartenmotiv gestalten': 'Create a card design',
'Kartenvorschau': 'Card preview',
'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.':
'Card preview. Drag with the mouse or use the arrow keys to move the image.',
'Bilddatei wählen': 'Choose image file',
'Bild auswählen': 'Choose image',
'Ziehe deine Idee hierher': 'Drag your idea here',
'PNG / JPG / WebP · Max. 5 MiB': 'PNG / JPG / WebP · Max. 5 MiB',
'Zoom': 'Zoom',
'{percent} %': '{percent}%',
'Bild ersetzen': 'Replace image',
'Herunterladen': 'Download',
'Speichern': 'Save',
'Speichert …': 'Saving …',
'Gespeichert': 'Saved',
'Erst ein Bild wählen': 'Choose an image first',
'Neue Karte': 'New card',
'Bearbeitet: {name}': 'Editing: {name}',
'Dein Bild bleibt auf deinem Gerät, bis du speicherst.': 'Your image stays on your device until you save.',
// Editor, Meldungen und Rückfragen (editor/editor-page.ts, editor/upload.ts, editor/download.ts, editor/save.ts, card/render.ts)
'Bitte zuerst ein Bild wählen.': 'Please choose an image first.',
'Bitte ein PNG-, JPG- oder WebP-Bild wählen.': 'Please choose a PNG, JPG or WebP image.',
'Das Bild ist größer als 5 MiB.': 'The image is larger than 5 MiB.',
'Das Bild konnte nicht gelesen werden.': 'The image couldn’t be read.',
'Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.':
'The PNG is larger than 4 MB – Wallet may only accept smaller images.',
'Das PNG konnte nicht erzeugt werden.': 'The PNG couldn’t be created.',
'Das Bild konnte nicht erzeugt werden.': 'The image couldn’t be created.',
'Die Vorschau konnte nicht gezeichnet werden.': 'The preview couldn’t be drawn.',
'Canvas wird in diesem Browser nicht unterstützt.': 'This browser doesn’t support canvas.',
'Speichern fehlgeschlagen – {reason}': 'Saving failed – {reason}',
'Karte wird geladen …': 'Loading card …',
'Vorlage wird geladen …': 'Loading template …',
'Diese Vorlage ist nicht (mehr) verfügbar.': 'This template is no longer available.',
'Melde dich an, um diese Karte zu öffnen.': 'Sign in to open this card.',
'Ungespeicherte Änderungen verwerfen?': 'Discard unsaved changes?',
'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.': 'Everything you’ve changed since the last save will be lost.',
'Verwerfen': 'Discard',
// Galerie-Raster, Galeriebilder und Zahl der Meldungen (community/gallery-grid.ts, community/gallery.ts, pages/format.ts).
// „Ausgeblendet“ steht weiter oben.
'Keine Vorschau': 'No preview',
'Karten mit diesem Tag zeigen': 'Show cards with this tag',
'Das Bild konnte nicht geladen werden.': 'The image couldn’t be loaded.',
'1 Meldung': '1 report',
'{count} Meldungen': '{count} reports',
// Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“, „Anonym“, „Anzeigename (optional)“,
// die Erklärung dazu und „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle.
'Karte veröffentlichen': 'Publish card',
'„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.':
'“{name}” will be shown publicly in the community gallery, including its image.',
'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.':
'The card will be shown publicly in the community gallery, including its image.',
'Titel (Pflicht)': 'Title (required)',
'Herausgeber / Bank (optional)': 'Issuer / bank (optional)',
'Tags (optional)': 'Tags (optional)',
'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.': 'Up to 5, separated by commas, e.g. “visa, gold”.',
'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.':
'The image is mine or I’m allowed to use it, and it contains no real card data.',
'Veröffentlichen': 'Publish',
'Wird veröffentlicht …': 'Publishing …',
'Veröffentlichen fehlgeschlagen – {reason}': 'Publishing failed – {reason}',
'Bitte gib einen Titel ein.': 'Please enter a title.',
'Der Titel enthält ungültige Zeichen.': 'The title contains invalid characters.',
'Der Titel ist zu lang (höchstens {max} Zeichen).': 'The title is too long (at most {max} characters).',
'Herausgeber / Bank enthält ungültige Zeichen.': 'Issuer / bank contains invalid characters.',
'Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).': 'Issuer / bank is too long (at most {max} characters).',
'Tags enthalten ungültige Zeichen.': 'Tags contain invalid characters.',
'Ein Tag darf höchstens {max} Zeichen lang sein.': 'A tag can be at most {max} characters long.',
'Bitte gib höchstens {max} Tags an.': 'Please enter at most {max} tags.',
'Bitte bestätige die Erklärung zum Bild.': 'Please confirm the statement about the image.',
'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).': 'The card image is too large for the gallery (max. 5 MB).',
'Das Kartenbild konnte nicht erzeugt werden.': 'The card image couldn’t be created.',
// Startseite (pages/home-page.ts). „Gestalten“, „Gemeldet“, „Ausgeblendet“, „Nochmal versuchen“ und „Mehr laden“ stehen an anderer Stelle.
'Willkommen': 'Welcome',
'Ein bisschen Kunst für dein Wallet': 'A little art for your Wallet',
'Kartendesigns für Apple Wallet': 'Card designs for Apple Wallet',
'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.':
'Discover designs from the community, create your own and download it as a PNG.',
'Karten entdecken ↓': 'Discover cards ↓',
'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol':
'Sample design: purple gradient with waves and the AirCard-IOS symbol',
'Community-Karte „{title}“': 'Community card “{title}”',
'Community-Karten': 'Community cards',
'Entdecke Motive der Community – und veröffentliche deine eigenen.': 'Discover designs from the community – and publish your own.',
'Gestalten & veröffentlichen': 'Create & publish',
'Suche': 'Search',
'Suche zurücksetzen': 'Clear search',
'Tag': 'Tag',
'Tag-Filter zurücksetzen': 'Clear tag filter',
'Ansicht': 'View',
'Alle': 'All',
'Karten werden geladen …': 'Loading cards …',
'Die Community-Karten konnten gerade nicht geladen werden.': 'The community cards couldn’t be loaded right now.',
'Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.': 'More cards couldn’t be loaded. Please try again.',
'Noch keine Community-Karten – sei die erste!': 'No community cards yet – be the first!',
'Keine Karten in dieser Ansicht.': 'No cards in this view.',
'Nichts gefunden für „{query}“': 'Nothing found for “{query}”',
// Community-Detailseite (pages/community-page.ts). „Ausgeblendet“, „Melden“, „Löschen“, „Ausblenden“, „Einblenden“, „Herunterladen“,
// „Nochmal versuchen“, „von {name}“, „Anonym“, „Karte wird geladen …“, „Diese Karte ist nicht (mehr) verfügbar.“ und
// „Die Gründe konnten nicht geladen werden.“ stehen an anderer Stelle.
'Die Karte konnte gerade nicht geladen werden.': 'The card couldn’t be loaded right now.',
'Bild wird geladen …': 'Loading image …',
'Der Melden-Dialog konnte nicht geöffnet werden.': 'The report dialog couldn’t be opened.',
'Diese Karte ist für andere nicht sichtbar.': 'This card isn’t visible to others.',
'Zur Galerie': 'Back to gallery',
'← Zur Galerie': '← Back to gallery',
'Wird geladen …': 'Downloading …',
'Als Vorlage gestalten': 'Use as template',
'Zurückziehen': 'Withdraw',
'Moderation': 'Moderation',
'Karte eingeblendet.': 'Card unhidden.',
'Karte ausgeblendet.': 'Card hidden.',
'Karte „{title}“ zurückziehen?': 'Withdraw card “{title}”?',
'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.':
'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.',
'Karte „{title}“ löschen?': 'Delete card “{title}”?',
'Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.': 'The card, its image and all reports will be deleted permanently.',
// Meine Karten (pages/cards-page.ts). „Meine Karten“, „Anmelden“, „Herunterladen“, „Löschen“, „Speichern“, „Abbrechen“,
// „Nochmal versuchen“ und „Karten werden geladen …“ stehen an anderer Stelle.
'Melde dich an, um deine Karten zu sehen.': 'Sign in to see your cards.',
'Noch keine Karten gespeichert.': 'No cards saved yet.',
'Zum Editor': 'Go to editor',
'Gespeicherte Karten': 'Saved cards',
'Vorschau von „{name}“': 'Preview of “{name}”',
'Vorschau lädt …': 'Loading preview …',
'Der Veröffentlichungsstatus konnte nicht geladen werden.': 'The publishing status couldn’t be loaded.',
'Öffnen': 'Open',
'Umbenennen': 'Rename',
'Ansehen': 'View',
'Veröffentlicht': 'Published',
'Ausgeblendet (gemeldet)': 'Hidden (reported)',
'Wird erstellt …': 'Creating …',
'Neuer Name für „{name}“': 'New name for “{name}”',
'Die Karte und ihr Bild werden endgültig gelöscht.': 'The card and its image will be deleted permanently.',
'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst – das geht dann nur noch auf ihrer Community-Seite.':
'The published card stays in the community gallery until you withdraw it – after deleting this card, you can only do that on its community page.',
'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.':
'If this card is published, it stays in the gallery – you can withdraw it on its community page.',
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.',
'Diese Karte ist nicht (mehr) verfügbar.': 'This card is no longer available.',
'Dazu hast du keine Berechtigung.': 'You are not allowed to do that.',
'Nur für Admins.': 'Admins only.',
'Diese Datei gehört dir nicht.': 'This file doesn’t belong to you.',
'Bild nicht gefunden.': 'Image not found.',
'Das Bild muss eine PNG- oder WebP-Datei sein.': 'The image must be a PNG or WebP file.',
'Das Bild ist größer als 5 MB.': 'The image is larger than 5 MB.',
'Diese Karte ist schon veröffentlicht.': 'This card is already published.',
'Diese Bilder sind bereits veröffentlicht oder gesperrt.': 'These images are already published or blocked.',
'Das Bild hat nicht das Kartenformat.': 'The image doesn’t match the card format.',
'Diese Karte gehört nicht zu deinem Konto.': 'This card doesn’t belong to your account.',
'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.':
'You’ve already published 20 cards. Withdraw one to publish a new one.',
'Zu viele Meldungen – bitte später nochmal.': 'Too many reports – please try again later.',
'Ungültige Anfrage.': 'Invalid request.',
'Ungültige Karten-ID.': 'Invalid card ID.',
'Bitte wähle einen gültigen Grund.': 'Please choose a valid reason.',
'Ungültige Angabe zur Sichtbarkeit.': 'Invalid visibility value.',
'Dieses Logo ist nicht (mehr) verfügbar.': 'This logo is no longer available.',
'Ungültige Logo-ID.': 'Invalid logo ID.',
'Bitte gib einen Namen an.': 'Please enter a name.',
'Das Logo muss eine PNG-Datei sein.': 'The logo must be a PNG file.',
'Das Logo ist größer als 1 MB.': 'The logo is larger than 1 MB.',
'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).': 'The logo has invalid dimensions (8 × 8 to 720 × 250 pixels).',
'Diese Datei wird schon für ein Logo verwendet.': 'This file is already used for a logo.',
'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.':
'You’ve already uploaded 30 logos. Delete one to upload a new one.',
'Ungültige Angabe zum Herz.': 'Invalid heart value.',
'Ungültige Angabe zu „einfarbig“.': 'Invalid value for “single color”.',
'Die Anfrage ist zu groß.': 'The request is too large.',
'Nur application/json wird unterstützt.': 'Only application/json is supported.',
'Der Dienst ist gerade nicht erreichbar.': 'The service is currently unavailable.',
'Interner Fehler.': 'Internal error.',
'Etwas ist schiefgelaufen. Bitte nochmal versuchen.': 'Something went wrong. Please try again.',
'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.':
'Token sign-in is turned off on the server – please let the site owner know.',
};
+17
View File
@@ -0,0 +1,17 @@
// Hilfen der Englisch-Tests (nur Tests importieren sie): die sichtbaren Texte eines Teilbaums und die deutschen Texte, die auf einer
// englischen Seite noch darin stehen.
import { EN } from './en';
/** Alle sichtbaren Texte (Textknoten) und Textattribute unter `root`. */
export function texts(root: ParentNode): string[] {
const out: string[] = [];
const walker = document.createTreeWalker(root as Node, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node.textContent?.trim()) out.push(node.textContent.trim());
for (const el of root.querySelectorAll('*')) {
for (const attr of ['aria-label', 'title', 'placeholder', 'alt']) if (el.getAttribute(attr)) out.push(el.getAttribute(attr) as string);
}
return out;
}
/** Deutsche Wörterbuchtexte, die auf einer englischen Seite stehen: also Texte, die an `t()` vorbeigegangen sind. */
export const germanLeft = (root: ParentNode): string[] => texts(root).filter((text) => Object.hasOwn(EN, text) && EN[text] !== text);
+346
View File
@@ -0,0 +1,346 @@
// Stichproben der englischen Oberfläche von Konto, Editor und Passwort-Seite (Task 10): Die Texte erscheinen bei Englisch, und was an
// Texten hängt (Abgleich der Passwörter, übersetzte Fehler, Beschriftung des wiederverwendeten Login-Dialogs) arbeitet in beiden
// Sprachen gleich. Wer Module mit `vi.resetModules()` frisch lädt, stellt die Sprache an der frischen Kopie von i18n.ts ein.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { germanLeft, texts } from './english-check';
const mocks = vi.hoisted(() => ({
acct: {
get: vi.fn(),
create: vi.fn(),
createEmailPasswordSession: vi.fn(),
deleteSession: vi.fn(),
createRecovery: vi.fn(),
updateRecovery: vi.fn(),
},
getCard: vi.fn(),
saveCard: vi.fn(),
getCurrentUser: vi.fn(),
requireLogin: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
getGalleryCard: vi.fn(),
}));
vi.mock('../account/appwrite', () => ({ account: mocks.acct, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
beforeEach(() => {
vi.resetAllMocks();
});
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
const submit = (form: HTMLFormElement | null) => form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
/** Frische Kopie von i18n.ts in der gewünschten Sprache (die Module danach laden dieselbe Kopie). */
async function freshI18n(lang: 'de' | 'en') {
vi.resetModules();
const i18n = await import('./i18n');
i18n.setLang(lang, { persist: false });
return i18n;
}
describe('Fehlertexte und Standardname auf Englisch', () => {
it('errorMessage und validatePassword übersetzen beim Zurückgeben', async () => {
await freshI18n('en');
const { errorMessage } = await import('../account/errors');
const { validatePassword } = await import('../account/auth');
expect(errorMessage(fail(401, 'user_invalid_credentials'), 'login')).toBe('Email or password is incorrect.');
expect(errorMessage(fail(404, 'user_not_found'), 'reset')).toBe('The link is invalid or has expired.');
expect(errorMessage(fail(429, ''), 'login')).toBe('Too many attempts – please wait a moment.');
expect(errorMessage(new TypeError('x'), 'load')).toBe('Can’t reach the server.');
expect(errorMessage(new Error('x'), 'load')).toBe('Something went wrong. Please try again.');
expect(validatePassword('kurz')).toBe('The password must be 8 to 256 characters long.');
});
it('der Standardname einer neuen Karte folgt der Sprache, die Konstante bleibt der deutsche Schlüssel', async () => {
const i18n = await freshI18n('en');
const { DEFAULT_CARD_NAME, normalizeName } = await import('../account/cards');
const { initialEditorState } = await import('../editor/state');
expect(normalizeName(' ')).toBe('My card');
expect(initialEditorState().cardName).toBe('My card');
expect(DEFAULT_CARD_NAME).toBe('Meine Karte');
i18n.setLang('de', { persist: false });
expect(normalizeName(' ')).toBe('Meine Karte');
});
});
describe('Konto-Menü und Login-Dialog auf Englisch', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="account-slot"></div>';
});
it('das Menü zeigt „Sign in“ bzw. Konto-Menü mit englischen Texten und Fehlern', async () => {
await freshI18n('en');
const { mountAccountMenu } = await import('../account/account-menu');
const auth = await import('../account/auth');
const slot = document.getElementById('account-slot')!;
const menu = mountAccountMenu(slot);
menu.ready();
expect(texts(slot)).toEqual(['Sign in']);
mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' });
await auth.getCurrentUser();
expect(texts(slot)).toEqual(expect.arrayContaining(['Account menu', 'Account', 'My cards', 'Sign out']));
expect(germanLeft(slot)).toEqual([]);
mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
[...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click();
await vi.waitFor(() => expect(slot.querySelector('.account-error')!.textContent).toBe('Can’t reach the server.'));
menu.dispose();
});
it('alle drei Ansichten, der Abgleich der Passwörter (Fokus am Feld) und die Beschriftung nach dem Sprachwechsel', async () => {
const i18n = await freshI18n('de');
const { requireLogin } = await import('../account/login-dialog');
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
const button = (text: string) => [...dialog().querySelectorAll('button')].find((b) => b.textContent === text)!;
const input = (name: string) => dialog().querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const alert = () => dialog().querySelector('[role="alert"]')!.textContent;
// Der Dialog wird einmal gebaut: Deutsch öffnen und schließen, dann auf Englisch wieder öffnen.
const first = requireLogin('login');
expect(dialog().querySelector('.auth-close')!.getAttribute('aria-label')).toBe('Schließen');
dialog().querySelector<HTMLButtonElement>('.auth-close')!.click();
await first;
i18n.setLang('en', { persist: false });
void requireLogin('login');
expect(dialog().querySelector('h2')!.textContent).toBe('Sign in');
expect(dialog().querySelector('.auth-close')!.getAttribute('aria-label')).toBe('Close');
expect(dialog().querySelector('[role="tablist"]')!.getAttribute('aria-label')).toBe('Account');
expect(texts(dialog())).toEqual(expect.arrayContaining(['Sign in', 'Sign up', 'Email', 'Password', 'Forgot password?']));
expect(germanLeft(dialog())).toEqual([]);
button('Sign up').click();
expect(dialog().querySelector('h2')!.textContent).toBe('Sign up');
expect(texts(dialog())).toEqual(expect.arrayContaining(['At least 8 characters.', 'Confirm password']));
input('email').value = 'a@b.de';
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort124';
submit(dialog().querySelector('form'));
expect(alert()).toBe('The passwords don’t match.');
expect(document.activeElement).toBe(input('password-repeat'));
input('password').value = 'kurz';
input('password-repeat').value = 'kurz';
submit(dialog().querySelector('form'));
expect(alert()).toBe('The password must be 8 to 256 characters long.');
expect(document.activeElement).toBe(input('password'));
expect(germanLeft(dialog())).toEqual([]);
// vorhandene E-Mail: Meldung mit dem Link „Sign in“
mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists'));
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort123';
submit(dialog().querySelector('form'));
await vi.waitFor(() => expect(alert()).toBe('This email is already registered. Please sign in. Sign in'));
button('Sign in').click();
button('Forgot password?').click();
expect(dialog().querySelector('h2')!.textContent).toBe('Forgot password');
expect(texts(dialog())).toEqual(expect.arrayContaining(['Send link', 'Back to sign in']));
mocks.acct.createRecovery.mockResolvedValue({});
submit(dialog().querySelector('form'));
await vi.waitFor(() => expect(dialog().querySelector('[role="status"]')!.textContent).toBe('We’ve sent you a link. It’s valid for one hour.'));
expect(germanLeft(dialog())).toEqual([]);
});
});
describe('Seite „Passwort neu setzen“ auf Englisch', () => {
beforeEach(() => {
document.body.innerHTML = '<main id="app-root"></main>';
});
it('ohne gültigen Link', async () => {
await freshI18n('en');
history.replaceState(null, '', '/passwort-neu');
const { mountResetPage } = await import('../pages/reset-page');
const root = document.getElementById('app-root')!;
mountResetPage(root);
expect(root.querySelector('h1')!.textContent).toBe('Set new password');
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The link is invalid or has expired.');
expect(texts(root)).toContain('Request a new link');
expect(germanLeft(root)).toEqual([]);
});
it('mit Link: Formular, Abgleich der Passwörter (Fokus am Feld) und Erfolg', async () => {
await freshI18n('en');
history.replaceState(null, '', '/passwort-neu?userId=u1&secret=s3cret');
const { mountResetPage } = await import('../pages/reset-page');
const root = document.getElementById('app-root')!;
mountResetPage(root);
expect(texts(root)).toEqual(expect.arrayContaining(['New password', 'Confirm new password', 'At least 8 characters.', 'Save password']));
expect(germanLeft(root)).toEqual([]);
const input = (name: string) => root.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort124';
submit(root.querySelector('form'));
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The passwords don’t match.');
expect(document.activeElement).toBe(input('password-repeat'));
input('password').value = 'kurz';
input('password-repeat').value = 'kurz';
submit(root.querySelector('form'));
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The password must be 8 to 256 characters long.');
expect(document.activeElement).toBe(input('password'));
mocks.acct.updateRecovery.mockResolvedValue({});
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort123';
submit(root.querySelector('form'));
await vi.waitFor(() => expect(root.querySelector('[role="status"]')!.textContent).toBe('Password changed. You can now sign in.'));
expect(germanLeft(root)).toEqual([]);
history.replaceState(null, '', '/');
});
});
describe('Editor auf Englisch', () => {
let root: HTMLElement;
/** Lädt den Editor frisch in der Sprache `en`; Karten-Dienst, Anmeldung, Renderer und Logo-Auswahl sind ersetzt. */
async function mountEnglishEditor(url = '/gestalten') {
vi.resetModules();
vi.doMock('../account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
currentUserId: () => 'u1',
onAuthChange: () => () => {},
onLogout: () => () => {},
}));
vi.doMock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.doMock('../account/cards', () => ({ cardsService: () => ({ get: mocks.getCard, save: mocks.saveCard, loadImage: vi.fn() }) }));
vi.doMock('../card/render', () => ({ drawCard: vi.fn(), loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
vi.doMock('../community/gallery', () => ({ getGalleryCard: mocks.getGalleryCard, loadGalleryImage: vi.fn() }));
vi.doMock('../logos/logo-picker', () => ({
createLogoPicker: () => ({ el: document.createElement('div'), select: vi.fn(), selected: vi.fn(), dispose: vi.fn() }),
}));
vi.doMock('../editor/download', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/download')>()),
saveBlobAs: vi.fn(),
}));
vi.doMock('../editor/upload', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/upload')>()),
decodeImage: mocks.decodeImage,
}));
(await import('./i18n')).setLang('en', { persist: false });
history.replaceState(null, '', url);
document.body.innerHTML = '<main id="app-root"></main>';
root = document.getElementById('app-root')!;
const { mountEditor } = await import('../editor/editor-page');
const { createStore, initialEditorState } = await import('../editor/state');
const store = createStore(initialEditorState());
return { store, unmount: mountEditor(root, store) };
}
beforeEach(() => {
mocks.getCurrentUser.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
mocks.requireLogin.mockResolvedValue(true);
mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
vi.stubGlobal('requestAnimationFrame', () => 0);
vi.stubGlobal('cancelAnimationFrame', () => {});
});
afterEach(() => {
for (const id of ['auth', 'login-dialog', 'cards']) vi.doUnmock(`../account/${id}`);
vi.doUnmock('../card/render');
vi.doUnmock('../community/gallery');
vi.doUnmock('../logos/logo-picker');
vi.doUnmock('../editor/download');
vi.doUnmock('../editor/upload');
vi.unstubAllGlobals();
history.replaceState(null, '', '/');
});
const errorText = () => root.querySelector('.msg-error')!.textContent;
const button = (text: string) => [...root.querySelectorAll('button')].find((b) => b.textContent === text)!;
const pick = (file: File) => {
const input = root.querySelector<HTMLInputElement>('input[type="file"]')!;
Object.defineProperty(input, 'files', { configurable: true, value: [file] });
input.dispatchEvent(new Event('change'));
};
it('die Seite ist englisch; Dateifehler, Speichern, Download und Rückfrage ebenso', async () => {
const { store, unmount } = await mountEnglishEditor();
expect(store.get().cardName).toBe('My card');
expect(germanLeft(root)).toEqual([]);
expect(texts(root)).toEqual(
expect.arrayContaining([
'Template',
'Image only',
'Card preview',
'Choose image',
'Drag your idea here',
'Save',
'Customize',
'Light',
'Dark',
'No logo',
'Logo – AirCard-IOS · Symbol',
]),
);
// Typ und Größe prüft validateImageFile (übersetzt beim Zurückgeben); die Fehler von decodeImage tragen den deutschen Schlüssel
pick(new File(['x'], 'a.gif', { type: 'image/gif' }));
expect(errorText()).toBe('Please choose a PNG, JPG or WebP image.');
const { PIXELS_MESSAGE, READ_MESSAGE } = await import('../editor/upload');
mocks.decodeImage.mockRejectedValueOnce(new Error(PIXELS_MESSAGE));
pick(new File(['x'], 'a.png', { type: 'image/png' }));
await vi.waitFor(() => expect(errorText()).toBe('The image is too large (max. 40 megapixels).'));
mocks.decodeImage.mockRejectedValueOnce(new Error(READ_MESSAGE));
pick(new File(['x'], 'a.png', { type: 'image/png' }));
await vi.waitFor(() => expect(errorText()).toBe('The image couldn’t be read.'));
// Mit Bild: Zoom in der englischen Schreibweise, Speichern scheitert, dann klappt es
const image = { source: {} as CanvasImageSource, w: 3000, h: 2000 };
store.set({ image, imageBlob: new Blob(['png'], { type: 'image/png' }), imageChanged: true });
expect(root.querySelector('.zoom-value')!.textContent).toBe('100%');
mocks.saveCard.mockRejectedValueOnce(new TypeError('Failed to fetch'));
button('Save').click();
await vi.waitFor(() => expect(errorText()).toBe('Saving failed – Can’t reach the server.'));
mocks.saveCard.mockResolvedValueOnce({ id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x', config: store.get().config });
button('Save').click();
await vi.waitFor(() => expect(button('Saved')).toBeDefined());
expect(root.querySelector('.bound-label')!.textContent).toBe('Editing: Urlaub');
// Download: Logos nicht ladbar, dann ein PNG über der Wallet-Grenze, dann ein Export-Fehler mit deutscher Meldung aus card/render.ts
mocks.loadCardAssets.mockRejectedValueOnce(new Error('Failed to fetch'));
button('Download').click();
await vi.waitFor(() => expect(errorText()).toBe('The logos couldn’t be loaded.'));
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: {}, logoMissing: false });
mocks.exportCard.mockResolvedValueOnce(new Blob([new Uint8Array(4_000_100)], { type: 'image/png' }));
button('Download').click();
const warning = 'The PNG is larger than 4 MB – Wallet may only accept smaller images.';
await vi.waitFor(() => expect(root.querySelector('.msg-warning')!.textContent).toBe(warning));
mocks.exportCard.mockRejectedValueOnce(new Error('Das Bild konnte nicht erzeugt werden.'));
button('Download').click();
await vi.waitFor(() => expect(errorText()).toBe('The image couldn’t be created.'));
// „Neue Karte“ mit ungespeicherten Änderungen fragt nach
store.set({ imageChanged: true });
button('New card').click();
await vi.waitFor(() => expect(document.querySelector('dialog.confirm-dialog')).not.toBeNull());
const dialog = document.querySelector('dialog.confirm-dialog')!;
expect(texts(dialog)).toEqual(['Discard unsaved changes?', 'Everything you’ve changed since the last save will be lost.', 'Cancel', 'Discard']);
unmount();
dialog.remove();
});
it('Karte oder Vorlage öffnen: Ladehinweis, abgebrochene Anmeldung, fehlende Karte, fehlende Vorlage', async () => {
let known!: (user: null) => void;
mocks.getCurrentUser.mockReturnValue(new Promise<null>((resolve) => (known = resolve)));
mocks.requireLogin.mockResolvedValue(false);
let editor = await mountEnglishEditor('/gestalten?karte=abc');
expect(root.querySelector('.msg-notice')!.textContent).toBe('Loading card …');
known(null);
await vi.waitFor(() => expect(root.querySelector('.msg-warning')!.textContent).toBe('Sign in to open this card.'));
editor.unmount();
mocks.getCurrentUser.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
mocks.getCard.mockRejectedValue(fail(404, 'row_not_found'));
editor = await mountEnglishEditor('/gestalten?karte=abc');
await vi.waitFor(() => expect(errorText()).toBe('This card no longer exists or doesn’t belong to your account.'));
editor.unmount();
let answer!: (card: null) => void;
mocks.getGalleryCard.mockReturnValue(new Promise<null>((resolve) => (answer = resolve)));
editor = await mountEnglishEditor('/gestalten?vorlage=abc');
expect(root.querySelector('.msg-notice')!.textContent).toBe('Loading template …');
answer(null);
await vi.waitFor(() => expect(errorText()).toBe('This template is no longer available.'));
editor.unmount();
});
});
+258
View File
@@ -0,0 +1,258 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EN } from './en';
import { currentLang, initialLang, LANG_KEY, msg, onLangChange, setLang, t, type Lang } from './i18n';
// Ein Wörterbuch-Eintrag mit Platzhalter nur für diese Tests: der Bestand hat (noch) keinen Text mit {…}.
// (Ein vi.mock von ./en griffe hier nicht: das Test-Setup hat i18n.ts schon geladen.) Er wird nach jedem Test wieder entfernt.
const WITH_PLACEHOLDER = 'Nichts gefunden für „{q}“';
const dictionary = EN as Record<string, string>;
/** Abmeldungen der Hörer dieses Tests (sie werden nach jedem Test aufgehoben, auch wenn eine Prüfung scheitert). */
const stops: Array<() => void> = [];
function listen(): Lang[] {
const calls: Lang[] = [];
stops.push(onLangChange((lang) => calls.push(lang)));
return calls;
}
beforeEach(() => {
localStorage.clear();
dictionary[WITH_PLACEHOLDER] = 'Nothing found for “{q}”';
});
afterEach(() => {
delete dictionary[WITH_PLACEHOLDER];
for (const stop of stops.splice(0)) stop();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('initialLang', () => {
it.each([
[null, ['de-DE', 'en'], 'de'],
[null, ['en-US', 'de'], 'en'],
['en', ['de-DE'], 'en'],
['de', ['en-US'], 'de'],
['xx', ['de-AT'], 'de'],
['xx', ['en-GB'], 'en'],
['', ['de'], 'de'],
[null, [], 'en'],
] as const)('gespeichert %j, Browsersprachen %j → %s', (stored, languages, expected) => {
expect(initialLang(stored, languages)).toBe(expected);
});
it('nur die erste Browsersprache zählt, Groß-/Kleinschreibung ist egal', () => {
expect(initialLang(null, ['fr-FR', 'de-DE'])).toBe('en');
expect(initialLang(null, ['DE'])).toBe('de');
expect(initialLang(null, ['de_CH'])).toBe('de');
});
});
describe('t() und msg()', () => {
it('Deutsch: der Text bleibt, wie er ist', () => {
expect(t('Gestalten')).toBe('Gestalten');
});
it('Englisch: übersetzt über das Wörterbuch', () => {
setLang('en');
expect(t('Gestalten')).toBe('Create');
});
it('ein Text ohne Eintrag bleibt auch auf Englisch deutsch', () => {
setLang('en');
expect(t('Dieser Text steht in keinem Wörterbuch')).toBe('Dieser Text steht in keinem Wörterbuch');
});
it('ersetzt Platzhalter in beiden Sprachen', () => {
expect(t('Nichts gefunden für „{q}“', { q: 'x' })).toBe('Nichts gefunden für „x“');
setLang('en');
expect(t('Nichts gefunden für „{q}“', { q: 'x' })).toBe('Nothing found for “x”');
});
it('ersetzt auch Platzhalter in Texten ohne Eintrag, Zahlen inklusive 0', () => {
setLang('en');
expect(t('{n} Karten von {name}', { n: 0, name: 'Ada' })).toBe('0 Karten von Ada');
});
it('ein unbekannter Platzhalter bleibt stehen', () => {
expect(t('Nichts gefunden für „{q}“', { x: 'y' })).toBe('Nichts gefunden für „{q}“');
expect(t('Hallo {name}')).toBe('Hallo {name}');
setLang('en');
expect(t('Nichts gefunden für „{q}“', {})).toBe('Nothing found for “{q}”');
});
it('ersetzt in einem Durchgang: Werte werden nicht erneut ausgewertet und nicht als Muster gelesen', () => {
expect(t('{a} {b}', { a: '{b}', b: 'x' })).toBe('{b} x');
expect(t('{a}', { a: '$& $1' })).toBe('$& $1');
});
it('Namen aus Object.prototype sind weder Wörterbucheintrag noch Platzhalter', () => {
setLang('en');
expect(t('constructor')).toBe('constructor');
expect(t('toString')).toBe('toString');
expect(t('Wert {toString}', { x: 1 })).toBe('Wert {toString}');
});
it('msg() gibt den Text unverändert zurück und übersetzt nicht', () => {
setLang('en');
expect(msg('Gestalten')).toBe('Gestalten');
expect(t(msg('Gestalten'))).toBe('Create');
});
});
describe('setLang und currentLang', () => {
it('currentLang liefert die zuletzt gesetzte Sprache', () => {
setLang('en');
expect(currentLang()).toBe('en');
setLang('de');
expect(currentLang()).toBe('de');
});
it('setzt <html lang>, auch ohne Wechsel (etwa beim Start)', () => {
setLang('en');
expect(document.documentElement.lang).toBe('en');
document.documentElement.lang = 'fr';
setLang('en');
expect(document.documentElement.lang).toBe('en');
setLang('de');
expect(document.documentElement.lang).toBe('de');
});
it('speichert die Wahl in localStorage', () => {
setLang('en');
expect(localStorage.getItem(LANG_KEY)).toBe('en');
expect(LANG_KEY).toBe('aircard.lang');
});
it('mit persist: false wird nichts gespeichert, die Sprache gilt trotzdem', () => {
setLang('en', { persist: false });
expect(localStorage.getItem(LANG_KEY)).toBeNull();
expect(currentLang()).toBe('en');
expect(t('Gestalten')).toBe('Create');
});
it('ein gesperrter Speicher ist kein Fehler; die Sprache wechselt trotzdem', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
expect(() => setLang('en')).not.toThrow();
expect(currentLang()).toBe('en');
});
});
describe('onLangChange', () => {
it('meldet jeden echten Wechsel genau einmal, gleiche Sprache nie', () => {
const calls = listen();
setLang('de'); // schon Deutsch (Test-Setup)
expect(calls).toEqual([]);
setLang('en');
setLang('en');
expect(calls).toEqual(['en']);
setLang('de');
expect(calls).toEqual(['en', 'de']);
});
it('persist: false meldet ebenfalls', () => {
const calls = listen();
setLang('en', { persist: false });
expect(calls).toEqual(['en']);
});
it('der Hörer sieht die neue Sprache schon (t() und currentLang)', () => {
const seen: string[] = [];
stops.push(
onLangChange((lang) => {
seen.push(`${lang}:${currentLang()}:${t('Gestalten')}:${document.documentElement.lang}`);
}),
);
setLang('en');
expect(seen).toEqual(['en:en:Create:en']);
});
it('die Abmeldung wirkt', () => {
const calls: Lang[] = [];
const stop = onLangChange((lang) => calls.push(lang));
setLang('en');
stop();
setLang('de');
expect(calls).toEqual(['en']);
});
it('ein Hörer darf sich beim Melden abmelden, die übrigen werden trotzdem gerufen', () => {
const calls: string[] = [];
const stopA = onLangChange(() => {
calls.push('a');
stopA();
});
stops.push(stopA);
const second = listen();
setLang('en');
setLang('de');
expect(calls).toEqual(['a']);
expect(second).toEqual(['en', 'de']);
});
});
describe('currentLang: Startsprache beim ersten Aufruf', () => {
/** Eine frische Kopie des Moduls; die Startsprache wird erst beim ersten Aufruf von currentLang() bestimmt. */
async function fresh() {
vi.resetModules();
return import('./i18n');
}
it('die gespeicherte Wahl geht vor der Browsersprache', async () => {
localStorage.setItem(LANG_KEY, 'en');
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
expect((await fresh()).currentLang()).toBe('en');
});
it('ohne Wahl entscheidet die erste Browsersprache', async () => {
vi.stubGlobal('navigator', { languages: ['de-AT', 'en'], language: 'de-AT' });
expect((await fresh()).currentLang()).toBe('de');
vi.stubGlobal('navigator', { languages: ['en-US', 'de'], language: 'en-US' });
expect((await fresh()).currentLang()).toBe('en');
});
it('ein ungültiger gespeicherter Wert zählt als „nichts gespeichert“', async () => {
localStorage.setItem(LANG_KEY, 'klingonisch');
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
expect((await fresh()).currentLang()).toBe('de');
});
it('ohne navigator.languages gilt navigator.language, ganz ohne Angabe Englisch', async () => {
vi.stubGlobal('navigator', { language: 'de' });
expect((await fresh()).currentLang()).toBe('de');
vi.stubGlobal('navigator', { languages: [], language: 'de-CH' });
expect((await fresh()).currentLang()).toBe('de');
vi.stubGlobal('navigator', {});
expect((await fresh()).currentLang()).toBe('en');
});
it('ein gesperrter Speicher beim Lesen: es gilt die Browsersprache', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blockiert', 'SecurityError');
});
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
expect((await fresh()).currentLang()).toBe('de');
});
it('wird nur beim ersten Aufruf bestimmt: spätere Änderungen am Speicher ändern nichts', async () => {
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
const mod = await fresh();
expect(mod.currentLang()).toBe('de');
localStorage.setItem(LANG_KEY, 'en');
expect(mod.currentLang()).toBe('de');
});
it('setLang meldet gegenüber der Startsprache, nicht gegenüber „nichts“', async () => {
vi.stubGlobal('navigator', { languages: ['de-DE'], language: 'de-DE' });
const mod = await fresh();
const calls: Lang[] = [];
mod.onLangChange((lang) => calls.push(lang));
mod.setLang('de', { persist: false }); // wie die Startsprache: kein Wechsel
expect(calls).toEqual([]);
mod.setLang('en', { persist: false });
expect(calls).toEqual(['en']);
});
});
+83
View File
@@ -0,0 +1,83 @@
// Sprache der Seite (Deutsch/Englisch) im gettext-Stil: Der deutsche Text ist zugleich der Schlüssel.
// `t('Deutscher Text', { name })` schlägt ihn bei Englisch in EN (en.ts) nach und setzt die Platzhalter `{name}` ein;
// fehlt der Eintrag, bleibt der deutsche Text. Übersetzt wird beim Aufruf, ein Sprachwechsel baut die Oberfläche neu auf (main.ts).
import { EN } from './en';
export type Lang = 'de' | 'en';
export const LANG_KEY = 'aircard.lang';
/**
* Startsprache: die gespeicherte Wahl (`de` oder `en`), sonst Deutsch, wenn die erste Browsersprache mit `de` beginnt,
* andernfalls Englisch. Dieselbe Regel steht im Skript in index.html, das <html lang> vor dem ersten Paint setzt.
*/
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang {
if (stored === 'de' || stored === 'en') return stored;
return (browserLanguages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en';
}
function readStored(): string | null {
try {
return localStorage.getItem(LANG_KEY);
} catch {
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt die Browsersprache
}
}
/** Die bevorzugten Browsersprachen: `navigator.languages`, ersatzweise `[navigator.language]`. */
function browserLanguages(): readonly string[] {
if (navigator.languages?.length) return navigator.languages;
return navigator.language ? [navigator.language] : [];
}
// Die Sprache der Seite; null, bis sie zum ersten Mal gebraucht wird.
let lang: Lang | null = null;
const listeners = new Set<(lang: Lang) => void>();
/** Die Sprache der Seite; beim ersten Aufruf aus der gespeicherten Wahl bzw. der Browsersprache bestimmt. */
export function currentLang(): Lang {
lang ??= initialLang(readStored(), browserLanguages());
return lang;
}
/**
* Stellt die Sprache um: setzt <html lang>, merkt sie sich (außer mit `persist: false`) und meldet einen echten Wechsel an
* die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer.
*/
export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
const changed = next !== currentLang();
lang = next;
document.documentElement.lang = next;
if (opts.persist ?? true) {
try {
localStorage.setItem(LANG_KEY, next);
} catch {
// gilt dann nur für diese Sitzung
}
}
// Kopie, damit sich ein Hörer beim Melden abmelden darf.
if (changed) for (const fn of [...listeners]) fn(next);
}
/** Meldet jeden Sprachwechsel (nicht den bloßen Aufruf mit der aktuellen Sprache). Gibt die Abmeldung zurück. */
export function onLangChange(fn: (lang: Lang) => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
/**
* Übersetzt einen deutschen Text in die Sprache der Seite und setzt die Platzhalter `{name}` aus `params` ein;
* ein Platzhalter ohne Wert bleibt stehen. Ohne Eintrag in EN bleibt der deutsche Text.
*/
export function t(text: string, params?: Readonly<Record<string, string | number>>): string {
const base = currentLang() === 'en' && Object.hasOwn(EN, text) ? EN[text] : text;
if (!params) return base;
return base.replace(/\{(\w+)\}/g, (placeholder, name: string) => (Object.hasOwn(params, name) ? String(params[name]) : placeholder));
}
/** Markiert einen deutschen Text, der vorab in einer Konstante steht; übersetzt wird erst beim Anzeigen mit `t()`. */
export function msg<T extends string>(text: T): T {
return text;
}
+260
View File
@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } }));
vi.mock('../account/auth', () => ({
currentUserId: () => 'u1',
onAuthChange: () => () => undefined,
onLogout: () => () => undefined,
}));
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
/** Eine frische Kopie von logo-api.ts und community/api.ts (der JWT-Cache lebt im Modul); beide aus derselben Ladung. */
async function fresh() {
vi.resetModules();
const logoApi = await import('./logo-api');
const api = await import('../community/api');
return { logoApi, api };
}
type ApiErr = InstanceType<Awaited<ReturnType<typeof fresh>>['api']['ApiError']>;
/** Der ApiError, mit dem `promise` scheitert. */
async function rejection(promise: Promise<unknown>): Promise<ApiErr> {
try {
await promise;
} catch (err) {
return err as ApiErr;
}
throw new Error('Die Anfrage ist nicht gescheitert.');
}
const sentBody = (call = 0) => JSON.parse(fetchMock.mock.calls[call][1].body as string) as unknown;
const sentHeaders = (call = 0) => fetchMock.mock.calls[call][1].headers as Record<string, string>;
beforeEach(() => {
mocks.createJWT.mockReset();
let n = 0;
mocks.createJWT.mockImplementation(async () => ({ jwt: `jwt${++n}` }));
fetchMock.mockReset();
fetchMock.mockImplementation(async () => json(200, { ok: true }));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('uploadLogo', () => {
const input = { name: 'Beispielbank', displayName: 'Kenso', mono: true, fileId: 'file1' };
it('POST /api/logos/hochladen mit JWT und genau den vier Feldern; liefert { id }', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(201, { id: 'file1' }));
await expect(logoApi.uploadLogo(input)).resolves.toEqual({ id: 'file1' });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/hochladen');
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
expect(sentBody()).toEqual(input);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('weitere Felder des Aufrufers gehen nicht mit', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(201, { id: 'file1' }));
const wider = { ...input, ownerId: 'u2', hidden: false };
await logoApi.uploadLogo(wider);
expect(sentBody()).toEqual(input);
});
it.each([[{}], [{ id: '' }], [{ id: 42 }], [{ id: null }], [{ ok: true }]])(
'eine Antwort ohne brauchbare id (%j): ApiError 503, Ausgang offen (die Zeile kann angelegt sein)',
async (body) => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValue(json(201, body));
const err = await rejection(logoApi.uploadLogo(input));
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: 'Der Dienst ist gerade nicht erreichbar.', serverCode: 'unavailable' });
expect(err.definitive).toBe(false);
expect(api.isDefinitiveFailure(err)).toBe(false);
},
);
it('429 „schon 30 Logos“ (Limit des Dienstes) ist eindeutig: es wurde nichts angelegt, die Datei darf aufgeräumt werden', async () => {
const { logoApi, api } = await fresh();
const text = 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.';
fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyLogos' }));
const err = await rejection(logoApi.uploadLogo(input));
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyLogos', definitive: true });
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it('ein anderes 429 (etwa vom Proxy) bleibt ein Fehler mit offenem Ausgang', async () => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValueOnce(json(429, { error: 'Zu viele Anfragen.' }));
expect(api.isDefinitiveFailure(await rejection(logoApi.uploadLogo(input)))).toBe(false);
fetchMock.mockResolvedValueOnce(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.', code: 'tooManyReports' }));
expect(api.isDefinitiveFailure(await rejection(logoApi.uploadLogo(input)))).toBe(false);
});
it.each([
[400, 'logoFormat', 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', true],
[400, 'badRequest', 'Der Name ist zu lang (höchstens 40 Zeichen).', true],
[401, 'unauthorized', 'Bitte melde dich an.', true],
[403, 'foreignFile', 'Diese Datei gehört dir nicht.', true],
[409, 'logoTaken', 'Diese Datei wird schon für ein Logo verwendet.', true],
[500, 'internal', 'Interner Fehler.', false],
] as const)('Fehler %i %s: Status, Text und Schlüssel des Dienstes bleiben, eindeutig: %s', async (status, code, error, definitive) => {
const { logoApi, api } = await fresh();
fetchMock.mockImplementation(async () => json(status, { error, code }));
const err = await rejection(logoApi.uploadLogo(input));
expect(err).toMatchObject({ code: status, message: error, serverCode: code });
expect(api.isDefinitiveFailure(err)).toBe(definitive);
});
it('der Dienst steht (502 vom Proxy, Netzfehler): nicht erreichbar, Ausgang offen', async () => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }));
const proxy = await rejection(logoApi.uploadLogo(input));
expect(proxy).toMatchObject({ code: 503, serverCode: 'unavailable' });
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
const network = await rejection(logoApi.uploadLogo(input));
expect(network).toMatchObject({ code: 503, serverCode: 'unavailable' });
expect(api.isDefinitiveFailure(proxy) || api.isDefinitiveFailure(network)).toBe(false);
});
});
describe('setHeart', () => {
it('POST /api/logos/herz { id, herz } mit JWT; liefert Zustand und Zähler des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { herz: true, likeCount: 3 }));
await expect(logoApi.setHeart('l1', true)).resolves.toEqual({ herz: true, likeCount: 3 });
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/herz');
expect(sentBody()).toEqual({ id: 'l1', herz: true });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('herz: false nimmt das Herz zurück', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { herz: false, likeCount: 0 }));
await expect(logoApi.setHeart('l1', false)).resolves.toEqual({ herz: false, likeCount: 0 });
expect(sentBody()).toEqual({ id: 'l1', herz: false });
});
it.each([
[{}],
[{ ok: true }],
[{ herz: true }],
[{ likeCount: 3 }],
[{ herz: 'ja', likeCount: 3 }],
[{ herz: true, likeCount: -1 }],
[{ herz: true, likeCount: 2.5 }],
[{ herz: true, likeCount: '3' }],
])(
'eine Antwort ohne brauchbaren Zustand oder Zähler (%j): ApiError 503, Ausgang offen',
async (body) => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValue(json(200, body));
const err = await rejection(logoApi.setHeart('l1', true));
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, serverCode: 'unavailable' });
expect(api.isDefinitiveFailure(err)).toBe(false);
},
);
it('404 bei fehlendem oder ausgeblendetem Logo: logoGone', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(404, { error: 'Dieses Logo ist nicht (mehr) verfügbar.', code: 'logoGone' }));
expect(await rejection(logoApi.setHeart('l1', true))).toMatchObject({
code: 404,
message: 'Dieses Logo ist nicht (mehr) verfügbar.',
serverCode: 'logoGone',
});
});
it('ohne Sitzung (401 von der API): ein Versuch mit neuem JWT, dann der Fehler', async () => {
const { logoApi } = await fresh();
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' }));
expect(await rejection(logoApi.setHeart('l1', true))).toMatchObject({ code: 401, serverCode: 'unauthorized' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('reportLogo', () => {
it('POST /api/logos/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { ok: true }));
await expect(logoApi.reportLogo('l1', 'spam')).resolves.toEqual({ schonGemeldet: false });
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/melden');
expect(sentBody()).toEqual({ id: 'l1', reason: 'spam' });
expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined();
expect(mocks.createJWT).not.toHaveBeenCalled();
});
it('{ schonGemeldet: true } bei doppelter Meldung', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(logoApi.reportLogo('l1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true });
});
it('429 Rate-Limit: Text und Schlüssel des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.', code: 'tooManyReports' }));
expect(await rejection(logoApi.reportLogo('l1', 'spam'))).toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
serverCode: 'tooManyReports',
});
});
it('404 bei fehlendem oder ausgeblendetem Logo: logoGone', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(404, { error: 'Dieses Logo ist nicht (mehr) verfügbar.', code: 'logoGone' }));
expect(await rejection(logoApi.reportLogo('l1', 'spam'))).toMatchObject({ code: 404, serverCode: 'logoGone' });
});
});
describe('deleteLogo', () => {
it('POST /api/logos/loeschen { id } mit JWT; liefert nichts', async () => {
const { logoApi } = await fresh();
await expect(logoApi.deleteLogo('l1')).resolves.toBeUndefined();
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/loeschen');
expect(sentBody()).toEqual({ id: 'l1' });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('403 bei fremdem Logo: Text und Schlüssel des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(403, { error: 'Dazu hast du keine Berechtigung.', code: 'forbidden' }));
expect(await rejection(logoApi.deleteLogo('l1'))).toMatchObject({ code: 403, serverCode: 'forbidden' });
});
});
describe('setLogoVisibility', () => {
it('POST /api/admin/logo-sichtbarkeit { id, sichtbar } mit JWT; liefert nichts', async () => {
const { logoApi } = await fresh();
await expect(logoApi.setLogoVisibility('l1', false)).resolves.toBeUndefined();
await logoApi.setLogoVisibility('l1', true);
expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/logo-sichtbarkeit');
expect(sentBody(0)).toEqual({ id: 'l1', sichtbar: false });
expect(sentBody(1)).toEqual({ id: 'l1', sichtbar: true });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('403 für Nicht-Admins: Text und Schlüssel des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' }));
expect(await rejection(logoApi.setLogoVisibility('l1', true))).toMatchObject({
code: 403,
message: 'Nur für Admins.',
serverCode: 'adminOnly',
});
});
});
+61
View File
@@ -0,0 +1,61 @@
// Schreibzugriffe der Community-Logos: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT, über `apiPost` und
// mit denselben Fehlern (`ApiError`) wie community/api.ts. Lesen läuft direkt über Appwrite (logos.ts).
import { ApiError, apiPost, unavailableError, type ReportReason } from '../community/api';
export interface LogoUploadInput {
name: string;
displayName: string;
mono: boolean;
/** Die Datei im Logo-Bucket (frisch hochgeladen, mit `delete("user:<ich>")`); ihre ID wird auch die ID des Logos. */
fileId: string;
}
/**
* Legt aus der hochgeladenen Datei ein Logo an (der Dienst prüft Felder, Datei, Maße und das Limit von 30 je Konto).
* Das Limit (429 `tooManyLogos`) ist eine eindeutige Ablehnung: es wurde nichts angelegt, die Datei darf aufgeräumt werden
* (wie beim Limit der Karten in `publishCard`); jedes andere 429 und alle 5xx lassen den Ausgang offen. Eine Antwort ohne
* `id` ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein).
*/
export async function uploadLogo(input: LogoUploadInput): Promise<{ id: string }> {
const body: LogoUploadInput = { name: input.name, displayName: input.displayName, mono: input.mono, fileId: input.fileId };
let res: Record<string, unknown>;
try {
res = await apiPost('/api/logos/hochladen', body, { auth: true });
} catch (err) {
if (err instanceof ApiError && err.code === 429 && err.serverCode === 'tooManyLogos') {
throw new ApiError(429, err.message, true, err.serverCode);
}
throw err;
}
if (typeof res.id !== 'string' || res.id === '') throw unavailableError();
return { id: res.id };
}
/**
* Setzt oder nimmt mein Herz für ein Logo und liefert Zustand und Zähler, wie der Dienst sie nach dem Zählen der Zeilen kennt.
* Eine Antwort ohne beides ist ein Fehler mit offenem Ausgang.
*/
export async function setHeart(id: string, herz: boolean): Promise<{ herz: boolean; likeCount: number }> {
const res = await apiPost('/api/logos/herz', { id, herz }, { auth: true });
const { likeCount } = res;
if (typeof res.herz !== 'boolean' || typeof likeCount !== 'number' || !Number.isInteger(likeCount) || likeCount < 0) {
throw unavailableError();
}
return { herz: res.herz, likeCount };
}
/** Meldet ein Logo (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte es schon gemeldet. */
export async function reportLogo(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> {
const res = await apiPost('/api/logos/melden', { id, reason }, { auth: false });
return { schonGemeldet: res.schonGemeldet === true };
}
/** Löscht ein Logo samt Datei, Herzen und Meldungen (Hochlader oder Admin). */
export async function deleteLogo(id: string): Promise<void> {
await apiPost('/api/logos/loeschen', { id }, { auth: true });
}
/** Admin: blendet ein Logo ein oder aus. */
export async function setLogoVisibility(id: string, visible: boolean): Promise<void> {
await apiPost('/api/admin/logo-sichtbarkeit', { id, sichtbar: visible }, { auth: true });
}
+406
View File
@@ -0,0 +1,406 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CommunityLogo } from './logos';
const mocks = vi.hoisted(() => ({ getLogo: vi.fn(), loadLogoBlob: vi.fn(), createImageBitmap: vi.fn() }));
// Zeile und Datei kommen über getLogo/loadLogoBlob; Appwrite, Bilddekodierung und Canvas sind hier ersetzt.
vi.mock('./logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
/** Eine frische Kopie des Moduls, damit der Zwischenspeicher je Test leer beginnt. */
async function fresh() {
vi.resetModules();
return import('./logo-asset');
}
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'l1',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'l1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h }) as unknown as ImageBitmap;
const file = () => new Blob(['png'], { type: 'image/png' });
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
interface CanvasCall {
name: string;
args: unknown[];
/** Stand von `globalCompositeOperation` und `fillStyle` im Moment des Aufrufs. */
globalCompositeOperation: unknown;
fillStyle: unknown;
}
interface FakeCanvas {
width: number;
height: number;
calls: CanvasCall[];
getContext: ReturnType<typeof vi.fn>;
}
const canvases: FakeCanvas[] = [];
/** `document.createElement('canvas')` liefert aufzeichnende Fakes (happy-dom hat keinen 2D-Kontext). */
function stubCanvas(options: { noContext?: boolean } = {}): void {
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
if (tag !== 'canvas') throw new Error(`unerwartetes Element: ${tag}`);
const ctx: Record<string, unknown> = { globalCompositeOperation: 'source-over', fillStyle: '#000000' };
const canvas: FakeCanvas = { width: 0, height: 0, calls: [], getContext: vi.fn(() => (options.noContext ? null : ctx)) };
const record =
(name: string) =>
(...args: unknown[]) =>
void canvas.calls.push({ name, args, globalCompositeOperation: ctx.globalCompositeOperation, fillStyle: ctx.fillStyle });
ctx.drawImage = record('drawImage');
ctx.fillRect = record('fillRect');
canvases.push(canvas);
return canvas as unknown as HTMLElement;
}) as typeof document.createElement);
}
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
mocks.getLogo.mockResolvedValue(logo());
mocks.loadLogoBlob.mockResolvedValue(file());
mocks.createImageBitmap.mockResolvedValue(bitmap(200, 100));
vi.stubGlobal('createImageBitmap', mocks.createImageBitmap);
canvases.length = 0;
stubCanvas();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('LOGO_MISSING', () => {
it('der Hinweis aus der Spec, auf Englisch übersetzt', async () => {
const { LOGO_MISSING } = await fresh();
// Nach resetModules ist auch i18n frisch (die Sprache des Setups gilt für die alte Kopie): die Sprache hier selbst setzen.
const { t, setLang } = await import('../i18n/i18n');
setLang('de', { persist: false });
expect(LOGO_MISSING).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(t(LOGO_MISSING)).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
setLang('en', { persist: false });
expect(t(LOGO_MISSING)).toBe('The selected logo is no longer available.');
});
});
describe('loadLogoAsset: Laden', () => {
it('mehrfarbiges Logo: das dekodierte Bild in Originalgröße, ohne Canvas', async () => {
const { loadLogoAsset } = await fresh();
const blob = file();
const decoded = bitmap(200, 100);
mocks.loadLogoBlob.mockResolvedValue(blob);
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBe(decoded);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.getLogo).toHaveBeenCalledWith('l1');
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'l1', fileId: 'l1' }));
expect(mocks.createImageBitmap).toHaveBeenCalledWith(blob);
expect(canvases).toHaveLength(0);
});
it('fehlende Zeile (gelöscht oder ausgeblendet für mich): null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('ausgeblendetes Logo (Hochlader und Admins können die Zeile lesen): trotzdem null, die Datei wird nicht geladen', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('Datei fehlt (404): null', async () => {
const { loadLogoAsset } = await fresh();
mocks.loadLogoBlob.mockRejectedValue(fileGone());
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.loadLogoBlob.mockRejectedValue(Object.assign(new Error('weg'), { code: 404 }));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('ein echter Ladefehler (Netz, 5xx) ist kein fehlendes Logo: er wird weitergereicht', async () => {
const { loadLogoAsset } = await fresh();
const failure = Object.assign(new Error('Bad gateway'), { code: 502, type: 'general_server_error' });
mocks.loadLogoBlob.mockRejectedValue(failure);
await expect(loadLogoAsset('l1', 'light')).rejects.toBe(failure);
const offline = new TypeError('Failed to fetch');
mocks.getLogo.mockRejectedValue(offline);
await expect(loadLogoAsset('l2', 'light')).rejects.toBe(offline);
});
it('nicht lesbare Datei: null (das Logo ist nicht verfügbar)', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockRejectedValue(new DOMException('kaputt', 'InvalidStateError'));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(0, 0));
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
});
it('mehr als 1 000 000 Pixel (nur eine veränderte Datei): null, genau 1 000 000 und die größte erlaubte Größe: ein Bild', async () => {
const { loadLogoAsset } = await fresh();
mocks.createImageBitmap.mockResolvedValue(bitmap(1001, 1000));
await expect(loadLogoAsset('big', 'light')).resolves.toBeNull();
mocks.createImageBitmap.mockResolvedValue(bitmap(1000, 1000));
await expect(loadLogoAsset('limit', 'light')).resolves.toMatchObject({ w: 1000, h: 1000 });
mocks.createImageBitmap.mockResolvedValue(bitmap(720, 250));
await expect(loadLogoAsset('max', 'light')).resolves.toMatchObject({ w: 720, h: 250 });
});
it('zu großes einfarbiges Logo: null, es wird kein Canvas dafür angelegt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(bitmap(30000, 30000));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(canvases).toHaveLength(0);
});
});
describe('loadLogoAsset: einfarbige Logos', () => {
it.each([
['light', '#FFFFFF'],
['dark', '#111111'],
] as const)('Details %s: Canvas in Bildgröße, Bild zeichnen, dann source-in mit %s füllen', async (tone, color) => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
mocks.createImageBitmap.mockResolvedValue(decoded);
const asset = await loadLogoAsset('l1', tone);
expect(canvases).toHaveLength(1);
const canvas = canvases[0];
expect(asset?.source).toBe(canvas);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect([canvas.width, canvas.height]).toEqual([200, 100]);
expect(canvas.getContext).toHaveBeenCalledWith('2d');
expect(canvas.calls.map((c) => c.name)).toEqual(['drawImage', 'fillRect']);
// erst das Bild (die Form kommt aus dem Alphakanal) …
expect(canvas.calls[0].args[0]).toBe(decoded);
expect(canvas.calls[0].globalCompositeOperation).toBe('source-over');
// … dann die Farbe nur dort, wo das Bild deckt
expect(canvas.calls[1]).toMatchObject({ args: [0, 0, 200, 100], globalCompositeOperation: 'source-in', fillStyle: color });
});
it('mehrfarbige Logos bleiben unverändert (kein Canvas, beide Tonarten dasselbe Bild)', async () => {
const { loadLogoAsset } = await fresh();
const decoded = bitmap(200, 100);
mocks.createImageBitmap.mockResolvedValue(decoded);
expect((await loadLogoAsset('l1', 'light'))?.source).toBe(decoded);
expect((await loadLogoAsset('l1', 'dark'))?.source).toBe(decoded);
expect(canvases).toHaveLength(0);
});
it('ohne 2D-Kontext wirft es (das ist kein fehlendes Logo)', async () => {
const { loadLogoAsset } = await fresh();
stubCanvas({ noContext: true });
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow(/Canvas/);
});
});
describe('loadLogoAsset: Zwischenspeicher', () => {
it('gleiche (id, tone) lädt nur einmal, auch bei gleichzeitigen Aufrufen und später', async () => {
const { loadLogoAsset } = await fresh();
const a = loadLogoAsset('l1', 'light');
const b = loadLogoAsset('l1', 'light');
expect(b).toBe(a);
const first = await a;
expect(await loadLogoAsset('l1', 'light')).toBe(first);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(1);
expect(mocks.createImageBitmap).toHaveBeenCalledTimes(1);
});
it('eine andere Tonart ist ein eigenes Bild, die Zeile wird aber nur einmal geholt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const light = await loadLogoAsset('l1', 'light');
const dark = await loadLogoAsset('l1', 'dark');
expect(dark).not.toBe(light);
expect(canvases).toHaveLength(2);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlendes Logo (null) bleibt ebenfalls zwischengespeichert', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(null);
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('forgetLogo: danach wird Zeile und Bild neu geladen', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
await loadLogoAsset('l1', 'dark');
forgetLogo('l1');
mocks.getLogo.mockResolvedValue(null); // inzwischen gelöscht
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
expect(first).not.toBeNull();
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('forgetLogo vergisst nur dieses Logo', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
mocks.getLogo.mockImplementation(async (id: string) => logo({ id, fileId: id }));
const other = await loadLogoAsset('l2', 'light');
await loadLogoAsset('l1', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l2', 'light')).toBe(other);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
// l1 und l1.x sind verschiedene Logos: das Präfix darf nicht auch l1.x erwischen
mocks.getLogo.mockClear();
const dotted = await loadLogoAsset('l1.x', 'light');
forgetLogo('l1');
expect(await loadLogoAsset('l1.x', 'light')).toBe(dotted);
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
});
it('ein fehlgeschlagener Ladevorgang bleibt nicht im Cache (Zeile und Bild)', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow('Failed to fetch');
await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
mocks.loadLogoBlob.mockRejectedValueOnce(Object.assign(new Error('Bad gateway'), { code: 502 }));
await expect(loadLogoAsset('l2', 'light')).rejects.toThrow('Bad gateway');
await expect(loadLogoAsset('l2', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
});
it('ein Fehler nach forgetLogo wirft den neuen Eintrag nicht aus dem Cache', async () => {
const { loadLogoAsset, forgetLogo } = await fresh();
let fail!: (e: Error) => void;
mocks.getLogo.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
const stale = loadLogoAsset('l1', 'light');
stale.catch(() => undefined);
forgetLogo('l1');
const current = await loadLogoAsset('l1', 'light');
fail(new Error('spät'));
await expect(stale).rejects.toThrow('spät');
expect(await loadLogoAsset('l1', 'light')).toBe(current);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
});
it('rememberLogo legt die Zeile in den Cache: getLogo wird nicht gebraucht', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ id: 'neu', fileId: 'neu' }));
await expect(loadLogoAsset('neu', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
expect(mocks.getLogo).not.toHaveBeenCalled();
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'neu', fileId: 'neu' }));
});
it('rememberLogo mit einem ausgeblendeten Logo: null', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
});
it('rememberLogo mit einem jetzt ausgeblendeten Logo verwirft schon geladene Bilder (ein gesperrtes Logo wird nicht mehr gezeichnet)', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
await expect(loadLogoAsset('l1', 'light')).resolves.not.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.not.toBeNull();
rememberLogo(logo({ hidden: true }));
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
});
it('rememberLogo mit einem sichtbaren Logo lässt geladene Bilder stehen', async () => {
const { loadLogoAsset, rememberLogo } = await fresh();
const first = await loadLogoAsset('l1', 'light');
rememberLogo(logo({ likeCount: 7 }));
expect(await loadLogoAsset('l1', 'light')).toBe(first);
});
});
describe('loadLogoAsset: ohne createImageBitmap', () => {
class FakeImage {
static mode: 'load' | 'error' = 'load';
static size = { w: 200, h: 100 };
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
#src = '';
get src() {
return this.#src;
}
set src(value: string) {
this.#src = value;
this.naturalWidth = FakeImage.size.w;
this.naturalHeight = FakeImage.size.h;
queueMicrotask(() => (FakeImage.mode === 'load' ? this.onload?.() : this.onerror?.()));
}
}
const created: string[] = [];
const revoked: string[] = [];
const realCreate = URL.createObjectURL;
const realRevoke = URL.revokeObjectURL;
beforeEach(() => {
FakeImage.mode = 'load';
FakeImage.size = { w: 200, h: 100 };
created.length = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => {
created.push(`blob:fake/${created.length + 1}`);
return created[created.length - 1];
});
URL.revokeObjectURL = vi.fn((url: string) => void revoked.push(url));
vi.stubGlobal('createImageBitmap', undefined);
vi.stubGlobal('Image', FakeImage);
});
afterEach(() => {
URL.createObjectURL = realCreate;
URL.revokeObjectURL = realRevoke;
});
it('lädt das Logo über ein Image aus einer Object-URL und gibt die URL danach frei', async () => {
const { loadLogoAsset } = await fresh();
const asset = await loadLogoAsset('l1', 'light');
expect(asset?.source).toBeInstanceOf(FakeImage);
expect([asset?.w, asset?.h]).toEqual([200, 100]);
expect(mocks.createImageBitmap).not.toHaveBeenCalled();
expect(created).toEqual(['blob:fake/1']);
expect(revoked).toEqual(['blob:fake/1']);
});
it('Ladefehler des Bildes: null, die URL ist trotzdem freigegeben', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.mode = 'error';
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
expect(revoked).toEqual(['blob:fake/1']);
});
it('auch hier gilt die Pixelgrenze', async () => {
const { loadLogoAsset } = await fresh();
FakeImage.size = { w: 2000, h: 1000 };
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
});
it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => {
const { loadLogoAsset } = await fresh();
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
const asset = await loadLogoAsset('l1', 'dark');
expect(asset?.source).toBe(canvases[0]);
expect(canvases[0].calls[0].args[0]).toBeInstanceOf(FakeImage);
expect(canvases[0].calls[1]).toMatchObject({ globalCompositeOperation: 'source-in', fillStyle: '#111111' });
});
});
+143
View File
@@ -0,0 +1,143 @@
// Community-Logos als Bild für den Kartenrenderer: Zeile und PNG laden, dekodieren, einfarbige Logos in der Details-Farbe füllen.
// Zeilen und fertige Bilder bleiben im Speicher (je Logo und Details-Farbe nur ein Ladevorgang). Ein Logo, das es nicht (mehr)
// gibt, ergibt null; ein echter Ladefehler (Netz, Server) wirft und bleibt nicht im Zwischenspeicher.
import { TONE_COLOR, type DetailsTone } from '../card/config';
// Nur der Typ: render.ts importiert dieses Modul (Standard-Lader), zur Laufzeit darf kein Kreis entstehen.
import type { LogoAsset } from '../card/render';
import { msg } from '../i18n/i18n';
import { getLogo, loadLogoBlob, type CommunityLogo } from './logos';
/** Hinweis im Editor, wenn die Karte ein Logo wählt, das gelöscht oder ausgeblendet wurde. */
export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
/**
* Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles
* Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet
* (und auch nicht als Kachel im Logo-Raster, siehe logo-picker.ts).
*/
export const MAX_LOGO_PIXELS = 1_000_000;
type Decoded = LogoAsset;
// Zeilen (auch „gibt es nicht“ = null) und fertige Bilder; die Schlüssel der Bilder sind „<id>:<tone>“ (IDs enthalten kein „:“).
const rows = new Map<string, Promise<CommunityLogo | null>>();
const assets = new Map<string, Promise<LogoAsset | null>>();
/** Verwirft die fertigen Bilder eines Logos (beide Details-Farben). */
function dropImages(id: string): void {
for (const key of assets.keys()) if (key.startsWith(`${id}:`)) assets.delete(key);
}
/**
* Legt eine Zeile, die der Aufrufer schon hat (Logo-Liste, frisch hochgeladenes Logo), in den Zwischenspeicher: spart `getLogo`.
* Ist sie ausgeblendet, fallen schon geladene Bilder des Logos weg: ein gesperrtes Logo darf nicht mehr gezeichnet werden.
*/
export function rememberLogo(logo: CommunityLogo): void {
rows.set(logo.id, Promise.resolve(logo));
if (logo.hidden) dropImages(logo.id);
}
/** Vergisst Zeile und Bilder eines Logos (nach Löschen oder Ausblenden); der nächste Zugriff lädt neu. */
export function forgetLogo(id: string): void {
rows.delete(id);
dropImages(id);
}
/** Die Zeile eines Logos, einmal je ID; ein Fehler wird nicht festgehalten. */
function rowFor(id: string): Promise<CommunityLogo | null> {
const cached = rows.get(id);
if (cached) return cached;
const request = getLogo(id);
// Nur entfernen, was noch derselbe Eintrag ist: forgetLogo/rememberLogo können ihn inzwischen ersetzt haben.
request.catch(() => {
if (rows.get(id) === request) rows.delete(id);
});
rows.set(id, request);
return request;
}
/** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */
function isFileMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 || type === 'storage_file_not_found';
}
/** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */
function decodeWithImage(blob: Blob): Promise<Decoded> {
const url = URL.createObjectURL(blob);
return new Promise<Decoded>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ source: img, w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
img.src = url;
}).finally(() => URL.revokeObjectURL(url));
}
/** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */
async function decode(blob: Blob): Promise<Decoded | null> {
try {
let decoded: Decoded;
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(blob);
decoded = { source: bitmap, w: bitmap.width, h: bitmap.height };
} else {
decoded = await decodeWithImage(blob);
}
return decoded.w > 0 && decoded.h > 0 ? decoded : null;
} catch {
return null;
}
}
/** Ein Canvas in Bildgröße, in dem nur noch die Form des Bildes (sein Alphakanal) steht, gefüllt mit `color`. */
function fillShape({ source, w, h }: Decoded, color: string): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
ctx.drawImage(source, 0, 0);
// „source-in“: die Farbe bleibt nur dort, wo das Bild schon deckt.
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = color;
ctx.fillRect(0, 0, w, h);
return canvas;
}
async function build(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
const logo = await rowFor(id);
// Ausgeblendete Logos können Hochlader und Admins noch lesen. Ein gesperrtes Logo darf aber nie in einer Karte stehen,
// die sich veröffentlichen lässt: es gilt als nicht verfügbar.
if (!logo || logo.hidden) return null;
let blob: Blob;
try {
blob = await loadLogoBlob(logo);
} catch (err) {
if (isFileMissing(err)) return null;
throw err;
}
const decoded = await decode(blob);
if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null;
if (!logo.mono) return decoded;
return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h };
}
/**
* Das Community-Logo `id` als Bild für den Renderer. Einfarbige Logos (`mono`) haben die Farbe der Details (Hell/Dunkel),
* mehrfarbige bleiben unverändert. null: das Logo gibt es nicht (mehr), ist ausgeblendet, oder seine Datei ist unbrauchbar.
* Wirft bei einem echten Ladefehler; der bleibt nicht im Zwischenspeicher, ein späterer Aufruf versucht es neu.
*/
export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
const key = `${id}:${tone}`;
const cached = assets.get(key);
if (cached) return cached;
const request = build(id, tone);
request.catch(() => {
if (assets.get(key) === request) assets.delete(key);
});
assets.set(key, request);
return request;
}
+913
View File
@@ -0,0 +1,913 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
prepareLogo: vi.fn(),
uploadPreparedLogo: vi.fn(),
withLogin: vi.fn(),
}));
// api.ts und with-login.ts hängen am Appwrite-Client; `prepare` und `upload` kommen meist von außen, nur die Standardwerte
// (prepareLogo, uploadPreparedLogo über withLogin) laufen über die Fakes hier.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
vi.mock('../account/with-login', async (importOriginal) => ({
...(await importOriginal<typeof import('../account/with-login')>()),
withLogin: mocks.withLogin,
}));
vi.mock('./prepare-logo', async (importOriginal) => ({
...(await importOriginal<typeof import('./prepare-logo')>()),
prepareLogo: mocks.prepareLogo,
}));
vi.mock('./upload-logo', () => ({ uploadPreparedLogo: mocks.uploadPreparedLogo }));
import { DISPLAY_NAME_KEY } from '../account/display-name';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { ApiError } from '../community/api';
import { setLang, t } from '../i18n/i18n';
import { LOGO_CONSENT_TEXT, logoUploadDialog, validateLogoForm, type LogoDialogOptions } from './logo-dialog';
import type { CommunityLogo } from './logos';
import { LOGO_ACCEPT, LOGO_EMPTY_MESSAGE, type PreparedLogo } from './prepare-logo';
import type { LogoForm } from './upload-logo';
type Prepare = (file: File) => Promise<PreparedLogo>;
type Upload = (prepared: PreparedLogo, form: LogoForm) => Promise<CommunityLogo>;
const prepare = vi.fn<Prepare>();
const upload = vi.fn<Upload>();
/** Die Blobs, für die der Dialog Object-URLs angelegt hat (blob:logo-1, blob:logo-2, …), und die wieder freigegebenen URLs. */
const created: Blob[] = [];
const revoked: string[] = [];
const prepared = (patch: Partial<PreparedLogo> = {}): PreparedLogo => ({
png: new Blob(['png-bytes'], { type: 'image/png' }),
width: 200,
height: 80,
hasTransparency: true,
...patch,
});
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'L1',
name: 'Beispielbank',
displayName: '',
fileId: 'L1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const imageFile = (name = 'Beispielbank.png', type = 'image/png') => new File(['x'.repeat(100)], name, { type });
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.logo-dialog');
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const previews = () => [...dialogEl()!.querySelectorAll<HTMLElement>('.logo-preview')];
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
/** Wählt eine Datei im Datei-Feld (oder hebt die Auswahl auf), wie es der Browser mit `change` meldet. */
function choose(file: File | null): void {
Object.defineProperty(input('file'), 'files', { value: file ? [file] : [], configurable: true });
input('file').dispatchEvent(new Event('change', { bubbles: true }));
}
/** Tippt in ein Textfeld, so dass auch das `input`-Ereignis kommt (das Setzen von `value` allein löst es nicht aus). */
function typeInto(name: string, value: string): void {
input(name).value = value;
input(name).dispatchEvent(new Event('input', { bubbles: true }));
}
function fill(values: { name?: string; displayName?: string; consent?: boolean } = {}): void {
input('name').value = values.name ?? 'Beispielbank';
input('displayName').value = values.displayName ?? '';
input('consent').checked = values.consent ?? true;
}
function open(opts: LogoDialogOptions = {}): Promise<CommunityLogo | null> {
return logoUploadDialog({ prepare, upload, ...opts });
}
/** Öffnet den Dialog und wählt eine Datei; wartet, bis die Verarbeitung durch ist. Das Ergebnis steckt in einem Objekt (kein Warten auf das Schließen). */
async function setup(opts: LogoDialogOptions = {}, file: File = imageFile()): Promise<{ outcome: Promise<CommunityLogo | null> }> {
const outcome = open(opts);
choose(file);
await settle();
return { outcome };
}
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '';
for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin]) fn.mockReset();
mocks.withLogin.mockImplementation((fn: () => Promise<unknown>) => fn());
prepare.mockResolvedValue(prepared());
created.length = 0;
revoked.length = 0;
let counter = 0;
URL.createObjectURL = vi.fn((blob: Blob) => {
created.push(blob);
return `blob:logo-${++counter}`;
});
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
});
afterEach(() => {
document.body.innerHTML = '';
localStorage.clear();
});
describe('validateLogoForm', () => {
const ok = { hasFile: true, name: 'Beispielbank', displayName: '', consent: true };
it('gültige Werte: kein Problem', () => {
expect(validateLogoForm(ok)).toBeNull();
expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toBeNull();
});
it('ohne Datei', () => {
expect(validateLogoForm({ ...ok, hasFile: false })).toEqual({ field: 'file', message: 'Bitte wähle eine Datei.' });
});
it('Name leer, auch nur aus Leerzeichen', () => {
const problem = { field: 'name', message: 'Bitte gib einen Namen an.' };
expect(validateLogoForm({ ...ok, name: '' })).toEqual(problem);
expect(validateLogoForm({ ...ok, name: ' ' })).toEqual(problem);
});
it('Name länger als 40 Zeichen; 40 Zeichen gehen durch, Leerzeichen außen zählen nicht', () => {
expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })).toEqual({
field: 'name',
message: 'Der Name ist zu lang (höchstens 40 Zeichen).',
});
expect(validateLogoForm({ ...ok, name: 'x'.repeat(40) })).toBeNull();
expect(validateLogoForm({ ...ok, name: ` ${'x'.repeat(40)} ` })).toBeNull();
});
// Dasselbe Muster wie der Dienst (deploy/api/common.js): Steuer-, Nullbreiten- und Bidi-Zeichen und das BOM.
it.each([
['Tabulator', 'a\tb'],
['Nullbreiten-Leerzeichen', 'a​b'],
['Rechts-nach-links-Überschreibung', 'logo‮gnp.exe'],
['C1-Steuerzeichen', 'a\u0085b'],
['BOM mitten im Namen', 'ab'],
])('Steuerzeichen im Namen werden abgelehnt: %s', (_label, name) => {
expect(validateLogoForm({ ...ok, name })).toEqual({ field: 'name', message: 'Der Name enthält ungültige Zeichen.' });
});
it('Anzeigename: höchstens 30 Zeichen, keine Steuerzeichen, leer ist erlaubt', () => {
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })).toEqual({
field: 'displayName',
message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).',
});
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(30) })).toBeNull();
expect(validateLogoForm({ ...ok, displayName: ' ' })).toBeNull();
expect(validateLogoForm({ ...ok, displayName: 'Ken⁠so' })).toEqual({
field: 'displayName',
message: 'Der Anzeigename enthält ungültige Zeichen.',
});
});
it('ohne Häkchen', () => {
expect(validateLogoForm({ ...ok, consent: false })).toEqual({
field: 'consent',
message: 'Bitte bestätige, dass du das Logo teilen darfst.',
});
});
it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Anzeigename, Häkchen', () => {
const bad = { hasFile: false, name: '', displayName: 'x'.repeat(31), consent: false };
expect(validateLogoForm(bad)?.field).toBe('file');
expect(validateLogoForm({ ...bad, hasFile: true })?.field).toBe('name');
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('displayName');
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank', displayName: '' })?.field).toBe('consent');
});
it('Englisch: die Meldungen kommen in der Sprache der Seite', () => {
setLang('en');
expect(validateLogoForm({ ...ok, hasFile: false })?.message).toBe('Please choose a file.');
expect(validateLogoForm({ ...ok, name: '' })?.message).toBe('Please enter a name.');
expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })?.message).toBe('The name is too long (at most 40 characters).');
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })?.message).toBe(
'The display name is too long (at most 30 characters).',
);
expect(validateLogoForm({ ...ok, consent: false })?.message).toBe('Please confirm that you’re allowed to share the logo.');
});
});
describe('Logo-Dialog: Aufbau', () => {
it('ist ein eigenes <dialog> mit Überschrift, den Feldern in Formularreihenfolge und beiden Knöpfen', () => {
void open();
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo hochladen');
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'displayName', 'consent']);
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
'Datei (Pflicht)',
'Einfarbig – passt sich Hell/Dunkel an',
'Name (Pflicht)',
'Anzeigename (optional)',
LOGO_CONSENT_TEXT,
]);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Hochladen')).toBeTruthy();
});
it('das Datei-Feld nimmt PNG, JPG, WebP und SVG und nennt die Grenzen der Spec', () => {
void open();
expect(input('file').type).toBe('file');
expect(input('file').getAttribute('accept')).toBe(LOGO_ACCEPT);
expect(dialogEl()!.textContent).toContain('PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.');
});
it('beim Öffnen: Name leer, Häkchen ungesetzt, „Einfarbig“ gesperrt, keine Vorschau, Fokus im Datei-Feld', () => {
void open();
expect(input('name').value).toBe('');
expect(input('displayName').value).toBe('');
expect(input('displayName').placeholder).toBe('Anonym');
expect(input('consent').checked).toBe(false);
expect(input('mono').checked).toBe(false);
expect(input('mono').disabled).toBe(true);
expect(previews()).toHaveLength(0);
expect(document.activeElement).toBe(input('file'));
expect(dialogEl()!.textContent).not.toMatch(/@/);
});
it('der Anzeigename ist mit dem zuletzt benutzten Namen vorbelegt', () => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
void open();
expect(input('displayName').value).toBe('Kenso');
});
it('das Pflicht-Häkchen hat genau den Wortlaut der Spec', () => {
void open();
expect(LOGO_CONSENT_TEXT).toBe(
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.',
);
expect(input('consent').closest('label')!.textContent).toBe(LOGO_CONSENT_TEXT);
});
it('jedes Feld hat sein Label, alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => {
void open();
const labels = [...form().querySelectorAll('label')];
const inputs = [...form().querySelectorAll('input')];
expect(labels.map((l) => l.control)).toEqual(inputs);
for (const control of inputs) expect(control.labels).toHaveLength(1);
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
const heading = dialogEl()!.querySelector('h2')!;
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id);
expect(inputs.map((i) => i.id)).not.toContain(heading.id);
});
it('Englisch: Überschrift, Beschriftungen, Platzhalter und Knöpfe', () => {
setLang('en');
void open();
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo');
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
'File (required)',
'Single color – adapts to light/dark mode',
'Name (required)',
'Display name (optional)',
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
]);
expect(input('displayName').placeholder).toBe('Anonymous');
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Upload')).toBeTruthy();
});
});
describe('Logo-Dialog: Datei wählen', () => {
it('ruft prepare auf, zeigt „Logo wird verarbeitet …“ und danach zwei Vorschauen (dunkel, hell) mit dem fertigen PNG', async () => {
const p = prepared();
prepare.mockResolvedValue(p);
const file = imageFile();
void open();
choose(file);
expect(prepare).toHaveBeenCalledExactlyOnceWith(file);
expect(statusText()).toBe('Logo wird verarbeitet …');
expect(previews()).toHaveLength(0);
await settle();
expect(statusText()).toBe('');
expect(errorText()).toBe('');
const [dark, light] = previews();
expect(previews()).toHaveLength(2);
expect(dark.classList.contains('logo-preview-dark')).toBe(true);
expect(light.classList.contains('logo-preview-light')).toBe(true);
expect(dark.querySelector('figcaption')!.textContent).toBe('Dunkel');
expect(light.querySelector('figcaption')!.textContent).toBe('Hell');
// Beide zeigen dieselbe Object-URL des fertigen PNGs; sie wird nur einmal angelegt.
expect(created).toHaveLength(1);
expect(created[0]).toBe(p.png);
for (const preview of previews()) {
const img = preview.querySelector('img')!;
expect(img.getAttribute('src')).toBe('blob:logo-1');
expect(img.getAttribute('alt')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/);
}
});
it.each([
['einfacher Dateiname', 'Beispielbank.png', 'Beispielbank'],
['nur die letzte Endung fällt weg', 'Meine Bank.final.svg', 'Meine Bank.final'],
['ohne Endung', 'logo', 'logo'],
['Leerzeichen außen fallen weg', ' Mit Leerzeichen .webp', 'Mit Leerzeichen'],
['über 40 Zeichen: gekürzt', `${'x'.repeat(60)}.png`, 'x'.repeat(40)],
['nach dem Kürzen bleibt kein Leerzeichen am Ende', `${'a'.repeat(39)} bbb.png`, 'a'.repeat(39)],
// 😀 belegt zwei Zeichen (UTF-16): ein Schnitt mitten darin würde ein halbes Zeichen hinterlassen.
['der Schnitt trennt kein Emoji', `${'a'.repeat(39)}😀b.png`, 'a'.repeat(39)],
])('der Name wird aus dem Dateinamen ohne Endung vorbelegt: %s', async (_label, fileName, expected) => {
await setup({}, imageFile(fileName));
expect(input('name').value).toBe(expected);
expect(validateLogoForm({ hasFile: true, name: input('name').value, displayName: '', consent: true })).toBeNull();
});
it('eine zweite Datei ersetzt den vorbelegten Namen, aber keinen selbst getippten', async () => {
await setup({}, imageFile('Erste.png'));
expect(input('name').value).toBe('Erste');
choose(imageFile('Zweite.png'));
await settle();
expect(input('name').value).toBe('Zweite');
typeInto('name', 'Mein Name');
choose(imageFile('Dritte.png'));
await settle();
expect(input('name').value).toBe('Mein Name');
// Wer das Feld leert, bekommt beim nächsten Logo wieder einen Vorschlag.
typeInto('name', '');
choose(imageFile('Vierte.png'));
await settle();
expect(input('name').value).toBe('Vierte');
});
it('„Einfarbig“ ist gesperrt, wenn das Logo keine transparenten Bereiche hat', async () => {
prepare.mockResolvedValue(prepared({ hasTransparency: false }));
await setup();
expect(input('mono').disabled).toBe(true);
expect(input('mono').checked).toBe(false);
});
it('„Einfarbig“ ist wählbar, wenn das Logo transparente Bereiche hat', async () => {
await setup();
expect(input('mono').disabled).toBe(false);
expect(input('mono').checked).toBe(false);
});
it('„Einfarbig“ zeigt beide Vorschauen als Maske auf dieselbe URL (Weiß auf dunklem, #111111 auf hellem Grund); abwählen bringt das Bild zurück', async () => {
await setup();
input('mono').click();
expect(input('mono').checked).toBe(true);
expect(previews()).toHaveLength(2);
const [dark, light] = previews();
for (const preview of previews()) {
expect(preview.querySelector('img')).toBeNull();
const mask = preview.querySelector<HTMLElement>('.logo-preview-mask')!;
expect(mask.getAttribute('style')).toContain('url("blob:logo-1")');
expect(mask.getAttribute('role')).toBe('img');
expect(mask.getAttribute('aria-label')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/);
}
// Gefüllt wie auf der Karte (TONE_COLOR): Weiß auf dunklem, #111111 auf hellem Grund.
expect(dark.querySelector<HTMLElement>('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#FFFFFF');
expect(light.querySelector<HTMLElement>('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#111111');
expect(created).toHaveLength(1); // keine neue URL für die Maske
input('mono').click();
expect(input('mono').checked).toBe(false);
for (const preview of previews()) {
expect(preview.querySelector('.logo-preview-mask')).toBeNull();
expect(preview.querySelector('img')!.getAttribute('src')).toBe('blob:logo-1');
}
});
it('eine neue Datei gibt die Object-URL der alten frei und setzt „Einfarbig“ zurück', async () => {
await setup({}, imageFile('Erste.png'));
input('mono').click();
expect(input('mono').checked).toBe(true);
prepare.mockResolvedValue(prepared());
choose(imageFile('Zweite.png'));
expect(revoked).toEqual(['blob:logo-1']); // sofort beim Wählen, die Vorschau gehört nicht mehr zur Auswahl
expect(previews()).toHaveLength(0);
await settle();
expect(input('mono').checked).toBe(false);
expect(previews()).toHaveLength(2);
expect(previews()[0].querySelector('img')!.getAttribute('src')).toBe('blob:logo-2');
expect(revoked).toEqual(['blob:logo-1']);
});
it('die Auswahl aufzuheben räumt Vorschau und Object-URL weg', async () => {
await setup();
choose(null);
await settle();
expect(previews()).toHaveLength(0);
expect(revoked).toEqual(['blob:logo-1']);
expect(input('mono').disabled).toBe(true);
expect(buttonByText('Hochladen')!.disabled).toBe(false);
});
it('falscher Dateityp oder zu groß: Meldung der Spec, prepare wird nicht gerufen, keine Vorschau', async () => {
void open();
choose(imageFile('notizen.txt', 'text/plain'));
await settle();
expect(prepare).not.toHaveBeenCalled();
expect(errorText()).toBe('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.');
expect(previews()).toHaveLength(0);
const big = imageFile('gross.png');
Object.defineProperty(big, 'size', { value: 5_242_881 });
choose(big);
await settle();
expect(prepare).not.toHaveBeenCalled();
expect(errorText()).toBe('Die Datei ist größer als 5 MB.');
});
it('schlägt die Verarbeitung fehl, erscheint ihr Text (z. B. „Das Logo ist leer.“), ohne Vorschau und ohne Name', async () => {
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
await setup();
expect(errorText()).toBe('Das Logo ist leer.');
expect(statusText()).toBe('');
expect(previews()).toHaveLength(0);
expect(input('name').value).toBe('');
expect(created).toHaveLength(0);
expect(buttonByText('Hochladen')!.disabled).toBe(false);
});
it('der Text der Verarbeitung erscheint in der Sprache der Seite', async () => {
setLang('en');
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
await setup();
expect(errorText()).toBe('The logo is empty.');
});
it('ein unerwarteter Fehler der Verarbeitung zeigt nicht seinen Text, sondern die allgemeine Meldung', async () => {
prepare.mockRejectedValue(new Error('boom: interne Details'));
await setup();
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
it('nach einer gescheiterten Verarbeitung gilt keine Datei als gewählt', async () => {
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
await setup();
fill();
send();
expect(errorText()).toBe('Bitte wähle eine Datei.');
expect(upload).not.toHaveBeenCalled();
});
it('eine neue Wahl macht die laufende Verarbeitung der alten Datei hinfällig', async () => {
let finishFirst!: (p: PreparedLogo) => void;
const first = prepared({ png: new Blob(['erste'], { type: 'image/png' }) });
const second = prepared({ png: new Blob(['zweite'], { type: 'image/png' }), hasTransparency: false });
prepare.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
prepare.mockResolvedValueOnce(second);
void open();
choose(imageFile('Erste.png'));
choose(imageFile('Zweite.png'));
await settle();
expect(created).toHaveLength(1);
expect(created[0]).toBe(second.png);
expect(input('name').value).toBe('Zweite');
expect(input('mono').disabled).toBe(true);
finishFirst(first); // kommt zu spät und bleibt folgenlos
await settle();
expect(created).toHaveLength(1);
expect(input('name').value).toBe('Zweite');
expect(input('mono').disabled).toBe(true);
expect(previews()).toHaveLength(2);
expect(statusText()).toBe('');
});
it('während der Verarbeitung ist „Hochladen“ gesperrt und ein Absenden bleibt wirkungslos', async () => {
let finish!: (p: PreparedLogo) => void;
prepare.mockReturnValue(new Promise((resolve) => (finish = resolve)));
void open();
choose(imageFile());
fill();
expect(buttonByText('Hochladen')!.disabled).toBe(true);
send();
expect(errorText()).toBe('');
expect(upload).not.toHaveBeenCalled();
finish(prepared());
await settle();
expect(buttonByText('Hochladen')!.disabled).toBe(false);
});
it('eine Verarbeitung, die erst nach dem Schließen fertig wird, legt keine Object-URL mehr an', async () => {
let finish!: (p: PreparedLogo) => void;
prepare.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const outcome = open();
choose(imageFile());
buttonByText('Abbrechen')!.click();
await outcome;
finish(prepared());
await settle();
expect(created).toHaveLength(0);
});
it('der Dateiname erscheint als Text, nie als HTML', async () => {
await setup({}, imageFile('<img src=x onerror=alert(1)>.png'));
expect(input('name').value).toBe('<img src=x onerror=alert(1)>');
for (const img of dialogEl()!.querySelectorAll('img')) expect(img.getAttribute('src')).toBe('blob:logo-1');
expect(dialogEl()!.querySelectorAll('img')).toHaveLength(2);
});
});
describe('Logo-Dialog: Prüfung vor dem Senden', () => {
const invalid = async (values: Parameters<typeof fill>[0], message: string, field: string) => {
await setup();
fill(values);
send();
await settle();
expect(errorText()).toBe(message);
expect(upload).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(input(field));
};
it('keine Datei gewählt', () => {
void open();
fill();
send();
expect(errorText()).toBe('Bitte wähle eine Datei.');
expect(upload).not.toHaveBeenCalled();
expect(input('file').getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(input('file'));
});
it('Name leer', async () => {
await invalid({ name: ' ' }, 'Bitte gib einen Namen an.', 'name');
});
it('Name länger als 40 Zeichen', async () => {
await invalid({ name: 'x'.repeat(41) }, 'Der Name ist zu lang (höchstens 40 Zeichen).', 'name');
});
it('Steuerzeichen im Namen', async () => {
await invalid({ name: 'a\tb' }, 'Der Name enthält ungültige Zeichen.', 'name');
});
it('Anzeigename länger als 30 Zeichen', async () => {
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
});
it('Pflicht-Häkchen fehlt', async () => {
await invalid({ consent: false }, 'Bitte bestätige, dass du das Logo teilen darfst.', 'consent');
});
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
upload.mockResolvedValue(logo());
await setup();
fill({ name: '' });
send();
expect(input('name').getAttribute('aria-invalid')).toBe('true');
fill({ name: 'Ok' });
send();
expect(input('name').getAttribute('aria-invalid')).toBeNull();
expect(errorText()).toBe('');
});
it('Grenzen sind inklusive: 40 Zeichen Name und 30 Zeichen Anzeigename gehen durch', async () => {
upload.mockResolvedValue(logo());
const { outcome } = await setup();
fill({ name: 'x'.repeat(40), displayName: 'y'.repeat(30) });
send();
await outcome;
expect(upload).toHaveBeenCalledOnce();
});
it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => {
upload.mockResolvedValue(logo());
const { outcome } = await setup();
fill({ name: 'Größe 😀 – Möwe-Bank', displayName: 'Zoë' });
send();
await outcome;
expect(upload).toHaveBeenCalledOnce();
});
});
describe('Logo-Dialog: Senden', () => {
it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => {
const p = prepared();
prepare.mockResolvedValue(p);
const result = logo({ id: 'L9' });
upload.mockResolvedValue(result);
const { outcome } = await setup();
fill({ name: ' Meine Bank ', displayName: ' Kenso ' });
send();
await expect(outcome).resolves.toBe(result);
expect(upload).toHaveBeenCalledOnce();
expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', displayName: 'Kenso', mono: false });
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
expect(dialogEl()).toBeNull();
});
it('„Einfarbig“ geht mit: gewählt → mono true', async () => {
upload.mockResolvedValue(logo());
const { outcome } = await setup();
input('mono').click();
fill();
send();
await outcome;
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: true });
});
it('ohne transparente Bereiche geht mono nie als true mit', async () => {
prepare.mockResolvedValue(prepared({ hasTransparency: false }));
upload.mockResolvedValue(logo());
const { outcome } = await setup();
input('mono').click(); // gesperrt: ändert nichts
expect(input('mono').checked).toBe(false);
input('mono').checked = true; // selbst ein von außen gesetztes Häkchen zählt nicht
fill();
send();
await outcome;
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: false });
});
it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Alt');
upload.mockResolvedValue(logo());
const { outcome } = await setup();
expect(input('displayName').value).toBe('Alt');
fill({ displayName: '' });
send();
await outcome;
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
});
it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => {
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const { outcome } = await setup();
fill();
const dialogForm = form();
send();
send();
expect(upload).toHaveBeenCalledOnce();
finish(logo());
await outcome;
// Ein verspätetes Absenden am schon geschlossenen Dialog bleibt wirkungslos.
dialogForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
expect(upload).toHaveBeenCalledOnce();
});
it('während des Sendens: Knöpfe und Felder gesperrt, Statustext, danach alles wieder frei', async () => {
let fail!: (e: Error) => void;
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
await setup();
fill();
send();
expect(buttonByText('Hochladen')!.disabled).toBe(true);
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(true);
expect(form().getAttribute('aria-busy')).toBe('true');
expect(statusText()).toBe('Wird hochgeladen …');
fail(new Error('weg'));
await settle();
expect(buttonByText('Hochladen')!.disabled).toBe(false);
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(false);
expect(form().getAttribute('aria-busy')).toBe('false');
expect(statusText()).toBe('');
});
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const { outcome } = await setup();
fill();
send();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
finish(logo());
await outcome;
});
it('schließt der Browser den Dialog mitten im Senden, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
let resolved: CommunityLogo | null | undefined;
const result = logo();
const { outcome } = await setup();
void outcome.then((value) => (resolved = value));
fill();
send();
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
await settle();
expect(dialogEl()?.open).toBe(true);
dialogEl()!.dispatchEvent(new Event('close'));
await settle();
expect(resolved).toBeUndefined();
expect(dialogEl()?.open).toBe(true);
finish(result);
await outcome;
expect(resolved).toBe(result);
expect(dialogEl()).toBeNull();
});
it('scheitert das Senden, nachdem der Browser den Dialog geschlossen hat, ist der Fehler sichtbar (Dialog wieder offen)', async () => {
let fail!: (e: Error) => void;
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
await setup();
fill();
send();
dialogEl()!.close();
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await settle();
expect(dialogEl()?.open).toBe(true);
expect(errorText()).toBe('Hochladen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
});
it('Fehler des Dienstes: „Hochladen fehlgeschlagen – <Text>“, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
upload.mockRejectedValueOnce(
new ApiError(
429,
'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
true,
'tooManyLogos',
),
);
const { outcome } = await setup();
fill({ displayName: 'Kenso' });
send();
await settle();
expect(errorText()).toBe(
'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
);
expect(dialogEl()?.open).toBe(true);
// Die Eingaben bleiben, und der Anzeigename wird erst bei Erfolg gemerkt.
expect(input('name').value).toBe('Beispielbank');
expect(input('displayName').value).toBe('Kenso');
expect(input('consent').checked).toBe(true);
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
upload.mockResolvedValueOnce(logo());
send();
await expect(outcome).resolves.toMatchObject({ id: 'L1' });
expect(upload).toHaveBeenCalledTimes(2);
expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet
expect(prepare).toHaveBeenCalledOnce();
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
});
it('Fehlertext des Dienstes in der Sprache der Seite (nach Fehlerschlüssel)', async () => {
setLang('en');
upload.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await setup();
fill();
send();
await settle();
expect(errorText()).toBe('Upload failed – You are not allowed to do that.');
});
it('Servertexte erscheinen als Text, nie als HTML', async () => {
upload.mockRejectedValue(new ApiError(400, '<img src=x onerror=alert(1)>'));
await setup();
fill();
send();
await settle();
expect(errorText()).toBe('Hochladen fehlgeschlagen – <img src=x onerror=alert(1)>');
expect(dialogEl()!.querySelector('[role="alert"] img')).toBeNull();
});
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“ statt „Hochladen fehlgeschlagen“', async () => {
upload.mockRejectedValue(new LoginCancelled());
await setup();
fill();
send();
await settle();
expect(errorText()).toBe(LOGIN_NEEDED);
});
it('andere Fehler werden übersetzt, interne Details erscheinen nie', async () => {
upload.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await setup();
fill();
send();
await settle();
expect(errorText()).toBe('Hochladen fehlgeschlagen – Keine Verbindung zum Server.');
upload.mockRejectedValueOnce(new Error('boom: interne Details'));
send();
await settle();
expect(errorText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
});
describe('Logo-Dialog: Standardwerte', () => {
it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => {
const p = prepared();
mocks.prepareLogo.mockResolvedValue(p);
const result = logo();
mocks.uploadPreparedLogo.mockResolvedValue(result);
const file = imageFile();
const outcome = logoUploadDialog();
choose(file);
await settle();
expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file);
fill({ displayName: 'Kenso' });
send();
await expect(outcome).resolves.toBe(result);
expect(mocks.withLogin).toHaveBeenCalledOnce();
expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', displayName: 'Kenso', mono: false });
});
});
describe('Logo-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden und gibt die Object-URL frei', async () => {
const { outcome } = await setup();
fill();
buttonByText('Abbrechen')!.click();
await expect(outcome).resolves.toBeNull();
expect(upload).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
expect(revoked).toEqual(['blob:logo-1']);
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen; ohne Senden ist Esc nicht verhindert', async () => {
const first = open();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(false);
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBeNull();
expect(dialogEl()).toBeNull();
const second = open();
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBeNull();
});
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
void open();
input('name').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Abbrechen-Signal schließt den Dialog, auch während des Sendens; eine spätere Antwort bleibt folgenlos', async () => {
const controller = new AbortController();
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const { outcome } = await setup({ signal: controller.signal });
fill({ displayName: 'Kenso' });
send();
controller.abort();
await expect(outcome).resolves.toBeNull();
expect(dialogEl()).toBeNull();
expect(revoked).toEqual(['blob:logo-1']);
finish(logo());
await settle();
expect(dialogEl()).toBeNull();
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
});
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
const controller = new AbortController();
let fail!: (e: Error) => void;
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
const { outcome } = await setup({ signal: controller.signal });
fill();
send();
controller.abort();
await outcome;
fail(new ApiError(503, 'weg'));
await settle();
expect(dialogEl()).toBeNull();
});
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('der Fokus kehrt zum auslösenden Knopf zurück', async () => {
const opener = document.createElement('button');
document.body.append(opener);
opener.focus();
const outcome = open();
buttonByText('Abbrechen')!.click();
await outcome;
expect(document.activeElement).toBe(opener);
});
});
+435
View File
@@ -0,0 +1,435 @@
// Dialog „Logo hochladen“ (Spec § 4): Datei wählen, im Browser verarbeiten (prepare-logo.ts), auf dunklem und hellem Grund ansehen,
// Name, Anzeigename und Pflicht-Häkchen angeben, hochladen (upload-logo.ts). Eigenes <dialog> wie publish-dialog.ts; alle Texte
// gehen als Text in den DOM, nie als HTML.
import '../account/account.css';
import '../community/community.css';
import './logos.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { TONE_COLOR } from '../card/config';
import { ApiError, apiErrorText } from '../community/api';
import { CONTROL_CHARACTERS, DISPLAY_NAME_MAX } from '../community/publish-dialog';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import type { CommunityLogo } from './logos';
import {
LOGO_ACCEPT,
LOGO_EMPTY_MESSAGE,
LOGO_PIXELS_MESSAGE,
LOGO_READ_MESSAGE,
LOGO_SIZE_MESSAGE,
LOGO_SMALL_MESSAGE,
LOGO_TYPE_MESSAGE,
prepareLogo,
validateLogoFile,
type PreparedLogo,
} from './prepare-logo';
import { uploadPreparedLogo, type LogoForm } from './upload-logo';
/** Wortlaut aus Spec § 4. */
export const LOGO_CONSENT_TEXT = msg(
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.',
);
/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. Der Anzeigename: wie beim Veröffentlichen. */
const NAME_MAX = 40;
/** Die Meldungen von `prepareLogo` und `validateLogoFile`: Sie kommen mit fertig übersetztem Text im Fehler an. */
const PREPARE_MESSAGES = [
LOGO_TYPE_MESSAGE,
LOGO_SIZE_MESSAGE,
LOGO_PIXELS_MESSAGE,
LOGO_READ_MESSAGE,
LOGO_EMPTY_MESSAGE,
LOGO_SMALL_MESSAGE,
];
export interface LogoFormValues {
/** Gibt es ein fertig verarbeitetes Logo? */
hasFile: boolean;
name: string;
displayName: string;
consent: boolean;
}
export type LogoField = 'file' | 'name' | 'displayName' | 'consent';
/**
* Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt.
* Der Dienst prüft die Grenzen noch einmal.
*/
export function validateLogoForm(values: LogoFormValues): { field: LogoField; message: string } | null {
if (!values.hasFile) return { field: 'file', message: t('Bitte wähle eine Datei.') };
const name = values.name.trim();
if (name === '') return { field: 'name', message: t('Bitte gib einen Namen an.') };
if (CONTROL_CHARACTERS.test(name)) return { field: 'name', message: t('Der Name enthält ungültige Zeichen.') };
if (name.length > NAME_MAX) {
return { field: 'name', message: t('Der Name ist zu lang (höchstens {max} Zeichen).', { max: NAME_MAX }) };
}
const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) {
return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') };
}
if (displayName.length > DISPLAY_NAME_MAX) {
return {
field: 'displayName',
message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }),
};
}
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige, dass du das Logo teilen darfst.') };
return null;
}
/**
* Der Vorschlag für den Namen: der Dateiname ohne Endung, auf 40 Zeichen gekürzt (nie mitten in einem Zeichen aus zwei
* UTF-16-Einheiten).
*/
function suggestedName(fileName: string): string {
let name = '';
for (const char of fileName.replace(/\.[^.]*$/, '').trim()) {
if (name.length + char.length > NAME_MAX) break;
name += char;
}
return name.trim();
}
/**
* Der Grund zu einem Fehler beim Verarbeiten oder Hochladen, in der Sprache der Seite. Meldungen des Dienstes (über ihren
* Schlüssel) und die fertigen Texte von `prepareLogo` erscheinen im Wortlaut; alles andere übersetzt `errorMessage`.
*/
function failureReason(err: unknown): string {
if (err instanceof ApiError) return apiErrorText(err);
if (err instanceof Error && PREPARE_MESSAGES.some((message) => t(message) === err.message)) return err.message;
return errorMessage(err, 'save');
}
/** Eine Vorschau auf dunklem oder hellem Grund: das PNG als Bild, einfarbig als Maske in der Farbe der Kartendetails. */
function previewSurface(side: 'dark' | 'light', url: string, mono: boolean): HTMLElement {
const dark = side === 'dark';
const label = dark ? t('Logo-Vorschau auf dunklem Grund') : t('Logo-Vorschau auf hellem Grund');
let logo: HTMLElement;
if (mono) {
// Nur die Deckkraft zählt (Maske auf dieselbe URL, siehe logos.css). Gefüllt wird wie auf der Karte: Weiß auf dunklem,
// #111111 auf hellem Grund.
logo = el('span', { class: 'logo-preview-mask', role: 'img', 'aria-label': label });
logo.style.setProperty('--logo-url', `url("${url}")`);
logo.style.setProperty('background-color', TONE_COLOR[dark ? 'light' : 'dark']);
} else {
logo = el('img', { class: 'logo-preview-img', src: url, alt: label });
}
return el(
'figure',
{ class: `logo-preview logo-preview-${side}` },
el('div', { class: 'logo-preview-surface' }, logo),
el('figcaption', { class: 'logo-preview-caption' }, dark ? t('Dunkel') : t('Hell')),
);
}
export interface LogoDialogOptions {
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
/** Verarbeitet die gewählte Datei zum fertigen Logo; wirft einen Fehler mit übersetztem Text. Standard: `prepareLogo`. */
prepare?: (file: File) => Promise<PreparedLogo>;
/** Lädt das Logo hoch und liefert das neue Logo; wirft bei Fehler. Standard: `uploadPreparedLogo` mit Anmeldung bei Bedarf. */
upload?: (prepared: PreparedLogo, form: LogoForm) => Promise<CommunityLogo>;
}
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem hochgeladenen Logo, sonst mit null (abgebrochen). Fehler
* erscheinen im Dialog; er bleibt dann für einen neuen Versuch offen. Während des Hochladens lässt er sich nicht schließen
* (Abbrechen gesperrt, Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben, das dann doch
* hochgeladen wird; nur `signal` schließt immer.
*/
export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<CommunityLogo | null> {
const prepare = opts.prepare ?? prepareLogo;
const upload = opts.upload ?? ((prepared, form) => withLogin(() => uploadPreparedLogo(prepared, form)));
return new Promise<CommunityLogo | null>((resolve, reject) => {
if (opts.signal?.aborted) {
resolve(null);
return;
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let result: CommunityLogo | null = null;
let closed = false;
/** Das Hochladen läuft. */
let busy = false;
/** Die gewählte Datei wird gerade verarbeitet. */
let preparing = false;
/** Zählt die Dateiwahlen: eine neue Wahl macht die Verarbeitung der früheren hinfällig. */
let selection = 0;
/** Der Name im Feld stammt vom Nutzer, nicht aus dem Dateinamen. */
let nameTyped = false;
let prepared: PreparedLogo | null = null;
/** Object-URL des fertigen PNGs für beide Vorschauen. */
let previewUrl: string | null = null;
const text = (name: string, extra: Record<string, string> = {}) =>
el('input', { class: 'auth-input', type: 'text', name, id: `logo-upload-${name}`, autocomplete: 'off', ...extra });
const fileInput = el('input', {
class: 'auth-input logo-file',
type: 'file',
name: 'file',
id: 'logo-upload-file',
accept: LOGO_ACCEPT,
'aria-required': 'true',
'aria-describedby': 'logo-upload-file-hint',
});
const monoInput = el('input', {
type: 'checkbox',
name: 'mono',
id: 'logo-upload-mono',
'aria-describedby': 'logo-upload-mono-hint',
});
const nameInput = text('name', { 'aria-required': 'true' });
const displayInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'logo-upload-displayName-hint' });
displayInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Veröffentlichen-Dialog
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'logo-upload-consent' });
const inputs: Record<LogoField, HTMLInputElement> = {
file: fileInput,
name: nameInput,
displayName: displayInput,
consent: consentInput,
};
const field = (label: string, control: HTMLInputElement, hint?: string) =>
el(
'div',
{ class: 'auth-field' },
el('label', { class: 'auth-label', for: control.id }, label),
control,
hint ? el('p', { class: 'auth-hint', id: `${control.id}-hint` }, hint) : '',
);
const previewsEl = el('div', { class: 'logo-previews', role: 'group', 'aria-label': t('Vorschau') });
const fields = el(
'div',
{ class: 'publish-fields' },
field(t('Datei (Pflicht)'), fileInput, t('PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.')),
previewsEl,
// Gleich unter der Vorschau, weil es sie verändert.
el(
'div',
{ class: 'logo-mono' },
el('label', { class: 'publish-consent' }, monoInput, el('span', {}, t('Einfarbig – passt sich Hell/Dunkel an'))),
el('p', { class: 'auth-hint', id: 'logo-upload-mono-hint' }, t('Nur wählbar, wenn das Logo transparente Bereiche hat.')),
),
field(t('Name (Pflicht)'), nameInput),
field(
t('Anzeigename (optional)'),
displayInput,
t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(LOGO_CONSENT_TEXT))),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Hochladen'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog logo-dialog', 'aria-labelledby': 'logo-upload-heading' },
// Eigene ID für die Überschrift: `logo-upload-<name>` gehört den Feldern.
el('div', { class: 'report-card' }, el('h2', { id: 'logo-upload-heading', class: 'report-title' }, t('Logo hochladen')), form),
);
/** Einfarbig gilt nur für ein Logo mit transparenten Bereichen (wie `uploadPreparedLogo`). */
const isMono = () => prepared !== null && prepared.hasTransparency && monoInput.checked;
const releasePreview = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
};
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
releasePreview();
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(result);
};
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
function reopen(): boolean {
if (dialog.open) return true;
try {
dialog.showModal();
return true;
} catch {
return false;
}
}
/** Sperren, Statustext und „Einfarbig“ passend zum Zustand (Hochladen läuft, Datei wird verarbeitet, Logo fertig). */
function refresh(): void {
form.setAttribute('aria-busy', String(busy));
for (const control of [fileInput, nameInput, displayInput, consentInput]) control.disabled = busy;
monoInput.disabled = busy || !prepared?.hasTransparency;
send.disabled = busy || preparing;
cancel.disabled = busy;
let status = '';
if (busy) status = t('Wird hochgeladen …');
else if (preparing) status = t('Logo wird verarbeitet …');
statusEl.textContent = status;
}
function renderPreviews(): void {
if (!prepared || !previewUrl) {
previewsEl.replaceChildren();
return;
}
const mono = isMono();
previewsEl.replaceChildren(previewSurface('dark', previewUrl, mono), previewSurface('light', previewUrl, mono));
}
/** Setzt das fertige Logo (oder keins): neue Object-URL für die Vorschau, die alte wird freigegeben, „Einfarbig“ beginnt aus. */
function setPrepared(next: PreparedLogo | null): void {
releasePreview();
prepared = next;
previewUrl = next ? URL.createObjectURL(next.png) : null;
monoInput.checked = false;
renderPreviews();
refresh();
}
function clearInvalid(): void {
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
}
/** Den Namen aus dem Dateinamen vorbelegen, solange der Nutzer keinen eigenen eingetippt hat. */
function suggestName(fileName: string): void {
if (nameTyped && nameInput.value.trim() !== '') return;
nameInput.value = suggestedName(fileName);
nameTyped = false;
}
async function onFile(): Promise<void> {
const file = fileInput.files?.[0];
const ticket = ++selection;
preparing = false;
setPrepared(null);
errorEl.textContent = '';
fileInput.removeAttribute('aria-invalid');
if (!file) return;
const check = validateLogoFile(file);
if (!check.ok) {
fileInput.value = ''; // „keine Datei gewählt“ stimmt dann mit dem Zustand überein
errorEl.textContent = check.message;
return;
}
preparing = true;
refresh();
let next: PreparedLogo;
try {
next = await prepare(file);
} catch (err) {
if (closed || ticket !== selection) return;
preparing = false;
fileInput.value = '';
errorEl.textContent = failureReason(err);
refresh();
return;
}
if (closed || ticket !== selection) return;
preparing = false;
setPrepared(next);
suggestName(file.name);
}
async function submit(): Promise<void> {
clearInvalid();
errorEl.textContent = '';
const problem = validateLogoForm({
hasFile: prepared !== null,
name: nameInput.value,
displayName: displayInput.value,
consent: consentInput.checked,
});
if (problem) {
errorEl.textContent = problem.message;
inputs[problem.field].setAttribute('aria-invalid', 'true');
inputs[problem.field].focus();
return;
}
const logoForm: LogoForm = { name: nameInput.value.trim(), displayName: displayInput.value.trim(), mono: isMono() };
busy = true; // vor dem ersten await: ein zweiter Klick findet den Dialog schon gesperrt
refresh();
let logo: CommunityLogo;
try {
logo = await upload(prepared!, logoForm); // `validateLogoForm` hat „keine Datei“ schon abgefangen
} catch (err) {
if (closed) return;
busy = false;
refresh();
errorEl.textContent =
err instanceof LoginCancelled ? t(LOGIN_NEEDED) : t('Hochladen fehlgeschlagen – {reason}', { reason: failureReason(err) });
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
return;
}
if (closed) return;
rememberDisplayName(logoForm.displayName);
result = logo;
finish();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || preparing || closed) return;
void submit();
});
fileInput.addEventListener('change', () => void onFile());
monoInput.addEventListener('change', renderPreviews);
nameInput.addEventListener('input', () => {
nameTyped = true;
});
cancel.addEventListener('click', finish);
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Hochladen darf der Dialog nicht zugehen.
dialog.addEventListener('cancel', (event) => {
if (busy) event.preventDefault();
});
// Schließt der Browser den Dialog trotzdem mitten im Hochladen, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
dialog.addEventListener('close', () => {
if (busy && !closed) {
reopen();
return;
}
finish();
});
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
refresh();
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
return;
}
fileInput.focus();
});
}
+224
View File
@@ -0,0 +1,224 @@
// Kachelbild eines Community-Logos: laden, Pixelgrenze, Platzhalter und Object-URL. Datei und `Image` sind ersetzt.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// `logo-asset` (Quelle der Pixelgrenze) hängt an Appwrite und Anmeldung.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { MAX_LOGO_PIXELS } from './logo-asset';
import { showLogoImage } from './logo-image';
import type { CommunityLogo } from './logos';
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'l1',
name: 'Beispielbank',
displayName: 'Kenso',
fileId: 'l1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
/** Was das ersetzte `Image` meldet. Mit `hold` bleibt die Prüfung offen, bis der Test `held` auslöst. */
let image: { width: number; height: number; fails: boolean; hold: boolean };
let held: Array<() => void>;
let created: string[];
let revoked: string[];
class FakeImage {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_value: string) {
const settle = () => {
if (image.fails) {
this.onerror?.();
return;
}
this.naturalWidth = image.width;
this.naturalHeight = image.height;
this.onload?.();
};
if (image.hold) held.push(settle);
else queueMicrotask(settle);
}
}
const load = vi.fn<(row: CommunityLogo) => Promise<Blob>>();
let art: HTMLElement;
beforeEach(() => {
image = { width: 200, height: 80, fails: false, hold: false };
held = [];
created = [];
revoked = [];
load.mockReset().mockResolvedValue(new Blob(['png']));
URL.createObjectURL = vi.fn(() => {
created.push(`blob:logo-${created.length + 1}`);
return created[created.length - 1];
});
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
vi.stubGlobal('Image', FakeImage);
art = document.createElement('span');
});
afterEach(() => {
vi.unstubAllGlobals();
});
const state = () => art.getAttribute('data-state');
describe('showLogoImage: Anzeige', () => {
it('lädt die Datei des Logos; erst „loading“, danach „ready“', async () => {
const row = logo();
showLogoImage(art, row, load);
expect(state()).toBe('loading');
expect(load).toHaveBeenCalledExactlyOnceWith(row);
await flush();
expect(state()).toBe('ready');
});
it('mehrfarbig: ein dekoratives <img> (alt leer, nicht ziehbar) auf die Object-URL des PNGs', async () => {
showLogoImage(art, logo(), load);
await flush();
const img = art.querySelector('img')!;
expect(img.getAttribute('src')).toBe(created[0]);
expect(img.getAttribute('alt')).toBe('');
expect(img.getAttribute('draggable')).toBe('false');
expect(art.children).toHaveLength(1);
});
it('einfarbig: ein Element mit Maske auf die Object-URL, gefüllt in Weiß, ohne <img>', async () => {
showLogoImage(art, logo({ mono: true }), load);
await flush();
expect(art.querySelector('img')).toBeNull();
const mask = art.querySelector<HTMLElement>('.logo-tile-mask')!;
expect(mask.style.getPropertyValue('--logo-url')).toBe(`url("${created[0]}")`);
expect(mask.style.getPropertyValue('background-color')).toBe('#FFFFFF');
expect(mask.getAttribute('aria-hidden')).toBe('true');
});
it('die Object-URL bleibt, solange das Bild steht (der Aufrufer gibt sie über die Rückgabe frei)', async () => {
showLogoImage(art, logo(), load);
await flush();
expect(created).toHaveLength(1);
expect(revoked).toEqual([]);
});
});
describe('showLogoImage: Pixelgrenze und Platzhalter', () => {
it('Grenze: 1 000 000 Pixel (die Datei ist höchstens 720 × 250 groß)', () => {
expect(MAX_LOGO_PIXELS).toBe(1_000_000);
});
it('genau 1 000 000 Pixel gehen durch', async () => {
image = { ...image, width: 1000, height: 1000 };
showLogoImage(art, logo(), load);
await flush();
expect(state()).toBe('ready');
expect(art.querySelector('img')).not.toBeNull();
});
it.each([
['1000 × 1001', 1000, 1001],
['60 000 × 60 000 (Dekompressionsbombe)', 60_000, 60_000],
['Maße 0 (kein echtes Bild)', 0, 0],
])('%s: nicht angezeigt (Platzhalter), die URL wird freigegeben', async (_name, width, height) => {
image = { ...image, width, height };
showLogoImage(art, logo(), load);
await flush();
expect(state()).toBe('unusable');
expect(art.children).toHaveLength(0);
expect(revoked).toEqual(created);
expect(created).toHaveLength(1);
});
it('auch ein einfarbiges Logo über der Grenze wird nicht angezeigt (keine Maske)', async () => {
image = { ...image, width: 5000, height: 5000 };
showLogoImage(art, logo({ mono: true }), load);
await flush();
expect(state()).toBe('unusable');
expect(art.querySelector('.logo-tile-mask')).toBeNull();
expect(revoked).toEqual(created);
});
it('nicht dekodierbar: Platzhalter, URL freigegeben', async () => {
image = { ...image, fails: true };
showLogoImage(art, logo(), load);
await flush();
expect(state()).toBe('unusable');
expect(revoked).toEqual(created);
});
it('die Datei lässt sich nicht laden (404, Netz): Platzhalter, es wird keine URL angelegt, nichts wirft', async () => {
load.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
showLogoImage(art, logo(), load);
await flush();
expect(state()).toBe('unusable');
expect(created).toEqual([]);
load.mockRejectedValue(new TypeError('Failed to fetch'));
showLogoImage(art, logo(), load);
await flush();
expect(created).toEqual([]);
});
});
describe('showLogoImage: freigeben', () => {
it('die Rückgabe gibt die angezeigte URL genau einmal frei; ein zweiter Aufruf schadet nicht', async () => {
const release = showLogoImage(art, logo(), load);
await flush();
release();
expect(revoked).toEqual([created[0]]);
release();
expect(revoked).toEqual([created[0]]);
});
it('freigegeben, bevor die Datei ankommt: es wird nichts angelegt und nichts angezeigt', async () => {
let arrive!: (blob: Blob) => void;
load.mockReturnValue(new Promise<Blob>((resolve) => (arrive = resolve)));
const release = showLogoImage(art, logo(), load);
release();
arrive(new Blob(['spät']));
await flush();
expect(created).toEqual([]);
expect(art.children).toHaveLength(0);
expect(state()).toBe('loading'); // die Fläche gehört niemandem mehr
});
it('freigegeben während der Bildprüfung: nichts angezeigt, die schon angelegte URL wird freigegeben', async () => {
image = { ...image, hold: true };
const release = showLogoImage(art, logo(), load);
await flush();
expect(created).toHaveLength(1); // Datei da, Prüfung offen
release();
expect(revoked).toEqual([]); // noch gehört die URL keinem angezeigten Bild
for (const settle of held) settle();
await flush();
expect(revoked).toEqual(created);
expect(art.children).toHaveLength(0);
expect(state()).toBe('loading');
});
it('eine abgelehnte Prüfung nach dem Freigeben macht den Platzhalter nicht mehr sichtbar', async () => {
image = { ...image, hold: true, fails: true };
const release = showLogoImage(art, logo(), load);
await flush();
release();
for (const settle of held) settle();
await flush();
expect(state()).toBe('loading');
expect(revoked).toEqual(created);
});
});
+70
View File
@@ -0,0 +1,70 @@
// Das PNG eines Community-Logos als Bild einer Kachel: laden, auf die Pixelgrenze prüfen und auf der Bildfläche zeigen. Die Object-URL
// gehört dem Aufrufer, der sie über die zurückgegebene Funktion freigibt. Fremde Dateien sind ungeprüft: Bilder über der Grenze oder
// nicht lesbare zeigt es nicht (neutraler Platzhalter), es stürzt nie ab.
import { TONE_COLOR } from '../card/config';
import { el } from '../editor/dom';
import { MAX_LOGO_PIXELS } from './logo-asset';
import type { CommunityLogo } from './logos';
/**
* Lädt die Object-URL in ein `Image` und lehnt ab, was sich nicht lesen lässt oder mehr als `MAX_LOGO_PIXELS` Pixel hat (so wie
* `checkGalleryImage` die Galerie-Bilder prüft): ein winziges, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten
* und den Tab einfrieren. Die Maße sind nach dem Laden bekannt, das Bild ist aber noch nicht dekodiert.
*/
function checkImage(url: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const probe = new Image();
probe.onload = () => {
const { naturalWidth: w, naturalHeight: h } = probe;
if (w > 0 && h > 0 && w * h <= MAX_LOGO_PIXELS) resolve();
else reject(new Error('Das Logo ist zu groß.'));
};
probe.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
probe.src = url;
});
}
/** Einfarbiges Logo: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL), gefüllt in Weiß wie auf der dunklen Karte. */
function maskOf(url: string): HTMLElement {
const mask = el('span', { class: 'logo-tile-mask', 'aria-hidden': 'true' });
mask.style.setProperty('--logo-url', `url("${url}")`);
mask.style.setProperty('background-color', TONE_COLOR.light);
return mask;
}
/**
* Zeigt das Logo auf `art` (dekorativ: mehrfarbig als Bild, einfarbig als weiße Maske) und setzt dort `data-state`: `loading`, dann
* `ready` oder `unusable` (Ladefehler, nicht lesbar, zu groß: die Fläche bleibt leer, der Platzhalter kommt aus dem Stylesheet).
* `load` holt die Datei. Die zurückgegebene Funktion gibt die Object-URL frei; eine Datei, die erst danach eintrifft, wird nicht mehr
* angezeigt und ihre URL sofort wieder freigegeben.
*/
export function showLogoImage(art: HTMLElement, logo: CommunityLogo, load: (logo: CommunityLogo) => Promise<Blob>): () => void {
let shown: string | null = null;
let released = false;
art.setAttribute('data-state', 'loading');
void (async () => {
let url: string | null = null;
try {
const blob = await load(logo);
if (released) return;
url = URL.createObjectURL(blob);
await checkImage(url);
if (!released) {
shown = url;
art.replaceChildren(logo.mono ? maskOf(url) : el('img', { class: 'logo-tile-img', src: url, alt: '', draggable: 'false' }));
art.setAttribute('data-state', 'ready');
return;
}
} catch {
if (!released) art.setAttribute('data-state', 'unusable');
}
if (url) URL.revokeObjectURL(url); // nicht angezeigt (abgelehnt oder die Kachel ist inzwischen weg)
})();
return () => {
released = true;
if (shown) URL.revokeObjectURL(shown);
shown = null;
};
}
+769
View File
@@ -0,0 +1,769 @@
// Infozeile zum gewählten Logo (Spec § 3 und § 6): Anzeige, Melden, Löschen und die Admin-Aktionen. Die Zeile des Logos und die Auswahl
// gehören dem Picker (hier eine Attrappe als `host`); Datenschicht, Rückfrage und Melden-Dialog sind Attrappen. Das Zusammenspiel
// mit dem echten Picker steht in logo-picker.test.ts.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ forgetLogo: vi.fn() }));
// Die Infozeile hängt (über Melden-Dialog und API-Fehler) an Appwrite und Anmeldung; hier kommt alles über `deps`.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({
currentUser: () => null,
currentUserId: () => null,
isAdmin: () => false,
onAuthChange: () => () => {},
onLogout: () => () => {},
}));
vi.mock('./logo-asset', async (importOriginal) => ({
...(await importOriginal<typeof import('./logo-asset')>()),
forgetLogo: mocks.forgetLogo,
}));
import { ApiError } from '../community/api';
import { setLang } from '../i18n/i18n';
import { createLogoInfo, failureText, type LogoInfo, type LogoInfoDeps, type LogoInfoHost } from './logo-info';
import type { CommunityLogo, LogoReport } from './logos';
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'a',
name: 'Logo A',
displayName: 'Kenso',
fileId: 'a',
ownerId: 'u9',
mono: false,
likeCount: 12,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const report = (reason: string, id = reason): LogoReport => ({ id, logoId: 'a', reason, createdAt: '2026-10-04T12:00:00.000Z' });
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const flush = async () => {
for (let i = 0; i < 50; i++) await Promise.resolve();
};
// ---- Attrappen ------------------------------------------------------------------------------------------------------------
let selectedId: string | null;
let row: CommunityLogo | null;
let gone: boolean;
let user: string | null;
let admin: boolean;
let abort: AbortController;
let info: LogoInfo;
const host = {
selectedId: () => selectedId,
selected: () => row,
gone: () => gone,
patchRow: vi.fn((id: string, patch: Partial<CommunityLogo>) => {
if (row?.id === id) row = { ...row, ...patch };
}),
removeLogo: vi.fn<LogoInfoHost['removeLogo']>(),
} satisfies LogoInfoHost;
const fx = {
reportLogo: vi.fn<LogoInfoDeps['reportLogo']>(),
deleteLogo: vi.fn<LogoInfoDeps['deleteLogo']>(),
setLogoVisibility: vi.fn<LogoInfoDeps['setLogoVisibility']>(),
listLogoReports: vi.fn<LogoInfoDeps['listLogoReports']>(),
confirm: vi.fn<LogoInfoDeps['confirm']>(),
reportDialog: vi.fn<LogoInfoDeps['reportDialog']>(),
};
const deps: LogoInfoDeps = { ...fx, userId: () => user, isAdmin: () => admin };
beforeEach(() => {
for (const fn of [...Object.values(fx), mocks.forgetLogo, host.patchRow, host.removeLogo]) fn.mockReset();
host.patchRow.mockImplementation((id, patch) => {
if (row?.id === id) row = { ...row, ...patch };
});
selectedId = null;
row = null;
gone = false;
user = 'u1';
admin = false;
abort = new AbortController();
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
fx.deleteLogo.mockResolvedValue(undefined);
fx.setLogoVisibility.mockResolvedValue(undefined);
fx.listLogoReports.mockResolvedValue([]);
fx.confirm.mockResolvedValue(true);
fx.reportDialog.mockResolvedValue('cancelled');
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
/** Baut die Infozeile; `shown` ist das gewählte Logo (null: keins). */
async function mount(shown: CommunityLogo | null = logo()): Promise<LogoInfo> {
row = shown;
selectedId = shown?.id ?? null;
info = createLogoInfo(host, deps, abort.signal);
document.body.append(info.el);
await flush();
return info;
}
/** Zeigt der Picker ein anderes Logo (oder keins), meldet er es mit update(). */
function choose(next: CommunityLogo | null): void {
row = next;
selectedId = next?.id ?? null;
info.update();
}
// ---- Zugriffe auf das DOM -------------------------------------------------------------------------------------------------
/** Sichtbar: weder selbst noch ein Vorfahre ist mit `hidden` verborgen. */
const visible = (node: Element) => node.closest('[hidden]') === null;
const line = () => {
const text = info.el.querySelector('.logo-info-text')!;
return visible(text) ? text.textContent : '';
};
const goneText = () => info.el.querySelector('.logo-info-gone')!.textContent;
const message = () => info.el.querySelector('[role="alert"]')!.textContent;
const buttons = () => [...info.el.querySelectorAll('button')].filter(visible).map((b) => b.textContent);
const button = (label: string) => [...info.el.querySelectorAll('button')].find((b) => b.textContent === label && visible(b)) as HTMLButtonElement;
const reportCount = () => {
const text = info.el.querySelector('.logo-info-reports')!;
return visible(text) ? text.textContent : '';
};
const reasons = () => [...info.el.querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent);
const reasonsNote = () => info.el.querySelector('.logo-info-note')!.textContent;
const hiddenNote = () => {
const note = info.el.querySelector('.logo-info-hidden')!;
return visible(note) ? note.textContent : '';
};
describe('Anzeige', () => {
it('ohne Auswahl ist die Infozeile leer: kein Text, keine Knöpfe, keine Meldung', async () => {
await mount(null);
expect(line()).toBe('');
expect(buttons()).toEqual([]);
expect(goneText()).toBe('');
expect(hiddenNote()).toBe('');
expect(message()).toBe('');
});
it('gewähltes Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste', async () => {
user = null;
await mount();
expect(line()).toBe('Logo A · von Kenso · ♥ 12');
expect(buttons()).toEqual(['Melden']);
expect(button('Melden').type).toBe('button');
});
it('ohne Anzeigename steht „von Anonym“', async () => {
await mount(logo({ displayName: '' }));
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
});
it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => {
await mount(logo({ name: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' }));
expect(line()).toBe('<img src=x onerror=alert(1)> · von <b>fett</b> · ♥ 12');
expect(info.el.querySelector('img')).toBeNull();
expect(info.el.querySelector('b')).toBeNull();
});
it('update() folgt der Zeile des Pickers: neue Zahl, anderes Logo, danach wieder keins', async () => {
await mount();
row = logo({ likeCount: 13 });
info.update();
expect(line()).toBe('Logo A · von Kenso · ♥ 13');
choose(logo({ id: 'b', name: 'Logo B', displayName: 'Anna', likeCount: 0 }));
expect(line()).toBe('Logo B · von Anna · ♥ 0');
choose(null);
expect(line()).toBe('');
expect(buttons()).toEqual([]);
});
it('ein Logo, dessen Zeile noch fehlt (wird gerade geholt), zeigt weder Zeile noch Hinweis', async () => {
await mount(null);
selectedId = 'x'; // gewählt, die Zeile ist noch unbekannt
info.update();
expect(line()).toBe('');
expect(goneText()).toBe('');
expect(buttons()).toEqual([]);
});
it('gewähltes Logo nicht auffindbar: „Das gewählte Logo ist nicht mehr verfügbar.“, ohne Knöpfe', async () => {
await mount(null);
selectedId = 'x';
gone = true;
info.update();
expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
expect(line()).toBe('');
expect(buttons()).toEqual([]);
expect(info.el.querySelector('.logo-info-gone')!.getAttribute('role')).toBe('status');
// Taucht die Zeile doch auf, ist der Hinweis weg.
row = logo({ id: 'x', name: 'Logo X' });
gone = false;
info.update();
expect(goneText()).toBe('');
expect(line()).toBe('Logo X · von Kenso · ♥ 12');
});
it('eine Zeile, die der Picker inzwischen kennt, geht dem Hinweis „nicht mehr verfügbar“ vor, auch wenn gone() noch wahr meldet', async () => {
await mount(null);
selectedId = 'x';
gone = true;
info.update();
expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
row = logo({ id: 'x', name: 'Logo X' });
info.update();
expect(goneText()).toBe('');
expect(line()).toBe('Logo X · von Kenso · ♥ 12');
});
it('ohne einen sichtbaren Knopf ist auch die Knopfleiste verborgen (sonst bliebe ein leerer Abstand in der Karte)', async () => {
const actions = () => info.el.querySelector<HTMLElement>('.logo-info-actions')!;
user = 'u2'; // weder Hochlader noch Admin
await mount(logo({ hidden: true })); // melden lässt es sich nicht, ein- und ausblenden nur ein Admin
expect(buttons()).toEqual([]);
expect(actions().hidden).toBe(true);
expect(hiddenNote()).toBe('Ausgeblendet');
user = 'u9'; // der Hochlader darf löschen
info.update();
expect(buttons()).toEqual(['Löschen']);
expect(actions().hidden).toBe(false);
});
it('Englisch: Zeile, Knopf und Hinweis sind übersetzt', async () => {
setLang('en', { persist: false });
await mount(logo({ displayName: '' }));
expect(line()).toBe('Logo A · by Anonymous · ♥ 12');
expect(buttons()).toEqual(['Report']);
choose(null);
selectedId = 'x';
gone = true;
info.update();
expect(goneText()).toBe('The selected logo is no longer available.');
});
});
describe('Melden', () => {
it('öffnet den Dialog für Logos und sendet reportLogo(id, Grund) – auch als Gast', async () => {
user = null;
await mount();
button('Melden').click();
await flush();
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
const opts = fx.reportDialog.mock.calls[0][0];
expect(opts.subject).toBe('logo');
expect(opts.signal).toBe(abort.signal);
fx.reportLogo.mockResolvedValue({ schonGemeldet: true });
await expect(opts.send('spam')).resolves.toEqual({ schonGemeldet: true });
expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('a', 'spam');
});
it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten; danach geht es wieder', async () => {
const open = deferred<'reported'>();
fx.reportDialog.mockReturnValue(open.promise);
await mount();
button('Melden').click();
button('Melden').click();
await flush();
expect(fx.reportDialog).toHaveBeenCalledTimes(1);
open.resolve('reported');
await flush();
button('Melden').click();
expect(fx.reportDialog).toHaveBeenCalledTimes(2);
});
it('ein Dialog, der sich nicht öffnen lässt, zeigt einen Fehlertext; der Knopf geht danach wieder', async () => {
fx.reportDialog.mockRejectedValueOnce(new Error('kein <dialog>'));
await mount();
button('Melden').click();
await flush();
expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
button('Melden').click(); // der nächste Versuch räumt den Text ab
expect(message()).toBe('');
await flush();
expect(fx.reportDialog).toHaveBeenCalledTimes(2);
});
it('ein ausgeblendetes Logo lässt sich nicht melden (der Dienst kennt es für Besucher nicht)', async () => {
user = 'u9';
await mount(logo({ hidden: true }));
expect(buttons()).toEqual(['Löschen']);
expect(hiddenNote()).toBe('Ausgeblendet');
});
});
describe('Löschen durch den Hochlader', () => {
it('„Löschen“ sehen nur der Hochlader und Admins; Gäste und andere Nutzer nur „Melden“', async () => {
user = 'u9'; // ownerId des Beispiel-Logos
await mount();
expect(buttons()).toEqual(['Melden', 'Löschen']);
user = 'u2';
info.update();
expect(buttons()).toEqual(['Melden']);
user = null;
info.update();
expect(buttons()).toEqual(['Melden']);
expect(reportCount()).toBe('');
expect(button('Ausblenden')).toBeUndefined();
});
it('ein Logo ohne bekannten Hochlader gehört niemandem (auch nicht einem Konto mit leerer ID)', async () => {
user = '';
await mount(logo({ ownerId: '' }));
expect(buttons()).toEqual(['Melden']);
});
it('fragt mit Namen nach; bestätigt: deleteLogo, forgetLogo und removeLogo in dieser Reihenfolge', async () => {
user = 'u9';
const order: string[] = [];
fx.confirm.mockImplementation(async () => {
order.push('confirm');
return true;
});
fx.deleteLogo.mockImplementation(async () => {
order.push('delete');
});
mocks.forgetLogo.mockImplementation(() => {
order.push('forget');
});
host.removeLogo.mockImplementation(() => {
order.push('remove');
});
await mount();
button('Löschen').click();
await flush();
expect(order).toEqual(['confirm', 'delete', 'forget', 'remove']);
expect(fx.confirm).toHaveBeenCalledExactlyOnceWith(
{
title: 'Logo „Logo A“ löschen?',
text: 'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.',
confirmLabel: 'Löschen',
cancelLabel: 'Abbrechen',
},
abort.signal,
);
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(message()).toBe('');
});
it('abgelehnt: es passiert nichts, auch kein Fehlertext', async () => {
user = 'u9';
fx.confirm.mockResolvedValue(false);
await mount();
button('Löschen').click();
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(host.removeLogo).not.toHaveBeenCalled();
expect(message()).toBe('');
expect(buttons()).toEqual(['Melden', 'Löschen']);
});
it('Fehler des Dienstes: der Text steht in der Meldezeile, nichts wird entfernt; ein neuer Versuch geht', async () => {
user = 'u9';
fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await mount();
button('Löschen').click();
await flush();
expect(message()).toBe('Dazu hast du keine Berechtigung.');
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(host.removeLogo).not.toHaveBeenCalled();
button('Löschen').click();
await flush();
expect(message()).toBe('');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
});
it('404 „logoGone“ (das Logo gibt es schon nicht mehr, etwa weil die Antwort einer ersten Anfrage verloren ging): gilt als Erfolg wie ein gelungenes Löschen', async () => {
user = 'u9';
const order: string[] = [];
fx.deleteLogo.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
mocks.forgetLogo.mockImplementation(() => void order.push('forget'));
host.removeLogo.mockImplementation(() => void order.push('remove'));
await mount();
button('Löschen').click();
await flush();
expect(order).toEqual(['forget', 'remove']);
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(message()).toBe(''); // kein Fehlertext
});
it('404 „logoGone“ gilt für den Admin ebenso, auch bei einem fremden Logo', async () => {
admin = true;
fx.deleteLogo.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
await mount();
button('Löschen').click();
await flush();
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(message()).toBe('');
});
it('404 „logoGone“ nach dem Verlassen der Seite: der Zwischenspeicher vergisst das Logo trotzdem, die Kachel wird nicht mehr angefasst', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').click();
await flush();
abort.abort();
request.reject(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
await flush();
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).not.toHaveBeenCalled();
});
it.each([
['ein 404 ohne diesen Schlüssel (etwa ein Dienst, der die Anfrage nicht kennt)', new ApiError(404, 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', true, 'generic')],
['ein 404 ohne Schlüssel', new ApiError(404, 'Nicht gefunden', true)],
['logoGone mit einem anderen Status', new ApiError(500, 'Dieses Logo ist nicht (mehr) verfügbar.', false, 'logoGone')],
['ein 403', new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')],
])('%s bleibt ein Fehler: Text in der Meldezeile, nichts wird vergessen oder entfernt', async (_name, error) => {
user = 'u9';
fx.deleteLogo.mockRejectedValueOnce(error);
await mount();
button('Löschen').click();
await flush();
expect(message()).not.toBe('');
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(host.removeLogo).not.toHaveBeenCalled();
});
it('ein Fehler anderer Art (Netz) bekommt den allgemeinen Text', async () => {
user = 'u9';
fx.deleteLogo.mockRejectedValueOnce(new TypeError('offline'));
await mount();
button('Löschen').click();
await flush();
expect(message()).toBe('Keine Verbindung zum Server.');
});
it('eine Rückfrage, die sich nicht öffnen lässt, löscht nichts und sagt es', async () => {
user = 'u9';
fx.confirm.mockRejectedValueOnce(new Error('kein <dialog>'));
await mount();
button('Löschen').click();
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
it('meldet sich der Nutzer während der Rückfrage ab, wird nichts gelöscht', async () => {
user = 'u9';
const answer = deferred<boolean>();
fx.confirm.mockReturnValue(answer.promise);
await mount();
button('Löschen').click();
user = null;
info.update();
answer.resolve(true);
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
expect(host.removeLogo).not.toHaveBeenCalled();
});
it('solange eine Rückfrage oder Anfrage läuft, bleiben weitere Klicks ohne Wirkung', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').click();
button('Löschen').click(); // die Rückfrage ist noch nicht beantwortet
await flush();
button('Löschen').click(); // die Anfrage läuft
button('Melden').click();
await flush();
expect(fx.confirm).toHaveBeenCalledTimes(1);
expect(fx.deleteLogo).toHaveBeenCalledTimes(1);
expect(fx.reportDialog).not.toHaveBeenCalled();
request.resolve();
await flush();
expect(host.removeLogo).toHaveBeenCalledTimes(1);
});
it('verlässt der Nutzer die Seite während der Rückfrage, wird nichts gelöscht', async () => {
user = 'u9';
const answer = deferred<boolean>();
fx.confirm.mockReturnValue(answer.promise);
await mount();
button('Löschen').click();
abort.abort();
answer.resolve(true);
await flush();
expect(fx.deleteLogo).not.toHaveBeenCalled();
});
it('verlässt der Nutzer die Seite während der Anfrage, vergisst der Zwischenspeicher das Logo trotzdem, die Kachel wird nicht mehr angefasst', async () => {
user = 'u9';
const request = deferred<void>();
fx.deleteLogo.mockReturnValue(request.promise);
await mount();
button('Löschen').click();
await flush();
abort.abort();
request.resolve();
await flush();
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).not.toHaveBeenCalled();
});
});
describe('Admin', () => {
beforeEach(() => {
admin = true;
});
it('Nicht-Admins sehen keine Meldungen und kein Ausblenden und fragen die Gründe nicht ab', async () => {
admin = false;
await mount(logo({ reportCount: 3 }));
expect(reportCount()).toBe('');
expect(buttons()).toEqual(['Melden']);
expect(fx.listLogoReports).not.toHaveBeenCalled();
});
it('„<n> Meldungen“ mit den Gründen als Text; dazu „Ausblenden“ und „Löschen“ (auch für fremde Logos)', async () => {
fx.listLogoReports.mockResolvedValue([report('spam'), report('urheberrecht'), report('Eigener Grund')]);
await mount(logo({ reportCount: 3 }));
expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a');
expect(reportCount()).toBe('3 Meldungen');
expect(reasons()).toEqual(['Spam', 'Urheberrecht/Marke', 'Eigener Grund']);
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
});
it('„1 Meldung“ im Singular', async () => {
fx.listLogoReports.mockResolvedValue([report('unangemessen')]);
await mount(logo({ reportCount: 1 }));
expect(reportCount()).toBe('1 Meldung');
expect(reasons()).toEqual(['Unangemessen']);
});
it('ohne Meldungen: „0 Meldungen“ und keine Anfrage', async () => {
await mount(logo({ reportCount: 0 }));
expect(reportCount()).toBe('0 Meldungen');
expect(reasons()).toEqual([]);
expect(fx.listLogoReports).not.toHaveBeenCalled();
});
it('die Gründe werden je Logo nur einmal geholt, auch bei vielen Aktualisierungen', async () => {
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1 }));
row = logo({ reportCount: 1, likeCount: 99 });
info.update();
info.update();
await flush();
expect(fx.listLogoReports).toHaveBeenCalledTimes(1);
expect(reasons()).toEqual(['Spam']);
choose(logo({ id: 'b', reportCount: 1 }));
await flush();
expect(fx.listLogoReports).toHaveBeenLastCalledWith('b');
choose(logo({ reportCount: 1 })); // zurück zu A: frisch holen
await flush();
expect(fx.listLogoReports).toHaveBeenCalledTimes(3);
});
it('ändert sich die Zahl der Meldungen (die Zeile wurde neu geladen), werden die Gründe neu geholt', async () => {
fx.listLogoReports.mockResolvedValueOnce([report('spam')]);
await mount(logo({ reportCount: 1 }));
expect(reasons()).toEqual(['Spam']);
fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]);
row = logo({ reportCount: 2 });
info.update();
await flush();
expect(reportCount()).toBe('2 Meldungen');
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
});
it('wird dasselbe Logo nach „kein Logo“ erneut gewählt, werden die Gründe frisch geholt – auch nach einem Ladefehler', async () => {
fx.listLogoReports.mockRejectedValueOnce(new Error('offline'));
await mount(logo({ reportCount: 1 }));
expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.');
choose(null);
fx.listLogoReports.mockResolvedValueOnce([report('spam')]);
choose(logo({ reportCount: 1 }));
await flush();
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
expect(reasonsNote()).toBe('');
expect(reasons()).toEqual(['Spam']);
});
it('die Gründe lassen sich nicht laden: Hinweis dazu, die Zahl bleibt', async () => {
fx.listLogoReports.mockRejectedValue(new Error('offline'));
await mount(logo({ reportCount: 3 }));
expect(reportCount()).toBe('3 Meldungen');
expect(reasons()).toEqual([]);
expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.');
// Ein anderes Logo, das sich laden lässt, hat den Hinweis nicht.
fx.listLogoReports.mockResolvedValue([report('spam')]);
choose(logo({ id: 'b', reportCount: 1 }));
await flush();
expect(reasonsNote()).toBe('');
expect(reasons()).toEqual(['Spam']);
});
it('eine späte Antwort für ein inzwischen verlassenes Logo wird verworfen', async () => {
const slow = deferred<LogoReport[]>();
fx.listLogoReports.mockReturnValueOnce(slow.promise);
await mount(logo({ reportCount: 2 }));
fx.listLogoReports.mockResolvedValueOnce([report('sonstiges')]);
choose(logo({ id: 'b', reportCount: 1 }));
await flush();
expect(reasons()).toEqual(['Sonstiges']);
slow.resolve([report('spam'), report('spam', 'zwei')]);
await flush();
expect(reasons()).toEqual(['Sonstiges']);
});
it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo, die Zeile wird hidden; die Anzeige folgt', async () => {
const order: string[] = [];
fx.setLogoVisibility.mockImplementation(async () => {
order.push('visibility');
});
mocks.forgetLogo.mockImplementation(() => {
order.push('forget');
});
host.patchRow.mockImplementation((id, patch) => {
order.push('patch');
if (row?.id === id) row = { ...row, ...patch };
});
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1 }));
button('Ausblenden').click();
await flush();
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', false);
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true });
expect(order).toEqual(['visibility', 'forget', 'patch']);
expect(buttons()).toEqual(['Löschen', 'Einblenden']); // „Melden“ gibt es für ein ausgeblendetes Logo nicht
expect(hiddenNote()).toBe('Ausgeblendet');
expect(reportCount()).toBe('1 Meldung'); // Ausblenden ändert die Meldungen nicht
expect(reasons()).toEqual(['Spam']);
expect(message()).toBe('');
});
it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Meldungen sind danach gelöscht (0)', async () => {
fx.listLogoReports.mockResolvedValue([report('spam'), report('sonstiges')]);
await mount(logo({ hidden: true, reportCount: 2 }));
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
button('Einblenden').click();
await flush();
expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', true);
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: false, reportCount: 0 });
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
expect(hiddenNote()).toBe('');
expect(reportCount()).toBe('0 Meldungen');
expect(reasons()).toEqual([]);
expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen
});
it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => {
fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await mount(logo());
button('Ausblenden').click();
await flush();
expect(message()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(host.patchRow).not.toHaveBeenCalled();
expect(mocks.forgetLogo).not.toHaveBeenCalled();
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
button('Ausblenden').click();
await flush();
expect(message()).toBe('');
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
});
it('der Admin löscht wie der Hochlader, auch ein fremdes Logo', async () => {
await mount(logo({ ownerId: 'u5' }));
button('Löschen').click();
await flush();
expect(fx.confirm).toHaveBeenCalledTimes(1);
expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a');
expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a');
});
it('wird jemand Admin (Anmeldung), erscheinen die Admin-Teile nach update(); verliert er die Rolle, verschwinden sie', async () => {
admin = false;
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1 }));
expect(buttons()).toEqual(['Melden']);
admin = true;
info.update();
await flush();
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
expect(reasons()).toEqual(['Spam']);
admin = false;
info.update();
expect(buttons()).toEqual(['Melden']);
expect(reportCount()).toBe('');
});
it('Englisch: Meldungen, Gründe und Knöpfe sind übersetzt', async () => {
setLang('en', { persist: false });
fx.listLogoReports.mockResolvedValue([report('spam')]);
await mount(logo({ reportCount: 1, ownerId: 'u1' }));
expect(buttons()).toEqual(['Report', 'Delete', 'Hide']);
expect(reasons()).toEqual(['Spam']);
expect(reportCount()).toBe('1 report');
row = logo({ hidden: true, reportCount: 3 });
info.update();
expect(reportCount()).toBe('3 reports');
row = logo({ hidden: true, reportCount: 1 });
info.update();
expect(buttons()).toEqual(['Delete', 'Unhide']);
expect(hiddenNote()).toBe('Hidden');
});
});
describe('Meldezeile', () => {
it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => {
await mount();
expect(info.el.querySelector('[role="alert"]')!.classList.contains('auth-msg-error')).toBe(true);
info.setMessage('Etwas ging schief.');
expect(message()).toBe('Etwas ging schief.');
info.setMessage('');
expect(message()).toBe('');
});
it('der Text steht auch ohne Auswahl (Fehler am Herz eines nicht gewählten Logos) und überlebt update()', async () => {
await mount(null);
info.setMessage('Dieses Logo ist nicht (mehr) verfügbar.');
info.update();
expect(message()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
});
it('eine andere Auswahl löscht den Text; dieselbe Auswahl lässt ihn stehen', async () => {
await mount();
info.setMessage('Etwas ging schief.');
info.update();
row = logo({ likeCount: 13 });
info.update();
expect(message()).toBe('Etwas ging schief.');
choose(logo({ id: 'b', name: 'Logo B' }));
expect(message()).toBe('');
});
});
describe('failureText', () => {
it('Fehler des Dienstes über ihren Schlüssel, alles andere über errorMessage', () => {
expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(failureText(new TypeError('offline'))).toBe('Keine Verbindung zum Server.');
setLang('en', { persist: false });
expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('This logo is no longer available.');
});
});
+243
View File
@@ -0,0 +1,243 @@
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“. Wer das Logo
// hochgeladen hat, sieht dazu „Löschen“ (mit Rückfrage), Admins die Meldungen mit ihren Gründen sowie „Ausblenden“/„Einblenden“
// und „Löschen“. Zugleich die Meldezeile des Bereichs: Fehler von Herz, Hochladen und diesen Aktionen erscheinen hier, so bleibt
// der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten.
// Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`.
// Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn.
// Alle Texte gehen als Text in den DOM, nie als HTML.
import '../account/account.css';
import './logos.css';
import { errorMessage } from '../account/errors';
import { ApiError, apiErrorText } from '../community/api';
import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog';
import type { ConfirmOptions } from '../editor/confirm-dialog';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { reportsLabel } from '../pages/format';
import type { deleteLogo, reportLogo, setLogoVisibility } from './logo-api';
import { forgetLogo, LOGO_MISSING } from './logo-asset';
import type { CommunityLogo, listLogoReports, LogoReport } from './logos';
export interface LogoInfoDeps {
reportLogo: typeof reportLogo;
deleteLogo: typeof deleteLogo;
setLogoVisibility: typeof setLogoVisibility;
listLogoReports: typeof listLogoReports;
/** Die Rückfrage vor dem Löschen; `signal` schließt sie (Seite verlassen). Löst mit true auf, wenn bestätigt wurde. */
confirm(opts: ConfirmOptions, signal?: AbortSignal): Promise<boolean>;
/** Der Melden-Dialog (für Logos: `subject: 'logo'`). */
reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>;
userId(): string | null;
isAdmin(): boolean;
}
/** Was die Infozeile vom Picker braucht: Auswahl und Zeile lesen, die Zeile ändern, ein gelöschtes Logo melden. */
export interface LogoInfoHost {
/** Die ID des gewählten Logos; null: kein Logo. */
selectedId(): string | null;
/** Die Zeile des gewählten Logos, sobald bekannt; null: kein Logo oder (noch) unbekannt. */
selected(): CommunityLogo | null;
/** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */
gone(): boolean;
/** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */
patchRow(id: string, patch: Partial<CommunityLogo>): void;
/** Das Logo ist gelöscht: Kachel und Zeile entfernen, steht es in der Auswahl, wird sie aufgehoben. */
removeLogo(id: string): void;
}
export interface LogoInfo {
el: HTMLElement;
/** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung). */
update(): void;
/** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */
setMessage(text: string): void;
}
const REASONS_ERROR = msg('Die Gründe konnten nicht geladen werden.');
const DELETE_TEXT = msg(
'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.',
);
/** Der Grund eines Fehlers zum Anzeigen: Meldungen des Dienstes über ihren Schlüssel, alles andere über `errorMessage`. */
export function failureText(err: unknown): string {
return err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
}
/** Der Grund einer Meldung als Text: bekannte Werte mit ihrer Beschriftung, alles andere unverändert. */
function reasonLabel(reason: string): string {
const known = REPORT_REASONS.find((r) => r.value === reason);
return known ? t(known.label) : reason;
}
/** Die Gründe der Meldungen zu einem Stand (`key`: Logo-ID und Zahl der Meldungen); ein Admin holt sie einmal je Stand. */
interface Reasons {
key: string;
status: 'loading' | 'ready' | 'error';
list: LogoReport[];
}
/** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */
export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo {
/** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung. */
let busy = false;
/** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */
let shownId: string | null = null;
let reasons: Reasons | null = null;
// ---- Aufbau ---------------------------------------------------------------------------------------------------------------
const nameEl = el('strong', { class: 'logo-info-name' });
const byEl = el('span', { class: 'logo-info-by' });
const heartsEl = el('span', { class: 'logo-info-hearts' });
const textEl = el('p', { class: 'logo-info-text' }, nameEl, ' · ', byEl, ' · ', heartsEl);
const hiddenEl = el('span', { class: 'logo-badge logo-info-hidden' }, t('Ausgeblendet'));
const reportButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-report' }, t('Melden'));
const deleteButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small auth-btn-danger logo-info-delete' }, t('Löschen'));
const visibilityButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-visibility' });
const actionsEl = el('div', { class: 'logo-info-actions' }, reportButton, deleteButton, visibilityButton);
const reportsEl = el('p', { class: 'logo-info-reports' });
const reasonsEl = el('ul', { class: 'logo-info-reasons' });
const noteEl = el('p', { class: 'logo-info-note' });
const adminEl = el('div', { class: 'logo-info-admin' }, reportsEl, reasonsEl, noteEl);
const cardEl = el('div', { class: 'logo-info-card' }, textEl, hiddenEl, actionsEl, adminEl);
// Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' });
const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' });
const root = el('div', { class: 'logo-info' }, cardEl, goneEl, messageEl);
// ---- Anzeige --------------------------------------------------------------------------------------------------------------
function setMessage(text: string): void {
messageEl.textContent = text;
}
const isOwner = (logo: CommunityLogo): boolean => {
const me = deps.userId();
return me !== null && logo.ownerId !== '' && logo.ownerId === me;
};
/** Löschen dürfen der Hochlader und Admins (der Dienst prüft es noch einmal). */
const mayDelete = (logo: CommunityLogo): boolean => deps.isAdmin() || isOwner(logo);
/** Lädt die Gründe der Meldungen eines Logos; eine Antwort für ein inzwischen anderes Logo oder einen anderen Stand zählt nicht. */
async function loadReasons(id: string, key: string): Promise<void> {
let loaded: Reasons;
try {
loaded = { key, status: 'ready', list: await deps.listLogoReports(id) };
} catch {
loaded = { key, status: 'error', list: [] };
}
if (signal.aborted || reasons?.key !== key) return;
reasons = loaded;
render();
}
/** Meldungen eines Admins: Anzahl aus der Zeile, Gründe nachgeladen (nur, wenn es Meldungen gibt). */
function renderReports(logo: CommunityLogo): void {
reportsEl.textContent = reportsLabel(logo.reportCount);
const key = `${logo.id}:${logo.reportCount}`;
if (logo.reportCount > 0 && reasons?.key !== key) {
reasons = { key, status: 'loading', list: [] };
void loadReasons(logo.id, key);
}
const current = reasons?.key === key ? reasons : null;
reasonsEl.replaceChildren(...(current?.status === 'ready' ? current.list.map((r) => el('li', {}, reasonLabel(r.reason))) : []));
noteEl.textContent = current?.status === 'error' ? t(REASONS_ERROR) : '';
}
function render(): void {
const id = host.selectedId();
if (id !== shownId) {
shownId = id;
reasons = null;
setMessage(''); // ein Fehler gehört zur Auswahl, bei der er auftrat
}
const logo = host.selected();
cardEl.hidden = logo === null;
goneEl.textContent = logo === null && host.gone() ? t(LOGO_MISSING) : '';
if (logo === null) return;
const admin = deps.isAdmin();
nameEl.textContent = logo.name;
byEl.textContent = t('von {name}', { name: logo.displayName || t('Anonym') });
heartsEl.textContent = `♥ ${logo.likeCount}`;
hiddenEl.hidden = !logo.hidden;
// Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden.
reportButton.hidden = logo.hidden;
deleteButton.hidden = !mayDelete(logo);
visibilityButton.hidden = !admin;
visibilityButton.textContent = logo.hidden ? t('Einblenden') : t('Ausblenden');
actionsEl.hidden = reportButton.hidden && deleteButton.hidden && visibilityButton.hidden;
adminEl.hidden = !admin;
if (admin) renderReports(logo);
}
// ---- Aktionen -------------------------------------------------------------------------------------------------------------
/** Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. */
async function run(action: () => Promise<void>): Promise<void> {
if (busy) return;
busy = true;
setMessage('');
try {
await action();
} catch (err) {
if (!signal.aborted) setMessage(failureText(err));
} finally {
busy = false;
}
}
async function report(): Promise<void> {
const logo = host.selected();
if (!logo) return;
await deps.reportDialog({ subject: 'logo', signal, send: (reason) => deps.reportLogo(logo.id, reason) });
}
async function remove(): Promise<void> {
const logo = host.selected();
if (!logo || !mayDelete(logo)) return;
const confirmed = await deps.confirm(
{
title: t('Logo „{name}“ löschen?', { name: logo.name }),
text: t(DELETE_TEXT),
confirmLabel: t('Löschen'),
cancelLabel: t('Abbrechen'),
},
signal,
);
// Während der Rückfrage kann die Seite verlassen oder die Sitzung gewechselt haben: dann nichts mehr senden.
if (!confirmed || signal.aborted || !mayDelete(logo)) return;
try {
await deps.deleteLogo(logo.id);
} catch (err) {
// Das Logo gibt es schon nicht mehr (die Antwort einer ersten Anfrage ging verloren und der Nutzer versucht es noch einmal, oder es
// wurde anderswo gelöscht): Ziel erreicht. Nur der Schlüssel `logoGone` zählt, jedes andere 404 ist ein echter Fehler.
if (!(err instanceof ApiError && err.code === 404 && err.serverCode === 'logoGone')) throw err;
}
forgetLogo(logo.id); // auch nach dem Verlassen der Seite: der Zwischenspeicher darf das gelöschte Logo nicht behalten
if (signal.aborted) return;
host.removeLogo(logo.id);
render();
}
async function toggleVisibility(): Promise<void> {
const logo = host.selected();
if (!logo || !deps.isAdmin()) return;
const show = logo.hidden; // ausgeblendet: einblenden, sonst ausblenden
await deps.setLogoVisibility(logo.id, show);
// Zeile und Bilder neu holen: ein ausgeblendetes Logo gilt für die Karte als nicht verfügbar, ein eingeblendetes wieder als da.
forgetLogo(logo.id);
if (signal.aborted) return;
// Einblenden löscht die Meldungen (Spec § 6).
host.patchRow(logo.id, show ? { hidden: false, reportCount: 0 } : { hidden: true });
render();
}
reportButton.addEventListener('click', () => void run(report));
deleteButton.addEventListener('click', () => void run(remove));
visibilityButton.addEventListener('click', () => void run(toggleVisibility));
render();
return { el: root, update: render, setMessage };
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more