Compare commits

...
63 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 1cdebb7102 feat: Speichern veröffentlicht direkt, mehrere Karten auf einmal hochladen und veröffentlichen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:59:07 +00:00
ClaudeandClaude Opus 5.5 4e9659ca85 docs: Benutzernamen in Console-Klickliste, API-README und README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:34:15 +00:00
ClaudeandClaude Opus 5.5 dc6bd3362b fix(community): Zeile „von …“ bleibt leer, solange der Name des Hochladers noch gesucht wird
Logo-Kacheln, Logo-Infozeile und Detailseite zeigen keinen Ersatz („von Anonym“, alter Anzeigename) mehr, der gleich auf „von @name“
wechselt: Solange die Abfrage für den Hochlader läuft, ist die Zeile leer und behält ihre Höhe (U+200B vor dem leeren Inhalt in
logos.css und community.css); danach steht der Name, ohne Profil oder bei einem Fehler der Ersatz. Schon bekannte Hochlader zeigen
ihre Zeile sofort, ein gemeldeter Name beendet das Warten. Die Infozeile lässt dabei Zeile und Trenner weg („<Name> · ♥ <Zahl>“),
LogoInfoHost bekommt byline(logo) statt names().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:20:24 +00:00
ClaudeandClaude Opus 5.5 56cb67be4b feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen
Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil,
sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext,
ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort.

Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der
localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen
(„Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:06:44 +00:00
ClaudeandClaude Opus 5.5 3440eb46c3 fix(account): Registrieren zeigt die Fehlerzeile am Feld im Sichtbaren, der Rückfall-Dialog nennt Namen und Grund
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:49:45 +00:00
ClaudeandClaude Opus 5.5 53bcecf3cb fix(account): Abmelden nimmt den Merker „Benutzername schon gefragt“ weg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:47:45 +00:00
ClaudeandClaude Opus 5.5 7c55912ebe fix(account): ein vor dem Umbenennen gestartetes Lesen meldet den alten Namen nicht mehr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:46:47 +00:00
ClaudeandClaude Opus 5.5 f46a69df68 fix(account): Konto-Menü zeichnet bei unverändertem Namen nicht neu (Dropdown und Fokus bleiben)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:46:02 +00:00
ClaudeandClaude Opus 5.5 04404ffdae refactor(account): isRowMissing als gemeinsame Hilfsfunktion statt dreier Kopien
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:45:05 +00:00
ClaudeandClaude Opus 5.5 0ad51bf7c6 docs: Spec Benutzernamen – autocomplete="nickname"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:26:34 +00:00
ClaudeandClaude Opus 5.5 151e340fdd fix(account): Benutzernamenfeld mit autocomplete="nickname" statt "username"
Angemeldet wird per E-Mail: Das Namensfeld der Registrierung und das Feld im
Namensdialog sind ein Spitzname, kein Anmeldename. Mit `username` würden
Passwortmanager den Namen statt der E-Mail als Anmeldenamen speichern und später
in die Anmeldung füllen. Tests angepasst, dazu einer, der prüft, dass in keiner
Ansicht des Login-Dialogs ein Feld `autocomplete="username"` trägt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:26:18 +00:00
ClaudeandClaude Opus 5.5 3e4bc21f00 feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage
- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht
  sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor`
  gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die
  bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil,
  Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der
  Merker `aircard.usernamePrompted`. `IDS.profiles`.
- Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi
  prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am
  Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`.
- Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und
  Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto);
  danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und
  der Namensdialog fragt.
- Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername
  ändern“ bzw. „Benutzername wählen“.
- main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der
  Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist).
- Texte in `en.ts`, Stile in `account.css`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:15 +00:00
ClaudeandClaude Opus 5.5 19c8151c7e feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
  je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
  keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
  oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
  Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
  409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
  entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
  `usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
  `usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
  Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
  `holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:44 +00:00
ClaudeandClaude Opus 5.5 a65c075004 docs: Spec und Plan Benutzernamen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:23:08 +00:00
ClaudeandClaude Haiku 4.5 13b46da2e0 fix(logos): Fokus auf die Überschrift ohne Scrollen
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-05 06:06:04 +00:00
ClaudeandClaude Opus 5.5 6e5fe3e199 fix(pages): englischer Datumsformatierer nur einmal angelegt, Kommentar im Melden-Dialog berichtigt
- format.ts: `Intl.DateTimeFormat('en-US', …)` wurde bei jedem Aufruf von formatDate neu angelegt (Meine Karten, Galerie, Detailseite, Logo-Infozeile rufen es
  je Karte). Er hängt nicht von der Sprache ab und steht jetzt einmal auf Modulebene (EN_DATE); die Ausgabe ist gleich, die vorhandenen Tests bleiben unverändert.
- report-dialog.ts: Der Kommentar zu reportDialog sprach noch vom „Servertext“; angezeigt wird `apiErrorText`, der Text zum Schlüssel des Fehlers in der
  Sprache der Seite.
- Test: fünf Aufrufe auf Englisch legen höchstens einen Formatierer an (frische Modulkopie, Konstruktor gezählt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 691b28df19 fix(editor): der Standardname einer neuen Karte folgt der Sprache
Der Editor-Zustand überlebt den Sprachwechsel (nur die Seite wird neu aufgebaut). Sein Standardname „Meine Karte“ blieb dabei deutsch, auch wenn die Seite
danach Englisch sprach: Herunterladen („aircard-meine-karte.png“) und Speichern nahmen ihn mit.

- state.ts: `defaultCardName()` (der Standardname in der Sprache der Seite, trägt auch initialEditorState) und `followLanguage(store)`: ein Hörer von
  onLangChange, der den Namen einer noch nicht gespeicherten Karte ersetzt, wenn er dem unberührten Standardnamen der bisherigen Sprache gleicht. Ein eigener
  Name bleibt, ebenso der Name einer gespeicherten Karte (auch wenn er dem Standard gleicht). Er merkt sich den Standard der Sprache bis zum nächsten Wechsel,
  so braucht es kein Wörterbuch der Gegensprache; gibt die Abmeldung zurück.
- main.ts: `followLanguage(editorStore)` direkt nach bindEditorToAuth, also vor dem Hörer, der Rahmen und Seite neu aufbaut: die neue Seite sieht den neuen Namen.
- Tests: Wechsel hin und zurück, Start auf Englisch, eigener Name, zurückgesetzter Name, gespeicherte Karte, leerer Name, Rest des Zustands und genau
  eine Benachrichtigung, Abmeldung; in main.test.ts mit dem echten Sprachknopf samt Name, den die neu gebaute Seite beim Aufbau sieht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 e0f11f905e fix(editor): Vorschau und Miniaturen laden ein ausgeblendetes, eingeblendetes oder gelöschtes Logo neu
Blendete ein Admin das gewählte Logo im Picker aus (oder ein), forgetLogo leerte zwar den Zwischenspeicher, aber der Editor behielt seine schon
geladenen Logo-Bilder: Vorschau und Miniaturen zeigten weiter den alten Stand, bis sich Logo, Details oder Marke änderten.

- logo-picker.ts: neuer Rückruf `onLogoChanged(id)` (Typ LogoPickerHandlers). Der Picker ruft ihn, wenn die Infozeile ein Logo aus- oder eingeblendet hat
  (nach patchRow, die Zeile ist dann schon aktuell) und nach dem Löschen (nach onSelect(null)).
- editor-page.ts: Ist das gemeldete Logo das gewählte, verwirft der Editor seinen Logo-Zustand samt Sperren (logos, pendingKey, failedKey), blendet den Hinweis
  aus und ruft ensureLogos neu: Vorschau und alle vier Miniaturen werden mit dem neuen Stand gezeichnet, der Hinweis „nicht mehr verfügbar“ folgt. Ein
  anderes oder kein gewähltes Logo löst nichts aus; ein noch laufender Ladevorgang wird überholt (Token).
- editor-page.ts, followDownload: Stand die Karte nach einem Ladefehler ohne Logo da (`failed`), kehrte der Download früh zurück, obwohl er die Logos
  geladen hatte: das PNG hatte das Logo, die Vorschau nicht. Jetzt übernimmt die Vorschau es (Hinweis passend, failedKey leer).
- editor-page.ts, doSave: forgetLogo(config.logo) vor dem Laden der Bilder, wie Download und Veröffentlichen; ein inzwischen ausgeblendetes Logo kommt nicht
  mehr ins Vorschaubild der gespeicherten Karte.
- editor-page.ts: Beim Ladefehler für eine neue Auswahl blieb der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ der früheren Auswahl neben dem neuen
  Fehler stehen; die Logos der früheren Auswahl und ihr Hinweis fallen jetzt sofort weg (andere Hinweise bleiben).
- Tests: Picker (Rückruf nach Aus-/Einblenden und Löschen mit Reihenfolge, nicht bei Fehlern, Herz, Melden, nach dem Aufräumen) und Editor (Aus- und Einblenden,
  anderes oder kein Logo, überholter Ladevorgang, nach Ladefehler, mit dem echten Zwischenspeicher; Download-Übernahme, Speichern, alter Hinweis neben Fehler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 0e4759a05a fix(logos): Infozeile zeigt laufende Aktionen an, Fehler gehören zur Auswahl, Gründe nach „Einblenden“ verworfen
- logo-info.ts, run: Die Aktion merkt sich die Auswahl beim Start. Ein Fehler, der erst eintrifft, nachdem der Nutzer ein anderes Logo gewählt hat,
  erschien sonst in dessen Meldezeile (render() leert sie nur beim Wechsel, nicht danach). Jetzt steht er nur da, wenn noch dasselbe Logo gewählt ist.
  Ergebnisse, die an einer ID hängen (patchRow, removeLogo, forgetLogo), gelten wie bisher dem Logo, für das die Aktion lief.
- logo-info.ts: Solange eine Aktion läuft (Rückfrage, Dialog oder Anfrage), trägt die Infozeile aria-busy="true" und die anderen Aktionsknöpfe sind
  gesperrt. Der gedrückte Knopf bleibt bedienbar und behält den Fokus (ein gesperrter verlöre ihn, und der Picker holt den Fokus von „Löschen“ nur,
  wenn er in der Infozeile liegt). Danach ist alles wieder frei, auch nach einem Fehler und nach dem Verlassen der Seite.
- logo-info.ts, renderReports: Bei 0 Meldungen (etwa nach „Einblenden“, das sie löscht) wird der Gründe-Zwischenspeicher geleert. Kamen später genau so
  viele Meldungen hinzu wie vorher (gleicher Schlüssel <id>:<n>), wurden die alten, längst gelöschten Gründe gezeigt.
- Tests: Zustand während jeder Aktion (Löschen mit Rückfrage und Anfrage, Melden, Ausblenden, Fehler, abort), Fokus, wirkungslose Klicks; Fehler nach
  Wechsel der Auswahl (anderes Logo, „Kein Logo“, Aus-/Einblenden) und bei gleicher Auswahl; Gründe nach „Einblenden“ samt später Antwort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 02b01c9deb fix(logos): Picker hält den Fokus bei „Mehr laden“/„Nochmal versuchen“, Raster als Liste, Cursor ohne Vorgänger
- logo-picker.ts: Verschwindet „Mehr laden“ oder „Nochmal versuchen“, während es den Fokus hat, geht der Fokus weiter statt auf der Seite verloren zu
  gehen: an den Knopf, der seinen Platz einnimmt („Nochmal versuchen“ nach einem Fehler beim Nachladen), sonst an die erste Kachel, die der Ladevorgang
  angehängt hat, sonst an die Überschrift „Logo unten rechts“ (jetzt tabindex −1). Ein gesperrter Knopf verliert den Fokus, darum bleibt „Mehr laden“
  während des Ladens bedienbar, solange es ihn hat (ein zweiter Druck ist wirkungslos, load prüft es); ohne Fokus bleibt es gesperrt wie bisher.
  Wer den Fokus woanders hat, bekommt ihn nicht weggenommen.
- logo-picker.ts: das Raster hat role="list" (list-style: none nimmt dem <ul> sonst die Listensemantik) und kein eigenes aria-labelledby mehr; der
  Name steht an der Gruppe. Das lokale `const select` in createTile verdeckte die Funktion select() und heißt jetzt selectButton.
- logo-picker.ts, removeLogo: War das gelöschte Logo der Cursor und steht kein früheres mehr in der Liste, wurde der Cursor verworfen und „Mehr laden“
  verschwand samt aller weiteren Seiten. Jetzt beginnt die Ansicht wieder bei Seite 1 (load('reset')).
- Tests: Fokus in allen Fällen (letzte Seite, weitere Seite, Fehler, Retry bei leerer und gefüllter Liste, Fokus woanders, nur schon bekannte Logos,
  nach dem Aufräumen), role/aria-labelledby/tabindex, Cursor ohne Vorgänger (auch in der Admin-Ansicht „Gemeldet“) und die Fälle ohne Neuladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 8167a18857 fix(a11y): Datei-Feld nativ „required“, ungültige Felder verweisen auf die Fehlerzeile
- logo-dialog.ts: das Datei-Feld trägt `required` statt `aria-required` (für ein Datei-Feld gilt aria-required nicht; das Formular hat novalidate und
  prüft selbst, es ändert sich nichts am Ablauf).
- logo-dialog.ts und publish-dialog.ts: die Fehlerzeile hat eine ID (logo-upload-error, publish-error); solange ein Feld ungültig ist, steht sie in
  seinem aria-describedby, hinter dem Hinweis des Feldes (Datei, Anzeigename, Tags). Ein Screenreader liest den Fehler so mit dem Feld vor.
  Beim nächsten Senden fällt der Verweis mit aria-invalid weg, der Hinweis bleibt. Im Dialog „Logo hochladen“ löscht auch eine neue Dateiwahl die
  Markierung aller Felder (sie löscht ja den Fehlertext, auf den sie verwiesen).
- editor/dom.ts: setFieldInvalid(control, errorId, invalid) für beide Dialoge; eigene Tests (dom.test.ts).
- Tests: required statt aria-required; ID einmalig; je Feld Verweis samt Hinweis in Reihenfolge, Rücknahme, neue Dateiwahl; der vorhandene Hilfsfall
  `invalid` beider Dialoge prüft den Verweis für jedes Feld mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 29c9b2efaf fix(logos): SVG-Wurzelelement richtig finden, Quell-Canvas und Pixelpuffer früh freigeben, Konstanten umbenannt
- prepare-logo.ts, sizeSvg: Das Wurzelelement wird nicht mehr mit der ersten Fundstelle von „<svg“ gesucht. rootStart überspringt Byte-Order-Marke,
  Leerraum, XML-Deklaration und Anweisungen (<?…?>), Kommentare und den DOCTYPE (auch mit eigener DTD in […] samt Zeichenketten, Kommentaren und
  Anweisungen darin) und verlangt dahinter `<svg` mit Leerraum, „/“ oder „>“. Ein auskommentiertes <svg …> oder eines im DOCTYPE wurde sonst
  umgeschrieben, ein `<svg:svg …>` (Präfix) oder `<svg-x` zerstörte das Tag. Beides bleibt jetzt unverändert (quadratisch), wie ein Text ohne
  Wurzelelement. Ein Durchlauf ohne Zurücksetzen, auch ein feindlicher Prolog braucht nur linear viel Zeit.
- prepare-logo.ts, prepareLogo: renderLogo zeichnet das Quellbild auf den Quell-Canvas, liest den Pixelpuffer einmal als Argument von planLogo
  (keine Referenz danach) und setzt den Quell-Canvas im finally auf 0 × 0, noch vor dem PNG und auch bei Fehlern. Das Ergebnis ist gleich.
- prepare-logo.ts: LOGO_MAX_W/LOGO_MAX_H (720/250, die Dateigrenze) heißen LOGO_FILE_MAX_W/LOGO_FILE_MAX_H und kollidieren nicht mehr im Namen mit
  LOGO_MAX_H aus card/geometry.ts (125, Höhe auf der Karte). Einziger Verwender ist der Test.
- Tests: Prolog-Fälle (Kommentar, Anweisung, DOCTYPE mit DTD, BOM, mehrere, Präfix, Bindestrich, nicht endender Prolog, feindliche Eingaben mit
  Zeitgrenze); Quell-Canvas ist frei (auch vor toBlob, bei leer, zu klein, Zeichenfehler, fehlendem Ergebnis-Canvas, fehlendem PNG), der Pixelpuffer
  wird einmal gelesen. Die vorhandenen Größenprüfungen lesen die Größe beim Zeichnen (`used`), weil der Quell-Canvas am Ende 0 × 0 ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 76c3c3aea2 fix(logos): nur „Datei nicht gefunden“ gilt als fehlendes Logo, nicht genutzte Bilder werden freigegeben
- logo-asset.ts: isFileMissing galt für jedes 404 und für jeden Fehler mit dem Typ storage_file_not_found. Jetzt nur noch ein 404 mit diesem Typ
  (oder ohne bzw. mit leerem Typ), wie isRowMissing in logos.ts. Ein fehlender Bucket (404 storage_bucket_not_found) und andere Fehler sind echte
  Fehler: sie werden weitergereicht und nicht zwischengespeichert, statt stillschweigend „Logo nicht verfügbar“ zu melden.
- logo-asset.ts: dekodierte ImageBitmaps, die nicht bleiben, werden mit close() freigegeben: das Zwischenbild eines einfarbigen Logos nach dem
  Einfärben (auch wenn das Füllen scheitert), Bilder über der Pixelgrenze und Bilder ohne Fläche. Das Bild eines mehrfarbigen Logos bleibt offen,
  es ist das Ergebnis; ein `Image` (Ersatzweg ohne createImageBitmap) hat kein close und wird übersprungen.
- Tests: die Fehlerfälle (Bucket, Projekt, andere 404, Typ ohne 404) mit dem Nachweis, dass nichts zwischengespeichert wird; close() in allen
  Fällen samt Reihenfolge (erst nach dem Einfärben).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:32:51 +00:00
ClaudeandClaude Opus 5.5 1fd08d6373 fix(i18n): ein werfender Hörer hält die übrigen beim Sprachwechsel nicht auf
setLang rief die Hörer in einer Schleife ohne Schutz: warf einer (etwa beim Neuaufbau der Kopfzeile), blieben die folgenden ungerufen und der
Aufrufer bekam den Fehler zu sehen, obwohl die Sprache schon umgestellt war.

- i18n.ts: jeder Hörer läuft in try/catch; der Fehler geht mit console.error in die Konsole, der nächste Hörer folgt.
- i18n.ts: der Parameter `browserLanguages` von initialLang hieß wie die Funktion darunter und verdeckte sie; jetzt `languages`.
- Tests: ein werfender Hörer vor, zwischen und nach anderen: alle übrigen laufen, setLang wirft nicht, Sprache und <html lang> stehen, der Fehler
  steht einmal je Aufruf in der Konsole, der werfende Hörer bleibt angemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:32:01 +00:00
ClaudeandClaude Opus 5.5 0bcdfe258d fix(shell): geschlossenes Spenden-Banner kommt beim Sprachwechsel auch ohne localStorage nicht zurück
Das Banner merkte sich „geschlossen“ nur in localStorage. Bei gesperrtem Speicher (privates Fenster, gesperrte Website-Daten) baute der Neuaufbau
des Rahmens beim Sprachwechsel (main.ts) es wieder auf.

- donate-banner.ts: zusätzlich ein Merker im Speicher des Moduls (closedThisSession); er gilt neben localStorage.
- Tests: donate-banner.test.ts lädt je Test eine frische Modulkopie (der Merker lebt im Modul); neue Fälle: gesperrter Speicher, erneutes Einhängen
  nach dem Schließen (bleibt weg) und ohne Schließen (kommt wieder); main.test.ts: Sprachwechsel nach dem Schließen bei gesperrtem Speicher.
  Die beiden schon vorhandenen Fälle zum gesperrten Speicher sperren jetzt wirklich (vi.stubGlobal): vi.spyOn(Storage.prototype, …) greift in
  happy-dom nicht, die Fälle waren leer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:31:38 +00:00
ClaudeandClaude Opus 5.5 af05e5f1f1 fix(api): Logo löschen blendet das Logo zuerst aus, dann folgen Datei, Herzen, Meldungen und die Zeile
deleteLogo löschte zuerst die Datei und zuletzt die Zeile. Brach es dazwischen ab, blieb eine öffentliche Zeile mit gelöschter Datei stehen: unter
dieser Datei-ID kann jedes Konto im Bucket eine neue Datei anlegen, und alle gespeicherten Karten mit diesem Logo zeigten bis zur Wiederholung deren Bild.

- logos.js: Vor dem Löschen der Datei wird das Logo ausgeblendet wie bei der Admin-Sichtbarkeit (hideLogo: Datei, dann Zeile auf genau die ausgeblendeten
  Rechte mit hidden: true; die Rechte der Zeile bleiben die des Hochladers, auch wenn ein Admin löscht). Ein schon ausgeblendetes Logo überspringt das.
  Danach wie bisher Datei, Herzen, Meldungen, die Zeile zuletzt. Wer nicht löschen darf (403), ändert nichts. Das Zurückziehen von Karten bleibt unverändert.
- Tests: Reihenfolge (Datei und Zeile ausblenden, Datei, Herzen, Meldungen, Zeile); Abbruch nach dem Löschen der Datei bei „Herzen lesen“, „Meldungen lesen“
  und „Zeile löschen“ (Hochlader und Admin): die Zeile bleibt mit genau den ausgeblendeten Rechten, kein Gast liest sie, die Wiederholung vollendet das
  Löschen ohne erneutes Ausblenden; scheitert schon das Ausblenden, ist nichts gelöscht; Fremde und schon ausgeblendete Logos lösen kein Ausblenden aus.
- README und Spec: Reihenfolge beim Löschen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:19:57 +00:00
ClaudeandClaude Opus 5.5 7cb1381566 fix(api): Logzeile, wenn die Rückgabe von delete(owner) nach einem Fehler beim Hochladen scheitert
Scheitert das Festlegen oder die zweite Prüfung beim Hochladen eines Logos, nimmt die API die Zeile zurück und gibt der Datei delete("user:<ich>")
zurück, damit der Client sie aufräumen kann. Scheiterte diese Rückgabe, blieb die Datei ohne Zeile und ohne dieses Recht liegen, und das Log
schwieg: die Antwort bleibt der ursprüngliche Fehler, ein Rest ohne Zeile ist sonst nicht zu finden.

- logos.js: eine Logzeile `restore failed for logo <Zeilen-ID> (file <Datei-ID>)` (nur IDs, wie `rollback failed for logo …`).
- Test: zweite Prüfung scheitert, Zeile wird zurückgenommen, die Rückgabe scheitert: genau diese Zeile, Antwort 502, Datei bleibt festgelegt;
  ohne Fehler und bei gescheitertem Zurücknehmen steht keine solche Zeile im Log.
- README: neue Logzeile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:11:50 +00:00
ClaudeandClaude Opus 5.5 6134fd26d7 fix(api): Hochladen hält die Sperre des Logos bis zur zweiten Prüfung
Ab dem Anlegen steht die Zeile öffentlich in der Liste. Ein Herz, eine Meldung oder ein Löschen, das zwischen Anlegen, Festlegen der Dateirechte
und zweiter Prüfung eintraf, lief an der Sperre `logo:<id>` vorbei: entfernte die Prüfung das Logo danach, blieb eine Herz- oder Meldungszeile
ohne Logo zurück.

- logos.js: Der Upload nimmt die Sperre `logo:<Zeilen-ID>` innerhalb der Kontosperre (Reihenfolge Konto, dann Logo; nur das Hochladen nimmt beide,
  nichts kann sich verklemmen) für Anlegen, Festlegen, zweite Prüfung und Rücknahme.
- Tests mit Verzögerung (delayNext): Herz und Meldung, die in dieser Zeit eintreffen, warten und bekommen 404 ohne Reste (Beanstandung und
  Rücknahme nach einem Fehler); ein Löschen wartet, der Upload gelingt und danach ist das fertige Logo weg.
- README: Sperre beim Hochladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:11:06 +00:00
ClaudeandClaude Opus 5.5 738358fcac fix(logos): hochgeladenes Logo trägt die Datei-ID der Datei und die ID des Dienstes
Die API vergibt die Zeilen-ID des Logos jetzt selbst (Hash aus Konto und Datei, nicht mehr die Datei-ID). uploadPreparedLogo baute das neue Logo
aber noch mit `fileId: id`: das Bild des frisch hochgeladenen Logos wäre aus einer Datei geladen worden, die es nicht gibt.

- upload-logo.ts: `id` aus der Antwort der API, `fileId` aus der angelegten Datei (`$id`). Kommentare ohne „Zeilen-ID = Datei-ID“ (auch logo-api.ts).
- Test: Dienst-ID und Datei-ID sind verschieden und werden nicht vertauscht (Fakes und Standard-Abhängigkeiten).
- Sonst nirgends in web/src: Auswahl und Karten-Konfiguration nehmen `logo.id`, das Bild kommt über `logo.fileId` (loadLogoBlob), beides lief schon getrennt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:08:39 +00:00
ClaudeandClaude Opus 5.5 6d152c11b3 fix(api): Logo-Zeilen-ID ist ein Hash aus Konto und Datei, nicht mehr die Datei-ID
Die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten. War sie die Datei-ID, konnte jeder eine Datei
mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen: gespeicherte Karten anderer zeigten dann sein Bild.

- logos.js: Zeilen-ID = hash32("<ownerId>|<fileId>") (logoRowId); die Antwort 201 {id} trägt sie. Dieselbe Datei desselben Kontos ergibt dieselbe ID
  (409 logoTaken, bei einem Altlogo über die fileId der Zeile); alle anderen Regeln bleiben. Die Logzeilen nennen die Zeilen-ID, die Fehlerzeilen
  zusätzlich die Datei-ID (sie folgt nicht mehr aus der Zeilen-ID, und die Zeile ist beim Aufräumen vielleicht schon weg).
- Altlogos (Zeilen-ID = Datei-ID, eines ist live) gehen weiter: Herz, Melden, Löschen und Sichtbarkeit adressieren nur die Zeile und lesen die Datei aus fileId.
- Tests: seedLogo legt Zeile und Datei mit verschiedenen IDs an; neu: logoRowId, die ID eines gelöschten Logos lässt sich nicht neu belegen, anderes Konto
  bekommt eine verwendete Datei nicht (403), Altlogo (Herz, Meldung, Ausblenden/Einblenden, Löschen, erneutes Hochladen 409).
- README und Spec: ID-Regel. Der Client (upload-logo.ts) folgt im nächsten Commit; beide gehen zusammen live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:07:28 +00:00
ClaudeandClaude Opus 5.5 ec0b51a949 fix(i18n): „Explore" statt „Discover" im englischen Text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:44:15 +00:00
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
ClaudeandClaude Opus 5.5 8fb28eb1d7 feat(shell): Spenden-Link Buy Me a Coffee
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:30:30 +00:00
ClaudeandClaude Sonnet 5.5 a3523d3316 chore(web): veraltete Kommentare, Theme-Skript in IIFE, CARD_W/CARD_H und reportsLabel nur aus einer Quelle
- publish.ts und editor-page.ts: Kommentare zum Aufräumen bzw. zur Adresse /gestalten?karte= stimmen wieder.
- index.html: Inline-Skript für Hell/Dunkel in einer sofort aufgerufenen Funktion (keine globale Variable theme);
  Test führt es aus und vergleicht mit initialTheme().
- CARD_W/CARD_H nur noch aus card/geometry.ts (Startseite, Detailseite, Raster), Vorschauhöhe des Rasters daraus abgeleitet;
  reportsLabel nur aus pages/format.ts. Ein Test sorgt dafür, dass es dabei bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:40:17 +00:00
ClaudeandClaude Sonnet 5.5 f6b9917b07 docs: Scope-Namen der Console (documents.*), sicherer .env-Befehl, X-Real-Ip-Risiko
- Der Key aircard-api braucht in der Console documents.read/documents.write (entspricht rows.* der API) sowie files.read/files.write.
- Der Befehl zum Eintragen des Keys nutzt read -rs, schreibt bei leerer Eingabe nichts und beginnt mit einem Zeilenumbruch
  (klebt nie an einer letzten .env-Zeile ohne Umbruch); Hinweis zum Zeilenumbruch auch bei der Eingabe mit nano.
- deploy/README.md: nginx-503 bei fehlendem Dienst, up -d nach .env-Änderung, X-Real-Ip im Docker-Netz als akzeptiertes Risiko.
- Kommentare in server.js und compose-snippet.yml angepasst.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:40:17 +00:00
ClaudeandClaude Sonnet 5.5 aa9f9548ad fix(shell): aria-current in der Navigation, Suche wird bei Seitenwechsel geleert, Live-Regionen bleiben gerendert
- Router: onRouteChange meldet jede neue Seite (vor dem Aufbau). Die Kopfzeile markiert damit den aktuellen Navigationslink
  mit aria-current="page" („Community“ auch auf der Detailseite) und leert das Suchfeld, wenn die neue Seite nicht die
  Startseite mit ?q= ist (bisher blieb „testbank“ auf /gestalten stehen).
- Spenden-Knopf in der Kopfzeile trägt title „Unterstützen“ (zwischen 600 und 1179 px nur Symbol).
- .detail-msg:empty und .auth-msg:empty sind nicht mehr display: none (Live-Regionen standen nicht im Zugänglichkeitsbaum),
  sondern ohne Platz im Layout, aber gerendert; Test liest die CSS-Dateien.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:37:46 +00:00
ClaudeandClaude Sonnet 5.5 c62075f2d4 fix(community): Pixelgrenze beim Anzeigen, exakter Tag-Filter, unsichtbare Zeichen
- Gemeinsamer Helfer loadGalleryImageChecked/checkGalleryImage (gallery.ts): lädt das Bild über new Image(), lehnt über
  4 Megapixel oder nicht dekodierbare Dateien ab und liefert eine fertige Object-URL (der Aufrufer gibt sie frei).
  Raster -> „Keine Vorschau“ (auch bei <img>-Fehler), Hero -> Beispielmotiv, Detailseite -> „Das Bild konnte nicht geladen werden.“
  Verspätet eintreffende URLs werden sofort freigegeben.
- Tag-Filter sucht ",<tag>," (der Server speichert ",a,b,"): „gold“ trifft nicht mehr „marigold“.
- Veröffentlichen-Dialog lehnt dieselben unsichtbaren Zeichen ab wie die API (C1, Nullbreite, Bidi, BOM).
- Das Limit der API (429 „schon 20 Karten veröffentlicht“) gilt als eindeutige Ablehnung: die hochgeladenen Dateien werden aufgeräumt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:35:10 +00:00
ClaudeandClaude Sonnet 5.5 5500495443 fix(nginx): /api liefert ohne Dienst 503 mit JSON statt der App
Fällt aircard-api aus, verschwindet sein Traefik-Router und /api/* fiel auf index.html (200 text/html) zurück.
Jetzt 503 mit {"error":"Der Dienst ist gerade nicht erreichbar."} und Content-Type application/json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:30:28 +00:00
ClaudeandClaude Sonnet 5.5 4c90ee49e8 fix(api): Bildmaße prüfen, Dateien ohne delete(owner), Limits, Melder-Netz
- Beim Veröffentlichen liest die API die ersten 64 Bytes beider Dateien (PNG-IHDR, WebP VP8/VP8L/VP8X) und verlangt Bild 1536x969,
  Vorschau 480x302-304, sonst 400 "Das Bild hat nicht das Kartenformat."
- Veröffentlichte und ausgeblendete Dateien tragen kein delete(owner) mehr (Abweichung von Spec §7): kein Löschen und Neuanlegen
  derselben Datei-ID nach dem Veröffentlichen; scheitert das Festlegen, bekommen die Dateien delete(owner) für den Client-Aufräumer zurück
- Melder-Identität aus dem IPv4-/24 bzw. IPv6-/48; das Rate-Limit zählt weiter je Anschluss
- Höchstens 20 Veröffentlichungen je Konto (429); sourceCardId muss eine eigene Zeile in `cards` sein (403)
- Steuer-, Nullbreiten- und Bidi-Zeichen in Titel, Herausgeber, Anzeigename und Tags abgelehnt
- Tags als ",a,b," gespeichert (exakter Tag-Filter)
- fehlgeschlagenes Zurücknehmen wird mit `rollback failed for card <id>` protokolliert
- README: Endpunkt-Tabelle, Regeln, Scopes (documents.*), akzeptiertes Risiko X-Real-Ip im Docker-Netz

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:30:28 +00:00
156 changed files with 27177 additions and 1409 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, der Community-Logos und der Benutzernamen (`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) und `profiles` (Benutzernamen, eine Zeile je Konto, Unique-Index auf dem klein geschriebenen Namen); 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:**
+24 -8
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`, `profiles.js`, `server.js` | `aircard-ios.org/api/` (nur diese sechs Dateien) | API-Dienst `aircard-api` der Community-Galerie, der Community-Logos und der Benutzernamen, 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/profiles.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
@@ -41,7 +41,7 @@ Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff
printf 'AIRCARD_API_SALT=%s\n' "$(openssl rand -hex 32)" >> /home/webklar/apps/.env
```
Den Key `AIRCARD_API_KEY` legt Kenso nach [docs/appwrite-console.md](../docs/appwrite-console.md) (Abschnitt 7.3) an und trägt ihn mit dem verdeckten `read -s`-Befehl ein.
Den Key `AIRCARD_API_KEY` legt Kenso nach [docs/appwrite-console.md](../docs/appwrite-console.md) (Abschnitt 7.3) an (Scopes `documents.read`, `documents.write`, `files.read`, `files.write`, so beschriftet die Console die Zeilen-Scopes) und trägt ihn mit dem verdeckten `read -rs`-Befehl ein (leere Eingabe schreibt nichts).
## Starten
@@ -55,13 +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, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern, 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, der Community-Logos und der Benutzernamen 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; Benutzernamen anlegen und ändern, Veröffentlichen und Hochladen verlangen einen). 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`.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
- **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 (alle sechs: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`; `app.js` lädt `common.js`, `logos.js` und `profiles.js`, `logos.js` lädt `profiles.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.
- **Benutzernamen brauchen das Schema**: `POST /api/profil` schreibt in die Tabelle `profiles` (Zeilen-ID = Konto-ID, Unique-Index `username_key` auf `usernameKey`); dieselbe Tabelle fragen `/api/veroeffentlichen` und `/api/logos/hochladen` als Erstes ab (ohne Zeile für das Konto: `403` `usernameRequired`). Das Schema-Skript legt sie an, dieselben Scopes des Keys `aircard-api` genügen, in der Console ist nichts zu tun. Ohne die Tabelle antworten Hochladen, Veröffentlichen und `/api/profil` mit `500` „Interner Fehler.“, nicht mit `403`. Zwischen dem Neustart des Dienstes und dem Livegang der neuen Seite kann die alte Seite Konten ohne Benutzernamen nicht mehr hochladen oder veröffentlichen lassen (sie zeigt den Text des Dienstes, „Bitte wähle zuerst einen Benutzernamen.“): die neue Seite direkt danach live bringen.
- **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.
- **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`, für die Benutzernamen die Tabelle `profiles` mit dem Unique-Index `username_key`.
2. **API**: alle sechs Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.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); dasselbe gilt für `POST /api/profil` (ohne JWT ebenfalls `401`). 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
@@ -103,7 +119,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 sechs 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.
+49 -17
View File
@@ -1,25 +1,28 @@
# 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, die Community-Logos und die Benutzernamen 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, bei den Benutzernamen Anlegen und Ändern. Hochladen und Veröffentlichen gehen nur mit Benutzernamen. Die Website liest Galerie, Logos und Benutzernamen 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) und `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md` §2–4 (Benutzernamen). 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` |
| `profiles.js` | der Benutzernamen-Endpunkt (`createProfileRoutes`) und `hasProfile`, das `app.js` und `logos.js` vor dem Veröffentlichen und Hochladen fragen; bekommt Anmeldung, Sperre je Schlüssel und Log von `app.js` |
| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Namensmuster und Sperrliste der Benutzernamen |
| `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 sechs Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js` und `server.js`.
## Umgebung
| Variable | Standard | Bedeutung |
|---|---|---|
| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) |
| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api`. Scopes (so beschriftet die Console): `documents.read`, `documents.write`, `files.read`, `files.write`; `documents.*` entspricht den `rows.*` der API (TablesDB) |
| `AIRCARD_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex |
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz |
@@ -29,29 +32,58 @@ 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 |
|---|---|---|
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falsches Format oder > 5 MiB · `403` Datei nicht meine / falscher Bucket · `409` „Diese Karte ist schon veröffentlicht.“ |
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `403` Konto ohne Benutzernamen („Bitte wähle zuerst einen Benutzernamen.”, vor allen anderen Prüfungen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.”), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.”) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.”) · `409` „Diese Karte ist schon veröffentlicht.” · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.” |
| `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, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · `401` · `403` Konto ohne Benutzernamen (wie beim Veröffentlichen) · `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` |
| `POST /api/profil` `{username}` | Nutzer-JWT | `200 {username}` (Name angelegt oder geändert) · `401` · `400` Muster („Der Benutzername muss 3–20 Zeichen lang sein, …“) oder gesperrter Name („Dieser Benutzername ist nicht erlaubt.“) · `409` „Dieser Benutzername ist schon vergeben.“ |
| `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).“ · `usernameInvalid` „Der Benutzername muss 3–20 Zeichen lang sein, …“ · `usernameReserved` „Dieser Benutzername ist nicht erlaubt.“ |
| `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.“ · `usernameRequired` „Bitte wähle zuerst einen Benutzernamen.“ |
| `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.“ · `usernameTaken` „Dieser Benutzername ist schon vergeben.“ |
| `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
- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab.
- **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt, keine Steuerzeichen). `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Bild und Vorschau müssen verschiedene Dateien sein.
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403`. **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index, auf die Datei-ID-Spalten nicht) seitenweise à 100 mit Cursor und läuft zusammen mit dem Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")`, `delete("user:<ich>")` (kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen. Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
- **Felder**: Titel 1–60, Herausgeber ≤ 40 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spalte `tags` als `,tag1,tag2,` (Komma an beiden Enden, leer bei keinen Tags): der Tag-Filter der Website sucht `,<tag>,` und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein.
- **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 Zeichen (getrimmt; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. **Zeilen-ID des Logos** = `sha256(<ownerId>|<fileId>)` (erste 32 Hex), **nicht** die Datei-ID; die Antwort `201 {id}` trägt diese ID. Dieselbe Datei desselben Kontos ergibt dieselbe ID (`409`). Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten; wäre sie die Datei-ID, könnte jeder eine Datei mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen (gespeicherte Karten anderer zeigten dann sein Bild). Mit dem Konto im Hash geht das nicht. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat die Datei kein `delete("user:<ich>")` mehr, ein anderes Konto bekommt sie nicht (`403`). **Altlogos** (vor dieser Regel angelegt) haben noch Zeilen-ID = Datei-ID: Herz, Melden, Löschen und Sichtbarkeit adressieren die Zeile nur über ihre ID und die Datei über das Feld `fileId`, darum gehen beide Arten; dieselbe Datei eines Altlogos noch einmal hochzuladen ist ebenfalls `409`.
- **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`) → Benutzername (`403` `usernameRequired`) → 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`: Zeile mit dieser Zeilen-ID oder mit dieser `fileId`, also auch ein Altlogo) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → ab hier zusätzlich die Sperre des Logos `logo:<Zeilen-ID>` (immer erst Konto, dann Logo) bis zur zweiten Prüfung unten: 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> (file <Datei-ID>)` im Log; scheitert danach die Rückgabe von `delete("user:<ich>")`, bleibt die Datei ohne Zeile und ohne dieses Recht liegen und `restore failed for logo <id> (file <Datei-ID>)` steht im Log, die Antwort bleibt in beiden Fällen der ursprüngliche Fehler). **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> (file <Datei-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. **Sperre des Logos beim Hochladen**: Die Zeile steht ab dem Anlegen öffentlich in der Liste; ein Herz, eine Meldung oder ein Löschen, das bis zum Ende der zweiten Prüfung eintrifft, wartet auf die Sperre und sieht danach den Endzustand (das fertige Logo oder `404`, wenn die Prüfung es entfernt hat). So bleibt keine Herz- oder Meldungszeile ohne Logo zurück.
- **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>`, auch gegen ein gerade laufendes Hochladen dieses Logos). 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) blendet das Logo zuerst aus (Datei, dann Zeile auf die ausgeblendeten Rechte mit `hidden: true`; ein schon ausgeblendetes Logo überspringt das) und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Das Ausblenden zuerst verhindert, dass nach einem Abbruch (502) eine öffentliche Zeile mit gelöschter Datei stehen bleibt: unter deren Datei-ID könnte jedes Konto eine neue Datei anlegen, und alle gespeicherten Karten zeigten sie. Es bleibt höchstens eine unsichtbare Zeile (nur Hochlader und Admins lesen sie); die Wiederholung vollendet das Löschen. 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 Melder (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge). Der Melder-Hash ist `HMAC-SHA256(salt, ip)`; IPv6-Adressen werden vorher auf ihr /64-Netz gekürzt, IPv4-gemappte IPv6 auf IPv4. Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst darf deshalb nur über Traefik erreichbar sein (kein `ports:` in Compose).
- **Rechte** (genau diese Strings): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei sichtbar `read("any")`, `delete("user:<owner>")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`).
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
- **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")`). 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; Profile (Benutzernamen) tragen genau `read("any")`.
- **Benutzernamen** (`/api/profil`, `profiles.js`): `username` muss `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$` erfüllen (3–20 Zeichen, nur ASCII, kein Trimmen: Leerzeichen sind ungültig), sonst `400` `usernameInvalid` (auch wenn es keine Zeichenkette ist). Eindeutig ist der **Schlüssel** `usernameKey` = der Name klein geschrieben („Kenso“ und „kenso“ sind derselbe Name); gespeichert und angezeigt wird der Name, wie er eingegeben wurde. **Gesperrte Schlüssel** (`400` `usernameReserved`, in jeder Schreibweise): `admin`, `administrator`, `root`, `system`, `support`, `moderator`, `mod`, `aircard`, `aircardios`, `aircard-ios`, `aircard.ios`, `aircard_ios`, `webklar`, `anonym`, `anonymous`, `null`, `undefined`, `team`, `staff`, `official`, `offiziell`, `hilfe`, `help`, `kontakt`, `contact`, `info`. Die Zeile steht in der Tabelle `profiles` (Spalten `username`, `usernameKey`, je höchstens 20 Zeichen): Zeilen-ID = Konto-ID (ein Name je Konto, das Konto kommt aus dem JWT, nie aus dem Body), Recht genau `read("any")` (öffentlich lesbar; der Client liest die Namen direkt), Tabellenrechte keine. Ablauf je Konto nacheinander (Sperre `profile:<Konto-ID>`): Gehört der Schlüssel einem **anderen** Konto (Abfrage `equal usernameKey`), kommt `409` `usernameTaken`; die eigene Zeile zählt nicht, die Schreibweise des eigenen Namens zu ändern („kenso“ → „Kenso“) oder denselben Namen nochmal zu senden geht also. Sonst wird die Zeile des Kontos geändert (der alte Name ist damit sofort frei) oder, wenn es noch keine gibt, angelegt. **Gleichzeitigkeit**: Die Vorabprüfung ist nur der Normalfall mit klarer Antwort; entscheiden tut der Unique-Index `username_key` auf `usernameKey` (Schema-Skript). Wollen zwei Konten gleichzeitig denselben Namen, meldet Appwrite dem zweiten beim Anlegen bzw. Ändern `409`, und das wird ebenfalls `usernameTaken`: genau ein Konto bekommt den Namen. **Pflicht**: `/api/veroeffentlichen` und `/api/logos/hochladen` antworten `403` `usernameRequired`, wenn das Konto keine Zeile in `profiles` hat (`hasProfile`, ein `GET` der Zeile mit der Konto-ID). Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen (Body, Dateien, Karte), es wird nichts angelegt oder angefasst; bestehende Logos und Karten bleiben unberührt. Fehlt die Tabelle `profiles` (Schema nicht eingespielt), antworten beide Endpunkte `500` „Interner Fehler.“.
- **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 blendet zuerst aus und löscht dann 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 oder Wiederherstellen von `delete(owner)`, `profile created` bzw. `profile updated` (Benutzername angelegt bzw. geändert, ohne Namen und Konto-ID); 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 (bei einem Fehler beim Aufräumen eines Logos auch die Datei-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
@@ -59,4 +91,4 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server
node --test "deploy/api/*.test.js"
```
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen) und greifen nie auf das echte Appwrite zu.
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen und dem Unique-Index von `profiles`, der eine zweite Zeile mit demselben `usernameKey` mit `409` abweist) und greifen nie auf das echte Appwrite zu.
+126 -200
View File
@@ -2,116 +2,96 @@
/**
* 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, die Community-Logos und die Benutzernamen laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js, der Endpunkt der Benutzernamen in
* profiles.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 · 403 Datei nicht meine / falscher Bucket /
* Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet
* 409 schon veröffentlicht · 201 {id}
* 401 kein/ungültiges JWT · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) ·
* 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
* falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht
* meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id}
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/melden ohne Anmeldung {id, reason}
* POST /api/melden ohne Anmeldung {id, reason} (Melder = IPv4-/24 bzw. IPv6-/48, gehasht)
* 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
* POST /api/profil siehe profiles.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`.
* 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).
* 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) -> Benutzername (403, nur Veröffentlichen und
* Logo hochladen) -> JSON und Felder (400) -> Logik.
* 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, queryCursorAfter } = require('./appwrite.js');
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 { createProfileRoutes, hasProfile } = require('./profiles.js');
const TABLE_GALLERY = 'gallery';
const TABLE_REPORTS = 'reports';
const TABLE_CARDS = 'cards';
const BUCKET = 'gallery-images';
const MAX_BODY_BYTES = 8 * 1024;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
const HIDE_AT_REPORTS = 3;
const REPORT_LIST_LIMIT = 100;
const OWNER_ROWS_PAGE = 100;
const OWNER_ROWS_MAX_PAGES = 100;
const REPORT_DELETE_ROUNDS = 50;
/** 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;
const PREVIEW_WIDTH = 480;
const PREVIEW_HEIGHT_MIN = 302;
const PREVIEW_HEIGHT_MAX = 304;
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 60 * 1000;
const RATE_SWEEP_MS = 60 * 1000;
const RATE_MAX_KEYS = 50_000;
const LOG_BUDGET_PER_MINUTE = 120;
const LOG_WINDOW_MS = 60 * 1000;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
const MSG = {
unauthorized: 'Bitte melde dich an.',
notFound: 'Nicht gefunden.',
cardGone: 'Diese Karte ist nicht (mehr) verfügbar.',
forbidden: 'Dazu hast du keine Berechtigung.',
adminOnly: 'Nur für Admins.',
foreignFile: 'Diese Datei gehört dir nicht.',
fileMissing: 'Bild nicht gefunden.',
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.',
badReason: 'Bitte wähle einen gültigen Grund.',
badVisibility: 'Ungültige Angabe zur Sichtbarkeit.',
tooLarge: 'Die Anfrage ist zu groß.',
jsonOnly: 'Nur application/json wird unterstützt.',
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
internal: 'Interner Fehler.',
};
// ---- Reine Helfer ----------------------------------------------------------------------------------
function isId(value) {
return typeof value === 'string' && ID_PATTERN.test(value);
}
function assertOwner(owner) {
if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID');
return owner;
}
/** Sichtbare Galerie-Zeile: lesen darf jeder. */
function visibleRowPermissions() {
return ['read("any")'];
}
/** Ausgeblendete Zeile: nur Autor und Admins. */
function hiddenRowPermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function visibleFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`];
}
function hash32(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
}
/** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */
function galleryRowId(ownerId, sourceCardId) {
return hash32(`${ownerId}|${sourceCardId}`);
@@ -122,72 +102,16 @@ function reportRowId(galleryId, hash) {
return hash32(`${galleryId}|${hash}`);
}
function expandIpv6(address) {
let text = address;
const lastColon = text.lastIndexOf(':');
const tail = text.slice(lastColon + 1);
if (tail.includes('.')) {
const o = tail.split('.').map(Number);
text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const [head, rest] = text.split('::');
const headGroups = head === '' ? [] : head.split(':');
const restGroups = rest === undefined || rest === '' ? [] : rest.split(':');
const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups];
return groups.map((group) => Number.parseInt(group, 16));
}
/**
* Vereinheitlicht eine Client-Adresse für Hash und Rate-Limit: IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
*/
function normalizeIp(ip) {
if (typeof ip !== 'string') return '';
let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
if (zone !== -1) text = text.slice(0, zone);
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1];
if (net.isIPv4(text)) return text;
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
}
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`;
}
return text.toLowerCase().slice(0, 64);
}
function firstHeaderValue(req, name) {
const value = req.headers && req.headers[name];
const text = Array.isArray(value) ? value[0] : value;
if (typeof text !== 'string') return '';
return text.split(',')[0].trim();
}
/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */
function clientIp(req) {
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
}
/** HMAC-SHA256 (Hex) der normalisierten IP mit dem Server-Salz. */
function reporterHash(salt, ip) {
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex');
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */
function safeToken(value, fallback = 'unknown') {
const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60);
return text === '' ? fallback : text;
}
const 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;
const TEXT_FIELDS = [
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
@@ -268,14 +192,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, {
@@ -327,17 +243,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();
@@ -359,10 +264,6 @@ function createKeyedLock() {
};
}
function isDuplicate(err) {
return err instanceof AppwriteError && err.status === 409;
}
// ---- Die App ---------------------------------------------------------------------------------------
/**
@@ -377,7 +278,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;
@@ -400,7 +301,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;
@@ -444,41 +346,38 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
}
// ---- Dateien und Zeilen
function checkFile(file, userId) {
if (!file) throw new HttpError(400, MSG.fileMissing);
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
throw new HttpError(403, MSG.foreignFile);
}
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
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);
}
/** Liest den Anfang beider Dateien und verlangt Kartenformat: Bild 1536 x 969, Vorschau 480 x 302-304. */
async function checkImageSizes(data) {
const [image, preview] = await Promise.all([
appwrite.readFileHead(BUCKET, data.imageFileId, IMAGE_HEAD_BYTES),
appwrite.readFileHead(BUCKET, data.previewFileId, IMAGE_HEAD_BYTES),
]);
if (image === null || preview === null) throw new HttpError(400, MSG.fileMissing);
if (!isCardImageSize(readImageSize(image)) || !isCardPreviewSize(readImageSize(preview))) {
throw new HttpError(400, MSG.imageFormat);
}
}
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
/**
* Datei-IDs, die schon eine Galerie-Zeile dieses Autors verwendet (Bild oder Vorschau, auch ausgeblendete Karten).
* Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
* Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit
* Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab).
* Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht:
* ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId`
* nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
*/
async function fileIdsInUse(ownerId) {
const used = new Set();
let cursor = null;
for (let page = 0; page < OWNER_ROWS_MAX_PAGES; page += 1) {
const queries = [queryEqual('ownerId', [ownerId]), queryLimit(OWNER_ROWS_PAGE)];
if (cursor !== null) queries.push(queryCursorAfter(cursor));
const rows = await appwrite.listRows(TABLE_GALLERY, queries);
for (const row of rows) {
used.add(row.imageFileId);
used.add(row.previewFileId);
}
if (rows.length < OWNER_ROWS_PAGE) return used;
cursor = rows[rows.length - 1].$id;
}
return null;
function ownerRows(ownerId) {
return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]);
}
/** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:<id>") auf seine Zeile). */
async function ownsSourceCard(userId, cardId) {
const card = await appwrite.getRow(TABLE_CARDS, cardId);
return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`);
}
/**
@@ -522,6 +421,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
// ---- Endpunkte
async function publish({ req, raw }) {
const user = await requireUser(req);
// Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst.
if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired);
const validation = validatePublish(parseJsonObject(raw));
if (!validation.ok) throw new HttpError(400, validation.message);
const data = validation.data;
@@ -530,15 +431,20 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId),
]);
checkFile(image, user.id);
checkFile(preview, user.id);
checkFileExists(image, BUCKET);
checkFileExists(preview, BUCKET);
// Je Autor nacheinander: Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
// Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`owner:${user.id}`, async () => {
const used = await fileIdsInUse(user.id);
if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) {
throw new HttpError(403, MSG.filesTaken);
}
const rows = await ownerRows(user.id);
if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications);
if (!(await ownsSourceCard(user.id, data.sourceCardId))) throw new HttpError(403, MSG.notYourCard);
const used = new Set(rows.flatMap(fileIdsOf));
if (used.has(data.imageFileId) || used.has(data.previewFileId)) throw new HttpError(403, MSG.filesTaken);
for (const file of [image, preview]) checkFileOwnership(file, user.id);
for (const file of [image, preview]) checkFileContent(file);
await checkImageSizes(data);
const id = galleryRowId(user.id, data.sourceCardId);
try {
@@ -548,7 +454,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
{
title: data.title,
issuer: data.issuer,
tags: data.tags.join(','),
// ",a,b," statt "a,b": der Tag-Filter der Galerie sucht ",<tag>," und trifft so nur ganze Tags.
tags: data.tags.length > 0 ? `,${data.tags.join(',')},` : '',
displayName: data.displayName,
imageFileId: data.imageFileId,
previewFileId: data.previewFileId,
@@ -565,14 +472,21 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
throw err;
}
// Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen).
// Kanonischer Zustand: Beide Dateien tragen genau read("any") (kein update/delete, keine fremden Rollen).
// Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an.
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
try {
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
const pinned = await setFilePermissions(data, visibleFilePermissions());
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
} catch (err) {
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {});
let rolledBack = true;
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {
rolledBack = false;
say(`rollback failed for card ${id}`);
});
// Nur wenn keine Zeile mehr auf die Dateien zeigt: schon festgelegte Dateien bekommen delete(owner) zurück,
// damit der Client den Fehlversuch aufräumen kann (sonst blieben sie öffentlich und unlöschbar liegen).
if (rolledBack) await setFilePermissions(data, uploadFilePermissions(user.id)).catch(() => {});
throw err;
}
say(`card ${id} published`);
@@ -606,8 +520,10 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
const reporter = reporterHash(salt, clientIp(req));
const limit = rateLimit(reporter);
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64).
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 withLock(id, async () => {
@@ -660,6 +576,10 @@ 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 }),
// Benutzernamen (profiles.js): bekommen Anmeldung, Sperre je Schlüssel und das Log von hier.
...createProfileRoutes({ appwrite, say, withLock, requireUser }),
]);
async function handle(req, res) {
@@ -685,9 +605,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
@@ -695,12 +617,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);
}
}
@@ -724,11 +647,14 @@ module.exports = {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
reporterNetwork,
clientIp,
normalizeIp,
readImageSize,
validatePublish,
buildSearchText,
galleryRowId,
+636 -94
View File
File diff suppressed because it is too large. Load diff
+52 -4
View File
@@ -7,10 +7,14 @@
* 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)
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
* readFileHead(bucket, id, maxBytes) die ersten Bytes des Dateiinhalts (Buffer) oder null; liest nie mehr als
* nötig und bricht den Rest der Übertragung ab
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
* deleteFile(bucket, id) true, false bei 404 storage_file_not_found
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
@@ -28,6 +32,7 @@
*/
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_HEAD_BYTES = 64;
const ROW_NOT_FOUND = 'row_not_found';
const FILE_NOT_FOUND = 'storage_file_not_found';
@@ -71,9 +76,35 @@ 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}`}`;
};
/** Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er. */
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) {
/** 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) {
if (!response.body) return Buffer.from(await response.arrayBuffer()).subarray(0, limit);
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(Buffer.from(value));
size += value.length;
}
} finally {
await reader.cancel().catch(() => {});
}
return Buffer.concat(chunks).subarray(0, limit);
}
/**
* Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er.
* Mit `headBytes` ist `body` bei 2xx ein Buffer mit den ersten Bytes der Antwort statt geparstem JSON.
*/
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [], headBytes = 0 } = {}) {
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
@@ -86,6 +117,9 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
let text;
try {
response = await fetchImpl(`${base}${path}`, init);
if (headBytes > 0 && response.status >= 200 && response.status < 300) {
return { status: response.status, body: await readHead(response, headBytes) };
}
text = await response.text();
} catch (err) {
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
@@ -117,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;
@@ -147,6 +190,11 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
return result.status === 404 ? null : result.body;
},
async readFileHead(bucket, id, maxBytes = DEFAULT_HEAD_BYTES) {
const result = await send('GET', `${filePath(bucket, id)}/view`, { missingTypes: [FILE_NOT_FOUND], headBytes: maxBytes });
return result.status === 404 ? null : result.body;
},
async updateFilePermissions(bucket, id, permissions) {
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
+74 -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 });
@@ -149,6 +190,36 @@ test('Dateien: lesen, Rechte setzen, löschen', async () => {
assert.equal(fake.file('f1'), null);
});
test('readFileHead: liefert nur die ersten Bytes, mit Server-Key, auch bei einer großen Datei', async () => {
const { fake, appwrite } = await setup();
const content = Buffer.concat([Buffer.from('HEADBYTES'), Buffer.alloc(2 * 1024 * 1024, 7)]);
fake.addFile({ id: 'big', owner: 'u1', content, size: content.length });
const head = await appwrite.readFileHead('gallery-images', 'big', 9);
assert.ok(Buffer.isBuffer(head));
assert.equal(head.toString('latin1'), 'HEADBYTES');
assert.equal(lastCall(fake).method, 'GET');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/big/view');
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
const sixtyFour = await appwrite.readFileHead('gallery-images', 'big');
assert.equal(sixtyFour.length, 64, 'Standard: 64 Bytes');
fake.addFile({ id: 'small', owner: 'u1', content: Buffer.from('abc'), size: 3 });
assert.equal((await appwrite.readFileHead('gallery-images', 'small', 64)).toString(), 'abc', 'kürzere Datei: alles');
});
test('readFileHead: fehlende Datei ist null, andere Fehler werfen AppwriteError', async () => {
const { fake, appwrite } = await setup();
assert.equal(await appwrite.readFileHead('gallery-images', 'nope'), null);
fake.addFile({ id: 'f1', owner: 'u1' });
fake.failNext((c) => c.path.endsWith('/files/f1/view'), { status: 500 });
await assert.rejects(() => appwrite.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 500);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(() => wrongKey.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 401);
await assert.rejects(() => appwrite.readFileHead('gallery-images', '..'), TypeError);
});
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
const { fake, appwrite } = await setup();
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
@@ -305,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');
@@ -317,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'));
});
+394
View File
@@ -0,0 +1,394 @@
'use strict';
/**
* Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js), die Logos (logos.js) und die Benutzernamen
* (profiles.js): Fehlertexte und -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Muster und Sperrliste der Benutzernamen.
* 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}$/;
/** Benutzername: 3-20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich (nur ASCII). */
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
/**
* Gesperrte Benutzernamen als Schlüssel (klein geschrieben, siehe `usernameKey`): Rollen, der Dienst selbst und Platzhalter, die nach
* einem Konto aussehen. Jeder Eintrag passt zu USERNAME_PATTERN (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig).
*/
const RESERVED_USERNAMES = Object.freeze([
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
]);
const RESERVED_USERNAME_SET = new Set(RESERVED_USERNAMES);
// 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“.',
// Benutzernamen
usernameInvalid:
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.',
usernameReserved: 'Dieser Benutzername ist nicht erlaubt.',
usernameTaken: 'Dieser Benutzername ist schon vergeben.',
usernameRequired: 'Bitte wähle zuerst einen Benutzernamen.',
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);
}
/**
* Der Schlüssel eines Benutzernamens (Spalte `usernameKey`): der Name klein geschrieben. Über ihn ist ein Name nur einmal
* vergeben, egal wie er geschrieben ist („Kenso“ und „kenso“ sind derselbe). Für Zeichenketten, die schon USERNAME_PATTERN erfüllen.
*/
function usernameKey(name) {
return name.toLowerCase();
}
/** Steht der Schlüssel dieses Namens auf der Sperrliste? (In jeder Schreibweise: „Admin“, „AirCard-IOS“.) */
function isReservedUsername(name) {
return RESERVED_USERNAME_SET.has(usernameKey(name));
}
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,
USERNAME_PATTERN,
RESERVED_USERNAMES,
CONTROL_CHARACTERS,
MSG,
codeFor,
HttpError,
isId,
usernameKey,
isReservedUsername,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
};
+187 -15
View File
@@ -7,16 +7,67 @@
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
*
* 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.
* 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`, `logo_reports` und `profiles` und die Buckets `gallery-images`, `card-images`,
* `logo-images`. `profiles` hat wie in Appwrite einen Unique-Index auf `usernameKey` (UNIQUE_INDEXES): eine zweite Zeile mit
* demselben Schlüssel, angelegt oder per PATCH geändert, antwortet 409 `row_already_exists` (so meldet Appwrite auch eine
* Verletzung des Unique-Index); Testhilfe: `addProfile`.
*
* 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/holdNext (Aufruf scheitert, hängt, wird verzögert, wartet auf die Freigabe des Tests)
* 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) {
const head = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(head, 0);
head.writeUInt32BE(13, 8);
head.write('IHDR', 12, 'latin1');
head.writeUInt32BE(width, 16);
head.writeUInt32BE(height, 20);
head[24] = 8; // Bit-Tiefe
head[25] = 6; // RGBA
return head;
}
/** WebP-Anfang mit den Maßen; kind: 'VP8 ' (verlustbehaftet), 'VP8L' (verlustfrei) oder 'VP8X' (erweitert). */
function webpHeader(kind, width, height) {
const head = Buffer.alloc(40);
head.write('RIFF', 0, 'latin1');
head.writeUInt32LE(32, 4);
head.write('WEBP', 8, 'latin1');
head.write(kind, 12, 'latin1');
if (kind === 'VP8 ') {
head.writeUInt32LE(10, 16);
Buffer.from([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]).copy(head, 20);
head.writeUInt16LE(width & 0x3fff, 26);
head.writeUInt16LE(height & 0x3fff, 28);
} else if (kind === 'VP8L') {
head.writeUInt32LE(5, 16);
head[20] = 0x2f;
head.writeUInt32LE(((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14), 21);
} else if (kind === 'VP8X') {
head.writeUInt32LE(10, 16);
head[20] = 0x10; // Alpha-Flag
head.writeUIntLE(width - 1, 24, 3);
head.writeUIntLE(height - 1, 27, 3);
} else {
throw new Error(`Unbekannte WebP-Art: ${kind}`);
}
return head;
}
const COLUMNS = {
gallery: {
@@ -37,14 +88,60 @@ 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 },
},
// Benutzernamen: Zeilen-ID = Konto-ID, `usernameKey` = der Name klein geschrieben (Unique-Index)
profiles: {
username: { size: 20, required: true },
usernameKey: { size: 20, required: true },
},
};
/** Unique-Indizes wie in web/scripts/appwrite-setup.mjs: je Tabelle die Spalten, deren Werte nur in einer Zeile vorkommen dürfen. */
const UNIQUE_INDEXES = {
profiles: ['usernameKey'],
};
/** 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() };
const tables = {
gallery: new Map(),
reports: new Map(),
cards: new Map(),
logos: new Map(),
logo_likes: new Map(),
logo_reports: new Map(),
profiles: 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 = [] }) {
@@ -56,7 +153,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
users.set(jwt, account);
}
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000 }) {
/**
* Datei mit Metadaten und Inhalt. `role` bestimmt den Standardinhalt: 'image' = Kartenbild 1536 x 969, 'preview' =
* Vorschau 480 x 303 (Standard: WebP ist die Vorschau, alles andere das Bild); `content` überschreibt ihn.
*/
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000, role, content }) {
const kind = role || (mimeType === 'image/webp' ? 'preview' : 'image');
const [width, height] = kind === 'preview' ? [480, 303] : [1536, 969];
files.set(id, {
$id: id,
bucketId: bucket,
@@ -65,6 +168,17 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
mimeType,
sizeOriginal: size,
});
contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height)));
}
/** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */
function addCard(id, owner, permissions) {
return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]);
}
/** Benutzername eines Kontos (Tabelle `profiles`) mit den Rechten, die die API vergibt: Zeilen-ID = Konto-ID, öffentlich lesbar. */
function addProfile(userId, username = userId) {
return addRow('profiles', userId, { username, usernameKey: username.toLowerCase() }, ['read("any")']);
}
function addRow(table, id, data, permissions) {
@@ -88,13 +202,36 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
injections.push({ predicate, delayMs });
}
/**
* Nächster passender Aufruf wartet, bis die zurückgegebene Funktion aufgerufen wird (er steht dann schon in `calls`). So erzwingt
* ein Test eine Reihenfolge ohne Zeitannahmen, etwa: beide Anfragen haben vorab geprüft, bevor eine von ihnen schreibt.
* @returns {() => void} gibt den Aufruf frei
*/
function holdNext(predicate) {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
injections.push({ predicate, gate });
return release;
}
/**
* 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}`;
@@ -109,6 +246,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
return null;
}
/** Hat eine andere Zeile der Tabelle schon diese Werte in den Spalten eines Unique-Index? (`values` = die Zeile, wie sie danach aussähe.) */
function violatesUnique(table, id, values) {
const columns = UNIQUE_INDEXES[table];
if (!columns) return false;
return [...tables[table].values()].some((row) => row.$id !== id && columns.every((name) => row[name] === values[name]));
}
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
@@ -140,7 +284,9 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
if (injectionIndex !== -1) {
const [entry] = injections.splice(injectionIndex, 1);
if (entry.hang) return;
if (entry.delayMs) {
if (entry.gate) {
await entry.gate;
} else if (entry.delayMs) {
await new Promise((resolve) => setTimeout(resolve, entry.delayMs));
} else {
return error(entry.status, entry.type);
@@ -175,15 +321,16 @@ 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 values = { ...DEFAULTS[table], ...data };
if (violatesUnique(table, body.rowId, values)) return error(409, 'row_already_exists');
const row = addRow(table, body.rowId, values, 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) {
@@ -199,6 +346,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
const data = body.data || {};
const problem = validateData(table, data, { partial: true });
if (problem) return error(400, 'document_invalid_structure', problem);
if (violatesUnique(table, segments[6], { ...row, ...data })) return error(409, 'row_already_exists');
Object.assign(row, data);
if (Array.isArray(body.permissions)) row.$permissions = body.permissions;
return reply(200, row);
@@ -206,6 +354,17 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
}
}
// Dateiinhalt: GET .../files/{id}/view
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 7 && segments[6] === 'view' && req.method === 'GET') {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
const file = files.get(segments[5]);
if (!file) return error(404, 'storage_file_not_found');
const content = contents.get(segments[5]);
res.writeHead(200, { 'Content-Type': file.mimeType, 'Content-Length': content.length });
res.end(content);
return;
}
// Dateien
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
@@ -214,12 +373,17 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
if (req.method === 'GET') return reply(200, file);
if (req.method === 'DELETE') {
files.delete(segments[5]);
contents.delete(segments[5]);
return reply(204);
}
if (req.method === 'PUT') {
const body = call.body || {};
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
file.$permissions = body.permissions;
if (swaps.has(segments[5])) {
contents.set(segments[5], swaps.get(segments[5]));
swaps.delete(segments[5]);
}
return reply(200, file);
}
}
@@ -240,10 +404,14 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
addUser,
addRawUser,
addFile,
addCard,
addProfile,
addRow,
failNext,
hangNext,
delayNext,
holdNext,
swapContentAfterPermissions,
row: (table, id) => tables[table].get(id) || null,
rows: (table) => [...tables[table].values()],
file: (id) => files.get(id) || null,
@@ -270,7 +438,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;
@@ -295,12 +466,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 };
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
+437
View File
@@ -0,0 +1,437 @@
'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 · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) · 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 = Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-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}
* Blendet das Logo zuerst aus (Datei und Zeile), löscht dann Datei, Herzen, Meldungen und zuletzt die Zeile: ein Abbruch
* hinterlässt höchstens eine unsichtbare Zeile, nie eine öffentliche mit gelöschter Datei.
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
*
* Tabellen `logos` (Zeilen-ID = hash32(`<Konto>|<Datei>`), siehe logoRowId; Altlogos haben noch Zeilen-ID = Datei-ID), `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 (auch beim Hochladen,
* vom Anlegen der Zeile bis zur zweiten Prüfung, innerhalb der Sperre des Kontos).
* 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 { hasProfile } = require('./profiles.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 Konto und Datei: dieselbe Datei desselben Kontos ergibt dieselbe ID (Appwrite-409 beim Anlegen). Die ID ist
* nicht die Datei-ID: die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten, und jeder
* könnte eine Datei mit genau dieser ID anlegen und als Logo hochladen. Mit dem Konto im Hash lässt sich die alte ID so nicht neu belegen.
* Ältere Logos haben noch Zeilen-ID = Datei-ID; alle anderen Endpunkte adressieren die Zeile nur über ihre ID und lesen die Datei
* aus `fileId`, daher gehen beide Arten.
*/
function logoRowId(ownerId, fileId) {
return hash32(`${ownerId}|${fileId}`);
}
/** 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> (file <id>)` im Log (mit der Datei-ID: sie
* folgt nicht aus der Zeilen-ID, und bleibt nur die Datei liegen, gibt es keine Zeile mehr, an der man sie fände).
*/
async function removeRejectedLogo(rowId, fileId, problem) {
let clean = true;
await appwrite.deleteFile(BUCKET, fileId).catch(() => {
clean = false;
});
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
clean = false;
});
say(clean ? `logo ${rowId} removed after upload check (${problem.code})` : `rollback failed for logo ${rowId} (file ${fileId})`);
}
async function uploadLogo({ req, raw }) {
const user = await requireUser(req);
// Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst.
if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired);
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);
const rowId = logoRowId(user.id, data.fileId);
// 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);
// Dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung): entweder unter dieser
// Zeilen-ID oder, bei einem Altlogo mit Zeilen-ID = Datei-ID, über die `fileId` der Zeile.
if (rows.some((row) => row.$id === rowId || row.fileId === data.fileId)) throw new HttpError(409, MSG.logoTaken);
checkFileOwnership(file, user.id);
checkLogoContent(file);
await checkLogoSize(data.fileId);
// Ab dem Anlegen steht die Zeile öffentlich in der Liste. Solange die Prüfung das Logo noch entfernen kann, dürfen Herz, Meldung
// und Löschen nicht dazwischenkommen (ihre Zeilen blieben ohne Logo zurück): der Upload hält die Sperre des Logos bis zum Ende.
// Nur das Hochladen nimmt zwei Sperren, immer erst die des Kontos, dann die des Logos; alle anderen Anfragen nehmen nur die des
// Logos, so kann sich nichts verklemmen.
return withLogoLock(rowId, async () => {
try {
await appwrite.createRow(
TABLE_LOGOS,
rowId,
{
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 gibt es schon, steht aber nicht in der Liste des Kontos (ein Rest): das Logo ist vergeben.
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, rowId).catch(() => {
rolledBack = false;
say(`rollback failed for logo ${rowId} (file ${data.fileId})`);
});
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf). Scheitert
// auch das, bleibt die Datei ohne Zeile und ohne delete(owner) liegen: die Logzeile nennt ihre ID, nur so findet man sie.
if (rolledBack) {
await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {
say(`restore failed for logo ${rowId} (file ${data.fileId})`);
});
}
throw err;
}
if (problem !== null) {
await removeRejectedLogo(rowId, data.fileId, problem);
throw problem;
}
say(`logo ${rowId} uploaded`);
return { status: 201, body: { id: rowId } };
});
});
}
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);
// Zuerst nicht mehr öffentlich (wie beim Ausblenden: Datei, dann Zeile). Bricht das Löschen ab, nachdem die Datei weg ist, bliebe
// sonst eine öffentliche Zeile mit einer Datei-ID zurück, unter der jedes Konto eine neue Datei anlegen könnte: alle gespeicherten
// Karten zeigten dann deren Bild. So bleibt höchstens eine unsichtbare Zeile; die Wiederholung (Hochlader oder Admin) vollendet es.
if (row.hidden !== true) await hideLogo(id, row);
// Dann Datei, Herzen, Meldungen, die 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, logoRowId, logoLikeId, logoReportId, validateLogoUpload };
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
'use strict';
/**
* Benutzernamen im API-Dienst `aircard-api` (Node 22, nur Bordmittel): ein Name je Konto, eindeutig ohne Rücksicht auf Groß- und
* Kleinschreibung. app.js hängt die Route an und gibt Anmeldung, Sperre je Schlüssel und das Log mit (wie bei logos.js).
*
* POST /api/profil Nutzer-JWT {username}
* 401 · 400 `usernameInvalid` (Muster) · 400 `usernameReserved` (Sperrliste) · 409 `usernameTaken` (ein anderes Konto hat den
* Namen) · 200 {username}
* Legt die Zeile des Kontos an (Zeilen-ID = Konto-ID, Recht genau read("any")) oder ändert sie: der alte Name ist damit sofort
* wieder frei. Die eigene Schreibweise zu ändern („kenso“ → „Kenso“) ist erlaubt.
*
* Tabelle `profiles`: `username` (der Name, wie eingegeben) und `usernameKey` (klein geschrieben). Eindeutig ist `usernameKey`: die
* Vorabprüfung fängt den Normalfall mit einer klaren Antwort ab, bei einem Rennen zweier Konten entscheidet der Unique-Index
* `username_key` (Appwrite antwortet 409, das wird ebenfalls `usernameTaken`). Sperre je Konto: `profile:<Konto>`.
* Hochladen und Veröffentlichen verlangen ein Profil (`hasProfile`, 403 `usernameRequired`), siehe logos.js und app.js.
* Logs nennen nie Namen, Schlüssel, Konto-IDs oder JWTs: nur „profile created“ bzw. „profile updated“.
*/
const { queryEqual, queryLimit } = require('./appwrite.js');
const {
MSG,
USERNAME_PATTERN,
HttpError,
isDuplicate,
parseJsonObject,
visibleRowPermissions,
usernameKey,
isReservedUsername,
} = require('./common.js');
const TABLE_PROFILES = 'profiles';
/**
* Hat das Konto einen Benutzernamen? Die Zeilen-ID des Profils ist die Konto-ID. Ein Appwrite-Fehler wird nicht verschluckt
* (auch eine fehlende Tabelle nicht): der Aufrufer antwortet dann mit 502 bzw. 500, nie mit „kein Profil“.
* @param {object} appwrite Objekt aus createAppwrite
* @param {string} userId Konto-ID
* @returns {Promise<boolean>}
*/
async function hasProfile(appwrite, userId) {
return (await appwrite.getRow(TABLE_PROFILES, userId)) !== null;
}
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @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 {(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 createProfileRoutes({ appwrite, say, withLock, requireUser }) {
async function saveProfile({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
// Kein Trimmen: das Muster lässt keine Leerzeichen zu, der Client schickt den Namen schon bereinigt.
const { username } = body;
if (typeof username !== 'string' || !USERNAME_PATTERN.test(username)) throw new HttpError(400, MSG.usernameInvalid);
if (isReservedUsername(username)) throw new HttpError(400, MSG.usernameReserved);
const key = usernameKey(username);
// Je Konto nacheinander: Nachsehen, ob es schon eine Zeile gibt, und Anlegen bzw. Ändern dürfen sich nicht überholen
// (zwei Anfragen desselben Kontos würden sonst beide anlegen wollen und die zweite fälschlich „vergeben“ melden).
return withLock(`profile:${user.id}`, async () => {
// Vorabprüfung: hat ein anderes Konto diesen Schlüssel? Der eigene Eintrag zählt nicht (nur die Schreibweise ändern ist erlaubt).
const holders = await appwrite.listRows(TABLE_PROFILES, [queryEqual('usernameKey', [key]), queryLimit(1)]);
if (holders.some((row) => row.$id !== user.id)) throw new HttpError(409, MSG.usernameTaken);
const own = await appwrite.getRow(TABLE_PROFILES, user.id);
try {
if (own) await appwrite.updateRow(TABLE_PROFILES, user.id, { username, usernameKey: key });
else await appwrite.createRow(TABLE_PROFILES, user.id, { username, usernameKey: key }, visibleRowPermissions());
} catch (err) {
// Ein anderes Konto war schneller: der Unique-Index auf usernameKey meldet 409, beim Anlegen wie beim Ändern. Ein 409 wegen der
// eigenen Zeilen-ID gibt es nicht: unter der Sperre des Kontos gibt es die Zeile jetzt (dann ändern wir) oder nicht (dann legen wir an).
if (isDuplicate(err)) throw new HttpError(409, MSG.usernameTaken);
throw err;
}
say(own ? 'profile updated' : 'profile created');
return { status: 200, body: { username } };
});
}
return [['POST /api/profil', { name: 'profil', json: true, run: saveProfile }]];
}
module.exports = { createProfileRoutes, hasProfile };
+496
View File
@@ -0,0 +1,496 @@
'use strict';
// Tests für deploy/api/profiles.js (Benutzernamen): echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im
// Speicher (fake-appwrite.js, Tabelle `profiles` mit Unique-Index auf `usernameKey`) und das echte appwrite.js.
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const { createApp } = require('./app.js');
const { createProfileRoutes, hasProfile } = require('./profiles.js');
const { MSG, USERNAME_PATTERN, RESERVED_USERNAMES, usernameKey, isReservedUsername } = require('./common.js');
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
const ME = 'user-me';
const OTHER = 'user-other';
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
/** Die Sperrliste der Spec (§ 2), wörtlich: der Test hält sie fest, damit eine Änderung auffällt. */
const SPEC_RESERVED = [
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
];
// ---- Aufbau ---------------------------------------------------------------------------------
const cleanups = [];
afterEach(async () => {
for (const fn of cleanups.splice(0).reverse()) await fn();
});
async function setup() {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
fake.addUser(JWT_OTHER, { id: OTHER });
const logs = [];
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
const app = createApp({ appwrite, salt: SALT, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const call = (method, path, options) => request(port, method, path, options);
const post = (path, body, jwt) => call('POST', path, { body, headers: jwt ? { 'X-Aircard-Jwt': jwt } : {} });
/** POST /api/profil mit dem Namen (Standard: als ME). */
const profile = (username, jwt = JWT_ME) => post('/api/profil', { username }, jwt);
return { fake, appwrite, logs, call, post, profile };
}
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
return new Promise((resolve, reject) => {
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
const requestHeaders = { ...headers };
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
if (payload !== undefined) requestHeaders['Content-Length'] = Buffer.byteLength(payload);
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json = null;
try {
json = JSON.parse(text);
} catch {
// kein JSON
}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
/** Erwartet Status und genau {error: <Text des MSG-Schlüssels>, code: <Schlüssel>}. */
function expectError(res, status, code, label = code) {
assert.equal(res.status, status, `${label}: Status`);
assert.deepEqual(res.json, { error: MSG[code], code }, label);
}
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
/** Aufrufe bei Appwrite auf `profiles`: Zeile anlegen, per Abfrage suchen (die Vorabprüfung), Zeile ändern. */
const isCreate = (c) => c.method === 'POST' && c.path.endsWith('/tables/profiles/rows');
const isList = (c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows');
const isUpdate = (c) => c.method === 'PATCH' && c.path.includes('/tables/profiles/rows/');
/** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */
async function until(condition) {
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht');
}
/**
* Zwei Anfragen, die beide vorab geprüft haben („Name frei“), bevor eine davon schreibt: das Schreiben (`isWrite`) wird festgehalten, bis
* beide Abfragen bei Appwrite angekommen sind. So entscheidet erst der Unique-Index, nicht die Vorabprüfung.
*/
async function raceTwo(fake, isWrite, first, second) {
const releases = [fake.holdNext(isWrite), fake.holdNext(isWrite)];
const pending = Promise.all([first(), second()]);
await until(() => fake.calls.filter(isList).length === 2);
for (const release of releases) release();
return pending;
}
// ---- Reine Helfer (common.js) -----------------------------------------------------------------------
test('USERNAME_PATTERN: 3–20 Zeichen, erstes ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich', () => {
assert.equal(USERNAME_PATTERN.source, '^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
for (const ok of ['abc', 'Kenso', 'k3nso', '007', 'a.b', 'a_b', 'a-b', 'A.b_c-d9', 'a'.repeat(20), 'Z9.']) {
assert.ok(USERNAME_PATTERN.test(ok), ok);
}
const bad = ['', 'a', 'ab', '_abc', '.abc', '-abc', 'a'.repeat(21), 'a b', ' abc', 'abc ', 'abc\n', 'a\nbc', 'Jürgen', 'abc', 'a@b', 'a/b', 'a+b', 'abc😀'];
for (const name of bad) assert.ok(!USERNAME_PATTERN.test(name), JSON.stringify(name));
});
test('usernameKey: der Name klein geschrieben, sonst unverändert', () => {
assert.equal(usernameKey('Kenso'), 'kenso');
assert.equal(usernameKey('AirCard-IOS'), 'aircard-ios');
assert.equal(usernameKey('a.B_c-D9'), 'a.b_c-d9');
assert.equal(usernameKey('kenso'), 'kenso');
});
test('Sperrliste: genau die Schlüssel der Spec (§ 2), gesperrt in jeder Schreibweise; andere Namen sind frei', () => {
assert.deepEqual([...RESERVED_USERNAMES].sort(), [...SPEC_RESERVED].sort());
for (const key of SPEC_RESERVED) {
assert.ok(isReservedUsername(key), key);
assert.ok(isReservedUsername(key.toUpperCase()), key.toUpperCase());
assert.ok(isReservedUsername(`${key[0].toUpperCase()}${key.slice(1)}`), `${key} mit großem Anfang`);
// jeder gesperrte Name passt zum Muster (sonst wäre der Eintrag wirkungslos: er scheiterte schon als „ungültig“)
assert.match(key, USERNAME_PATTERN);
}
for (const free of ['kenso', 'admins', 'admin1', 'root_', 'air-card-ios', 'aircard-ios2', 'team.x', 'xinfo', 'helpme']) {
assert.ok(!isReservedUsername(free), free);
}
});
test('MSG: die vier Benutzernamen-Texte stehen wörtlich fest (Spec § 4)', () => {
assert.equal(
MSG.usernameInvalid,
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.',
);
assert.equal(MSG.usernameReserved, 'Dieser Benutzername ist nicht erlaubt.');
assert.equal(MSG.usernameTaken, 'Dieser Benutzername ist schon vergeben.');
assert.equal(MSG.usernameRequired, 'Bitte wähle zuerst einen Benutzernamen.');
});
test('createProfileRoutes liefert genau die Route POST /api/profil mit Name, json und run', () => {
const routes = createProfileRoutes({
appwrite: {},
say: () => {},
withLock: (key, fn) => fn(),
requireUser: async () => ({ id: ME, isAdmin: false }),
});
assert.deepEqual(
routes.map(([key]) => key),
['POST /api/profil'],
);
const [[, route]] = routes;
assert.equal(route.name, 'profil');
assert.equal(route.json, true);
assert.equal(typeof route.run, 'function');
});
test('hasProfile: true mit Zeile (Zeilen-ID = Konto-ID), false ohne; ein Appwrite-Fehler wird nicht verschluckt', async () => {
const { fake, appwrite } = await setup();
assert.equal(await hasProfile(appwrite, ME), false);
fake.addProfile(ME, 'Kenso');
assert.equal(await hasProfile(appwrite, ME), true);
assert.equal(await hasProfile(appwrite, OTHER), false, 'ein anderes Konto hat nichts davon');
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 500 });
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 500);
// fehlt die Tabelle ganz (Schema nicht eingespielt), ist das ein Fehler und kein „kein Profil“
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 404, type: 'table_not_found' });
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 404);
});
test('Fake-Appwrite: der Unique-Index auf usernameKey weist eine zweite Zeile (Anlegen und Ändern) mit 409 ab', async () => {
const { fake, appwrite } = await setup();
const isConflict = (err) => err instanceof AppwriteError && err.status === 409;
await appwrite.createRow('profiles', 'a', { username: 'Kenso', usernameKey: 'kenso' }, ['read("any")']);
await assert.rejects(appwrite.createRow('profiles', 'b', { username: 'KENSO', usernameKey: 'kenso' }, ['read("any")']), isConflict);
assert.equal(fake.row('profiles', 'b'), null);
await appwrite.createRow('profiles', 'b', { username: 'Other', usernameKey: 'other' }, ['read("any")']);
await assert.rejects(appwrite.updateRow('profiles', 'b', { username: 'Kenso', usernameKey: 'kenso' }), isConflict);
assert.equal(fake.row('profiles', 'b').usernameKey, 'other');
// die eigene Zeile ändern (nur die Schreibweise) ist kein Verstoß
await appwrite.updateRow('profiles', 'a', { username: 'KENSO', usernameKey: 'kenso' });
assert.equal(fake.row('profiles', 'a').username, 'KENSO');
// Spaltenlängen wie in der echten Tabelle (username und usernameKey: 20)
await assert.rejects(appwrite.createRow('profiles', 'c', { username: 'x'.repeat(21), usernameKey: 'x' }, []), (err) => err.status === 400);
});
// ---- Anlegen und Ändern ----------------------------------------------------------------------------
test('Profil anlegen: 200 {username}, Zeilen-ID = Konto-ID, Rechte genau read("any"), usernameKey klein, der Name wie eingegeben', async () => {
const { profile, fake } = await setup();
const res = await profile('Kenso.IOS');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { username: 'Kenso.IOS' });
assert.equal(fake.rows('profiles').length, 1);
const row = fake.row('profiles', ME);
assert.equal(row.username, 'Kenso.IOS');
assert.equal(row.usernameKey, 'kenso.ios');
assert.deepEqual(row.$permissions, ['read("any")']);
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME);
for (const c of nonAccountCalls(fake)) {
assert.equal(c.headers['x-appwrite-key'], fake.key);
assert.equal(c.headers['x-appwrite-jwt'], undefined);
}
});
test('Profil anlegen: die Zeile gehört dem Konto aus dem JWT, andere Felder im Body (Konto-ID, Schlüssel, Rechte) werden ignoriert', async () => {
const { post, fake } = await setup();
const res = await post(
'/api/profil',
{ username: 'Kenso', usernameKey: 'anders', userId: OTHER, ownerId: OTHER, $id: OTHER, $permissions: ['read("any")', 'update("any")'] },
JWT_ME,
);
assert.deepEqual(res.json, { username: 'Kenso' });
assert.equal(fake.row('profiles', OTHER), null);
const row = fake.row('profiles', ME);
assert.equal(row.usernameKey, 'kenso');
assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(Object.keys(row).filter((key) => !key.startsWith('$')).sort(), ['username', 'usernameKey']);
});
test('Profil anlegen an den Grenzen: 3 und 20 Zeichen gehen', async () => {
const { profile, fake } = await setup();
assert.deepEqual((await profile('abc')).json, { username: 'abc' });
assert.deepEqual((await profile('X'.repeat(20), JWT_OTHER)).json, { username: 'X'.repeat(20) });
assert.equal(fake.row('profiles', OTHER).usernameKey, 'x'.repeat(20));
});
test('Profil ändern: dieselbe Zeile wird aktualisiert, der alte Schlüssel ist sofort für ein anderes Konto frei, der neue vergeben', async () => {
const { profile, fake } = await setup();
assert.equal((await profile('Kenso')).status, 200);
expectError(await profile('kenso', JWT_OTHER), 409, 'usernameTaken', 'solange der Name vergeben ist');
const res = await profile('Kenso.neu');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { username: 'Kenso.neu' });
assert.equal(fake.rows('profiles').length, 1, 'eine Zeile je Konto');
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu');
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso.neu');
assert.deepEqual(fake.row('profiles', ME).$permissions, ['read("any")']);
assert.equal(fake.calls.filter(isCreate).length, 1, 'beim Ändern wird nichts neu angelegt');
assert.equal(fake.calls.filter(isUpdate).length, 1);
// der alte Name ist frei: ein anderes Konto nimmt ihn, das alte Konto bekommt ihn nicht mehr zurück
assert.deepEqual((await profile('kenso', JWT_OTHER)).json, { username: 'kenso' });
expectError(await profile('Kenso'), 409, 'usernameTaken', 'zurück');
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu', 'nach dem Fehlschlag bleibt das Profil, wie es war');
assert.equal(fake.row('profiles', OTHER).usernameKey, 'kenso');
});
test('„kenso“ → „Kenso“ beim selben Konto ist erlaubt (nur die Schreibweise), ebenso derselbe Name nochmal', async () => {
const { profile, fake } = await setup();
assert.deepEqual((await profile('kenso')).json, { username: 'kenso' });
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
assert.equal(fake.row('profiles', ME).username, 'Kenso');
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso');
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' }, 'derselbe Name nochmal');
assert.deepEqual((await profile('KENSO')).json, { username: 'KENSO' });
assert.equal(fake.rows('profiles').length, 1);
assert.equal(fake.row('profiles', ME).username, 'KENSO');
});
test('vergebener Name (in jeder Schreibweise): 409 usernameTaken, es wird nichts angelegt oder geändert', async () => {
const { profile, fake } = await setup();
fake.addProfile(OTHER, 'Kenso');
for (const name of ['kenso', 'KENSO', 'Kenso', 'kEnSo']) expectError(await profile(name), 409, 'usernameTaken', name);
assert.equal(fake.row('profiles', ME), null, 'kein Profil angelegt');
assert.equal(fake.rows('profiles').length, 1);
// wer schon ein Profil hat, behält es
fake.addProfile(ME, 'Me.me');
expectError(await profile('kenso'), 409, 'usernameTaken');
assert.equal(fake.row('profiles', ME).username, 'Me.me');
assert.equal(fake.row('profiles', OTHER).username, 'Kenso', 'das andere Profil ist unberührt');
});
// ---- Gleichzeitigkeit --------------------------------------------------------------------------------
test('Rennen: zwei Konten wollen gleichzeitig denselben Namen – genau eines bekommt ihn, das andere 409 (Unique-Index, nicht nur die Vorabprüfung)', async () => {
const { profile, fake } = await setup();
const results = await raceTwo(
fake,
isCreate,
() => profile('Rennen', JWT_ME),
() => profile('rennen', JWT_OTHER),
);
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
const winner = results.findIndex((res) => res.status === 200);
const loser = results.find((res) => res.status === 409);
assert.deepEqual(loser.json, { error: MSG.usernameTaken, code: 'usernameTaken' });
assert.equal(fake.rows('profiles').length, 1, 'genau eine Zeile');
const [row] = fake.rows('profiles');
assert.equal(row.$id, winner === 0 ? ME : OTHER);
assert.equal(row.usernameKey, 'rennen');
assert.equal(results[winner].json.username, row.username);
// beide Vorabprüfungen haben den Namen für frei gehalten und beide haben angelegt: erst der Unique-Index hat entschieden
assert.equal(fake.calls.filter(isCreate).length, 2);
});
test('Rennen beim Umbenennen: zwei Konten wollen denselben neuen Namen – genau eines bekommt ihn, das andere behält seinen alten', async () => {
const { profile, fake } = await setup();
fake.addProfile(ME, 'Alt.eins');
fake.addProfile(OTHER, 'Alt.zwei');
const results = await raceTwo(
fake,
isUpdate,
() => profile('Gemeinsam', JWT_ME),
() => profile('GEMEINSAM', JWT_OTHER),
);
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
assert.deepEqual(results.find((res) => res.status === 409).json, { error: MSG.usernameTaken, code: 'usernameTaken' });
assert.equal(fake.calls.filter(isUpdate).length, 2, 'beide haben ändern wollen: erst der Unique-Index hat entschieden');
const [winner, loser] = results[0].status === 200 ? [ME, OTHER] : [OTHER, ME];
assert.equal(fake.row('profiles', winner).usernameKey, 'gemeinsam');
assert.equal(fake.row('profiles', loser).username, loser === ME ? 'Alt.eins' : 'Alt.zwei', 'wer verliert, behält den alten Namen');
assert.equal(fake.rows('profiles').length, 2);
});
test('Sperre je Konto (profile:<id>): zwei gleichzeitige Anfragen desselben Kontos laufen nacheinander, beide gelingen, am Ende gilt die zweite', async () => {
const { profile, fake } = await setup();
const release = fake.holdNext(isCreate);
const first = profile('Alpha');
await until(() => fake.calls.some(isCreate)); // die erste steht beim Anlegen
const second = profile('Beta');
await until(() => fake.calls.filter((c) => c.path === '/v1/account').length === 2); // die zweite ist angemeldet ...
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(fake.calls.filter(isList).length, 1, '... wartet aber auf die Sperre, statt schon abzufragen');
release();
assert.deepEqual((await first).json, { username: 'Alpha' });
assert.deepEqual((await second).json, { username: 'Beta' });
assert.equal(fake.rows('profiles').length, 1);
assert.equal(fake.row('profiles', ME).username, 'Beta');
assert.equal(fake.row('profiles', ME).usernameKey, 'beta');
assert.equal(fake.calls.filter(isCreate).length, 1, 'die zweite hat die Zeile der ersten geändert, nicht noch einmal angelegt');
assert.equal(fake.calls.filter(isUpdate).length, 1);
});
// ---- Prüfungen -----------------------------------------------------------------------------------------
test('gesperrte Namen (Spec § 2, in jeder Schreibweise): 400 usernameReserved, ohne Abfrage bei Appwrite', async () => {
const { profile, fake } = await setup();
for (const name of ['Admin', 'AirCard-IOS', 'AIRCARD.IOS', 'Aircard_Ios', 'ROOT', 'Null', ...SPEC_RESERVED]) {
expectError(await profile(name), 400, 'usernameReserved', name);
}
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0, 'die Prüfung braucht Appwrite nur für das JWT');
});
test('ungültige Namen (Muster): 400 usernameInvalid, nichts angelegt, ohne Abfrage bei Appwrite', async () => {
const { profile, fake } = await setup();
const cases = [
['zu kurz', 'ab'],
['ein Zeichen', 'a'],
['leer', ''],
['Unterstrich vorn', '_abc'],
['Punkt vorn', '.abc'],
['Bindestrich vorn', '-abc'],
['21 Zeichen', 'a'.repeat(21)],
['Leerzeichen', 'a b'],
['Leerzeichen vorn', ' abc'],
['Leerzeichen hinten', 'abc '],
['Zeilenumbruch', 'abc\n'],
['Umlaut', 'Jürgen'],
['Emoji', 'abc😀'],
['Klammeraffe', '@kenso'],
['Schrägstrich', 'a/b'],
];
for (const [label, name] of cases) expectError(await profile(name), 400, 'usernameInvalid', label);
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0);
});
test('kein Text als Name (fehlt, null, Zahl, Liste, Objekt, Wahrheitswert): 400 usernameInvalid; kein JSON-Objekt: 400 badRequest', async () => {
const { post, call, fake } = await setup();
for (const body of [{}, { username: null }, { username: 7 }, { username: ['abc'] }, { username: { a: 1 } }, { username: true }, { name: 'Kenso' }]) {
expectError(await post('/api/profil', body, JWT_ME), 400, 'usernameInvalid', JSON.stringify(body));
}
for (const raw of ['[]', '"abc"', '7', '{kaputt']) {
const res = await call('POST', '/api/profil', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } });
expectError(res, 400, 'badRequest', raw);
}
assert.equal(fake.rows('profiles').length, 0);
});
test('ohne oder mit ungültigem JWT: 401, noch vor der Prüfung des Namens, nichts angelegt', async () => {
const { post, profile, fake } = await setup();
expectError(await post('/api/profil', { username: 'Kenso' }), 401, 'unauthorized', 'ohne JWT');
expectError(await post('/api/profil', {}), 401, 'unauthorized', 'ohne JWT, ungültiger Body');
expectError(await profile('Kenso', 'xxx.yyy.zzz'), 401, 'unauthorized', 'unbekanntes JWT');
expectError(await profile('Kenso', 'zwei teile'), 401, 'unauthorized', 'unzulässige Zeichen');
expectError(await profile('Admin', ''), 401, 'unauthorized', 'leeres JWT');
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0, 'ohne gültiges JWT wird nichts abgefragt oder geschrieben');
});
test('nur POST: GET /api/profil gibt es nicht (404)', async () => {
const { call } = await setup();
expectError(await call('GET', '/api/profil'), 404, 'notFound');
});
// ---- Fehler bei Appwrite -----------------------------------------------------------------------------------
test('Appwrite antwortet mit Fehler: 502 unavailable ohne halben Zustand, der nächste Versuch gelingt', async () => {
const { profile, fake } = await setup();
// die Vorabprüfung scheitert
fake.failNext(isList, { status: 500 });
expectError(await profile('Kenso'), 502, 'unavailable', 'Vorabprüfung');
assert.equal(fake.rows('profiles').length, 0);
// das Anlegen scheitert
fake.failNext(isCreate, { status: 503 });
expectError(await profile('Kenso'), 502, 'unavailable', 'Anlegen');
assert.equal(fake.rows('profiles').length, 0);
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
// das Ändern scheitert: das alte Profil bleibt
fake.failNext(isUpdate, { status: 500 });
expectError(await profile('Neu.name'), 502, 'unavailable', 'Ändern');
assert.equal(fake.row('profiles', ME).username, 'Kenso');
assert.deepEqual((await profile('Neu.name')).json, { username: 'Neu.name' });
assert.equal(fake.row('profiles', ME).usernameKey, 'neu.name');
});
test('fehlt die Tabelle `profiles` (Schema nicht eingespielt): 500 internal, kein Absturz', async () => {
const { profile, fake } = await setup();
fake.failNext(isList, { status: 404, type: 'table_not_found' });
expectError(await profile('Kenso'), 500, 'internal');
});
// ---- Logs -----------------------------------------------------------------------------------------------------
test('Logs: eine Ereigniszeile je Anlegen und Ändern, nie Name, Konto-ID, Schlüssel oder JWT', async () => {
const { profile, logs, fake } = await setup();
await profile('GEHEIMER.Name'); // anlegen
await profile('GEHEIMER.Neu'); // ändern
await profile('ab'); // 400
await profile('Admin'); // 400
await profile('geheimer.neu', JWT_OTHER); // 409
await profile('x', 'xxx.yyy.zzz'); // 401
const lines = logs.map((line) => line.replace(/^\[aircard-api\] \S+ /, ''));
assert.deepEqual(lines, ['profile created', 'profile updated']);
assert.ok(logs.every((line) => line.startsWith('[aircard-api] ')));
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, 'GEHEIMER', 'geheimer', ME, OTHER]) {
assert.ok(!logs.join('\n').includes(secret), `Log enthält „${secret}“`);
}
});
+1 -1
View File
@@ -5,7 +5,7 @@
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
*
* Umgebung:
* AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (rows.read/write, files.read/write)
* AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (Console-Scopes documents.read/write = rows.* der API, files.read/write)
* AIRCARD_API_SALT Pflicht Salz für die Melder-Hashes (32 Byte hex)
* AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
+2 -1
View File
@@ -85,7 +85,8 @@
# --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) ---
# Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1.
# Kein `ports:`: erreichbar nur über Traefik (der Dienst vertraut dem Header X-Real-Ip).
# Kein `ports:` (veröffentlicht wird der Dienst nur über Traefik). Im Docker-Netz erreichen ihn andere Container, und er
# vertraut dort dem Header X-Real-Ip: akzeptiertes Risiko, siehe deploy/api/README.md (Client-IP).
aircard-api:
image: node:22-alpine
container_name: webklar-aircard-api
+9
View File
@@ -49,6 +49,15 @@ server {
return 404;
}
# Die Community-API (aircard-api) liegt ebenfalls nur über Traefik auf /api. Fehlt der Dienst (Container weg, damit
# auch sein Traefik-Router), darf /api/* nie die App ausliefern: der Client bekäme HTML mit 200 statt eines Fehlers.
# Stattdessen 503 mit JSON-Text, den die Seite wie eine API-Antwort anzeigt. Gleiche Reihenfolge-Regel wie bei /v1.
# (Kein add_header: sonst entfallen die Sicherheits-Header des server-Blocks; default_type setzt den Content-Type.)
location ~ ^/api(/|$) {
default_type application/json;
return 503 '{"error":"Der Dienst ist gerade nicht erreichbar."}';
}
location = /index.html {
expires -1;
}
+12 -10
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`, `logo_reports` und `profiles` 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,14 +58,14 @@ 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`, `logo_reports` und `profiles` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
## 6. Key auf dem Server eintragen
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`.
2. Ans Ende springen und neue Zeilen anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=` (die Projekt-ID ist die in Schritt 1 notierte, siehe Hinweis oben):
2. Ans Ende springen. Steht der Cursor am Ende der letzten Zeile, einmal Enter drücken, damit die neuen Zeilen in eigenen Zeilen stehen (sonst klebt der erste neue Eintrag an einem bestehenden Wert und macht ihn kaputt). Dann neue Zeilen anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=` (die Projekt-ID ist die in Schritt 1 notierte, siehe Hinweis oben):
```
AIRCARD_APPWRITE_PROJECT_ID=<Projekt-ID>
@@ -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**.
@@ -97,17 +97,19 @@ Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
3. Unter **Scopes** genau diese vier ankreuzen:
- `rows.read` und `rows.write`
- `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. 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 -s`), hängt nur eine Zeile an und zeigt den Key nirgends an:
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:
```
read -s -p "AIRCARD_API_KEY: " K; echo; printf 'AIRCARD_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K
read -rs -p "AIRCARD_API_KEY: " K; echo; if [ -n "$K" ]; then printf '\nAIRCARD_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; else echo "Leer – nichts geschrieben."; fi; unset K
```
Den Wert nicht im Chat teilen. Stand der Provisioning-Key aus Schritt 5 einmal im Chat, in der Console unter **API keys** löschen und bei Bedarf neu anlegen.
Den Wert nicht im Chat teilen. Wurde der Provisioning-Key aus Schritt 5 einmal im Chat geteilt, ihn in der Console unter **API keys** löschen und bei Bedarf neu anlegen.
## 8. Bescheid geben
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt“.
Schreib Claude „Console fertig”. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` (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,113 @@
# AirCard-IOS Benutzernamen – 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:** Eindeutige Benutzernamen pro Konto (Pflicht, änderbar), angezeigt als „von @name“ an Logos und Community-Karten.
**Architecture:** Neue öffentliche Tabelle `profiles` (Zeile = Konto, Unique-Index auf dem kleingeschriebenen Namen), geschrieben nur über `aircard-api` (`POST /api/profil`, neues Modul `deploy/api/profiles.js`); Hochladen/Veröffentlichen verlangen serverseitig ein Profil. Der Client liest Profile direkt aus Appwrite, fragt den Namen beim Registrieren bzw. nach dem Anmelden ab und zeigt ihn statt des alten Anzeigenamens.
**Tech Stack:** wie bisher (TypeScript, Vite, Vitest + happy-dom, appwrite 22.4.1; Node 22 ohne Abhängigkeiten; Appwrite 1.8.1 TablesDB).
**Spec:** `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md`
## Global Constraints
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
- Keine neuen Abhängigkeiten; Versionen unverändert; deutsche Kommentare im Stil des Bestands.
- Alle neuen sichtbaren Texte über `t()`/`msg()` mit Eintrag in `web/src/i18n/en.ts` (Schlüssel eindeutig, Vorhandenes wiederverwenden); Tests laufen auf Deutsch; `coverage.test.ts` (Abdeckung, Heuristik, AST-Wache) bleibt grün.
- Namensmuster genau `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`; Schlüssel = `toLowerCase()`; Sperrliste genau wie Spec § 2.
- Tabelle `profiles`: Zeilen-ID = Nutzer-ID, Spalten `username` (string 20, Pflicht), `usernameKey` (string 20, Pflicht), Index `username_key` unique auf `usernameKey`, rowSecurity an, keine Tabellenrechte, Zeilenrecht genau `['read("any")']`.
- API-Codes und Texte genau wie Spec § 4 (`usernameInvalid`, `usernameReserved`, `usernameTaken`, `usernameRequired`); Client-Tabelle `API_MESSAGES` bleibt im Gleichlauf mit `deploy/api/common.js` `MSG`.
- `sessionStorage`-Schlüssel `aircard.usernamePrompted`; jeder Storage-Zugriff in try/catch.
- Tests: `cd web && npx vitest run && npx tsc --noEmit && npm run build`; `node --test "deploy/api/*.test.js"`; `node --test web/scripts/appwrite-setup.test.mjs`.
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Subagents: `sonnet` (Umsetzung/Review), `haiku` für Kleinstes; nie Subagents aus Subagents; keine Browser-/Headless-Prüfungen (macht der Controller live).
## Review Focus
- **Zwei Konten wollen gleichzeitig denselben Namen**: genau eines bekommt ihn, das andere 409 `usernameTaken` (Unique-Index, nicht nur Vorabprüfung). Test in Task 1.
- **Registrierung mit vergebenem Namen**: Fehler am Feld, **bevor** das Konto angelegt wird (kein halbes Konto). Test in Task 2.
- **Altes Konto ohne Namen lädt hoch / veröffentlicht**: erst der Namensdialog; „Abbrechen“ → nichts passiert; Server würde ohne Profil 403 liefern. Tests in Tasks 1 und 3.
- **Umbenennen**: alter Name sofort für andere frei, eigene Kacheln/Infozeile/Detailseite zeigen sofort den neuen. Tests in Tasks 1 und 3.
- **Profil nicht lesbar / gelöscht / Netzfehler bei der Anzeige**: Fallback auf alten Anzeigenamen bzw. „Anonym“, keine Fehlermeldung, kein Absturz. Test in Task 3.
---
### Task 1: Server – Schema `profiles`, `POST /api/profil`, Pflicht beim Hochladen/Veröffentlichen
**Files:**
- Modify: `web/scripts/appwrite-setup.mjs` (+ Test): Tabelle `profiles` nach `logo_reports` mit Spalten und Unique-Index aus den Global Constraints.
- Create: `deploy/api/profiles.js`, `deploy/api/profiles.test.js`
- Modify: `deploy/api/common.js` (`MSG` + 4 Texte, Muster/Sperrliste/Schlüssel-Helfer), `deploy/api/app.js` (Routen einhängen; `publish` prüft Profil), `deploy/api/logos.js` (`hochladen` prüft Profil), `deploy/api/fake-appwrite.js` (Tabelle `profiles`, Unique-Index → 409 bei doppeltem `usernameKey`), `deploy/api/app.test.js`/`logos.test.js` (Profil in bestehenden Hochladen-/Veröffentlichen-Tests anlegen; neue 403-Tests), `deploy/api/README.md`, `deploy/README.md` (sechs Dateien kopieren)
**Interfaces:**
- Produces:
```js
// common.js
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
function usernameKey(name) // name.toLowerCase()
function isReservedUsername(name) // Schlüssel in der Sperrliste
// profiles.js
function createProfileRoutes({ appwrite, say, withLock, requireUser }) // → [['POST /api/profil', {name:'profil', json:true, run}]]
async function hasProfile(appwrite, userId) // getRow('profiles', userId) !== null — von logos.js und app.js genutzt
```
- [ ] **Step 1: Tests schreiben:** anlegen → 200 `{username}`, Zeile ID = Nutzer-ID, Rechte genau `['read("any")']`, `usernameKey` klein; ändern → alter Schlüssel frei (anderes Konto kann ihn danach nehmen); „kenso“ → „Kenso“ beim selben Konto erlaubt; vergeben → 409 `usernameTaken`; Rennen zweier Konten (Verzögerung im Fake) → genau ein 200, ein 409; gesperrt (`Admin`, `AirCard-IOS`) → 400 `usernameReserved`; Muster (`ab`, `_abc`, 21 Zeichen, `a b`, Umlaut) → 400 `usernameInvalid`; ohne JWT 401; `hochladen` und `veroeffentlichen` ohne Profil → 403 `usernameRequired` (vor allen anderen Prüfungen, nichts angelegt); Setup-Skript: `profiles` samt Unique-Index wird angelegt/geprüft.
- [ ] **Step 2:** `node --test "deploy/api/*.test.js"` und `node --test web/scripts/appwrite-setup.test.mjs` → FAIL.
- [ ] **Step 3:** Umsetzen.
- [ ] **Step 4:** beide Suiten grün; `cd web && npx vitest run` grün (Gleichlauftest `API_MESSAGES` erst in Task 2 — falls er hier rot wird, die vier Texte in `web/src/i18n/api-messages.ts` + `en.ts` gleich mit ergänzen).
- [ ] **Step 5:** Commit `feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)`.
### Task 2: Client – Profil-Daten, Dialog „Benutzername wählen“, Registrieren, Konto-Menü
**Files:**
- Create: `web/src/account/profile.ts` (+ Test), `web/src/account/username-dialog.ts` (+ Test)
- Modify: `web/src/account/appwrite.ts` (`IDS.profiles = 'profiles'`), `web/src/account/login-dialog.ts` (+ Test), `web/src/account/account-menu.ts` (+ Test), `web/src/main.ts` (+ Test: Abfrage einmal pro Sitzung nach Start/Anmeldung), `web/src/i18n/api-messages.ts`, `web/src/i18n/en.ts`, `web/src/account/account.css`
**Interfaces:**
- Produces:
```ts
// account/profile.ts
export const USERNAME_PATTERN: RegExp;
export function usernameKey(name: string): string;
export function validateUsername(name: string): string | null; // übersetzter Text oder null (Muster, Sperrliste)
export function getUsername(userId: string): Promise<string | null>; // Profil lesen, 404 → null
export function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>; // gesammelt, zwischengespeichert, Fehler → leere Map
export function isUsernameTaken(name: string): Promise<boolean>; // Abfrage equal('usernameKey', key)
export function saveUsername(name: string): Promise<string>; // POST /api/profil, aktualisiert Cache + meldet Änderung
export function myUsername(): string | null; // Cache des angemeldeten Kontos (null = keiner/unbekannt)
export function loadMyUsername(): Promise<string | null>; // nach Anmeldung/Start
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void;
// account/username-dialog.ts
export function usernameDialog(opts: { mode: 'prompt' | 'required' | 'change'; signal?: AbortSignal }): Promise<string | null>; // null = Später/Abbrechen
export function requireUsername(): Promise<boolean>; // hat Namen → true; sonst Dialog 'required'
```
- [ ] **Step 1: Tests:** `validateUsername` (Grenzen 3/20, erstes Zeichen, erlaubte Zeichen, Sperrliste case-insensitiv); `usernamesFor` sammelt IDs, eine Abfrage pro Aufruf, Cache, Fehler → leere Map; Registrieren: Feld „Benutzername“ Pflicht, Vorabprüfung (Muster, Sperre, `isUsernameTaken`) **vor** `register()`, Fehler am Feld; nach erfolgreichem `register` → `saveUsername`; scheitert das, schließt der Login-Dialog trotzdem erfolgreich und der Namensdialog erscheint; Dialog: Modi prompt („Später“), required („Abbrechen“), change (vorbelegt), Speichern ruft `saveUsername`, 409 → Text am Feld, Busy-Sperre; Konto-Menü: „@name“ + E-Mail klein, Eintrag „Benutzername ändern“ bzw. „Benutzername wählen“; `main.ts`: nach Start mit Sitzung bzw. nach Anmeldung ohne Namen genau einmal pro Sitzung Dialog `prompt`.
- [ ] **Step 2:** `cd web && npx vitest run src/account src/main.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen (Dialog nach dem Muster von `logo-dialog.ts`/`publish-dialog.ts`, Texte nur als Text).
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
- [ ] **Step 5:** Commit `feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage`.
### Task 3: Client – Anzeige „von @name“, Anzeigename-Felder weg, Pflicht vor Hochladen/Veröffentlichen
**Files:**
- Modify: `web/src/logos/logo-picker.ts`, `web/src/logos/logo-info.ts`, `web/src/pages/community-page.ts`, `web/src/logos/logo-dialog.ts`, `web/src/logos/upload-logo.ts`, `web/src/community/publish-dialog.ts`, `web/src/community/publish.ts`, `web/src/pages/cards-page.ts` (Veröffentlichen-Aufruf), Tests dazu; `web/src/i18n/en.ts`
- Delete: `web/src/account/display-name.ts` (+ Test)
**Interfaces:**
- Consumes: Task 2 (`usernamesFor`, `myUsername`, `onUsernameChange`, `requireUsername`, `currentUserId`).
- Byline-Regel (eine Hilfsfunktion, z. B. in `profile.ts`): `byline(ownerId, legacyDisplayName, names)` → `t('von @{name}', {name})` wenn Profil, sonst `t('von {name}', {name: legacy})` wenn Altname, sonst `t('von {name}', {name: t('Anonym')})`.
- [ ] **Step 1: Tests:** Kacheln/Infozeile/Detailseite zeigen „von @name“ (eine `usernamesFor`-Abfrage pro geladener Seite), Fallback Altname bzw. „Anonym“, Fehler beim Nachschlagen → Fallback ohne Fehlertext; eigene Umbenennung (`onUsernameChange`) aktualisiert eigene Einträge sofort; Dialoge „Logo hochladen“ und „Veröffentlichen“ ohne Anzeigename-Feld, senden `displayName: ''`; vor Hochladen bzw. Veröffentlichen `requireUsername()` → false = nichts passiert; Serverantwort 403 `usernameRequired` → Namensdialog `required`, danach erneuter Versuch möglich; `display-name.ts` und `aircard.displayName` nirgends mehr genutzt.
- [ ] **Step 2:** `cd web && npx vitest run src/logos src/community src/pages` → FAIL.
- [ ] **Step 3:** Umsetzen.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
- [ ] **Step 5:** Commit `feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen`.
### Task 4: Betrieb und Abnahme (Controller)
- [ ] Schema: Setup-Skript `--check` → anlegen → `--check` Exit 0 (Unique-Index `username_key`).
- [ ] API: Backup, sechs Dateien kopieren, `docker compose restart aircard-api`, Gesundheit + `POST /api/profil` ohne JWT = 401 `unauthorized`.
- [ ] Push `main` → Build → neues Bundle live.
- [ ] Live als Gast: Kacheln/Detailseite zeigen „von …“ (Altdaten „Anonym“). Kenso: registrieren bzw. Abfrage nach Anmeldung, Name ändern, Name am eigenen Logo sehen.
- [ ] Review (ein `sonnet`-Reviewer über alles), Fix-Welle nur für Critical/Important, Memory, Bericht.
@@ -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) blendet das Logo zuerst aus (Datei und Zeile), damit nach einem Abbruch keine öffentliche Zeile mit gelöschter Datei zurückbleibt, und entfernt dann 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 = `hash32("<ownerId>|<fileId>")` (seit 2026-10-05; zuerst war sie die Datei-ID, die Altlogos behalten sie). Dieselbe Datei desselben Kontos ergibt dieselbe ID, ein zweiter Versuch also 409. Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten und lässt sich so nicht mit einer Datei gleicher ID neu belegen. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat sie kein `delete(owner)` mehr.
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) | Blendet das Logo zuerst aus, löscht dann 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.
@@ -0,0 +1,109 @@
# AirCard-IOS – Benutzernamen – Design
Stand: 2026-10-05 · Status: Entwurf im Chat von Kenso freigegeben („ja passt, mach weiter“), Spec + Plan + Umsetzung ohne weitere Rückfrage · baut auf `2026-10-04-aircard-logos-sprache-design.md` auf (live, main 13b46da)
## 1. Ziel und Rahmen
Jedes Konto bekommt einen eindeutigen Benutzernamen. Logos und Community-Karten zeigen „von @name“ statt „Anonym“, damit man Leute wiedererkennt.
| Punkt | Entscheidung (Kenso) |
|---|---|
| Eindeutig | Ja, jeder Name nur einmal; Groß-/Kleinschreibung zählt beim Vergleich nicht. |
| Pflicht | Ja, auch für bestehende Konten. Registrieren nur mit Name; alte Konten werden nach dem Anmelden einmal gefragt; Hochladen und Veröffentlichen gehen erst mit Name. |
| Anzeige | Immer der Benutzername; das Feld „Anzeigename“ fällt weg; gilt auch für vorhandene Einträge des Kontos. |
| Ändern | Ja, im Konto-Menü; danach überall der neue Name, der alte wird frei. |
## 2. Regeln für den Namen
- Muster: `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`. Das sind 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`.
- Schlüssel für die Eindeutigkeit: der Name in Kleinbuchstaben (`usernameKey`). Gespeichert und angezeigt wird der Name, wie er eingegeben wurde.
- Gesperrte Schlüssel: `admin`, `administrator`, `root`, `system`, `support`, `moderator`, `mod`, `aircard`, `aircardios`, `aircard-ios`, `aircard.ios`, `aircard_ios`, `webklar`, `anonym`, `anonymous`, `null`, `undefined`, `team`, `staff`, `official`, `offiziell`, `hilfe`, `help`, `kontakt`, `contact`, `info`.
- Anzeige mit `@` davor: „von @name“ bzw. „by @name“.
## 3. Daten (Appwrite, Datenbank `aircard`, Setup-Skript erweitert)
**Tabelle `profiles`** (rowSecurity an, keine Tabellenrechte für Clients):
| Spalte | Typ | Inhalt |
|---|---|---|
| `username` | string 20, Pflicht | Name wie eingegeben |
| `usernameKey` | string 20, Pflicht | Name klein geschrieben |
- Zeilen-ID = Nutzer-ID (eine Zeile pro Konto).
- Index `username_key`: **unique** auf `usernameKey`. Damit kann auch bei Gleichzeitigkeit kein Name doppelt vergeben werden.
- Zeilenrecht genau `['read("any")']`, öffentlich lesbar. Schreiben nur über die API.
## 4. API-Dienst `aircard-api`
Eigenes Modul `deploy/api/profiles.js`, eingebunden wie `logos.js`. Der Betrieb kopiert künftig sechs Dateien: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`.
`POST /api/profil` `{username}`, Nutzer-JWT:
- 401 ohne Anmeldung.
- 400 `usernameInvalid` bei falschem Muster.
- 400 `usernameReserved` bei gesperrtem Schlüssel.
- 409 `usernameTaken`, wenn ein anderes Konto den Schlüssel hat. Das wird vorab per Abfrage geprüft; bei einem Rennen meldet Appwrite 409 auf dem Unique-Index, das wird ebenfalls zu `usernameTaken`.
- Sonst: Hat das Konto schon eine Zeile, wird sie aktualisiert (der alte Name ist damit frei); sonst wird sie mit ID = Nutzer-ID und `read("any")` angelegt. Antwort `200 {username}`.
- Groß-/Kleinschreibung des eigenen Namens ändern („kenso“ → „Kenso“) ist erlaubt.
- Sperre je Konto (`profile:<userId>`).
**Pflicht serverseitig:** `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten **403 `usernameRequired`**, wenn das Konto keine Zeile in `profiles` hat. Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen. Bestehende Logos und Karten bleiben unberührt.
Neue `MSG`-Texte:
- `usernameInvalid`: „Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.“
- `usernameReserved`: „Dieser Benutzername ist nicht erlaubt.“
- `usernameTaken`: „Dieser Benutzername ist schon vergeben.“
- `usernameRequired`: „Bitte wähle zuerst einen Benutzernamen.“
## 5. Oberfläche
- **Registrieren** (Login-Dialog, Reiter „Registrieren“): neues Pflichtfeld „Benutzername“ vor der E-Mail, `autocomplete="nickname"` (angemeldet wird per E-Mail; so speichern Passwortmanager weiter die E-Mail), Hinweis „3–20 Zeichen: Buchstaben, Zahlen, . _ -“.
- Vor dem Anlegen des Kontos werden Muster, Sperrliste und Verfügbarkeit geprüft (Abfrage auf `profiles`). Ein Fehler steht am Feld.
- Danach: Konto anlegen → anmelden → `POST /api/profil`.
- Scheitert dieser letzte Schritt (Rennen, Netz), ist man trotzdem angemeldet, und der Dialog „Benutzername wählen“ erscheint.
- **Dialog „Benutzername wählen“** mit Feld, „Speichern“ und „Später“ bzw. „Abbrechen“:
- Nach dem Anmelden oder beim Seitenstart mit Sitzung, wenn das Konto keinen Namen hat. Einmal pro Sitzung (Merker in `sessionStorage` `aircard.usernamePrompted`, Zugriffe in try/catch), mit „Später“.
- Vor dem Hochladen eines Logos und vor dem Veröffentlichen, wenn kein Name da ist. Hier gibt es „Abbrechen“ statt „Später“; ohne Namen geht es nicht weiter.
- Antwortet der Server mit `usernameRequired`, erscheint der Dialog ebenfalls.
- **Konto-Menü**: Statt der E-Mail steht „@name“ oben (die E-Mail darunter klein). Neuer Eintrag „Benutzername ändern“, der den Dialog vorbelegt öffnet. Ohne Namen heißt der Eintrag „Benutzername wählen“.
- **Anzeige „von @name“**: Logo-Kacheln, Logo-Infozeile, Detailseite einer Community-Karte.
- Der Name wird über `ownerId` in `profiles` nachgeschlagen, gesammelt je Seite (`equal('$id', ids)`) und zwischengespeichert.
- Ohne Profil: der früher gespeicherte `displayName` (ohne `@`), sonst „Anonym“.
- Nach einer eigenen Namensänderung aktualisieren Kacheln, Infozeile und Detailseite die eigenen Einträge sofort.
- **Weg fällt** das Feld „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ samt `account/display-name.ts` und dem `localStorage`-Schlüssel `aircard.displayName`. Der Client schickt `displayName: ''`; die API nimmt das Feld weiter an (Altbestand).
Alle Texte gibt es auf Deutsch und Englisch.
## 6. Fehlerbehandlung
| Fall | Meldung |
|---|---|
| Muster falsch / gesperrt / vergeben | die drei `MSG`-Texte aus § 4, am Feld |
| Server meldet `usernameRequired` | Dialog „Benutzername wählen“ |
| Netz/API weg beim Speichern | „Der Dienst ist gerade nicht erreichbar.“, Dialog bleibt offen |
| Profil lässt sich nicht lesen (Anzeige) | Fallback `displayName` bzw. „Anonym“, kein Fehlertext |
## 7. Tests
- **node:test:**
- `/api/profil`: anlegen, ändern, Groß/klein am eigenen Namen, vergeben 409, gesperrt 400, Muster 400, ohne JWT 401.
- Rennen mit zwei Konten: genau eines bekommt den Namen.
- Zeilenrechte genau `read("any")`.
- Hochladen und Veröffentlichen ohne Profil → 403 `usernameRequired`.
- Setup-Skript deckt `profiles` mit Unique-Index ab.
- **Vitest:**
- Prüfung des Namens (Muster, Sperrliste, Schlüssel).
- Registrieren mit Vorabprüfung und Fehlern am Feld.
- Dialog: Später, Abbrechen, Speichern, Fehler.
- Einmal-pro-Sitzung-Abfrage.
- Konto-Menü mit „@name“ und Ändern.
- Anzeige „von @name“ mit Fallbacks; sofortige Aktualisierung nach Umbenennen.
- Dialoge ohne „Anzeigename“.
- Hochladen und Veröffentlichen fragen vorher nach dem Namen.
- Gleichlauf der API-Codes, i18n-Abdeckung.
- **Live:** Gast sieht „von …“-Zeilen. Kenso registriert bzw. wird gefragt, ändert den Namen und sieht ihn an seinem Logo.
## 8. Nicht im Umfang
Profilseiten, Suche nach Benutzernamen, Moderation von Namen, Konto löschen, Avatar-Bilder, Benutzername als Anmeldename (angemeldet wird weiter per E-Mail).
+29 -14
View File
@@ -1,26 +1,43 @@
<!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="Explore 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.
try {
var theme = null;
// 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 {
theme = localStorage.getItem('aircard.theme');
var theme = null;
try {
theme = localStorage.getItem('aircard.theme');
} catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
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>
<body>
@@ -30,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"
},
+73 -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`,
* `logo_reports` und `profiles` (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, profiles (je Tabelle: Spalten, dann Indizes).
tables: [
{
id: 'cards',
@@ -88,6 +89,64 @@ 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'] }],
},
{
// Benutzernamen: eine Zeile je Konto, Zeilen-ID = Konto-ID, öffentlich lesbar (Zeilenrecht read("any") setzt die API); geschrieben wird nur
// über die API. `usernameKey` ist der Name klein geschrieben: der Unique-Index macht ihn auch bei gleichzeitigen Anfragen nur einmal vergebbar.
id: 'profiles',
name: 'Profiles',
permissions: [],
rowSecurity: true,
columns: [str('username', 20, true), str('usernameKey', 20, true)],
indexes: [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
},
],
buckets: [
{
@@ -110,6 +169,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,
},
],
};
+353 -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, profiles; 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', 'profiles']);
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,76 @@ 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('profiles entspricht Benutzernamen-Spec §3 (Unique-Index username_key auf usernameKey)', () => {
const t = table('profiles');
assert.equal(t.name, 'Profiles');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [str('username', 20, true), str('usernameKey', 20, true)]);
// unique: nur so kann bei Gleichzeitigkeit kein Name doppelt vergeben werden
assert.deepEqual(t.indexes, [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }]);
});
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 +618,31 @@ 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('compareIndex (unique)', () => {
const usernameKey = DESIRED.tables.find((t) => t.id === 'profiles').indexes[0];
const ok = () => ({ key: 'username_key', type: 'unique', columns: ['usernameKey'], lengths: [], orders: [], status: 'available' });
test('Unique-Index profiles.username_key: Typ, Spalte und Status werden verglichen', () => {
assert.deepEqual(compareIndex(ok(), usernameKey), []);
// ein normaler Index verhindert keine doppelten Namen: das muss auffallen
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, usernameKey), ['type ist key, erwartet unique']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['username'] }, usernameKey), ['columns ist [username], erwartet [usernameKey]']);
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, usernameKey), ['status ist processing, erwartet available']);
});
});
describe('compareBucket', () => {
@@ -588,6 +693,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 +742,41 @@ 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',
'Tabelle profiles',
'Spalte profiles.username',
'Spalte profiles.usernameKey',
'Index profiles.username_key',
'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));
// Erweiterung um die Benutzernamen (profiles)
const PROFILE_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) profiles(\.|$)/.test(o));
const T = '/v1/tablesdb/aircard/tables';
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
@@ -658,6 +809,36 @@ 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'] }],
[T, { tableId: 'profiles', name: 'Profiles', permissions: [], rowSecurity: true }],
str('profiles', 'username', 20, true),
str('profiles', 'usernameKey', 20, true),
// unique: das Skript legt den Index mit dem Typ `unique` an (Appwrite meldet dann 409 statt eine zweite Zeile mit demselben Schlüssel anzulegen)
[`${T}/profiles/indexes`, { key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
@@ -668,16 +849,23 @@ 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 ?? '');
const isProfilePost = ([path, body]) => /\/tables\/profiles(\/|$)/.test(path) || body.tableId === 'profiles';
test('--check auf leerem Projekt: Exit 1, alle 28 Objekte FEHLT, nichts angelegt', async () => {
test('--check auf leerem Projekt: Exit 1, alle 55 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, 55);
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: 55 fehlend.'), lines.join('\n'));
assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0);
});
@@ -706,13 +894,17 @@ 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, 33);
assert.equal(fake.state.indexes.size, 11);
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',
'profiles.username_key',
]);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
// Zweite Prüfung
@@ -732,24 +924,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 +949,85 @@ 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, 32);
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, 33);
assert.equal(fake.state.indexes.size, 11);
// danach ist das Schema vollständig
const again = await run(fake, { check: true });
assert.equal(again.code, 0, again.lines.join('\n'));
});
});
test('Bestehendes Schema ohne `profiles` wird um die Tabelle samt Unique-Index ergänzt, alles Bestehende bleibt unberührt', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Stand vor den Benutzernamen nachstellen: alles außer profiles
dropFromFake(fake, { tables: ['profiles'] });
const kept = EXPECTED_OBJECTS.filter((o) => !PROFILE_OBJECTS.includes(o));
assert.equal(PROFILE_OBJECTS.length, 4);
assert.equal(kept.length, 51);
const requestsBefore = fake.state.requests.length;
const postsBefore = fake.posts().length;
// --check: genau die vier neuen Objekte fehlen, nichts wird angelegt
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), PROFILE_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: 4 fehlend.'), check.lines.join('\n'));
assert.equal(fake.posts().length, postsBefore);
// Anlegen: Tabelle, beide Spalten, dann der Unique-Index (erst wenn die Spalten available sind)
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), PROFILE_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(isProfilePost));
assert.deepEqual(newPosts.at(-1).body, { key: 'username_key', type: 'unique', columns: ['usernameKey'] });
assert.equal(fake.state.indexes.get('aircard/profiles/username_key').type, 'unique');
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
assert.equal(fake.otherWrites().length, 0);
assert.equal((await run(fake, { check: true })).code, 0);
});
});
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 +1155,68 @@ 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('Abweichende Benutzernamen-Tabelle (Rechte, Länge, Pflicht, Index nur `key` statt `unique`) wird gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.tables.get('aircard/profiles').$permissions = ['read("any")'];
fake.state.tables.get('aircard/profiles').rowSecurity = false;
fake.state.columns.get('aircard/profiles/username').size = 30;
fake.state.columns.get('aircard/profiles/usernameKey').required = false;
fake.state.indexes.get('aircard/profiles/username_key').type = 'key';
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: profiles.$permissions ist [read("any")], erwartet []',
'ABWEICHUNG: profiles.rowSecurity ist false, erwartet true',
'ABWEICHUNG: profiles.username.size ist 30, erwartet 20',
'ABWEICHUNG: profiles.usernameKey.required ist false, erwartet true',
'ABWEICHUNG: profiles.username_key.type ist key, erwartet unique',
]);
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 +1399,12 @@ 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: Tabelle profiles/);
assert.match(stdout, /FEHLT: Index profiles\.username_key/);
assert.match(stdout, /FEHLT: Bucket logo-images/);
assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0);
});
+219 -1
View File
@@ -7,6 +7,10 @@ const mocks = vi.hoisted(() => ({
listeners: new Set<(user: TestUser | null) => void>(),
logout: vi.fn(),
requireLogin: vi.fn(),
/** Der Benutzername des angemeldeten Kontos laut Zwischenspeicher (null: keiner oder unbekannt). */
name: null as string | null,
nameListeners: new Set<(userId: string, username: string) => void>(),
usernameDialog: vi.fn(),
}));
vi.mock('./auth', () => ({
@@ -18,6 +22,14 @@ vi.mock('./auth', () => ({
},
}));
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.mock('./profile', () => ({
myUsername: () => mocks.name,
onUsernameChange: (fn: (userId: string, username: string) => void) => {
mocks.nameListeners.add(fn);
return () => mocks.nameListeners.delete(fn);
},
}));
vi.mock('./username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
import { mountAccountMenu, type AccountMenu } from './account-menu';
@@ -33,11 +45,21 @@ function setUser(user: TestUser | null) {
for (const fn of [...mocks.listeners]) fn(user);
}
/** Der Name des Kontos ändert sich (eigene Änderung oder nach dem Lesen), wie `onUsernameChange` es meldet. */
function setName(userId: string, name: string) {
mocks.name = name;
for (const fn of [...mocks.nameListeners]) fn(userId, name);
}
beforeEach(() => {
mocks.user = null;
mocks.name = null;
mocks.listeners.clear();
mocks.nameListeners.clear();
mocks.logout.mockReset();
mocks.requireLogin.mockReset();
mocks.usernameDialog.mockReset();
mocks.usernameDialog.mockResolvedValue(null);
document.body.innerHTML = '<div id="account-slot"></div>';
slot = document.getElementById('account-slot') as HTMLElement;
menu = mountAccountMenu(slot);
@@ -130,11 +152,207 @@ describe('Konto-Menü', () => {
expect(buttonByText('Anmelden')).toBeDefined();
});
it('dispose leert den Slot und meldet den Listener ab', () => {
it('dispose leert den Slot und meldet die Listener ab', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
menu.dispose();
expect(slot.childElementCount).toBe(0);
expect(mocks.listeners.size).toBe(0);
expect(mocks.nameListeners.size).toBe(0);
});
});
describe('Konto-Menü: Benutzername', () => {
const signedIn = () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
};
const head = () => panel()!.querySelector<HTMLElement>('.account-head')!;
const nameLine = () => panel()!.querySelector('.account-name');
const items = () => [...panel()!.querySelectorAll('.account-item')].map((item) => item.textContent);
it('mit Namen steht „@name“ oben und die E-Mail klein darunter', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(nameLine()?.textContent).toBe('@Kenso');
expect(panel()!.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
expect([...head().children].map((child) => child.className)).toEqual(['account-name', 'account-email']); // Name zuerst
expect(head().parentElement).toBe(panel());
expect(head().nextElementSibling?.textContent).toBe('Meine Karten'); // die Kopfzeile steht vor den Einträgen
});
it('der Avatar bleibt bei der Initiale der E-Mail', () => {
signedIn();
mocks.name = 'Zebra';
menu.ready();
expect(avatar()?.textContent).toBe('K');
});
it('ohne Namen steht nur die E-Mail oben (wie bisher)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(head().textContent).toBe('kenso@example.com');
});
it('Name und E-Mail erscheinen als Text, nie als HTML', () => {
mocks.user = { $id: 'u1', email: '<i>x</i>@example.com' };
mocks.name = '<b>Kenso</b>';
menu.ready();
expect(head().querySelector('b, i')).toBeNull();
expect(nameLine()?.textContent).toBe('@<b>Kenso</b>');
expect(nameLine()?.getAttribute('title')).toBe('@<b>Kenso</b>');
});
it('Eintrag „Benutzername ändern“ mit Namen, „Benutzername wählen“ ohne – zwischen „Meine Karten“ und „Abmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['Meine Karten', 'Benutzername ändern', 'Abmelden']);
setUser(null);
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['Meine Karten', 'Benutzername wählen', 'Abmelden']);
});
it('der Eintrag öffnet den Dialog im Modus „change“ und schließt das Menü', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
expect(panel()?.hidden).toBe(true);
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
});
it('ohne Namen öffnet „Benutzername wählen“ denselben Dialog', () => {
signedIn();
menu.ready();
buttonByText('Benutzername wählen')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
});
it('nach dem Dialog geht der Fokus zurück an den Avatar (der Eintrag im geschlossenen Menü kann ihn nicht halten)', async () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
});
it('ändert sich der Name (Speichern im Dialog), zeigt das Menü sofort den neuen', () => {
signedIn();
mocks.name = 'Alt';
menu.ready();
expect(nameLine()?.textContent).toBe('@Alt');
setName('u1', 'Neu');
expect(nameLine()?.textContent).toBe('@Neu');
expect(items()).toContain('Benutzername ändern');
});
it('ein erst später gelesener Name erscheint ohne weiteres Zutun (Menü steht schon, der Name kommt nach)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(buttonByText('Benutzername wählen')).toBeDefined();
setName('u1', 'Kenso');
expect(nameLine()?.textContent).toBe('@Kenso');
expect(buttonByText('Benutzername ändern')).toBeDefined();
});
it('meldet onUsernameChange denselben Namen noch einmal (Lesen des eigenen Profils nach dem Start), bleibt das Menü unangetastet: offen und mit dem Fokus', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
const before = { avatar: avatar(), panel: panel(), head: head() };
const signOut = buttonByText('Abmelden')!;
signOut.focus();
setName('u1', 'Kenso');
expect(avatar()).toBe(before.avatar); // dieselben Elemente, nichts wurde neu gebaut
expect(panel()).toBe(before.panel);
expect(head()).toBe(before.head);
expect(panel()?.hidden).toBe(false);
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(signOut);
expect(nameLine()?.textContent).toBe('@Kenso');
});
it('ein anderer Name oder nur eine andere Schreibweise zeichnet dagegen neu', () => {
signedIn();
mocks.name = 'kenso';
menu.ready();
const before = avatar();
setName('u1', 'Kenso'); // „kenso“ → „Kenso“ ist erlaubt (Spec § 4) und soll zu sehen sein
expect(avatar()).not.toBe(before);
expect(nameLine()?.textContent).toBe('@Kenso');
const second = avatar();
setName('u1', 'Mia');
expect(avatar()).not.toBe(second);
expect(nameLine()?.textContent).toBe('@Mia');
});
it('die Namensmeldung eines anderen Kontos ändert nichts am Menü des angemeldeten', () => {
signedIn();
mocks.name = 'Kenso'; // der Name des angemeldeten Kontos bleibt
menu.ready();
avatar()!.click();
const before = panel();
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia'); // das Konto u2 hat sich umbenannt (etwa in einem eben beendeten Aufruf)
expect(panel()).toBe(before);
expect(panel()?.hidden).toBe(false);
expect(nameLine()?.textContent).toBe('@Kenso');
});
it('abgemeldet bleibt der Anmelden-Knopf stehen, auch wenn ein Name gemeldet wird (das Konto, das speicherte, ist nicht mehr angemeldet)', () => {
menu.ready();
const signIn = buttonByText('Anmelden');
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia');
expect(buttonByText('Anmelden')).toBe(signIn);
});
it('nach dem Abmelden ist das Menü unabhängig vom Namen wieder „Anmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
setUser(null);
expect(slot.querySelector('.account-name')).toBeNull();
expect(buttonByText('Anmelden')).toBeDefined();
});
it('vor ready() und nach dispose zeichnet auch eine Namensänderung nichts', () => {
signedIn();
setName('u1', 'Kenso');
expect(slot.childElementCount).toBe(0);
menu.ready();
menu.dispose();
setName('u1', 'Neu');
expect(slot.childElementCount).toBe(0);
});
it('lässt sich der Dialog nicht öffnen, bleibt es bei einer Meldung in der Konsole (kein unbehandelter Fehler), der Fokus geht trotzdem zurück', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
signedIn();
menu.ready();
avatar()!.click();
buttonByText('Benutzername wählen')!.click();
await vi.waitFor(() => expect(error).toHaveBeenCalled());
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
error.mockRestore();
});
it('auf Englisch: Einträge in der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['My cards', 'Change username', 'Sign out']);
expect(nameLine()?.textContent).toBe('@Kenso');
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['My cards', 'Choose a username', 'Sign out']);
});
});
+40 -8
View File
@@ -1,10 +1,14 @@
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
// „Meine Karten“, „Benutzername ändern“, „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';
import { myUsername, onUsernameChange } from './profile';
import { usernameDialog } from './username-dialog';
export interface AccountMenu {
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
@@ -16,17 +20,21 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
let isReady = false;
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
let detach: (() => void) | null = null;
// Der Name, den das gezeichnete Menü zeigt (null: keiner oder gar kein Konto-Menü). Meldet `onUsernameChange` denselben Namen noch einmal
// (etwa das Lesen des eigenen Profils nach dem Start), bleibt das Menü stehen: ein neu gebautes Dropdown wäre zu und verlöre den Fokus.
let shownName: string | null = null;
function render(): void {
detach?.();
detach = null;
shownName = null;
if (!isReady) {
slot.replaceChildren();
return;
}
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 +46,28 @@ 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,
);
// Mit Benutzernamen steht „@name“ oben und die E-Mail klein darunter; ohne Namen (noch nicht gelesen oder nicht gewählt) nur die E-Mail.
const name = myUsername();
shownName = name;
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 head = el(
'div',
{ class: 'account-head' },
...(name ? [el('span', { class: 'account-name', title: `@${name}` }, `@${name}`)] : []),
email,
);
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
const changeName = el('button', { type: 'button', class: 'account-item' }, name ? t('Benutzername ändern') : t('Benutzername wählen'));
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 dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, head, myCards, changeName, signOut, error);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
let isOpen = false;
function setOpen(open: boolean): void {
@@ -77,6 +95,14 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
document.removeEventListener('keydown', onKeydown);
};
changeName.addEventListener('click', () => {
setOpen(false);
// Der Eintrag im geschlossenen (und nach dem Speichern neu gezeichneten) Menü kann den Fokus nicht halten: er geht an den Avatar.
usernameDialog({ mode: 'change' })
.catch((err: unknown) => console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err))
.finally(() => slot.querySelector<HTMLElement>('.account-avatar')?.focus());
});
signOut.addEventListener('click', () => {
signOut.disabled = true;
error.textContent = '';
@@ -90,6 +116,11 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
}
const off = onAuthChange(render);
// Der Name steht fest oder ändert sich (gelesen nach dem Start, gespeichert im Dialog): das Menü zeigt gleich den neuen.
const offName = onUsernameChange(() => {
if (myUsername() === shownName) return; // nichts Neues zu zeigen (derselbe Name, oder ein anderes Konto hat gespeichert)
render();
});
render();
return {
@@ -99,6 +130,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
},
dispose() {
off();
offName();
detach?.();
detach = null;
slot.replaceChildren();
+51 -3
View File
@@ -127,18 +127,38 @@
display: none;
}
.account-email {
/* Kopf des Menüs: „@name“ (falls das Konto einen hat) und darunter die E-Mail */
.account-head {
display: flex;
flex-direction: column;
min-width: 0;
margin-bottom: 4px;
padding: 6px 12px 10px;
overflow: hidden;
border-bottom: 1px solid var(--line);
}
.account-name {
min-width: 0;
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-email {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-name + .account-email {
font-size: 0.8rem; /* mit Namen steht die E-Mail klein darunter */
}
.account-item {
appearance: none;
display: block;
@@ -318,8 +338,17 @@
font-size: 0.95rem;
}
/* Leer bleibt die Zeile gerendert (Live-Region, role alert/status), nimmt aber keinen Platz ein: display: none nähme sie aus
dem Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
.auth-msg:empty {
display: none;
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.auth-msg-error {
@@ -337,6 +366,25 @@
text-align: center;
}
/* Dialog „Benutzername wählen“: Hülle, Felder, Hinweise, Meldungen und Knöpfe wie der Login-Dialog */
.username-dialog .auth-title {
margin-right: 0; /* kein Schließen-Knopf in der Ecke */
}
.username-text {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.username-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
/* Seite /passwort-neu */
.reset-page {
+8 -1
View File
@@ -7,7 +7,9 @@ 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),
* `profiles` zu den Benutzernamen (eine öffentlich lesbare Zeile je Konto; geschrieben wird nur über den API-Dienst).
*/
export const IDS = {
database: 'aircard',
@@ -16,6 +18,11 @@ export const IDS = {
gallery: 'gallery',
reports: 'reports',
galleryBucket: 'gallery-images',
logos: 'logos',
logoLikes: 'logo_likes',
logoReports: 'logo_reports',
logoBucket: 'logo-images',
profiles: 'profiles',
} 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. */
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { errorMessage, errorType, isInvalidLinkError, isUnauthorized, type ErrorContext } from './errors';
import { errorMessage, errorType, isInvalidLinkError, isRowMissing, isUnauthorized, type ErrorContext } from './errors';
/** Fehler wie vom SDK (AppwriteException): Felder code, type, message. */
const fail = (code: number, type: string, message = '') => ({ code, type, message });
@@ -149,6 +149,33 @@ describe('errorType', () => {
});
});
describe('isRowMissing', () => {
it('404 mit row_not_found heißt: die Zeile gibt es nicht (oder nicht für mich)', () => {
expect(isRowMissing(fail(404, 'row_not_found'))).toBe(true);
});
it('404 ohne Typ (leer oder fehlend) zählt ebenso', () => {
expect(isRowMissing(fail(404, ''))).toBe(true);
expect(isRowMissing({ code: 404 })).toBe(true);
});
it('eine fehlende Tabelle, Datenbank oder Datei ist ein echter Fehler, kein „Zeile weg“', () => {
for (const type of ['table_not_found', 'database_not_found', 'storage_file_not_found']) {
expect(isRowMissing(fail(404, type)), type).toBe(false);
}
});
it('andere Status und Nicht-Fehler nicht', () => {
expect(isRowMissing(fail(401, 'row_not_found'))).toBe(false);
expect(isRowMissing(fail(500, 'row_not_found'))).toBe(false);
expect(isRowMissing({ code: '404', type: 'row_not_found' })).toBe(false);
expect(isRowMissing(new TypeError('Failed to fetch'))).toBe(false);
expect(isRowMissing('row_not_found')).toBe(false);
expect(isRowMissing(null)).toBe(false);
expect(isRowMissing(undefined)).toBe(false);
});
});
describe('isInvalidLinkError (Passwort-neu-Seite)', () => {
it('Link-Fehler', () => {
expect(isInvalidLinkError(fail(401, 'user_invalid_token'))).toBe(true);
+34 -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;
@@ -22,6 +24,15 @@ export function isUnauthorized(err: unknown): boolean {
return field(err, 'code') === 401;
}
/**
* „Die Zeile gibt es nicht (oder nicht für mich)“: 404 `row_not_found` (auch ein 404 ohne Typ). Eine fehlende Tabelle, Datenbank oder
* Datei ist dagegen ein echter Fehler. Die Einzel-Abfragen der Tabellen (`getGalleryCard`, `getLogo`, `getUsername`) werten damit 404 als „nicht da“.
*/
export function isRowMissing(err: unknown): boolean {
const type = field(err, 'type');
return field(err, 'code') === 404 && (type === 'row_not_found' || type === undefined || type === '');
}
/** Bei `general_argument_invalid` nennt die Nachricht den Parameter: „Invalid `email` param: …“. */
function invalidParam(err: unknown): string | null {
const message = field(err, 'message');
@@ -40,46 +51,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);
}
+407 -4
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
get: vi.fn(),
@@ -9,9 +9,22 @@ const mocks = vi.hoisted(() => ({
updateRecovery: vi.fn(),
}));
// Die Namensprüfung im Netz und der Namensdialog sind ersetzt; Muster, Sperrliste und der Merker „schon gefragt“ bleiben echt
// (profile.ts wird nach jedem Zurücksetzen der Module mit `doMock` neu ersetzt, siehe beforeEach).
const profileMocks = vi.hoisted(() => ({
isUsernameTaken: vi.fn(),
saveUsername: vi.fn(),
usernameDialog: vi.fn(),
}));
vi.mock('./appwrite', () => ({ account: mocks }));
vi.mock('./username-dialog', () => ({ usernameDialog: profileMocks.usernameDialog }));
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
const INVALID =
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
const PROMPTED_KEY = 'aircard.usernamePrompted';
type Dialog = typeof import('./login-dialog');
let requireLogin: Dialog['requireLogin'];
@@ -19,7 +32,17 @@ let opener: HTMLButtonElement;
beforeEach(async () => {
for (const fn of Object.values(mocks)) fn.mockReset();
for (const fn of Object.values(profileMocks)) fn.mockReset();
profileMocks.isUsernameTaken.mockResolvedValue(false); // der Name ist frei
profileMocks.saveUsername.mockImplementation(async (name: string) => name);
profileMocks.usernameDialog.mockResolvedValue(null);
sessionStorage.clear();
vi.resetModules();
vi.doMock('./profile', async () => ({
...(await vi.importActual<typeof import('./profile')>('./profile')),
isUsernameTaken: profileMocks.isUsernameTaken,
saveUsername: profileMocks.saveUsername,
}));
document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>';
opener = document.getElementById('opener') as HTMLButtonElement;
opener.focus();
@@ -51,6 +74,7 @@ describe('requireLogin: Öffnen und Schließen', () => {
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
void requireLogin('register');
expect(title()).toBe('Registrieren');
expect(input('username').autocomplete).toBe('nickname');
expect(input('password').autocomplete).toBe('new-password');
expect(input('password-repeat').autocomplete).toBe('new-password');
});
@@ -213,13 +237,14 @@ describe('Registrieren', () => {
it('prüft vor dem Senden: Abgleich, dann Länge', () => {
void requireLogin('register');
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
submit();
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
fill({ password: 'kurz', 'password-repeat': 'kurz' });
submit();
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
expect(mocks.create).not.toHaveBeenCalled();
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled(); // erst die Eingaben, die ohne Netz zu prüfen sind
});
it('legt das Konto an, meldet an und löst mit true auf', async () => {
@@ -227,7 +252,7 @@ describe('Registrieren', () => {
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
const result = requireLogin('register');
fill({ email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
fill({ username: 'Neu.Konto', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
submit();
await expect(result).resolves.toBe(true);
expect(mocks.create).toHaveBeenCalledTimes(1);
@@ -237,7 +262,7 @@ describe('Registrieren', () => {
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
submit();
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!;
@@ -248,6 +273,330 @@ describe('Registrieren', () => {
});
});
describe('Registrieren: Benutzername', () => {
/** Meldet das Konto an wie ein erfolgreicher Server: anlegen, Sitzung, Nutzer. */
function serverAccepts(): void {
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
}
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
const names = () => [...dialogEl().querySelectorAll('input')].map((i) => i.name);
it('das Pflichtfeld „Benutzername“ steht vor der E-Mail, mit Hinweis, und hat zuerst den Fokus', () => {
void requireLogin('register');
expect(names()).toEqual(['username', 'email', 'password', 'password-repeat']);
const field = input('username');
expect(field.type).toBe('text');
expect(field.required).toBe(true);
expect(field.autocomplete).toBe('nickname');
expect(field.getAttribute('autocapitalize')).toBe('none');
expect(field.getAttribute('spellcheck')).toBe('false');
expect(field.labels![0].textContent).toContain('Benutzername');
const hint = dialogEl().querySelector(`#${field.getAttribute('aria-describedby')}`)!;
expect(hint.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
expect(document.activeElement).toBe(field);
});
it('kein Feld trägt autocomplete „username“: angemeldet wird per E-Mail, für Passwortmanager bleibt sie der Anmeldename', () => {
const tokens = () => [...dialogEl().querySelectorAll('input')].map((i) => i.autocomplete);
void requireLogin('register');
expect(tokens()).toEqual(['nickname', 'email', 'new-password', 'new-password']);
buttonByText('Anmelden').click();
expect(tokens()).toEqual(['email', 'current-password']);
buttonByText('Passwort vergessen?').click();
expect(tokens()).toEqual(['email']);
});
it('die Anmelden-Ansicht hat kein Namensfeld', () => {
void requireLogin('login');
expect(names()).toEqual(['email', 'password']);
});
it.each([
['leer', '', INVALID],
['nur Leerzeichen', ' ', INVALID],
['zu kurz', 'ab', INVALID],
['zu lang', 'x'.repeat(21), INVALID],
['falsches erstes Zeichen', '_abc', INVALID],
['Leerzeichen im Namen', 'ke nso', INVALID],
['Umlaut', 'Käse', INVALID],
['gesperrt', 'Admin', 'Dieser Benutzername ist nicht erlaubt.'],
['gesperrt in anderer Schreibweise', 'AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.'],
])('%s: der Fehler steht am Feld, es gibt keine Anfrage und kein Konto', (_label, name, message) => {
void requireLogin('register');
fill({ ...fields(), username: name });
submit();
expect(alertText()).toBe(message);
const field = input('username');
expect(field.getAttribute('aria-invalid')).toBe('true');
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
expect(document.activeElement).toBe(field);
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
expect(mocks.create).not.toHaveBeenCalled();
expect(dialogEl().open).toBe(true);
});
it('der Name wird vor den Passwörtern geprüft (Reihenfolge der Felder)', () => {
void requireLogin('register');
fill({ ...fields(), username: 'x', 'password-repeat': 'anders1234' });
submit();
expect(alertText()).toBe(INVALID);
expect(document.activeElement).toBe(input('username'));
});
it('die Markierung des Feldes fällt beim nächsten Absenden weg; der Hinweis bleibt verknüpft', async () => {
serverAccepts();
void requireLogin('register');
fill({ ...fields(), username: 'x' });
submit();
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(2);
fill({ username: 'Kenso' });
submit();
expect(input('username').hasAttribute('aria-invalid')).toBe(false);
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(1);
await vi.waitFor(() => expect(mocks.create).toHaveBeenCalled());
});
it('vergebener Name (Review-Fokus): Fehler am Feld, bevor ein Konto angelegt wird – kein halbes Konto', async () => {
profileMocks.isUsernameTaken.mockResolvedValue(true);
void requireLogin('register');
fill({ ...fields(), username: ' kenso ' });
dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.focus(); // der Nutzer hat den Knopf gedrückt
submit();
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
expect(profileMocks.isUsernameTaken).toHaveBeenCalledExactlyOnceWith('kenso'); // getrimmt, so wie er gespeichert würde
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
const field = input('username');
expect(field.getAttribute('aria-invalid')).toBe('true');
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
expect(document.activeElement).toBe(field); // der Fokus geht an das Feld, nicht zurück auf den Knopf
expect(dialogEl().open).toBe(true);
// das Formular ist wieder bedienbar
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
expect(field.readOnly).toBe(false);
expect(input('password').value).toBe('geheim123'); // nichts wurde verworfen
});
it('nach „vergeben“ geht ein anderer Name: dann wird das Konto angelegt', async () => {
serverAccepts();
profileMocks.isUsernameTaken.mockResolvedValueOnce(true);
const result = requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
fill({ username: 'Kenso2' });
submit();
await expect(result).resolves.toBe(true);
expect(profileMocks.isUsernameTaken).toHaveBeenLastCalledWith('Kenso2');
expect(mocks.create).toHaveBeenCalledTimes(1);
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
});
it('lässt sich die Verfügbarkeit nicht prüfen (kein Netz), wird kein Konto angelegt und der Fehler erklärt', async () => {
profileMocks.isUsernameTaken.mockRejectedValue(new TypeError('Failed to fetch'));
let settled = false;
void requireLogin('register').then(() => (settled = true));
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
expect(mocks.create).not.toHaveBeenCalled();
expect(settled).toBe(false);
expect(dialogEl().open).toBe(true);
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
});
it('der Ablauf: Verfügbarkeit prüfen, Konto anlegen, anmelden, Namen speichern – erst dann ist der Dialog fertig', async () => {
serverAccepts();
const order: string[] = [];
profileMocks.isUsernameTaken.mockImplementation(async () => (order.push('prüfen'), false));
mocks.create.mockImplementation(async () => (order.push('anlegen'), {}));
mocks.createEmailPasswordSession.mockImplementation(async () => (order.push('anmelden'), {}));
profileMocks.saveUsername.mockImplementation(async (name: string) => (order.push(`speichern ${name}`), name));
const result = requireLogin('register');
fill({ ...fields(), username: ' Kenso ' });
submit();
await expect(result).resolves.toBe(true);
expect(order).toEqual(['prüfen', 'anlegen', 'anmelden', 'speichern Kenso']);
expect(mocks.create.mock.calls[0][0]).toMatchObject({ email: 'neu@b.de', password: 'geheim123' });
expect(dialogEl().open).toBe(false);
expect(profileMocks.usernameDialog).not.toHaveBeenCalled(); // Name gespeichert: nichts mehr zu fragen
});
it('während geprüft und angelegt wird, ist das Formular gesperrt und ein zweites Absenden wird ignoriert', async () => {
serverAccepts();
let finishCheck!: (taken: boolean) => void;
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
submit();
expect(profileMocks.isUsernameTaken).toHaveBeenCalledTimes(1);
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
expect(input('username').readOnly).toBe(true);
finishCheck(false);
await expect(result).resolves.toBe(true);
expect(mocks.create).toHaveBeenCalledTimes(1);
});
it('scheitert das Speichern des Namens (Rennen, Netz), ist man trotzdem angemeldet: der Dialog schließt mit true, der Namensdialog erscheint', async () => {
serverAccepts();
const failure = new Error('409');
profileMocks.saveUsername.mockRejectedValue(failure);
const result = requireLogin('register');
fill(fields());
submit();
await expect(result).resolves.toBe(true);
expect(dialogEl().open).toBe(false);
expect(mocks.get).toHaveBeenCalled(); // angemeldet
// Der Namensdialog öffnet mit dem getippten Namen und dem Grund (er steht im Dialog), nicht leer und stumm.
expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt', initial: 'Kenso', failure });
});
it('der getippte Name geht getrimmt in den Namensdialog, genau der Fehler des Speicherns als Grund', async () => {
serverAccepts();
const taken = Object.assign(new Error('vergeben'), { code: 409 });
profileMocks.saveUsername.mockRejectedValue(taken);
const result = requireLogin('register');
fill({ ...fields(), username: ' Mia.K ' });
submit();
await result;
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Mia.K');
expect(profileMocks.usernameDialog.mock.calls[0][0]).toMatchObject({ initial: 'Mia.K' });
expect(profileMocks.usernameDialog.mock.calls[0][0].failure).toBe(taken);
});
it('der Namensdialog kommt erst, wenn der Login-Dialog zu ist, und ein Fehler beim Öffnen bleibt ohne Folgen', async () => {
serverAccepts();
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
profileMocks.saveUsername.mockRejectedValue(new Error('offline'));
let loginOpenWhenAsked: boolean | undefined;
profileMocks.usernameDialog.mockImplementation(async () => {
loginOpenWhenAsked = dialogEl().open;
throw new Error('showModal kaputt');
});
const result = requireLogin('register');
fill(fields());
submit();
await expect(result).resolves.toBe(true);
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(loginOpenWhenAsked).toBe(false);
error.mockRestore();
});
it('das Konto-Anlegen scheitert (E-Mail vergeben): kein Namensdialog, kein Speichern', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert.'));
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
});
it('Merker „in dieser Sitzung schon gefragt“: gesetzt, bevor die Anmeldung läuft (sie löst die einmalige Abfrage aus), und danach noch da', async () => {
serverAccepts();
let flagWhenAccountCreated: string | null | undefined;
mocks.create.mockImplementation(async () => {
flagWhenAccountCreated = sessionStorage.getItem(PROMPTED_KEY);
return {};
});
const result = requireLogin('register');
fill(fields());
submit();
await expect(result).resolves.toBe(true);
expect(flagWhenAccountCreated).toBe('1');
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
});
it('scheitert das Anlegen des Kontos, nimmt der Dialog den Merker wieder weg (wer sich danach anmeldet, wird noch gefragt)', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('war schon gefragt worden, bleibt der Merker auch nach einem gescheiterten Anlegen', async () => {
sessionStorage.setItem(PROMPTED_KEY, '1');
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
});
it('wird der Dialog während der Prüfung geschlossen, entsteht kein Konto', async () => {
serverAccepts();
let finishCheck!: (taken: boolean) => void;
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
await expect(result).resolves.toBe(false);
finishCheck(false);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.create).not.toHaveBeenCalled();
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('wird der Dialog während der Prüfung geschlossen und der Name ist vergeben, erscheint nichts mehr', async () => {
let finishCheck!: (taken: boolean) => void;
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
await expect(result).resolves.toBe(false);
finishCheck(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dialogEl().querySelector('[role="alert"]')?.textContent ?? '').toBe('');
expect(mocks.create).not.toHaveBeenCalled();
});
it('der getippte Name wandert beim Wechsel der Reiter mit (wie die E-Mail), Passwörter nie', () => {
void requireLogin('register');
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123' });
buttonByText('Anmelden').click();
expect(title()).toBe('Anmelden');
expect(input('email').value).toBe('a@b.de');
buttonByText('Registrieren').click();
expect(input('username').value).toBe('Kenso');
expect(input('email').value).toBe('a@b.de');
expect(input('password').value).toBe('');
});
it('nach dem Schließen sind Name und E-Mail weg, ein neuer Aufruf beginnt leer', async () => {
const first = requireLogin('register');
fill({ username: 'Kenso', email: 'a@b.de' });
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
await first;
void requireLogin('register');
expect(input('username').value).toBe('');
expect(input('email').value).toBe('');
expect(document.activeElement).toBe(input('username'));
});
it('die anderen Ansichten bleiben unberührt: Anmelden meldet an, ohne Name zu prüfen oder zu speichern', async () => {
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
const result = requireLogin();
fill({ email: 'a@b.de', password: 'geheim123' });
submit();
await expect(result).resolves.toBe(true);
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull(); // die einmalige Abfrage ist Sache von main.ts
});
});
describe('Passwort vergessen', () => {
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
mocks.createRecovery.mockResolvedValue({});
@@ -277,3 +626,57 @@ describe('Passwort vergessen', () => {
expect(title()).toBe('Anmelden');
});
});
describe('Registrieren: die Fehlerzeile bleibt im Sichtbaren', () => {
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
const errorLine = () => dialogEl().querySelector('[role="alert"]');
afterEach(() => {
vi.restoreAllMocks();
});
it('ein Fehler am Namensfeld scrollt die gemeinsame Fehlerzeile (sie steht unter allen Feldern) ins Sichtbare, nur so weit wie nötig', () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
void requireLogin('register');
fill({ ...fields(), username: 'x' });
submit();
expect(alertText()).toBe(INVALID);
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
expect(scroll.mock.contexts[0]).toBe(errorLine());
expect(document.activeElement).toBe(input('username')); // der Fokus bleibt am Feld
});
it('ein gesperrter Name ebenso, jeder neue Fehler scrollt wieder', () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
void requireLogin('register');
fill({ ...fields(), username: 'Admin' });
submit();
fill({ username: 'ab' });
submit();
expect(scroll).toHaveBeenCalledTimes(2);
expect(scroll.mock.contexts.every((node) => node === errorLine())).toBe(true);
});
it('„vergeben“ (nach der Abfrage am Dienst) ebenso', async () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
profileMocks.isUsernameTaken.mockResolvedValue(true);
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
expect(scroll.mock.contexts[0]).toBe(errorLine());
});
it('ohne Fehler am Feld wird nicht gescrollt (gültige Eingabe, Anmelden-Ansicht)', async () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
const registered = requireLogin('register');
fill(fields());
submit();
await registered;
expect(scroll).not.toHaveBeenCalled();
});
});
+134 -41
View File
@@ -1,19 +1,33 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren (mit Benutzernamen) und „Passwort vergessen“.
import './account.css';
import { el } from '../editor/dom';
import { el, setFieldInvalid } from '../editor/dom';
import { API_MESSAGES } from '../i18n/api-messages';
import { msg, t } from '../i18n/i18n';
import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors';
import {
USERNAME_HINT,
clearUsernamePrompted,
isUsernameTaken,
markUsernamePrompted,
saveUsername,
usernamePrompted,
validateUsername,
} from './profile';
import { usernameDialog } from './username-dialog';
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.');
/** Die Meldungszeile des Dialogs: ein Feld mit Fehler verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'auth-error';
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
@@ -28,6 +42,7 @@ type Panel = {
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
let view: LoginView = 'login';
let draftEmail = '';
let draftUsername = '';
let busy = false;
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
let session = 0;
@@ -37,7 +52,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' });
@@ -89,7 +104,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
function field(
labelText: string,
name: string,
type: 'email' | 'password',
type: 'text' | 'email' | 'password',
autocomplete: string,
value = '',
describedBy?: string,
@@ -101,8 +116,9 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
autocomplete,
value,
required: true,
autocapitalize: type === 'email' ? 'none' : null,
spellcheck: type === 'email' ? 'false' : null,
// E-Mail-Adresse und Benutzername: keine automatische Großschreibung, keine Rechtschreibprüfung
autocapitalize: type === 'password' ? null : 'none',
spellcheck: type === 'password' ? null : 'false',
'aria-describedby': describedBy,
});
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
@@ -135,7 +151,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 +161,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. */
@@ -161,25 +177,40 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : []));
}
/**
* Ein Fehler am Eingabefeld, das ihn verursacht: der Text steht in der Meldungszeile, das Feld gilt als ungültig und bekommt den Fokus.
* Die Meldungszeile steht unter allen Feldern, bei einem Fehler am ersten (dem Namen) kann sie auf einem kleinen Bildschirm außerhalb
* liegen: sie wird ins Sichtbare gerückt, nur so weit wie nötig.
*/
function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void {
showError(panel, text);
setFieldInvalid(input, ERROR_ID, true);
input.focus();
panel.errorEl.scrollIntoView({ block: 'nearest' });
}
function showOk(panel: Panel, text: string): void {
panel.errorEl.textContent = '';
panel.okEl.textContent = text;
}
/** Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück. */
/**
* Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück: an das Feld, das die Aktion nennt
* (sie hat dort einen Fehler gezeigt), sonst dorthin, wo er vorher war.
*/
async function run(
panel: Panel,
ctx: ErrorContext,
action: () => Promise<void>,
action: () => Promise<HTMLElement | void>,
onError?: (err: unknown) => Node | undefined,
): Promise<void> {
const mine = session;
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setBusy(panel, true);
panel.errorEl.textContent = '';
panel.okEl.textContent = '';
try {
await action();
focused = (await action()) ?? focused;
} catch (err) {
if (mine !== session) return;
showError(panel, errorMessage(err, ctx), onError?.(err));
@@ -189,17 +220,63 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
if (focused?.isConnected && dialog.contains(focused)) focused.focus();
}
/**
* Registrieren mit Benutzernamen (Spec § 5): erst die Verfügbarkeit des Namens, dann Konto anlegen und anmelden, zuletzt den Namen speichern.
* Ist der Name vergeben, steht der Fehler am Feld, bevor ein Konto entsteht (sonst bliebe ein Konto ohne Namen zurück). Scheitert nur das
* Speichern (Rennen um den Namen, Netz weg), ist man trotzdem angemeldet: der Dialog schließt, und der Namensdialog fragt noch einmal.
*/
async function registerWithName(
panel: Panel,
nameInput: HTMLInputElement,
name: string,
address: string,
password: string,
): Promise<HTMLElement | void> {
const mine = session;
const taken = await isUsernameTaken(name);
if (mine !== session) return; // der Dialog wurde inzwischen geschlossen: nichts mehr anlegen, nichts mehr zeigen
if (taken) {
showFieldError(panel, nameInput, t(API_MESSAGES.usernameTaken));
return nameInput;
}
// Schon die Anmeldung löst in main.ts die einmalige Abfrage nach dem Namen aus; hier wurde er gerade erst eingegeben. Darum steht der
// Merker „in dieser Sitzung schon gefragt“ vor dem Anlegen. Scheitert es, nimmt der Dialog ihn wieder weg (wer sich danach anmeldet, wird gefragt).
const asked = usernamePrompted();
markUsernamePrompted();
try {
await register(address, password);
} catch (err) {
if (!asked) clearUsernamePrompted();
throw err;
}
let failure: unknown;
let saved = true;
try {
await saveUsername(name);
} catch (err) {
saved = false;
failure = err;
}
finish(true);
if (!saved) {
// Der Namensdialog beginnt mit dem getippten Namen und nennt den Grund (vergeben, kein Netz …), statt leer und stumm zu erscheinen.
usernameDialog({ mode: 'prompt', initial: name, failure }).catch((err: unknown) => {
console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err);
});
}
}
function buildPanel(forView: LoginView): Panel {
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const form = el('form', { class: 'auth-form' });
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,41 +291,52 @@ 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 panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
// `nickname`, nicht `username`: angemeldet wird per E-Mail, und ein Passwortmanager soll das E-Mail-Feld als Anmeldenamen behalten.
const username = field(t('Benutzername'), 'username', 'text', 'nickname', draftUsername, 'auth-name-hint');
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 nameHint = el('p', { class: 'auth-hint', id: 'auth-name-hint' }, t(USERNAME_HINT));
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
form.append(username.label, nameHint, email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
// Der Fokus geht an das erste Feld, das noch leer ist.
const first = !draftUsername ? username.input : !draftEmail ? email.input : password.input;
const panel: Panel = { form, first, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
setFieldInvalid(username.input, ERROR_ID, false);
const name = username.input.value.trim(); // der Dienst trimmt nicht
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 (in der Reihenfolge der Felder); verglichen wird nie mit dem (übersetzten) Text.
const nameProblem = validateUsername(name);
if (nameProblem) {
showFieldError(panel, username.input, nameProblem);
return;
}
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(
panel,
'register',
async () => {
await register(address, password.input.value);
finish(true);
},
() => registerWithName(panel, username.input, name, address, password.input.value),
(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 +344,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;
@@ -264,11 +352,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */
function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void {
// Die eingegebene E-Mail wandert beim Wechsel in die neue Ansicht mit; Passwörter nie.
// Die eingegebene E-Mail und der Benutzername wandern beim Wechsel in die neue Ansicht mit; Passwörter nie.
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim();
const typedName = content.querySelector<HTMLInputElement>('input[name="username"]');
if (typedName) draftUsername = typedName.value.trim();
view = next;
title.textContent = TITLES[next];
title.textContent = t(TITLES[next]);
const panel = buildPanel(next);
if (next === 'forgot') {
content.replaceChildren(panel.form);
@@ -284,10 +374,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
if (pending) return pending;
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
draftEmail = '';
draftUsername = '';
busy = false;
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();
+661
View File
@@ -0,0 +1,661 @@
import { createRequire } from 'node:module';
import { Query } from 'appwrite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
listRows: vi.fn(),
getRow: vi.fn(),
apiPost: vi.fn(),
userId: null as string | null,
}));
vi.mock('./appwrite', () => ({
IDS: { database: 'db', profiles: 'profiles-table' },
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
account: {},
client: {},
storage: {},
APPWRITE_ENDPOINT: '',
}));
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
// Nur der Netzaufruf ist ersetzt; ApiError und unavailableError bleiben echt.
vi.mock('../community/api', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/api')>()),
apiPost: mocks.apiPost,
}));
// Muster, Sperrliste und Schlüssel liegen im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt sie von dort.
const server = createRequire(import.meta.url)('../../../deploy/api/common.js') as {
USERNAME_PATTERN: RegExp;
RESERVED_USERNAMES: readonly string[];
usernameKey: (name: string) => string;
isReservedUsername: (name: string) => boolean;
};
const INVALID = 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
const RESERVED = 'Dieser Benutzername ist nicht erlaubt.';
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
type Profile = typeof import('./profile');
let profile: Profile;
/** Das Modul hält den Zwischenspeicher der Namen: jeder Test bekommt eine frische Instanz (samt i18n und ApiError). */
let ApiError: typeof import('../community/api').ApiError;
beforeEach(async () => {
for (const fn of [mocks.listRows, mocks.getRow, mocks.apiPost]) fn.mockReset();
mocks.userId = null;
sessionStorage.clear();
vi.resetModules();
profile = await import('./profile');
({ ApiError } = await import('../community/api'));
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Regeln im Gleichlauf mit dem API-Dienst (deploy/api/common.js)', () => {
it('der Dienst liefert seine Regeln (sonst bestünde der Vergleich leer)', () => {
expect(server.RESERVED_USERNAMES.length).toBeGreaterThan(20);
expect(server.USERNAME_PATTERN.test('Kenso')).toBe(true);
});
it('das Muster ist dasselbe: genau ^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$', () => {
expect(profile.USERNAME_PATTERN.source).toBe('^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
expect(profile.USERNAME_PATTERN.source).toBe(server.USERNAME_PATTERN.source);
expect(profile.USERNAME_PATTERN.flags).toBe(server.USERNAME_PATTERN.flags);
});
it('die Sperrliste ist dieselbe (Einträge und Reihenfolge)', () => {
expect([...profile.RESERVED_USERNAMES]).toEqual([...server.RESERVED_USERNAMES]);
});
it('jeder gesperrte Schlüssel passt zum Muster (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig)', () => {
const unusable = profile.RESERVED_USERNAMES.filter((key) => !profile.USERNAME_PATTERN.test(key));
expect(unusable).toEqual([]);
});
it('Schlüssel und Sperre urteilen wie der Dienst', () => {
for (const name of ['Kenso', 'KENSO', 'Admin', 'AirCard-IOS', 'aircard.ios', 'Mod', 'modx', 'a.b_c-d', 'Hilfe']) {
expect(profile.usernameKey(name)).toBe(server.usernameKey(name));
expect(profile.validateUsername(name) === RESERVED).toBe(server.isReservedUsername(name));
}
});
});
describe('usernameKey', () => {
it('der Name in Kleinbuchstaben', () => {
expect(profile.usernameKey('Kenso')).toBe('kenso');
expect(profile.usernameKey('AirCard-IOS')).toBe('aircard-ios');
expect(profile.usernameKey('a.B_c-9')).toBe('a.b_c-9');
});
});
describe('validateUsername', () => {
it.each(['abc', 'a1b', 'Kenso', 'k.e_n-so', '0abc', '9..', 'x'.repeat(20), 'ABC', 'admin1', 'administrators', 'momo'])('gültig: %s', (name) => {
expect(profile.validateUsername(name)).toBeNull();
});
it('Grenzen: 3 Zeichen sind das Minimum, 20 das Maximum', () => {
expect(profile.validateUsername('ab')).toBe(INVALID);
expect(profile.validateUsername('abc')).toBeNull();
expect(profile.validateUsername('x'.repeat(20))).toBeNull();
expect(profile.validateUsername('x'.repeat(21))).toBe(INVALID);
});
it('das erste Zeichen ist ein Buchstabe oder eine Zahl, kein Satzzeichen', () => {
for (const name of ['_abc', '.abc', '-abc']) expect(profile.validateUsername(name)).toBe(INVALID);
for (const name of ['a_c', 'a.c', 'a-c', '1bc']) expect(profile.validateUsername(name)).toBeNull();
});
it.each(['', ' ', 'ab c', ' abc', 'abc ', 'abc\n', 'ab\tc', 'ab@c', 'ab/c', 'Käse', 'ünder', 'naïve', 'na😀me', 'ab:c', 'abc'])(
'ungültig (Muster): %j',
(name) => {
expect(profile.validateUsername(name)).toBe(INVALID);
},
);
it('gesperrt, in jeder Schreibweise', () => {
for (const key of profile.RESERVED_USERNAMES) {
expect(profile.validateUsername(key), key).toBe(RESERVED);
expect(profile.validateUsername(key.toUpperCase()), key).toBe(RESERVED);
}
expect(profile.validateUsername('Admin')).toBe(RESERVED);
expect(profile.validateUsername('AirCard-IOS')).toBe(RESERVED);
expect(profile.validateUsername('aIrCaRd.IoS')).toBe(RESERVED);
});
it('das Muster geht vor der Sperrliste', () => {
expect(profile.validateUsername('_admin')).toBe(INVALID);
});
it('der Text folgt der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
expect(profile.validateUsername('a')).toBe(
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
);
expect(profile.validateUsername('Admin')).toBe('This username is not allowed.');
});
});
describe('IDS', () => {
it('kennt die Tabelle profiles (Name wie im Setup-Skript)', async () => {
vi.stubEnv('VITE_APPWRITE_ENDPOINT', 'https://aircard.test/v1');
try {
const { IDS } = await vi.importActual<typeof import('./appwrite')>('./appwrite');
expect(IDS.profiles).toBe('profiles');
expect(IDS.database).toBe('aircard'); // und das Bestehende bleibt
expect(IDS.logos).toBe('logos');
} finally {
vi.unstubAllEnvs();
}
});
});
describe('getUsername', () => {
it('liest die Zeile mit der Konto-ID als Zeilen-ID', async () => {
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso', usernameKey: 'kenso' });
await expect(profile.getUsername('u1')).resolves.toBe('Kenso');
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
});
it('kein Profil (404) ergibt null', async () => {
mocks.getRow.mockRejectedValue(notFound());
await expect(profile.getUsername('u1')).resolves.toBeNull();
});
it('eine Zeile ohne lesbaren Namen ergibt null', async () => {
mocks.getRow.mockResolvedValue({ $id: 'u1' });
await expect(profile.getUsername('u1')).resolves.toBeNull();
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 42 });
await expect(profile.getUsername('u1')).resolves.toBeNull();
});
it('eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage', async () => {
for (const id of ['', '../x', 'a b', 'x'.repeat(40)]) await expect(profile.getUsername(id)).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
});
it('andere Fehler (Netz, 500, fehlende Tabelle) gehen durch', async () => {
const offline = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValueOnce(offline);
await expect(profile.getUsername('u1')).rejects.toBe(offline);
const server500 = Object.assign(new Error('boom'), { code: 500, type: 'general_server_error' });
mocks.getRow.mockRejectedValueOnce(server500);
await expect(profile.getUsername('u1')).rejects.toBe(server500);
const noTable = notFound('table_not_found');
mocks.getRow.mockRejectedValueOnce(noTable);
await expect(profile.getUsername('u1')).rejects.toBe(noTable);
});
});
describe('usernamesFor', () => {
const row = (id: string, username: string) => ({ $id: id, username, usernameKey: username.toLowerCase() });
it('sammelt die IDs in einer einzigen Abfrage (equal auf $id)', async () => {
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
const names = await profile.usernamesFor(['u1', 'u2']);
expect([...names]).toEqual([
['u1', 'Kenso'],
['u2', 'Mia'],
]);
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
databaseId: 'db',
tableId: 'profiles-table',
queries: [Query.equal('$id', ['u1', 'u2']), Query.limit(2)],
});
});
it('doppelte IDs zählen einmal, Unbrauchbares wird gar nicht erst gefragt', async () => {
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
await profile.usernamesFor(['u1', 'u1', '', '../x', 'u1']);
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([Query.equal('$id', ['u1']), Query.limit(1)]);
});
it('nichts zu fragen: leere Map ohne Anfrage', async () => {
expect((await profile.usernamesFor([])).size).toBe(0);
expect((await profile.usernamesFor(['', 'a b'])).size).toBe(0);
expect(mocks.listRows).not.toHaveBeenCalled();
});
it('merkt sich die Namen: dieselben IDs fragen nicht noch einmal, neue IDs allein', async () => {
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
await profile.usernamesFor(['u1', 'u2']);
const again = await profile.usernamesFor(['u2', 'u1']);
expect([...again].sort()).toEqual([
['u1', 'Kenso'],
['u2', 'Mia'],
]);
expect(mocks.listRows).toHaveBeenCalledTimes(1);
mocks.listRows.mockResolvedValueOnce({ rows: [row('u3', 'Lea')] });
const more = await profile.usernamesFor(['u1', 'u3']);
expect(mocks.listRows).toHaveBeenCalledTimes(2);
expect(mocks.listRows.mock.calls[1][0].queries).toEqual([Query.equal('$id', ['u3']), Query.limit(1)]);
expect([...more].sort()).toEqual([
['u1', 'Kenso'],
['u3', 'Lea'],
]);
});
it('Konten ohne Profil fehlen in der Map und werden beim nächsten Mal wieder gefragt (sie können sich später einen Namen wählen)', async () => {
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
const names = await profile.usernamesFor(['u1', 'u9']);
expect(names.has('u9')).toBe(false);
await profile.usernamesFor(['u9']);
expect(mocks.listRows).toHaveBeenCalledTimes(2);
});
it('Zeilen ohne lesbaren Namen zählen nicht', async () => {
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1' }, { $id: 'u2', username: '' }, { $id: 'u3', username: 7 }, row('u4', 'Mia')] });
const names = await profile.usernamesFor(['u1', 'u2', 'u3', 'u4']);
expect([...names]).toEqual([['u4', 'Mia']]);
});
it('Fehler ergeben eine leere Map statt eines Fehlers und merken nichts', async () => {
mocks.listRows.mockRejectedValueOnce(new TypeError('Failed to fetch'));
const names = await profile.usernamesFor(['u1', 'u2']);
expect(names).toBeInstanceOf(Map);
expect(names.size).toBe(0);
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]); // der nächste Versuch fragt wieder
});
it('bei einem Fehler bleiben die schon bekannten Namen erhalten', async () => {
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
await profile.usernamesFor(['u1']);
mocks.listRows.mockRejectedValueOnce(Object.assign(new Error('boom'), { code: 500 }));
const names = await profile.usernamesFor(['u1', 'u2']);
expect([...names]).toEqual([['u1', 'Kenso']]);
});
it('viele IDs gehen in Gruppen zu je 100 (eine Gruppe pro Abfrage)', async () => {
const ids = Array.from({ length: 250 }, (_, i) => `u${i}`);
mocks.listRows.mockImplementation(async ({ queries }: { queries: string[] }) => {
const asked = (JSON.parse(queries[0]) as { values: string[] }).values;
return { rows: asked.map((id) => row(id, `Name${id}`)) };
});
const names = await profile.usernamesFor(ids);
expect(names.size).toBe(250);
expect(mocks.listRows.mock.calls.map((call) => (JSON.parse(call[0].queries[0]) as { values: string[] }).values.length)).toEqual([100, 100, 50]);
expect(mocks.listRows.mock.calls.map((call) => call[0].queries[1])).toEqual([Query.limit(100), Query.limit(100), Query.limit(50)]);
});
it('ein Name, der während der Abfrage durch Umbenennen im Speicher landet, ist neuer als die Antwort', async () => {
mocks.userId = 'u1';
let answer!: (value: unknown) => void;
mocks.listRows.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const pending = profile.usernamesFor(['u1']);
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
await profile.saveUsername('Neu');
answer({ rows: [row('u1', 'Alt')] });
expect([...(await pending)]).toEqual([['u1', 'Neu']]);
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
});
});
describe('isUsernameTaken', () => {
it('fragt den Schlüssel (klein geschrieben) mit equal ab, eine Zeile genügt', async () => {
mocks.listRows.mockResolvedValue({ rows: [] });
await profile.isUsernameTaken('KenSo');
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
databaseId: 'db',
tableId: 'profiles-table',
queries: [Query.equal('usernameKey', 'kenso'), Query.limit(1)],
});
});
it('vergeben, wenn ein Profil den Schlüssel hat', async () => {
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
});
it('frei, wenn keine Zeile passt', async () => {
mocks.listRows.mockResolvedValue({ rows: [] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
});
it('der eigene Eintrag zählt nicht (nur die Schreibweise des eigenen Namens ändern ist erlaubt)', async () => {
mocks.userId = 'u1';
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1', username: 'kenso', usernameKey: 'kenso' }] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
});
it('Fehler gehen durch (die Registrierung zeigt sie am Dialog)', async () => {
const offline = new TypeError('Failed to fetch');
mocks.listRows.mockRejectedValue(offline);
await expect(profile.isUsernameTaken('Kenso')).rejects.toBe(offline);
});
});
describe('saveUsername', () => {
beforeEach(() => {
mocks.userId = 'u1';
});
it('sendet POST /api/profil mit Anmeldung und gibt den Namen der Antwort zurück', async () => {
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
expect(mocks.apiPost).toHaveBeenCalledExactlyOnceWith('/api/profil', { username: 'Kenso' }, { auth: true });
});
it('aktualisiert den Zwischenspeicher: myUsername und usernamesFor kennen den Namen sofort, ohne Abfrage', async () => {
expect(profile.myUsername()).toBeNull();
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await profile.saveUsername('Kenso');
expect(profile.myUsername()).toBe('Kenso');
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]);
expect(mocks.listRows).not.toHaveBeenCalled();
});
it('meldet die Änderung mit Konto-ID und Name an alle Hörer; abgemeldete Hörer hören nichts mehr', async () => {
const first = vi.fn();
const second = vi.fn();
const off = profile.onUsernameChange(first);
profile.onUsernameChange(second);
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
await profile.saveUsername('Kenso');
expect(first).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
expect(second).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
off();
mocks.apiPost.mockResolvedValueOnce({ username: 'Neu' });
await profile.saveUsername('Neu');
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenLastCalledWith('u1', 'Neu');
expect(profile.myUsername()).toBe('Neu');
});
it('die Schreibweise der Antwort gilt (der Dienst speichert den Namen, wie er eingegeben wurde)', async () => {
mocks.apiPost.mockResolvedValue({ username: 'KenSo' });
await expect(profile.saveUsername('kenso')).resolves.toBe('KenSo');
expect(profile.myUsername()).toBe('KenSo');
});
it('eine Antwort ohne Namen ist ein unklarer Ausgang: Fehler 503, nichts gemerkt, nichts gemeldet', async () => {
const listener = vi.fn();
profile.onUsernameChange(listener);
for (const body of [{}, { username: '' }, { username: 5 }]) {
mocks.apiPost.mockResolvedValueOnce(body);
const err = await profile.saveUsername('Kenso').catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as InstanceType<typeof ApiError>).serverCode).toBe('unavailable');
}
expect(profile.myUsername()).toBeNull();
expect(listener).not.toHaveBeenCalled();
});
it('Fehler des Dienstes gehen unverändert durch, der Zwischenspeicher bleibt', async () => {
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
await profile.saveUsername('Kenso');
const listener = vi.fn();
profile.onUsernameChange(listener);
const taken = new ApiError(409, 'Dieser Benutzername ist schon vergeben.', undefined, 'usernameTaken');
mocks.apiPost.mockRejectedValueOnce(taken);
await expect(profile.saveUsername('Mia')).rejects.toBe(taken);
expect(profile.myUsername()).toBe('Kenso');
expect(listener).not.toHaveBeenCalled();
});
it('ein Hörer, der wirft, stört weder die anderen noch das Ergebnis', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const later = vi.fn();
profile.onUsernameChange(() => {
throw new Error('kaputt');
});
profile.onUsernameChange(later);
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
expect(later).toHaveBeenCalledOnce();
expect(error).toHaveBeenCalledOnce();
});
it('ohne bekanntes Konto geht der Aufruf durch, gemerkt und gemeldet wird nichts', async () => {
mocks.userId = null;
const listener = vi.fn();
profile.onUsernameChange(listener);
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
expect(listener).not.toHaveBeenCalled();
expect(profile.myUsername()).toBeNull();
});
it('gemerkt wird für das Konto, das den Aufruf gestartet hat, auch wenn inzwischen ein anderes angemeldet ist', async () => {
let answer!: (value: unknown) => void;
mocks.apiPost.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const saving = profile.saveUsername('Kenso');
mocks.userId = 'u2'; // Kontowechsel, während die Antwort unterwegs ist
answer({ username: 'Kenso' });
await saving;
expect(profile.myUsername()).toBeNull(); // u2 hat (noch) keinen Namen
mocks.userId = 'u1';
expect(profile.myUsername()).toBe('Kenso');
});
});
describe('myUsername', () => {
it('null ohne Konto und null, solange der Name nicht bekannt ist', () => {
expect(profile.myUsername()).toBeNull();
mocks.userId = 'u1';
expect(profile.myUsername()).toBeNull();
});
it('der Name gehört zum angemeldeten Konto: bei einem Wechsel zeigt myUsername den des anderen (oder keinen)', async () => {
mocks.userId = 'u1';
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
await profile.loadMyUsername();
expect(profile.myUsername()).toBe('Kenso');
mocks.userId = 'u2';
expect(profile.myUsername()).toBeNull();
mocks.userId = null;
expect(profile.myUsername()).toBeNull();
mocks.userId = 'u1';
expect(profile.myUsername()).toBe('Kenso');
});
});
describe('loadMyUsername', () => {
it('ohne Konto null und keine Anfrage', async () => {
await expect(profile.loadMyUsername()).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
});
it('liest das Profil des angemeldeten Kontos, merkt den Namen und meldet ihn', async () => {
mocks.userId = 'u1';
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
const listener = vi.fn();
profile.onUsernameChange(listener);
await expect(profile.loadMyUsername()).resolves.toBe('Kenso');
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
expect(profile.myUsername()).toBe('Kenso');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
});
it('liest bei jedem Aufruf neu (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
mocks.userId = 'u1';
mocks.getRow.mockResolvedValueOnce({ $id: 'u1', username: 'Alt' }).mockResolvedValueOnce({ $id: 'u1', username: 'Neu' });
await profile.loadMyUsername();
await expect(profile.loadMyUsername()).resolves.toBe('Neu');
expect(profile.myUsername()).toBe('Neu');
});
it('ohne Profil (404) ist das Ergebnis null, nichts wird gemeldet', async () => {
mocks.userId = 'u1';
mocks.getRow.mockRejectedValue(notFound());
const listener = vi.fn();
profile.onUsernameChange(listener);
await expect(profile.loadMyUsername()).resolves.toBeNull();
expect(profile.myUsername()).toBeNull();
expect(listener).not.toHaveBeenCalled();
});
it('ein Name, der inzwischen gespeichert wurde, bleibt, auch wenn die Antwort „kein Profil“ sagt (Registrierung)', async () => {
mocks.userId = 'u1';
let answer!: (error: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
const loading = profile.loadMyUsername();
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await profile.saveUsername('Kenso'); // das Konto wurde gerade angelegt: die Abfrage war schneller als das Speichern
answer(notFound());
await expect(loading).resolves.toBe('Kenso');
expect(profile.myUsername()).toBe('Kenso');
});
it('andere Fehler gehen durch (der Aufrufer entscheidet; „unbekannt“ ist nicht „kein Name“)', async () => {
mocks.userId = 'u1';
const offline = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValue(offline);
await expect(profile.loadMyUsername()).rejects.toBe(offline);
});
it('eine Antwort, die vor dem Umbenennen abgeschickt wurde, meldet den alten Namen nicht noch einmal: der neuere Name gilt', async () => {
mocks.userId = 'u1';
let answer!: (row: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const listener = vi.fn();
profile.onUsernameChange(listener);
const loading = profile.loadMyUsername(); // das Lesen läuft (Stand vor dem Umbenennen)
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
await profile.saveUsername('Neu'); // währenddessen umbenannt
answer({ $id: 'u1', username: 'Alt' }); // die Antwort ist älter als das Umbenennen
await expect(loading).resolves.toBe('Neu');
expect(profile.myUsername()).toBe('Neu');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Neu'); // „Alt“ wurde nie gemeldet
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
});
it('dasselbe gilt für eine Antwort „kein Profil“, die vor dem Speichern abgeschickt wurde (Registrierung)', async () => {
mocks.userId = 'u1';
let answer!: (error: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
const listener = vi.fn();
profile.onUsernameChange(listener);
const loading = profile.loadMyUsername();
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await profile.saveUsername('Kenso');
answer(notFound());
await expect(loading).resolves.toBe('Kenso');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
});
it('ein Lesen, das erst nach dem Umbenennen beginnt, gilt wieder (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
mocks.userId = 'u1';
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
await profile.saveUsername('Neu');
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Anderswo' });
const listener = vi.fn();
profile.onUsernameChange(listener);
await expect(profile.loadMyUsername()).resolves.toBe('Anderswo');
expect(profile.myUsername()).toBe('Anderswo');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Anderswo');
});
it('das Umbenennen eines anderen Kontos macht ein laufendes Lesen nicht hinfällig', async () => {
mocks.userId = 'u1';
let answer!: (row: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const loading = profile.loadMyUsername();
mocks.userId = 'u2'; // der Aufruf gehört einem anderen Konto
mocks.apiPost.mockResolvedValue({ username: 'Mia' });
await profile.saveUsername('Mia');
mocks.userId = 'u1';
answer({ $id: 'u1', username: 'Kenso' });
await expect(loading).resolves.toBe('Kenso');
expect(profile.myUsername()).toBe('Kenso');
});
});
describe('byline („von @name“)', () => {
const names = new Map([
['u1', 'Kenso'],
['u2', 'Mia.K'],
]);
it('mit Profil: „von @name“ (der Name, wie er gespeichert ist), der alte Anzeigename zählt dann nicht', () => {
expect(profile.byline('u1', '', names)).toBe('von @Kenso');
expect(profile.byline('u2', 'Alter Name', names)).toBe('von @Mia.K');
});
it('ohne Profil: der früher gespeicherte Anzeigename, ohne @', () => {
expect(profile.byline('u9', 'Anna', names)).toBe('von Anna');
expect(profile.byline('u9', 'Anna', new Map())).toBe('von Anna');
});
it('ohne Profil und ohne Anzeigenamen: „von Anonym“', () => {
expect(profile.byline('u9', '', names)).toBe('von Anonym');
expect(profile.byline('u9', '', new Map())).toBe('von Anonym');
});
it('ein Eintrag ohne Konto-ID (Altbestand) fällt ebenso zurück, auch wenn die Map einen leeren Schlüssel hätte', () => {
expect(profile.byline('', 'Anna', names)).toBe('von Anna');
expect(profile.byline('', '', names)).toBe('von Anonym');
expect(profile.byline('', '', new Map([['', 'Spuk']]))).toBe('von Anonym');
});
it('ein leerer Name in der Map zählt nicht als Profil', () => {
expect(profile.byline('u3', 'Anna', new Map([['u3', '']]))).toBe('von Anna');
});
it('Werte mit geschweiften Klammern oder Markup bleiben unverändert (der eingesetzte Text wird nicht noch einmal ausgewertet)', () => {
expect(profile.byline('u9', '{name}', new Map())).toBe('von {name}');
expect(profile.byline('u9', '<b>fett</b>', new Map())).toBe('von <b>fett</b>');
});
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
expect(profile.byline('u1', '', names)).toBe('by @Kenso');
expect(profile.byline('u9', 'Anna', names)).toBe('by Anna');
expect(profile.byline('u9', '', names)).toBe('by Anonymous');
});
it('liest nur die übergebene Map: eine Änderung dort zeigt die nächste Zeile sofort (eigene Umbenennung)', () => {
const own = new Map([['u1', 'Alt']]);
expect(profile.byline('u1', '', own)).toBe('von @Alt');
own.set('u1', 'Neu');
expect(profile.byline('u1', '', own)).toBe('von @Neu');
});
});
describe('Merker „in dieser Sitzung schon gefragt“', () => {
it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => {
expect(profile.usernamePrompted()).toBe(false);
profile.markUsernamePrompted();
expect(profile.usernamePrompted()).toBe(true);
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBe('1');
});
it('gilt für die ganze Sitzung: auch eine frisch geladene Seite sieht ihn', async () => {
profile.markUsernamePrompted();
vi.resetModules();
const reloaded = await import('./profile');
expect(reloaded.usernamePrompted()).toBe(true);
});
it('löschen nimmt ihn wieder weg', () => {
profile.markUsernamePrompted();
profile.clearUsernamePrompted();
expect(profile.usernamePrompted()).toBe(false);
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBeNull();
});
it('ist der Speicher gesperrt, wirft nichts und der Merker gilt bis zum Neuladen der Seite', () => {
const denied = () => {
throw new DOMException('blockiert', 'SecurityError');
};
vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied });
try {
expect(profile.usernamePrompted()).toBe(false);
expect(() => profile.markUsernamePrompted()).not.toThrow();
expect(profile.usernamePrompted()).toBe(true);
expect(() => profile.clearUsernamePrompted()).not.toThrow();
expect(profile.usernamePrompted()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
+269
View File
@@ -0,0 +1,269 @@
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes
// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. Dazu die Zeile „von @name“ für
// Logos und Karten (`byline`).
import { Query, type Models } from 'appwrite';
import { ID_PATTERN } from '../card/config';
import { apiPost, unavailableError } from '../community/api';
import { API_MESSAGES } from '../i18n/api-messages';
import { msg, t } from '../i18n/i18n';
import { IDS, tables } from './appwrite';
import { currentUserId } from './auth';
import { isRowMissing } from './errors';
/** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */
export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
/**
* Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen.
* Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide.
*/
export const RESERVED_USERNAMES: readonly string[] = [
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
];
const RESERVED = new Set(RESERVED_USERNAMES);
/** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */
export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
/** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */
const LOOKUP_MAX = 100;
type TableRow = Models.Row & Record<string, unknown>;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */
export function usernameKey(name: string): string {
return name.toLowerCase();
}
/**
* Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste).
* Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht).
*/
export function validateUsername(name: string): string | null {
if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid);
if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved);
return null;
}
/**
* Die Zeile „von @name“ zu einem Eintrag (Logo-Kachel, Infozeile, Detailseite einer Karte): der Benutzername des Kontos `ownerId`, soweit
* `names` (aus `usernamesFor`) ihn kennt. Ohne Profil gilt der früher gespeicherte Anzeigename (ohne @), sonst „Anonym“. In der Sprache der Seite.
*/
export function byline(ownerId: string, legacyDisplayName: string, names: ReadonlyMap<string, string>): string {
const name = ownerId === '' ? '' : (names.get(ownerId) ?? '');
if (name !== '') return t('von @{name}', { name });
return t('von {name}', { name: legacyDisplayName !== '' ? legacyDisplayName : t('Anonym') });
}
// ---- Namen merken und melden ------------------------------------------------------------------------------
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
const names = new Map<string, string>();
/** Zählt die Namen, die diese Seite gespeichert hat; `savedAt` merkt je Konto den Stand beim letzten Speichern (siehe `loadMyUsername`). */
let saves = 0;
const savedAt = new Map<string, number>();
const listeners = new Set<(userId: string, username: string) => void>();
/** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */
function announce(userId: string, username: string): void {
names.set(userId, username);
// Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis.
for (const fn of [...listeners]) {
try {
fn(userId, username);
} catch (err) {
console.error('Ein Hörer der Namensänderung ist gescheitert', err);
}
}
}
/**
* Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils
* (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück.
*/
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
/** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */
export function myUsername(): string | null {
const userId = currentUserId();
return userId ? (names.get(userId) ?? null) : null;
}
// ---- Lesen --------------------------------------------------------------------------------------------------
/**
* Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein
* kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch.
*/
export async function getUsername(userId: string): Promise<string | null> {
if (!ID_PATTERN.test(userId)) return null;
try {
const row = await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId });
return text(row.username) || null;
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/**
* Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen
* in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein
* Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück.
*/
export async function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>> {
const found = new Map<string, string>();
const missing: string[] = [];
for (const id of new Set(userIds)) {
if (!ID_PATTERN.test(id)) continue;
const known = names.get(id);
if (known === undefined) missing.push(id);
else found.set(id, known);
}
try {
for (let from = 0; from < missing.length; from += LOOKUP_MAX) {
const ids = missing.slice(from, from + LOOKUP_MAX);
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.profiles,
queries: [Query.equal('$id', ids), Query.limit(ids.length)],
});
for (const row of list.rows) {
const name = text(row.username);
if (name === '') continue;
// Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort.
const current = names.get(row.$id) ?? name;
names.set(row.$id, current);
found.set(row.$id, current);
}
}
} catch {
// nur Beiwerk der Anzeige, siehe oben
}
return found;
}
/**
* Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten
* Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst.
* Fehler (Netz, Server) gehen durch.
*/
export async function isUsernameTaken(name: string): Promise<boolean> {
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.profiles,
queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)],
});
const own = currentUserId();
return list.rows.some((row) => row.$id !== own);
}
/**
* Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne
* Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein
* Name“, und nur bei „kein Name“ wird gefragt.
*/
export async function loadMyUsername(): Promise<string | null> {
const userId = currentUserId();
if (!userId) return null;
const started = saves;
const name = await getUsername(userId);
// Wurde der Name währenddessen gespeichert (Umbenennen, Registrierung: die Abfrage war schneller als das Speichern), ist die Antwort
// älter als der gemerkte Name: er gilt, und der alte wird nicht noch einmal gemeldet. Kein Profil heißt sonst ebenfalls „der gemerkte“.
if (name === null || (savedAt.get(userId) ?? 0) > started) return names.get(userId) ?? null;
announce(userId, name);
return name;
}
// ---- Speichern ----------------------------------------------------------------------------------------------
/**
* Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der
* Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es.
* Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst.
*/
export async function saveUsername(name: string): Promise<string> {
const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist
const res = await apiPost('/api/profil', { username: name }, { auth: true });
const saved = res.username;
// Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen.
if (typeof saved !== 'string' || saved === '') throw unavailableError();
if (userId) {
savedAt.set(userId, ++saves);
announce(userId, saved);
}
return saved;
}
// ---- „In dieser Sitzung schon gefragt“ ---------------------------------------------------------------------
/** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */
const PROMPTED_KEY = 'aircard.usernamePrompted';
/** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */
let promptedInMemory = false;
/** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */
export function usernamePrompted(): boolean {
if (promptedInMemory) return true;
try {
return sessionStorage.getItem(PROMPTED_KEY) === '1';
} catch {
return false;
}
}
/** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */
export function markUsernamePrompted(): void {
promptedInMemory = true;
try {
sessionStorage.setItem(PROMPTED_KEY, '1');
} catch {
// gesperrt oder voll: es bleibt beim Merker im Speicher
}
}
/** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */
export function clearUsernamePrompted(): void {
promptedInMemory = false;
try {
sessionStorage.removeItem(PROMPTED_KEY);
} catch {
// gesperrt: nichts zu entfernen
}
}
+741
View File
@@ -0,0 +1,741 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { germanLeft } from '../i18n/english-check';
const mocks = vi.hoisted(() => ({
saveUsername: vi.fn(),
loadMyUsername: vi.fn(),
/** Der Name des angemeldeten Kontos laut Zwischenspeicher (myUsername). */
name: null as string | null,
userId: null as string | null,
}));
vi.mock('./appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
const INVALID =
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
type Dialog = typeof import('./username-dialog');
let dialog: Dialog;
/** Der Dialog hält einen Zustand (nur einer zur Zeit): jeder Test bekommt frische Module; ApiError stammt aus derselben Kopie. */
let ApiError: typeof import('../community/api').ApiError;
let opener: HTMLButtonElement;
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.username-dialog');
const input = () => dialogEl()!.querySelector<HTMLInputElement>('input[name="username"]')!;
const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const errorEl = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
const errorText = () => errorEl().textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const heading = () => dialogEl()!.querySelector('h2')!.textContent;
const intro = () => dialogEl()!.querySelector('.username-text')!.textContent;
/** Die IDs in `aria-describedby` des Feldes. */
const describedBy = () => (input().getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
beforeEach(async () => {
mocks.saveUsername.mockReset();
mocks.loadMyUsername.mockReset();
mocks.name = null;
mocks.userId = null;
document.body.innerHTML = '<button id="opener">Konto</button>';
opener = document.getElementById('opener') as HTMLButtonElement;
opener.focus();
vi.resetModules();
// Nur Netzzugriff und Zwischenspeicher sind ersetzt; Muster, Sperrliste und die Texte bleiben echt. `doMock` nach jedem Zurücksetzen:
// ein einmal berechnetes `vi.mock` bliebe über `resetModules` hinweg bei der ersten Kopie von profile.ts und i18n.ts stehen.
vi.doMock('./profile', async () => ({
...(await vi.importActual<typeof import('./profile')>('./profile')),
saveUsername: mocks.saveUsername,
loadMyUsername: mocks.loadMyUsername,
myUsername: () => mocks.name,
}));
dialog = await import('./username-dialog');
({ ApiError } = await import('../community/api'));
});
afterEach(() => {
document.body.innerHTML = '';
vi.doUnmock('./profile');
vi.restoreAllMocks();
});
describe('Dialog „Benutzername wählen“: Aufbau', () => {
it('prompt: Überschrift, leeres und fokussiertes Feld, Hinweis, „Später“ und „Speichern“', () => {
void dialog.usernameDialog({ mode: 'prompt' });
expect(dialogEl()?.open).toBe(true);
expect(heading()).toBe('Benutzername wählen');
expect(intro()).toBe('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
expect(input().value).toBe('');
expect(document.activeElement).toBe(input());
expect(dialogEl()!.querySelector('label')!.textContent).toBe('Benutzername');
expect(dialogEl()!.querySelector('.auth-hint')!.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Später', 'Speichern']);
expect(buttonByText('Speichern')!.type).toBe('submit');
expect(buttonByText('Später')!.type).toBe('button');
});
it('das Feld ist ein Spitzname, kein Anmeldename (autocomplete „nickname“, nie „username“): keine Großschreibung, keine Rechtschreibprüfung', () => {
void dialog.usernameDialog({ mode: 'prompt' });
expect(input().autocomplete).toBe('nickname');
expect(input().getAttribute('autocapitalize')).toBe('none');
expect(input().getAttribute('spellcheck')).toBe('false');
expect(input().type).toBe('text');
});
it('required: „Abbrechen“ statt „Später“ und der Hinweis, dass zuerst ein Name nötig ist', () => {
void dialog.usernameDialog({ mode: 'required' });
expect(heading()).toBe('Benutzername wählen');
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
expect(input().value).toBe('');
});
it('change: das Feld ist mit dem aktuellen Namen vorbelegt (als Wert, nie als HTML), „Abbrechen“, Überschrift „Benutzername ändern“', () => {
mocks.name = '<b>Kenso</b>';
void dialog.usernameDialog({ mode: 'change' });
expect(heading()).toBe('Benutzername ändern');
expect(input().value).toBe('<b>Kenso</b>');
expect(dialogEl()!.querySelector('b')).toBeNull();
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
expect(document.activeElement).toBe(input());
});
it('change ohne bekannten Namen: wie „wählen“ mit „Abbrechen“, Feld leer', () => {
void dialog.usernameDialog({ mode: 'change' });
expect(heading()).toBe('Benutzername wählen');
expect(input().value).toBe('');
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Später')).toBeUndefined();
});
it('prompt und required bleiben leer, auch wenn ein Name bekannt ist (vorbelegt wird nur beim Ändern)', () => {
mocks.name = 'Kenso';
void dialog.usernameDialog({ mode: 'prompt' });
expect(input().value).toBe('');
});
it('Beschriftung und Verknüpfungen: Label gehört zum Feld, der Hinweis ist verknüpft, alle IDs sind eindeutig, die Überschrift benennt den Dialog', () => {
void dialog.usernameDialog({ mode: 'prompt' });
const label = dialogEl()!.querySelector('label')!;
expect(label.control).toBe(input());
expect(input().labels).toHaveLength(1);
const hint = dialogEl()!.querySelector('.auth-hint')!;
expect(describedBy()).toEqual([hint.id]);
expect(input().getAttribute('aria-required')).toBe('true');
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(dialogEl()!.querySelector('h2')!.id);
expect(errorEl().getAttribute('role')).toBe('alert');
expect(form().hasAttribute('novalidate')).toBe(true); // geprüft wird selbst, mit Texten der Seite
});
});
describe('Später, Abbrechen, Schließen', () => {
it('„Später“ löst mit null auf, speichert nichts, entfernt den Dialog und gibt den Fokus zurück', async () => {
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso'; // auch ein getippter Name wird dann nicht gespeichert
buttonByText('Später')!.click();
await expect(result).resolves.toBeNull();
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
expect(document.activeElement).toBe(opener);
});
it.each(['required', 'change'] as const)('%s: „Abbrechen“ löst mit null auf und speichert nichts', async (mode) => {
const result = dialog.usernameDialog({ mode });
buttonByText('Abbrechen')!.click();
await expect(result).resolves.toBeNull();
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
});
it('Esc (close-Ereignis des Dialogs) löst mit null auf', async () => {
const result = dialog.usernameDialog({ mode: 'prompt' });
dialogEl()!.close();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('ein Klick auf den Hintergrund schließt, ein Klick im Formular oder Markieren, das außerhalb endet, nicht', async () => {
const result = dialog.usernameDialog({ mode: 'prompt' });
input().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
input().click();
dialogEl()!.click(); // mousedown im Feld, click auf dem Dialog: Markieren
expect(dialogEl()?.open).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.click();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('signal: ein Abbruch schließt den Dialog mit null; ein schon abgebrochenes Signal öffnet gar nichts', async () => {
const controller = new AbortController();
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
expect(dialogEl()?.open).toBe(true);
controller.abort();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
controller.abort();
await expect(dialog.usernameDialog({ mode: 'prompt', signal: controller.signal })).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('schlägt showModal fehl, wird aufgeräumt und der Fehler gemeldet; der nächste Aufruf öffnet normal', async () => {
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => {
throw new Error('showModal kaputt');
});
await expect(dialog.usernameDialog({ mode: 'prompt' })).rejects.toThrow('showModal kaputt');
expect(dialogEl()).toBeNull();
spy.mockRestore();
const again = dialog.usernameDialog({ mode: 'prompt' });
expect(dialogEl()?.open).toBe(true);
buttonByText('Später')!.click();
await expect(again).resolves.toBeNull();
});
});
describe('Speichern', () => {
it('speichert den getrimmten Namen, löst mit dem Namen des Dienstes auf und schließt', async () => {
mocks.saveUsername.mockResolvedValue('KenSo');
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = ' Kenso ';
send();
await expect(result).resolves.toBe('KenSo');
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
expect(dialogEl()).toBeNull();
expect(document.activeElement).toBe(opener);
});
it('der Knopf „Speichern“ löst das Absenden aus (Enter im Feld ebenso: es ist ein Formular)', async () => {
mocks.saveUsername.mockResolvedValue('Kenso');
const result = dialog.usernameDialog({ mode: 'change' });
input().value = 'Kenso';
expect(buttonByText('Speichern')!.closest('form')).toBe(form());
send();
await expect(result).resolves.toBe('Kenso');
});
it('prüft vor dem Senden: Muster und Sperrliste, Text am Feld, kein Aufruf', async () => {
void dialog.usernameDialog({ mode: 'prompt' });
const invalid = async (value: string, message: string) => {
input().value = value;
send();
await settle();
expect(errorText()).toBe(message);
expect(input().getAttribute('aria-invalid')).toBe('true');
expect(describedBy()).toContain(errorEl().id); // das Feld verweist auf die Fehlerzeile
expect(document.activeElement).toBe(input());
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
};
await invalid('', INVALID);
await invalid(' ', INVALID);
await invalid('ab', INVALID);
await invalid('x'.repeat(21), INVALID);
await invalid('_abc', INVALID);
await invalid('na me', INVALID);
await invalid('Käse', INVALID);
await invalid('Admin', 'Dieser Benutzername ist nicht erlaubt.');
await invalid('AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.');
});
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden; der Hinweis bleibt verknüpft', async () => {
mocks.saveUsername.mockResolvedValue('Kenso');
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'x';
send();
expect(describedBy()).toHaveLength(2);
input().value = 'Kenso';
send();
expect(input().hasAttribute('aria-invalid')).toBe(false);
expect(errorText()).toBe('');
expect(describedBy()).toHaveLength(1);
await result;
});
it('vergeben (409 usernameTaken): der Text steht am Feld, der Dialog bleibt offen, ein neuer Name geht', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe(TAKEN);
expect(input().getAttribute('aria-invalid')).toBe('true');
expect(describedBy()).toContain(errorEl().id);
expect(document.activeElement).toBe(input());
expect(dialogEl()?.open).toBe(true);
expect(input().value).toBe('Kenso');
expect(buttonByText('Speichern')!.disabled).toBe(false);
expect(buttonByText('Später')!.disabled).toBe(false);
expect(input().readOnly).toBe(false);
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
input().value = 'Kenso2';
send();
await expect(result).resolves.toBe('Kenso2');
expect(mocks.saveUsername).toHaveBeenCalledTimes(2);
});
it('Muster und Sperrliste des Dienstes (400) stehen ebenfalls am Feld', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameReserved'));
void dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe('Dieser Benutzername ist nicht erlaubt.');
expect(input().getAttribute('aria-invalid')).toBe('true');
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameInvalid'));
send();
await settle();
expect(errorText()).toBe(INVALID);
expect(input().getAttribute('aria-invalid')).toBe('true');
});
it('Dienst nicht erreichbar: der Text steht im Dialog, das Feld gilt nicht als ungültig, der Dialog bleibt für einen neuen Versuch offen', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(input().hasAttribute('aria-invalid')).toBe(false);
expect(describedBy()).not.toContain(errorEl().id);
expect(dialogEl()?.open).toBe(true);
expect(input().value).toBe('Kenso');
expect(statusText()).toBe('');
mocks.saveUsername.mockResolvedValueOnce('Kenso');
send();
await expect(result).resolves.toBe('Kenso');
});
it('andere Fehler: kein Netz, abgelaufene Anmeldung (401) und alles Unbekannte werden erklärt statt verschluckt', async () => {
void dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
mocks.saveUsername.mockRejectedValueOnce(new TypeError('Failed to fetch'));
send();
await settle();
expect(errorText()).toBe('Keine Verbindung zum Server.');
mocks.saveUsername.mockRejectedValueOnce(Object.assign(new Error('unauthorized'), { code: 401, type: 'general_unauthorized_scope' }));
send();
await settle();
expect(errorText()).toBe('Bitte melde dich an.');
mocks.saveUsername.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.', undefined, 'unauthorized'));
send();
await settle();
expect(errorText()).toBe('Bitte melde dich an.');
mocks.saveUsername.mockRejectedValueOnce(new Error('???'));
send();
await settle();
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(dialogEl()?.open).toBe(true);
expect(input().hasAttribute('aria-invalid')).toBe(false);
});
it('der Fehler eines früheren Versuchs verschwindet beim nächsten', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
input().value = 'Kenso2';
send();
expect(errorText()).toBe('');
expect(input().hasAttribute('aria-invalid')).toBe(false);
await result;
});
});
describe('Vorbelegung und Grund beim Öffnen (der Rückfall nach einer Registrierung, deren letzter Schritt scheiterte)', () => {
it('`initial` belegt das Feld vor, auch im Modus „prompt“ (Überschrift „wählen“, „Später“), als Wert und nie als HTML; der Text ist markiert', () => {
void dialog.usernameDialog({ mode: 'prompt', initial: '<b>Kenso</b>' });
expect(input().value).toBe('<b>Kenso</b>');
expect(dialogEl()!.querySelector('b')).toBeNull();
expect(heading()).toBe('Benutzername wählen');
expect(buttonByText('Später')).toBeTruthy();
expect(document.activeElement).toBe(input());
expect(input().selectionStart).toBe(0);
expect(input().selectionEnd).toBe('<b>Kenso</b>'.length);
expect(errorText()).toBe(''); // ohne `failure` keine Meldung
});
it('`initial` geht dem Namen des Kontos im Modus „change“ vor', () => {
mocks.name = 'Alt';
void dialog.usernameDialog({ mode: 'change', initial: 'Getippt' });
expect(input().value).toBe('Getippt');
expect(heading()).toBe('Benutzername ändern');
});
it('`failure` mit einem Fehler am Namen (vergeben): der Grund steht schon da, am Feld, mit Markierung und Fokus', () => {
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
expect(errorText()).toBe(TAKEN);
expect(input().getAttribute('aria-invalid')).toBe('true');
expect(describedBy()).toContain(errorEl().id);
expect(document.activeElement).toBe(input());
expect(input().value).toBe('Kenso');
expect(dialogEl()?.open).toBe(true);
});
it('`failure` ohne Bezug zum Namen (Netz, Dienst): der Grund steht als Text, das Feld gilt nicht als ungültig, der Name bleibt zum Wiederholen', () => {
void dialog.usernameDialog({
mode: 'prompt',
initial: 'Kenso',
failure: new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'),
});
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(input().hasAttribute('aria-invalid')).toBe(false);
expect(describedBy()).not.toContain(errorEl().id);
expect(input().value).toBe('Kenso');
expect(document.activeElement).toBe(input());
});
it('andere Fehler als `failure` werden wie beim Speichern erklärt (kein Netz, abgelaufene Anmeldung, Unbekanntes)', async () => {
void dialog.usernameDialog({ mode: 'prompt', failure: new TypeError('Failed to fetch') });
expect(errorText()).toBe('Keine Verbindung zum Server.');
buttonByText('Später')!.click();
await settle();
void dialog.usernameDialog({ mode: 'prompt', failure: Object.assign(new Error('x'), { code: 401 }) });
expect(errorText()).toBe('Bitte melde dich an.');
buttonByText('Später')!.click();
await settle();
void dialog.usernameDialog({ mode: 'prompt', failure: new Error('???') });
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
it('der Grund verschwindet beim nächsten Speichern; ein anderer Name geht durch', async () => {
mocks.saveUsername.mockResolvedValue('Kenso2');
const result = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
input().value = 'Kenso2';
send();
expect(errorText()).toBe('');
expect(input().hasAttribute('aria-invalid')).toBe(false);
await expect(result).resolves.toBe('Kenso2');
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
});
it('beides gilt nur für diesen Aufruf: der nächste Dialog beginnt leer und ohne Meldung', async () => {
const first = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new TypeError('offline') });
buttonByText('Später')!.click();
await first;
void dialog.usernameDialog({ mode: 'prompt' });
expect(input().value).toBe('');
expect(errorText()).toBe('');
});
it('Englisch: der Grund erscheint in der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
expect(errorText()).toBe('This username is already taken.');
expect(germanLeft(dialogEl()!)).toEqual([]);
});
});
describe('Busy-Sperre beim Speichern', () => {
it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
expect(buttonByText('Speichern')!.disabled).toBe(true);
expect(buttonByText('Später')!.disabled).toBe(true);
expect(input().readOnly).toBe(true);
expect(form().getAttribute('aria-busy')).toBe('true');
expect(statusText()).toBe('Wird gespeichert …');
send();
expect(mocks.saveUsername).toHaveBeenCalledOnce();
finish('Kenso');
await expect(result).resolves.toBe('Kenso');
});
it('Esc und ein Klick auf den Hintergrund schließen den Dialog währenddessen nicht (Abbrechen wirkte sonst nur scheinbar)', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
const result = dialog.usernameDialog({ mode: 'required' });
input().value = 'Kenso';
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('Kenso');
await expect(result).resolves.toBe('Kenso');
});
it('schließt der Browser den Dialog trotzdem, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
let resolved: string | null | undefined;
const result = dialog.usernameDialog({ mode: 'prompt' }).then((name) => (resolved = name));
input().value = 'Kenso';
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('Kenso');
await result;
expect(resolved).toBe('Kenso');
expect(dialogEl()).toBeNull();
});
it('scheitert das Speichern, nachdem der Dialog zwischendurch zuging, ist er wieder offen und zeigt den Fehler', async () => {
let fail!: (error: Error) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((_, reject) => (fail = reject)));
void dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
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('Der Dienst ist gerade nicht erreichbar.');
});
it('nur `signal` schließt immer; eine danach eintreffende Antwort ändert nichts mehr', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
const controller = new AbortController();
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
input().value = 'Kenso';
send();
controller.abort();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
finish('Kenso');
await settle();
expect(dialogEl()).toBeNull();
});
});
describe('ein Dialog zur Zeit', () => {
it('ein zweiter Aufruf, während einer offen ist, teilt sich Dialog und Ergebnis (keine zwei Dialoge übereinander)', async () => {
mocks.saveUsername.mockResolvedValue('Kenso');
const first = dialog.usernameDialog({ mode: 'prompt' });
const second = dialog.usernameDialog({ mode: 'required' });
expect(second).toBe(first);
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
expect(heading()).toBe('Benutzername wählen');
input().value = 'Kenso';
send();
await expect(second).resolves.toBe('Kenso');
await expect(first).resolves.toBe('Kenso');
});
it('nach dem Schließen öffnet der nächste Aufruf neu, ohne Reste', async () => {
const first = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
buttonByText('Später')!.click();
await first;
const second = dialog.usernameDialog({ mode: 'required' });
expect(second).not.toBe(first);
expect(dialogEl()?.open).toBe(true);
expect(input().value).toBe('');
expect(errorText()).toBe('');
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
buttonByText('Abbrechen')!.click();
await second;
});
});
describe('Englisch', () => {
beforeEach(async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
});
it('alle Texte des Dialogs sind englisch, in jedem Modus', async () => {
mocks.name = 'Kenso';
const texts = () => [heading(), intro(), dialogEl()!.querySelector('label')!.textContent, dialogEl()!.querySelector('.auth-hint')!.textContent];
const labels = () => [...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent);
const prompt = dialog.usernameDialog({ mode: 'prompt' });
expect(texts()).toEqual([
'Choose a username',
'Your username is public and shown on your logos and cards.',
'Username',
'3–20 characters: letters, numbers, . _ -',
]);
expect(labels()).toEqual(['Later', 'Save']);
expect(germanLeft(dialogEl()!)).toEqual([]);
buttonByText('Later')!.click();
await prompt;
const required = dialog.usernameDialog({ mode: 'required' });
expect(intro()).toBe('Please choose a username first.');
expect(labels()).toEqual(['Cancel', 'Save']);
buttonByText('Cancel')!.click();
await required;
const change = dialog.usernameDialog({ mode: 'change' });
expect(heading()).toBe('Change username');
expect(input().value).toBe('Kenso');
expect(germanLeft(dialogEl()!)).toEqual([]);
buttonByText('Cancel')!.click();
await change;
});
it('Fehler und Status ebenso', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValueOnce(new Promise<string>((resolve) => (finish = resolve)));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'x';
send();
expect(errorText()).toBe(
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
);
input().value = 'Admin';
send();
expect(errorText()).toBe('This username is not allowed.');
input().value = 'Kenso';
send();
expect(statusText()).toBe('Saving …');
expect(germanLeft(dialogEl()!)).toEqual([]);
finish('Kenso');
await result;
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
const again = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe('This username is already taken.');
buttonByText('Later')!.click();
await again;
});
});
describe('requireUsername', () => {
it('ohne angemeldetes Konto gibt es nichts zu fordern: true, kein Dialog, keine Anfrage (die Anmeldung kommt mit der Aktion selbst)', async () => {
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(dialogEl()).toBeNull();
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
});
it('ein bekannter Name genügt: true ohne Dialog und ohne Anfrage', async () => {
mocks.userId = 'u1';
mocks.name = 'Kenso';
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(dialogEl()).toBeNull();
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
});
it('ist der Name noch nicht gelesen, wird er gelesen; hat das Konto einen, ist es true ohne Dialog', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue('Kenso');
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(mocks.loadMyUsername).toHaveBeenCalledOnce();
expect(dialogEl()).toBeNull();
});
it('ohne Namen erscheint der Dialog „required“; nach dem Speichern ist es true', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
mocks.saveUsername.mockResolvedValue('Kenso');
const result = dialog.requireUsername();
await settle();
expect(dialogEl()?.open).toBe(true);
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
expect(buttonByText('Abbrechen')).toBeTruthy();
input().value = 'Kenso';
send();
await expect(result).resolves.toBe(true);
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
expect(dialogEl()).toBeNull();
});
it('„Abbrechen“ ergibt false: ohne Namen geht es nicht weiter', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const result = dialog.requireUsername();
await settle();
buttonByText('Abbrechen')!.click();
await expect(result).resolves.toBe(false);
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
});
it('Esc ergibt ebenfalls false', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const result = dialog.requireUsername();
await settle();
dialogEl()!.close();
await expect(result).resolves.toBe(false);
});
it('lässt sich das Profil nicht lesen, entscheidet der Dienst (er antwortet ohne Profil mit 403): true, kein Dialog', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(dialogEl()).toBeNull();
});
it('das Signal geht an den Dialog: das Verlassen der Seite schließt ihn, das Ergebnis ist false', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const controller = new AbortController();
const result = dialog.requireUsername(controller.signal);
await settle();
expect(dialogEl()?.open).toBe(true);
controller.abort();
await expect(result).resolves.toBe(false);
expect(dialogEl()).toBeNull();
expect(mocks.saveUsername).not.toHaveBeenCalled();
});
it('ein schon abgebrochenes Signal öffnet keinen Dialog (false); mit bekanntem Namen ist es trotzdem true', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const controller = new AbortController();
controller.abort();
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(false);
expect(dialogEl()).toBeNull();
mocks.name = 'Kenso';
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(true);
});
it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
mocks.saveUsername.mockResolvedValue('Kenso');
const prompt = dialog.usernameDialog({ mode: 'prompt' });
const required = dialog.requireUsername();
await settle();
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
input().value = 'Kenso';
send();
await expect(required).resolves.toBe(true);
await expect(prompt).resolves.toBe('Kenso');
});
it('„Später“ in einem geteilten Dialog ergibt für requireUsername false', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const prompt = dialog.usernameDialog({ mode: 'prompt' });
const required = dialog.requireUsername();
await settle();
buttonByText('Später')!.click();
await expect(prompt).resolves.toBeNull();
await expect(required).resolves.toBe(false);
});
});
+255
View File
@@ -0,0 +1,255 @@
// Dialog „Benutzername wählen“ (Spec § 5): ein Feld, „Speichern“ und „Später“ bzw. „Abbrechen“. Drei Anlässe: die einmalige Abfrage nach
// dem Anmelden (`prompt`, mit „Später“), die Pflicht vor dem Hochladen und Veröffentlichen (`required`, nur „Abbrechen“) und das Ändern
// aus dem Konto-Menü (`change`, mit dem aktuellen Namen vorbelegt). Eigenes <dialog> nach dem Muster von publish-dialog.ts; alle Texte
// gehen als Text in den DOM, nie als HTML.
import './account.css';
import { ApiError, apiErrorText } from '../community/api';
import { el, setFieldInvalid } from '../editor/dom';
import { API_MESSAGES } from '../i18n/api-messages';
import { msg, t } from '../i18n/i18n';
import { currentUserId } from './auth';
import { errorMessage, isUnauthorized } from './errors';
import { USERNAME_HINT, loadMyUsername, myUsername, saveUsername, validateUsername } from './profile';
const RUNNING = msg('Wird gespeichert …');
const PUBLIC_NOTE = msg('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'username-error';
/** Fehlercodes des Dienstes, die am Namen selbst liegen: ihr Text steht am Feld, und das Feld gilt als ungültig. */
const FIELD_CODES: ReadonlySet<string> = new Set(['usernameInvalid', 'usernameReserved', 'usernameTaken']);
/** `prompt`: einmalige Abfrage („Später“); `required`: ohne Namen geht es nicht weiter („Abbrechen“); `change`: aus dem Konto-Menü, vorbelegt. */
export type UsernameDialogMode = 'prompt' | 'required' | 'change';
export interface UsernameDialogOptions {
mode: UsernameDialogMode;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
/** Der Name, mit dem das Feld beginnt (geht dem aktuellen Namen im Modus `change` vor), etwa der zuvor getippte nach einem Fehler. */
initial?: string;
/** Ein Fehler, dessen Grund beim Öffnen schon im Dialog steht, als hätte das Speichern ihn eben gemeldet. */
failure?: unknown;
}
/** Der Text zu einem fehlgeschlagenen Speichern in der Sprache der Seite; Fehler des Dienstes über ihren Code (`apiErrorText`). */
function failureText(err: unknown): string {
if (err instanceof ApiError) return apiErrorText(err);
if (isUnauthorized(err)) return t(API_MESSAGES.unauthorized); // die Anmeldung ist abgelaufen (schon der Abruf des JWT scheitert)
return errorMessage(err, 'save');
}
function build(opts: UsernameDialogOptions): Promise<string | null> {
return new Promise<string | null>((resolve, reject) => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
// Vorbelegt wird nur beim Ändern; kennt das Konto noch keinen Namen, ist es ein gewöhnliches „Wählen“.
const current = opts.mode === 'change' ? myUsername() : null;
let result: string | null = null;
let closed = false;
let busy = false;
const input = el('input', {
class: 'auth-input',
type: 'text',
name: 'username',
id: 'username-name',
// `nickname`, nicht `username`: der Name ist ein Spitzname, kein Anmeldename (angemeldet wird per E-Mail); so bleibt für Passwortmanager
// das E-Mail-Feld der Anmeldung der Anmeldename, und der Browser schlägt hier nicht die gespeicherte Anmeldung vor.
autocomplete: 'nickname',
autocapitalize: 'none',
spellcheck: 'false',
'aria-required': 'true',
'aria-describedby': 'username-name-hint',
});
input.value = opts.initial ?? current ?? '';
const field = el(
'div',
{ class: 'auth-field' },
el('label', { class: 'auth-label', for: input.id }, t('Benutzername')),
input,
el('p', { class: 'auth-hint', id: 'username-name-hint' }, t(USERNAME_HINT)),
);
const intro = el('p', { class: 'username-text' }, opts.mode === 'required' ? t(API_MESSAGES.usernameRequired) : t(PUBLIC_NOTE));
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, opts.mode === 'prompt' ? t('Später') : t('Abbrechen'));
const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Speichern'));
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el(
'form',
{ class: 'auth-form', novalidate: true },
intro,
field,
errorEl,
statusEl,
el('div', { class: 'username-actions' }, cancel, save),
);
const dialog = el(
'dialog',
{ class: 'auth-dialog username-dialog', 'aria-labelledby': 'username-heading' },
el(
'div',
{ class: 'auth-card' },
el('h2', { id: 'username-heading', class: 'auth-title' }, current ? t('Benutzername ändern') : t('Benutzername wählen')),
form,
),
);
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(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;
}
}
function setBusy(value: boolean): void {
busy = value;
form.setAttribute('aria-busy', String(value));
input.readOnly = value; // bleibt fokussierbar, ein Screenreader verliert den Fokus nicht
save.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? t(RUNNING) : '';
}
/** Ein Problem mit dem Namen: der Text steht in der Fehlerzeile, das Feld gilt als ungültig und bekommt den Fokus. */
function showFieldProblem(text: string): void {
errorEl.textContent = text;
setFieldInvalid(input, ERROR_ID, true);
input.focus();
}
/** Der Grund eines gescheiterten Speicherns: am Namen (vergeben, gesperrt, ungültig) am Feld, sonst (Netz, Dienst, Anmeldung) nur als Text. */
function showFailure(err: unknown): void {
if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) {
showFieldProblem(failureText(err));
} else {
errorEl.textContent = failureText(err); // der Name kann richtig sein
input.focus();
}
}
async function submit(): Promise<void> {
setFieldInvalid(input, ERROR_ID, false);
errorEl.textContent = '';
const name = input.value.trim(); // der Dienst trimmt nicht
const problem = validateUsername(name);
if (problem) {
showFieldProblem(problem);
return;
}
setBusy(true);
try {
result = await saveUsername(name);
} catch (err) {
if (closed) return;
setBusy(false);
showFailure(err);
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
return;
}
if (closed) return;
finish();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || closed) return;
void submit();
});
cancel.addEventListener('click', finish);
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Speichern darf der Dialog nicht zugehen.
dialog.addEventListener('cancel', (event) => {
if (busy) event.preventDefault();
});
// Schließt der Browser den Dialog trotzdem mitten im Speichern, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
dialog.addEventListener('close', () => {
if (busy && !closed) {
reopen();
return;
}
finish();
});
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
return;
}
input.focus();
if (input.value !== '') input.select(); // zum Ändern (oder nach einem Fehler): gleich überschreiben können
if (opts.failure !== undefined) showFailure(opts.failure);
});
}
/** Der offene Dialog; es gibt höchstens einen. */
let running: Promise<string | null> | null = null;
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem gespeicherten Namen (so, wie der Dienst ihn gespeichert hat), sonst mit
* null („Später“, „Abbrechen“, Esc, Hintergrund, `signal`). Fehler erscheinen im Dialog; er bleibt dann für einen neuen Versuch offen.
* Während des Speicherns lässt er sich nicht schließen (Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben,
* das dann doch gespeichert wird; nur `signal` schließt immer.
* Läuft schon ein Namensdialog (etwa die einmalige Abfrage, wenn gleichzeitig der Dienst „Name nötig“ meldet), teilt sich der Aufruf ihn
* samt Ergebnis, statt einen zweiten darüberzulegen (seine Angaben `signal`, `initial` und `failure` gelten dann nicht); für einen
* Aufrufer mit „Name nötig“ ist null dann ebenso ein „nicht weiter“.
*/
export function usernameDialog(opts: UsernameDialogOptions): Promise<string | null> {
if (opts.signal?.aborted) return Promise.resolve(null);
if (running) return running;
const promise: Promise<string | null> = build(opts).finally(() => {
if (running === promise) running = null;
});
running = promise;
return promise;
}
/**
* Hat das angemeldete Konto einen Benutzernamen? Wenn nicht, fragt der Dialog `required` danach (Spec § 5: vor dem Hochladen eines Logos und
* vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts.
* Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name,
* antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt:
* der Dienst entscheidet. `signal` schließt den Dialog (Seite verlassen); dann ist das Ergebnis false.
*/
export async function requireUsername(signal?: AbortSignal): Promise<boolean> {
if (currentUserId() === null) return true;
let name = myUsername();
if (name === null) {
try {
name = await loadMyUsername();
} catch {
return true;
}
}
if (name !== null) return true;
return (await usernameDialog({ mode: 'required', signal })) !== null;
}
+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

+110 -5
View File
@@ -1,7 +1,10 @@
import { readFileSync } from 'node:fs';
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));
@@ -14,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="Explore 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" />');
});
@@ -31,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');
});
});
@@ -47,3 +53,102 @@ describe('Icon-Dateien', () => {
expect(pngInfo(publicFile('apple-touch-icon.png'))).toEqual({ width: 180, height: 180, colorType: 2 });
});
});
describe('Inline-Skript für Hell/Dunkel und Sprache in index.html', () => {
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
/** 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: (key: string) => {
if (storageThrows) throw new Error('gesperrt');
return key === 'aircard.lang' ? (browser.stored ?? null) : stored;
},
},
window: {
matchMedia: () => ({ matches: prefersDark }),
navigator: { languages: browser.languages, language: browser.language },
},
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
};
runInNewContext(script, sandbox);
return { attributes, sandbox };
}
it('ist da und läuft ohne Browser-Umgebung durch', () => {
expect(script).toContain('data-theme');
expect(() => run(null, true)).not.toThrow();
});
it('legt keine globale Variable an (Skript steckt in einer sofort aufgerufenen Funktion)', () => {
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([
['dark', false],
['light', true],
['dark', true],
['light', false],
['blau', true],
['blau', false],
['', true],
[null, true],
[null, false],
] as const)('gleiche Regel wie initialTheme(): gespeichert %j, Systemvorgabe dunkel %s', (stored, prefersDark) => {
expect(run(stored, prefersDark).attributes['data-theme']).toBe(initialTheme(stored, prefersDark));
});
it('gesperrter localStorage: es gilt die Systemvorgabe', () => {
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,
);
+314 -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) =>
@@ -365,6 +373,37 @@ describe('apiPost', () => {
});
});
describe('isUsernameRequired: verlangt der Dienst einen Benutzernamen?', () => {
it('ja bei 403 mit dem Schlüssel usernameRequired (Hochladen und Veröffentlichen ohne Profil)', async () => {
const api = await freshApi();
const err = await (async () => {
fetchMock.mockImplementation(async () => json(403, { error: 'Bitte wähle zuerst einen Benutzernamen.', code: 'usernameRequired' }));
return api.apiPost('/api/veroeffentlichen', {}, { auth: true }).catch((e: unknown) => e);
})();
expect(err).toMatchObject({ code: 403, serverCode: 'usernameRequired' });
expect(api.isUsernameRequired(err)).toBe(true);
expect(api.isDefinitiveFailure(err)).toBe(true); // es wurde nichts angelegt: die Dateien dieses Versuchs dürfen aufgeräumt werden
});
it('der Schlüssel entscheidet, nicht der Status und nicht der Text', async () => {
const api = await freshApi();
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'usernameRequired'))).toBe(true);
expect(api.isUsernameRequired(new api.ApiError(400, 'x', undefined, 'usernameRequired'))).toBe(true);
expect(api.isUsernameRequired(new api.ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.'))).toBe(false); // ohne Schlüssel
});
it('andere Fehler nicht: andere 403, Netz, Dienst nicht erreichbar, kein ApiError', async () => {
const api = await freshApi();
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'forbidden'))).toBe(false);
expect(api.isUsernameRequired(new api.ApiError(409, 'x', undefined, 'usernameTaken'))).toBe(false);
expect(api.isUsernameRequired(api.unavailableError())).toBe(false);
expect(api.isUsernameRequired(new TypeError('Failed to fetch'))).toBe(false);
expect(api.isUsernameRequired(Object.assign(new Error('x'), { code: 403, serverCode: 'usernameRequired' }))).toBe(false);
expect(api.isUsernameRequired(null)).toBe(false);
expect(api.isUsernameRequired(undefined)).toBe(false);
});
});
describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => {
it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => {
const api = await freshApi();
@@ -513,6 +552,26 @@ describe('publishCard', () => {
expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true);
});
it('429 „schon 20 Karten veröffentlicht“ (Limit des Dienstes) behält den Text und ist eindeutig: es wurde nichts angelegt', 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 }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 429, message: text });
expect(api.isAlreadyPublished(err)).toBe(false);
// damit räumt der Client die zwei hochgeladenen Dateien auf (ein anderes 429, z. B. vom Proxy, bleibt offen)
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it('ein anderes 429 beim Veröffentlichen bleibt ein Fehler mit offenem Ausgang', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Anfragen.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 429, message: 'Zu viele Anfragen.' });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
const api = await freshApi();
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
@@ -573,3 +632,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));
}
});
});
+60 -17
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,12 +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 = API_MESSAGES.tooManyPublications;
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
const JWT_TTL_MS = 10 * 60 * 1000;
@@ -31,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
@@ -39,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 {
@@ -52,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`).
@@ -94,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
}
@@ -132,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;
@@ -150,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;
}
@@ -187,9 +218,18 @@ export function isAlreadyPublished(err: unknown): boolean {
return err instanceof ApiError && err.code === 409;
}
/**
* Wahr, wenn der Dienst einen Benutzernamen verlangt (403 `usernameRequired`): das Konto hat noch keinen, und Hochladen und
* Veröffentlichen gehen erst mit Namen (Spec § 4). Der Aufrufer zeigt dann den Namensdialog; angelegt wurde nichts.
*/
export function isUsernameRequired(err: unknown): boolean {
return err instanceof ApiError && err.serverCode === 'usernameRequired';
}
/**
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an; alle anderen Fehler unverändert.
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist
* eine eindeutige Ablehnung (es wurde nichts angelegt, die Dateien dürfen aufgeräumt werden); alle anderen Fehler unverändert.
*/
export async function publishCard(input: PublishInput): Promise<{ id: string }> {
let res: Json;
@@ -197,11 +237,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, '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 };
}
+43
View File
@@ -0,0 +1,43 @@
// Direkt veröffentlichen: nach dem Speichern (Editor) und beim Mehrfach-Upload („Meine Karten“) ohne Dialog.
// Titel = Name der Karte. Ist die Karte schon veröffentlicht, wird die alte Veröffentlichung zurückgezogen und die neue Fassung
// veröffentlicht, damit die Galerie immer den gespeicherten Stand zeigt. Fehlt der Benutzername, fragt der Namensdialog einmal nach.
import type { SavedCard } from '../account/cards';
import { requireUserId } from '../account/cards';
import { usernameDialog } from '../account/username-dialog';
import { withLogin } from '../account/with-login';
import { isAlreadyPublished, isUsernameRequired, withdrawCard } from './api';
import { myPublications } from './gallery';
import { publishSavedCard, type PublishForm } from './publish';
const TITLE_MAX = 60;
export function autoPublishForm(name: string): PublishForm {
const title = name.trim().slice(0, TITLE_MAX).trim();
return { title: title === '' ? 'AirCard' : title, issuer: '', tags: '' };
}
export async function autoPublish(card: SavedCard, signal?: AbortSignal): Promise<{ id: string }> {
const form = autoPublishForm(card.name);
const publish = () => withLogin(() => publishSavedCard(card, form));
let askedName = false;
let replaced = false;
for (;;) {
try {
return await publish();
} catch (err) {
if (isUsernameRequired(err) && !askedName) {
askedName = true;
if ((await usernameDialog({ mode: 'required', signal })) === null) throw err;
continue;
}
if (isAlreadyPublished(err) && !replaced) {
replaced = true;
const old = (await myPublications(requireUserId())).get(card.id);
if (!old) throw err;
await withLogin(() => withdrawCard(old.id));
continue;
}
throw err;
}
}
}
+16 -1
View File
@@ -564,6 +564,12 @@
overflow-wrap: anywhere;
}
/* Solange der Name des Autors noch gesucht wird, ist die Zeile leer (kein Knoten darin). Ein Zeichen ohne Breite (U+200B) hält die Zeile
in der Höhe des Textes, damit die Seite nicht springt, wenn „von @name · Datum“ dazukommt. */
.detail-meta:empty::before {
content: '\200b';
}
.detail-tags {
display: flex;
flex-wrap: wrap;
@@ -629,8 +635,17 @@
color: var(--danger-text);
}
/* Leer bleibt die Meldungszeile gerendert (Live-Region), nimmt aber keinen Platz ein: mit display: none stünde sie nicht im
Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
.detail-msg:empty {
display: none;
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.detail-admin {
+65 -15
View File
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GalleryCard } from './gallery';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
import type { CheckedImage, GalleryCard } from './gallery';
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
@@ -18,14 +20,15 @@ const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
...patch,
});
const blob = () => new Blob(['x'], { type: 'image/webp' });
let counter: number;
/** Ein geprüftes Bild, wie es `loadGalleryImageChecked` liefert: Blob plus fertige Object-URL. */
const image = (): CheckedImage => ({ blob: new Blob(['x'], { type: 'image/webp' }), url: `blob:t-${++counter}` });
const flush = async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
let host: HTMLElement;
let grid: GalleryGrid | null;
let counter: number;
const revoked: string[] = [];
beforeEach(() => {
@@ -34,7 +37,6 @@ beforeEach(() => {
grid = null;
counter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:t-${++counter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
@@ -117,7 +119,7 @@ describe('mountGalleryGrid: Kacheln', () => {
describe('mountGalleryGrid: Vorschaubilder', () => {
it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => {
const load = vi.fn(async () => blob());
const load = vi.fn(async () => image());
grid = mountGalleryGrid(host, { load, onTag: () => {} });
const cards = [card(1), card(2)];
grid.append(cards);
@@ -126,10 +128,13 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
expect(load.mock.calls.map((c) => (c as unknown as [GalleryCard])[0].id)).toEqual(['g1', 'g2']);
const srcs = [...host.querySelectorAll<HTMLImageElement>('.gallery-thumb img')].map((i) => i.getAttribute('src'));
expect(srcs).toEqual(['blob:t-1', 'blob:t-2']);
// Vorschau 480 breit, Höhe nach 1536 : 969 (wie exportCard sie rendert)
const first = host.querySelector('.gallery-thumb img');
expect([first?.getAttribute('width'), first?.getAttribute('height')]).toEqual(['480', '303']);
});
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
grid = mountGalleryGrid(host, { load: async () => blob(), onTag: () => {} });
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
grid.append([card(1), card(2), card(3)]);
await flush();
expect(counter).toBe(3);
@@ -138,28 +143,29 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']);
});
it('legt nach dem Aufräumen keine URL mehr an (späte Antwort) und stößt keine neuen Ladungen an', async () => {
const resolvers: Array<(b: Blob) => void> = [];
const load = vi.fn(() => new Promise<Blob>((r) => resolvers.push(r)));
it('eine nach dem Aufräumen eintreffende URL wird sofort freigegeben (späte Antwort), neue Ladungen starten nicht mehr', async () => {
const resolvers: Array<(i: CheckedImage) => void> = [];
const load = vi.fn(() => new Promise<CheckedImage>((r) => resolvers.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
expect(load).toHaveBeenCalledTimes(2);
grid.dispose();
for (const resolve of resolvers) resolve(blob());
for (const resolve of resolvers) resolve(image());
await flush();
expect(counter).toBe(0);
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2']);
expect(host.querySelector('img')).toBeNull();
grid.append([card(3)]);
expect(load).toHaveBeenCalledTimes(2);
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
});
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
const pending: Array<(b: Blob) => void> = [];
const load = vi.fn(() => new Promise<Blob>((r) => pending.push(r)));
const pending: Array<(i: CheckedImage) => void> = [];
const load = vi.fn(() => new Promise<CheckedImage>((r) => pending.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
expect(load).toHaveBeenCalledTimes(6);
pending[0](blob());
pending[0](image());
await flush();
expect(load).toHaveBeenCalledTimes(7);
});
@@ -167,12 +173,56 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => {
const load = vi.fn(async (c: GalleryCard) => {
if (c.id === 'g1') throw new Error('404');
return blob();
return image();
});
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
await flush();
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull();
expect(tiles()[0].querySelector('.gallery-thumb')?.hasAttribute('aria-busy')).toBe(false);
});
it('eine abgelehnte Vorschau (zu viele Pixel, nicht dekodierbar) zeigt „Keine Vorschau“, nie ein defektes Bild', async () => {
const load = vi.fn(async () => {
throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab
});
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1)]);
await flush();
expect(host.querySelector('.gallery-thumb img')).toBeNull();
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
});
it('zeigt das Bild nur, wenn es im Browser wirklich lädt: Ladefehler des <img> ersetzt es durch „Keine Vorschau“ und gibt die URL frei', async () => {
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
grid.append([card(1)]);
await flush();
host.querySelector('.gallery-thumb img')?.dispatchEvent(new Event('error'));
expect(host.querySelector('.gallery-thumb img')).toBeNull();
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
expect(revoked).toEqual(['blob:t-1']);
grid.dispose();
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([]);
});
});
+30 -20
View File
@@ -1,12 +1,15 @@
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket) und werden als Object-URL angezeigt;
// `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
// 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; übersetzt wird beim Aufbau.
import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
import { el } from '../editor/dom';
import type { GalleryCard } from './gallery';
import { t } from '../i18n/i18n';
import { reportsLabel } from '../pages/format';
import type { CheckedImage, GalleryCard } from './gallery';
export interface GalleryGridOptions {
/** Lädt die Vorschau einer Karte. */
load: (card: GalleryCard) => Promise<Blob>;
/** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */
load: (card: GalleryCard) => Promise<CheckedImage>;
/** Ein Tag-Knopf wurde angeklickt. */
onTag: (tag: string) => void;
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
@@ -22,13 +25,8 @@ export interface GalleryGrid {
const PARALLEL = 6;
const MAX_TAGS = 3;
const THUMB_W = 480;
const THUMB_H = 303; // 480 x 1536:969
/** „1 Meldung“, „3 Meldungen“. */
function reportsLabel(count: number): string {
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
}
const THUMB_W = PREVIEW_WIDTH;
const THUMB_H = Math.round((THUMB_W * CARD_H) / CARD_W); // wie exportCard: 480 x 1536:969 = 303
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
const list = el('ul', { class: 'gallery-grid' });
@@ -39,16 +37,28 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
let active = 0;
const showEmpty = (thumb: HTMLElement) =>
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, t('Keine Vorschau')));
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
try {
const blob = await opts.load(card);
if (disposed) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
const url = URL.createObjectURL(blob);
const { url } = await opts.load(card);
if (disposed) {
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
return;
}
urls.add(url);
thumb.replaceChildren(el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' }));
const img = el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' });
// Die Datei kam an und wurde geprüft, lässt sich aber nicht darstellen: lieber der Hinweis als ein defektes Bild.
img.addEventListener('error', () => {
if (disposed) return;
urls.delete(url);
URL.revokeObjectURL(url);
showEmpty(thumb);
});
thumb.replaceChildren(img);
} catch {
if (disposed) return;
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
if (!disposed) showEmpty(thumb);
} finally {
thumb.removeAttribute('aria-busy');
}
@@ -80,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);
}
@@ -89,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);
}
+151 -7
View File
@@ -1,5 +1,5 @@
import { AppwriteException, Query } from 'appwrite';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
listRows: vi.fn(),
@@ -30,12 +30,15 @@ vi.mock('../account/cards', async (importOriginal) => ({
}));
import {
MAX_GALLERY_PIXELS,
PAGE_SIZE,
checkGalleryImage,
galleryQueries,
getGalleryCard,
listGallery,
listReports,
loadGalleryImage,
loadGalleryImageChecked,
myPublications,
toGalleryCard,
type GalleryCard,
@@ -119,14 +122,24 @@ describe('galleryQueries', () => {
expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö'));
});
it('Tag: contains auf tags, getrimmt und klein; die Galerie bleibt auf sichtbare Karten beschränkt', () => {
it('Tag: exakter Treffer auf ",tag," in tags (der Server speichert ",a,b,"), getrimmt und klein; nur sichtbare Karten', () => {
expect(galleryQueries({ tag: ' Visa ' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
Query.contains('tags', ',visa,'),
]);
expect(JSON.stringify(Query.contains('tags', 'visa'))).toContain('contains');
expect(JSON.stringify(Query.contains('tags', ',visa,'))).toContain('contains');
});
it('Tag: "gold" trifft nicht "marigold" (Trennzeichen an beiden Seiten)', () => {
const [, , , tagQuery] = galleryQueries({ tag: 'gold' });
expect(JSON.parse(tagQuery)).toEqual({ method: 'contains', attribute: 'tags', values: [',gold,'] });
const stored = (tags: string) => tags.includes(JSON.parse(tagQuery).values[0] as string);
expect(stored(',visa,gold,')).toBe(true);
expect(stored(',gold,')).toBe(true);
expect(stored(',marigold,')).toBe(false);
expect(stored(',goldig,')).toBe(false);
});
it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => {
@@ -140,7 +153,7 @@ describe('galleryQueries', () => {
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
Query.contains('tags', 'gold'),
Query.contains('tags', ',gold,'),
Query.search('searchText', 'visa'),
]);
});
@@ -183,7 +196,7 @@ describe('galleryQueries', () => {
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
Query.contains('tags', ',visa,'),
Query.contains('searchText', 'x'),
]);
});
@@ -252,6 +265,12 @@ describe('toGalleryCard', () => {
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
});
it('Tags im Speicherformat ",a,b," (Komma an beiden Enden) werden zur sauberen Liste', () => {
expect(toGalleryCard(row(1, { tags: ',visa,gold,' })).tags).toEqual(['visa', 'gold']);
expect(toGalleryCard(row(1, { tags: ',blau,' })).tags).toEqual(['blau']);
expect(toGalleryCard(row(1, { tags: ',' })).tags).toEqual([]);
});
it('reportCount: nur endliche, nicht negative Zahlen', () => {
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0);
@@ -297,7 +316,7 @@ describe('listGallery', () => {
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
Query.contains('tags', ',visa,'),
Query.contains('searchText', 'ab'),
]);
});
@@ -457,3 +476,128 @@ describe('loadGalleryImage', () => {
},
);
});
describe('checkGalleryImage (Pixelgrenze beim Anzeigen)', () => {
interface Behaviour {
load: 'ok' | 'fail';
width: number;
height: number;
}
let behaviour: Behaviour;
let created: FakeImage[];
let urlCounter: number;
let revoked: string[];
class FakeImage {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
#src = '';
constructor() {
created.push(this);
}
set src(value: string) {
this.#src = value;
queueMicrotask(() => {
if (behaviour.load === 'ok') {
this.naturalWidth = behaviour.width;
this.naturalHeight = behaviour.height;
this.onload?.();
} else {
this.onerror?.();
}
});
}
get src(): string {
return this.#src;
}
}
const blob = new Blob(['x'], { type: 'image/png' });
beforeEach(() => {
behaviour = { load: 'ok', width: 1536, height: 969 };
created = [];
urlCounter = 0;
revoked = [];
vi.stubGlobal('Image', FakeImage);
vi.stubGlobal(
'URL',
Object.assign(URL, {
createObjectURL: vi.fn(() => `blob:check-${++urlCounter}`),
revokeObjectURL: vi.fn((url: string) => {
revoked.push(url);
}),
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('Grenze: 4 Megapixel (die Karte hat 1536 x 969, rund 1,5)', () => {
expect(MAX_GALLERY_PIXELS).toBe(4_000_000);
});
it('eine Karte in Normalgröße: liefert Blob und fertige Object-URL, die nicht freigegeben wird (der Aufrufer macht das)', async () => {
const image = await checkGalleryImage(blob);
expect(image).toEqual({ blob, url: 'blob:check-1' });
expect(created).toHaveLength(1);
expect(created[0].src).toBe('blob:check-1');
expect(revoked).toEqual([]);
});
it('genau 4 000 000 Pixel gehen noch durch', async () => {
behaviour = { ...behaviour, width: 2000, height: 2000 };
await expect(checkGalleryImage(blob)).resolves.toMatchObject({ url: 'blob:check-1' });
});
it('mehr als 4 000 000 Pixel: abgelehnt, die URL wird freigegeben (Dekompressionsbombe)', async () => {
behaviour = { ...behaviour, width: 2000, height: 2001 };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('riesige Maße (60 000 x 60 000): abgelehnt, ohne dass das Bild je angezeigt würde', async () => {
behaviour = { ...behaviour, width: 60_000, height: 60_000 };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('nicht dekodierbar (Ladefehler): abgelehnt, URL freigegeben', async () => {
behaviour = { ...behaviour, load: 'fail' };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('Maße 0 (kein echtes Bild): abgelehnt, URL freigegeben', async () => {
behaviour = { ...behaviour, width: 0, height: 0 };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('loadGalleryImageChecked lädt die Datei und prüft sie; Vorschau und Vollbild nutzen dieselbe Prüfung', async () => {
const card = toGalleryCard(row(3));
mocks.fetchFile.mockResolvedValue(blob);
await expect(loadGalleryImageChecked(card, 'preview')).resolves.toEqual({ blob, url: 'blob:check-1' });
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'pre3');
await expect(loadGalleryImageChecked(card, 'full')).resolves.toEqual({ blob, url: 'blob:check-2' });
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'img3');
behaviour = { ...behaviour, width: 8000, height: 8000 };
await expect(loadGalleryImageChecked(card, 'full')).rejects.toThrow();
expect(revoked).toEqual(['blob:check-3']);
});
it('ein Fehler beim Laden der Datei geht durch, ohne dass eine URL angelegt wird', async () => {
const card = toGalleryCard(row(3));
mocks.fetchFile.mockRejectedValue(notFound('storage_file_not_found'));
await expect(loadGalleryImageChecked(card, 'full')).rejects.toMatchObject({ code: 404 });
expect(urlCounter).toBe(0);
});
});
+48 -9
View File
@@ -3,6 +3,8 @@
import { AppwriteException, Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite';
import { isRowMissing } from '../account/errors';
import { msg } from '../i18n/i18n';
export interface GalleryCard {
id: string;
@@ -32,7 +34,7 @@ export type GalleryFilter = 'all' | 'reported' | 'hidden';
export interface GalleryOptions {
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
q?: string;
/** Nur Karten mit diesem Tag (Teilstring in `tags`, klein). */
/** Nur Karten mit genau diesem Tag (klein; der Server speichert `tags` als `,a,b,`, gesucht wird `,<tag>,`). */
tag?: string;
cursor?: string;
filter?: GalleryFilter;
@@ -90,7 +92,7 @@ export function galleryQueries(opts: GalleryOptions): string[] {
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
const tag = (opts.tag ?? '').trim().toLowerCase();
if (tag) queries.push(Query.contains('tags', tag));
if (tag) queries.push(Query.contains('tags', `,${tag},`)); // mit Trennzeichen: „gold“ trifft nicht „marigold“
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
if (q) {
queries.push(
@@ -111,13 +113,6 @@ export async function listGallery(opts: GalleryOptions): Promise<{ cards: Galler
return { cards, nextCursor: cards.length === PAGE_SIZE ? cards[cards.length - 1].id : null };
}
/** „Die Zeile gibt es nicht (oder nicht für mich)“; fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */
function isRowMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 && (type === 'row_not_found' || type === undefined || type === '');
}
/**
* Eine Karte per ID. Gibt es sie nicht oder ist sie für mich ausgeblendet (Appwrite antwortet beides mit 404), ist das
* Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
@@ -171,3 +166,47 @@ export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'previe
if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
return fetchFile(IDS.galleryBucket, fileId);
}
/** 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 = 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 {
blob: Blob;
url: string;
}
/**
* Prüft ein Galerie-Bild, bevor es angezeigt wird: Das Bild wird über eine Object-URL in ein `Image` geladen (dann sind
* die Maße bekannt, es ist aber noch nicht dekodiert) und abgelehnt, wenn das nicht klappt oder Breite × Höhe über
* `MAX_GALLERY_PIXELS` liegt. Ein kleines, stark komprimiertes PNG/WebP kann sonst Gigabytes an Pixeln enthalten und den
* Tab einfrieren (Startseite und Galerie zeigen fremde Bilder). Die Server-Prüfung beim Veröffentlichen ersetzt das nicht.
* Bei Erfolg liefert es dieselbe Object-URL, die dann angezeigt wird; bei Ablehnung ist sie schon freigegeben.
*/
export async function checkGalleryImage(blob: Blob): Promise<CheckedImage> {
const url = URL.createObjectURL(blob);
try {
const img = new Image();
const loaded = new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(IMAGE_UNUSABLE));
});
img.src = url;
await loaded;
const { naturalWidth: w, naturalHeight: h } = img;
if (!w || !h || w * h > MAX_GALLERY_PIXELS) throw new Error(IMAGE_UNUSABLE);
return { blob, url };
} catch (err) {
URL.revokeObjectURL(url);
throw err;
}
}
/**
* Wie `loadGalleryImage`, aber geprüft (`checkGalleryImage`) und mit fertiger Object-URL: der gemeinsame Weg für Raster,
* Hero und Detailseite. Wirft bei Ladefehler, zu großen Maßen und nicht dekodierbaren Dateien.
*/
export async function loadGalleryImageChecked(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage> {
return checkGalleryImage(await loadGalleryImage(card, size));
}
+31
View File
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
// Als Text lesen: vitest verarbeitet CSS-Importe (auch ?raw) nicht zu Text.
const accountCss = readFileSync(resolve(import.meta.dirname, '../account/account.css'), 'utf8');
const communityCss = readFileSync(resolve(import.meta.dirname, 'community.css'), 'utf8');
/** Die Deklarationen einer Regel, deren Auswahlkriterium genau `selector` ist (mehrere Regeln: alle zusammen). */
function declarations(css: string, selector: string): string {
const found: string[] = [];
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selectors = rule[1].replace(/\/\*[\s\S]*?\*\//g, '').split(',').map((part) => part.trim());
if (selectors.includes(selector)) found.push(rule[2]);
}
return found.join('\n');
}
describe('Live-Regionen (role alert/status) bleiben gerendert, auch leer', () => {
it.each([
['community.css', '.detail-msg:empty', communityCss],
['account.css', '.auth-msg:empty', accountCss],
])('%s: %s ist nicht display: none, sondern aus dem Layout genommen und optisch leer', (_file, selector, css) => {
const rule = declarations(css, selector);
expect(rule).not.toBe('');
expect(rule).not.toMatch(/display\s*:\s*none/);
expect(rule).not.toMatch(/visibility\s*:\s*hidden/);
expect(rule).toMatch(/position\s*:\s*absolute/); // kein Platz im Layout (kein Gap im Flex-Container)
expect(rule).toMatch(/width\s*:\s*1px/);
});
});
+344 -23
View File
@@ -1,15 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen.
const mocks = vi.hoisted(() => ({ usernameDialog: vi.fn() }));
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen. Der Namensdialog ist eine Attrappe.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
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,
@@ -27,16 +33,18 @@ 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 errorLine = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
/** Die IDs in `aria-describedby` eines Feldes (leer, wenn es das Attribut nicht hat). */
const describedBy = (name: string) => (input(name).getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function fill(values: Partial<PublishFormValues> = {}): void {
const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values };
const v = { title: 'Meine Karte', issuer: '', tags: '', consent: true, ...values };
input('title').value = v.title;
input('issuer').value = v.issuer;
input('tags').value = v.tags;
input('displayName').value = v.displayName;
consent().checked = v.consent;
}
@@ -46,11 +54,14 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<P
beforeEach(() => {
submit.mockReset();
mocks.usernameDialog.mockReset();
mocks.usernameDialog.mockResolvedValue(null);
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('Veröffentlichen-Dialog: Felder', () => {
@@ -58,24 +69,23 @@ describe('Veröffentlichen-Dialog: Felder', () => {
void open({ cardName: 'Urlaub' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'displayName', 'consent']);
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'consent']);
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
'Titel (Pflicht)',
'Herausgeber / Bank (optional)',
'Tags (optional)',
'Anzeigename (optional)',
]);
for (const name of ['title', 'issuer', 'tags', 'displayName']) expect(input(name).value).toBe('');
for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
expect(consent().type).toBe('checkbox');
expect(consent().checked).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Veröffentlichen')).toBeTruthy();
});
it('der Anzeigename ist leer und nennt „Anonym“ als Platzhalter (nie eine E-Mail)', () => {
it('es gibt kein Feld „Anzeigename“ mehr (die Karte zeigt den Benutzernamen, Spec § 5), und nie eine E-Mail im Dialog', () => {
void open();
expect(input('displayName').value).toBe('');
expect(input('displayName').placeholder).toBe('Anonym');
expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/);
expect(dialogEl()!.textContent).not.toMatch(/@/);
});
@@ -89,7 +99,7 @@ describe('Veröffentlichen-Dialog: Felder', () => {
void open();
const labels = [...form().querySelectorAll('label')];
const inputs = [...form().querySelectorAll('input')];
expect(labels).toHaveLength(5);
expect(labels).toHaveLength(4);
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);
@@ -116,6 +126,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
expect(submit).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).toBe('true');
expect(describedBy(field)).toContain(errorLine().id); // das ungültige Feld verweist auf die Fehlerzeile
expect(document.activeElement).toBe(input(field));
};
@@ -139,10 +150,6 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags');
});
it('Anzeigename länger als 30 Zeichen', async () => {
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
});
it('Checkbox fehlt', async () => {
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
});
@@ -151,10 +158,42 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
// Dasselbe Muster wie deploy/api/app.js: C1-Steuerzeichen, Nullbreiten-, Bidi-Zeichen und BOM sind unsichtbar oder ordnen Text um.
it.each([
['Nullbreiten-Leerzeichen', 'a\u200bb'],
['Rechts-nach-links-Überschreibung', 'foto\u202egnp.exe'],
['Isolate', 'a\u2066b'],
['Wortverbinder', 'a\u2060b'],
['C1-Steuerzeichen', 'a\u0085b'],
['BOM mitten im Titel', 'a\ufeffb'],
])('unsichtbare Zeichen werden abgelehnt: %s (Titel)', async (_name, title) => {
await invalid({ title }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
it('ein Titel nur aus unsichtbaren Zeichen gilt als ungültig', async () => {
await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
it('unsichtbare Zeichen im Herausgeber', async () => {
await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer');
});
it('unsichtbares Zeichen in einem Tag', async () => {
await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags');
});
it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30), tags: 'A, a, , b, c, d, e,' });
fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', tags: 'grün, ✨' });
send();
await expect(outcome).resolves.toBe('published');
});
it('Grenzen sind inklusive: 60/40 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), tags: 'A, a, , b, c, d, e,' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledOnce();
@@ -172,8 +211,52 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
expect(errorText()).toBe('');
});
describe('Fehlerzeile und Felder (Barrierefreiheit)', () => {
it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => {
void open();
expect(errorLine().id).toBe('publish-error');
expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1);
});
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
void open();
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
expect(describedBy('title')).toEqual([]);
expect(describedBy('consent')).toEqual([]);
});
it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', () => {
void open();
fill({ title: '' });
send();
expect(describedBy('title')).toEqual(['publish-error']);
fill({ tags: 'a,b,c,d,e,f' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem
fill({ consent: false });
send();
expect(describedBy('consent')).toEqual(['publish-error']);
expect(describedBy('tags')).toEqual(['publish-tags-hint']); // nur das Feld mit dem aktuellen Problem
});
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', () => {
submit.mockResolvedValue({ id: 'g1' });
void open();
fill({ tags: 'a,b,c,d,e,f' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
fill({ tags: 'a,b' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
expect(input('tags').hasAttribute('aria-invalid')).toBe(false);
});
});
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
expect(validatePublishForm(ok)).toBeNull();
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
@@ -182,23 +265,23 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
});
describe('Veröffentlichen-Dialog: Senden', () => {
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => {
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg; einen Anzeigenamen gibt es nicht mehr', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' });
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' });
expect(dialogEl()).toBeNull();
});
it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => {
it('leere optionale Felder werden leer gesendet', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'Nur Titel' });
send();
await outcome;
expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' });
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Nur Titel', issuer: '', tags: '' });
});
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
@@ -346,6 +429,138 @@ describe('Veröffentlichen-Dialog: Senden', () => {
});
});
describe('Veröffentlichen-Dialog: Benutzername nötig (403 usernameRequired)', () => {
const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired');
const REASON = 'Veröffentlichen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.';
it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => {
mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch
submit.mockRejectedValueOnce(required());
const controller = new AbortController();
void open({ signal: controller.signal });
fill();
send();
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal });
expect(dialogEl()?.open).toBe(true);
expect(errorText()).toBe(REASON);
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
});
it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben bleiben, der Fokus liegt auf „Veröffentlichen“ und ein neuer Versuch geht durch', async () => {
mocks.usernameDialog.mockResolvedValue('Kenso');
submit.mockRejectedValueOnce(required());
const outcome = open();
fill({ title: 'Mein Motiv', issuer: 'Bank', tags: 'visa' });
send();
await settle();
expect(errorText()).toBe('');
expect(input('title').value).toBe('Mein Motiv');
expect(input('issuer').value).toBe('Bank');
expect(consent().checked).toBe(true);
expect(dialogEl()?.open).toBe(true);
expect(document.activeElement).toBe(buttonByText('Veröffentlichen'));
submit.mockResolvedValueOnce({ id: 'g1' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledTimes(2);
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist veröffentlicht, der Dialog bleibt zum Abbrechen oder Wiederholen', async () => {
mocks.usernameDialog.mockResolvedValue(null);
submit.mockRejectedValueOnce(required());
void open();
fill();
send();
await settle();
expect(errorText()).toBe(REASON);
expect(dialogEl()?.open).toBe(true);
expect(submit).toHaveBeenCalledOnce();
// ein neuer Versuch fragt der Dienst wieder, und der Namensdialog erscheint wieder
submit.mockRejectedValueOnce(required());
send();
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(2);
});
it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => {
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
submit.mockRejectedValueOnce(required());
void open();
fill();
send();
await settle();
expect(errorText()).toBe(REASON);
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
});
it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => {
let answer!: (name: string | null) => void;
mocks.usernameDialog.mockReturnValue(new Promise<string | null>((resolve) => (answer = resolve)));
submit.mockRejectedValueOnce(required());
const controller = new AbortController();
const outcome = open({ signal: controller.signal });
fill();
send();
await settle();
controller.abort();
await expect(outcome).resolves.toBe('cancelled');
answer('Kenso');
await settle();
expect(dialogEl()).toBeNull();
expect(submit).toHaveBeenCalledOnce();
});
it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => {
submit.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
void open();
fill();
send();
await settle();
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
send();
await settle();
submit.mockRejectedValueOnce(new Error('boom'));
send();
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Englisch: der Grund erscheint auf Englisch', async () => {
setLang('en', { persist: false });
mocks.usernameDialog.mockResolvedValue(null);
submit.mockRejectedValueOnce(required());
void open();
fill();
send();
await settle();
expect(errorText()).toBe('Publishing failed – Please choose a username first.');
expect(germanLeft(dialogEl()!)).toEqual([]);
});
});
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: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open();
@@ -421,3 +636,109 @@ 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)',
]);
expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.');
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: '', 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({ 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');
});
});
+79 -58
View File
@@ -1,76 +1,84 @@
// 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.
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt
// es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403
// `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen.
// 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 { errorMessage } from '../account/errors';
import { usernameDialog } from '../account/username-dialog';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom';
import { el, setFieldInvalid } 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, isUsernameRequired } 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;
const TAGS_MAX = 5;
const TAG_MAX = 20;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'publish-error';
/**
* 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 den Namen im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
*/
export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
export interface PublishFormValues extends PublishForm {
consent: boolean;
}
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
export type PublishField = 'title' | 'issuer' | 'tags' | '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.' };
}
if (displayName.length > DISPLAY_NAME_MAX) {
return { field: 'displayName', message: `Der Anzeigename ist zu lang (höchstens ${DISPLAY_NAME_MAX} Zeichen).` };
}
if (!values.consent) return { field: 'consent', message: 'Bitte bestätige die Erklärung zum Bild.' };
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. */
@@ -107,13 +115,11 @@ 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 consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
title: titleInput,
issuer: issuerInput,
tags: tagsInput,
displayName: nameInput,
consent: consentInput,
};
@@ -130,30 +136,29 @@ 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“.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, 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 = () => {
@@ -183,31 +188,46 @@ 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 {
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
}
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();
}
/**
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
* und der Fokus liegt auf „Veröffentlichen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
*/
async function askForUsername(): Promise<void> {
let saved: string | null = null;
try {
saved = await usernameDialog({ mode: 'required', signal: opts.signal });
} catch {
// der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen
}
if (closed || saved === null) return;
errorEl.textContent = '';
send.focus();
}
async function submit(): Promise<void> {
const values: PublishFormValues = {
title: titleInput.value.trim(),
issuer: issuerInput.value.trim(),
tags: tagsInput.value,
displayName: nameInput.value.trim(),
consent: consentInput.checked,
};
clearInvalid();
@@ -215,13 +235,13 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
const problem = validatePublishForm(values);
if (problem) {
errorEl.textContent = problem.message;
inputs[problem.field].setAttribute('aria-invalid', 'true');
setFieldInvalid(inputs[problem.field], ERROR_ID, true);
inputs[problem.field].focus();
return;
}
setBusy(true);
try {
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags, displayName: values.displayName });
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags });
} catch (err) {
if (closed) return;
if (isAlreadyPublished(err)) {
@@ -233,6 +253,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
setBusy(false);
errorEl.textContent = publishFailureText(err);
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
if (isUsernameRequired(err)) await askForUsername();
return;
}
if (closed) return;
+242 -7
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',
@@ -27,7 +52,7 @@ const card: SavedCard = {
config: DEFAULT_CONFIG,
};
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' };
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold' };
const png = () => new Blob(['png'], { type: 'image/png' });
const webp = () => new Blob(['webp'], { type: 'image/webp' });
@@ -89,20 +114,26 @@ describe('publishSavedCard', () => {
expect(preview.type).toBe('image/png');
});
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste)', async () => {
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste); der Anzeigename ist immer leer (die Seite zeigt den Benutzernamen)', async () => {
const fake = fakeDeps();
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,', displayName: ' K ' }, fake.deps);
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,' }, fake.deps);
expect(fake.of('publishCard')[0].args[0]).toEqual({
title: 'Meine Karte',
issuer: 'Bank',
tags: ['visa', 'gold'],
displayName: 'K',
displayName: '',
imageFileId: 'f1',
previewFileId: 'f2',
sourceCardId: 'card1',
});
});
it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => {
const fake = fakeDeps();
await publishSavedCard(card, { ...form, displayName: 'Kenso' } as typeof form, fake.deps);
expect(fake.of('publishCard')[0].args[0]).toMatchObject({ displayName: '' });
});
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
it.each([400, 401, 403, 404, 409])(
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
@@ -242,6 +273,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' } });
+39 -10
View File
@@ -1,18 +1,27 @@
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
// Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder.
// Aufgeräumt wird nur bei eindeutigem Scheitern (Upload-Fehler, eindeutige Ablehnung der API): dann löscht der Client genau
// die zwei Dateien dieses Versuchs wieder. Bleibt der Ausgang offen, bleiben sie liegen (siehe publishSavedCard).
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 {
@@ -20,9 +29,14 @@ export interface PublishForm {
issuer: string;
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
tags: string;
displayName: string;
}
/**
* Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigt die Detailseite den Namen des Kontos;
* der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen).
*/
const NO_DISPLAY_NAME = '';
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
export interface PublishDeps {
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
@@ -46,10 +60,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 };
@@ -81,8 +110,8 @@ async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void
*
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409, oder schon der JWT-Abruf), wurde nichts angelegt:
* beide Dateien werden gelöscht.
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409 und 403 `usernameRequired`, oder schon der JWT-Abruf), wurde nichts
* angelegt: beide Dateien werden gelöscht.
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
@@ -121,7 +150,7 @@ export async function publishSavedCard(
title: form.title.trim(),
issuer: form.issuer.trim(),
tags: normalizeTags(form.tags),
displayName: form.displayName.trim(),
displayName: NO_DISPLAY_NAME,
imageFileId: uploaded[0],
previewFileId: uploaded[1],
sourceCardId: card.id,
+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.');
});
});
+51 -29
View File
@@ -1,40 +1,51 @@
// 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;
}
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem Ergebnis der letzten Meldung, sonst mit `cancelled`.
* Fehler des Dienstes erscheinen mit ihrem Servertext im Dialog; der Dialog bleibt dann offen für einen neuen Versuch.
* Fehler des Dienstes erscheinen im Dialog in der Sprache der Seite (`apiErrorText`: der Text zum Schlüssel des Fehlers, nicht der
* Servertext); der Dialog bleibt dann offen für einen neuen Versuch.
*/
export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome> {
return new Promise<ReportOutcome>((resolve, reject) => {
@@ -44,32 +55,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 +114,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 +139,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);
}
}
+4 -1
View File
@@ -4,4 +4,7 @@
* Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/<name>`).
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
*/
export const DONATE_URL: string = '';
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',
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { el, setFieldInvalid } from './dom';
describe('setFieldInvalid', () => {
const field = (describedBy?: string) => el('input', { 'aria-describedby': describedBy });
it('markiert das Feld und verweist auf die Fehlerzeile', () => {
const input = field();
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.getAttribute('aria-describedby')).toBe('fehler');
});
it('der Hinweis des Feldes bleibt davor stehen', () => {
const input = field('hinweis');
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-describedby')).toBe('hinweis fehler');
});
it('mehrfach markiert verweist es nur einmal auf die Fehlerzeile', () => {
const input = field('a b');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-describedby')).toBe('a b fehler');
});
it('zurücknehmen entfernt Markierung und Verweis, die Hinweise bleiben', () => {
const input = field('hinweis');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
expect(input.getAttribute('aria-describedby')).toBe('hinweis');
});
it('ohne Hinweis verschwindet aria-describedby ganz', () => {
const input = field();
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.hasAttribute('aria-describedby')).toBe(false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
});
it('ein Feld, das nie markiert war, bleibt beim Zurücknehmen unberührt', () => {
const plain = field();
setFieldInvalid(plain, 'fehler', false);
expect(plain.getAttributeNames()).toEqual([]);
const withHint = field('hinweis');
setFieldInvalid(withHint, 'fehler', false);
expect(withHint.getAttribute('aria-describedby')).toBe('hinweis');
});
it('eine andere Fehlerzeile bleibt, wo sie ist (nur die genannte ID wird verwaltet)', () => {
const input = field('hinweis anderer');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.getAttribute('aria-describedby')).toBe('hinweis anderer');
});
});
+23
View File
@@ -17,6 +17,23 @@ export function el<K extends keyof HTMLElementTagNameMap>(
return node;
}
/**
* Markiert ein Formularfeld als ungültig (`aria-invalid`) und verknüpft es mit der Fehlerzeile `errorId` (`aria-describedby`, hinter einem
* Hinweis, den das Feld schon hat): ein Screenreader liest den Fehler dann mit dem Feld vor. Mit `invalid = false` fallen beide wieder
* weg, der Hinweis bleibt.
*/
export function setFieldInvalid(control: HTMLElement, errorId: string, invalid: boolean): void {
const described = (control.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id !== '' && id !== errorId);
if (invalid) {
control.setAttribute('aria-invalid', 'true');
described.push(errorId);
} else {
control.removeAttribute('aria-invalid');
}
if (described.length > 0) control.setAttribute('aria-describedby', described.join(' '));
else control.removeAttribute('aria-describedby');
}
// Feste Symbole (Strichstärke aus dem Stylesheet); die Zeichenketten sind Konstanten, keine Nutzereingaben.
const ICONS = {
imagePlus:
@@ -32,6 +49,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.
File diff suppressed because it is too large. Load diff
+157 -42
View File
@@ -5,11 +5,16 @@ 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 { autoPublish } from '../community/auto-publish';
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { publishFailureText } from '../community/publish-dialog';
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 +26,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 +72,29 @@ 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 });
},
// Das gewählte Logo wurde ausgeblendet, eingeblendet oder gelöscht (der Zwischenspeicher der Logos ist schon geleert): was der Editor
// von ihm gezeichnet hat, gilt nicht mehr. Es wird neu geladen, damit Vorschau, Miniaturen und Hinweis dem neuen Stand folgen.
onLogoChanged(id) {
const { config } = store.get();
if (config.logo !== id) return;
logos = null;
pendingKey = '';
failedKey = '';
showWarning();
ensureLogos(config);
},
});
options.logoSlot.append(logoPicker.el);
let exporting = false;
@@ -88,7 +114,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 +126,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 +173,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,22 +185,47 @@ 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), oder stand die Karte nach einem Ladefehler ohne Logo 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)) return;
// Nach einem Ladefehler steht die Karte ohne Logo da (`failed`): hat der Download die Logos doch geladen, übernimmt die Vorschau sie,
// damit sie dasselbe zeigt wie das PNG, und die Sperre dieses Schlüssels fällt weg.
if (logos.failed) failedKey = '';
else if (logos.missing === assets.logoMissing) return;
logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key: logos.key };
showWarning();
schedule();
}
// Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts).
let savedFlash = false;
/** Gespeichert, aber das direkte Veröffentlichen ist gescheitert: der Text erscheint nach dem Speichern. */
let publishError: string | null = null;
let flashTimer: ReturnType<typeof setTimeout> | undefined;
function onSaveStatus(status: SaveStatus, message?: string): void {
@@ -163,7 +237,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (!disposed) sync(store.get());
}, SAVED_FLASH_MS);
}
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : null);
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : status === 'saved' ? publishError : null);
publishError = null;
store.set({ saving: status === 'saving' });
}
@@ -177,6 +252,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
async function doSave(): Promise<void> {
const { config, image, imageBlob, imageChanged, cardName, saved } = clickSnapshot ?? takeSnapshot();
if (!image || !imageBlob) throw new Error(NO_IMAGE);
// Zeile und Bild des Logos neu vom Server prüfen (wie beim Download): ein inzwischen ausgeblendetes oder gelöschtes Logo käme sonst aus
// dem Zwischenspeicher ins Vorschaubild der gespeicherten Karte.
if (config.logo) forgetLogo(config.logo);
const assets = await loadCardAssets(config, image);
const previewBlob = await exportCard(config, assets, {
width: PREVIEW_WIDTH,
@@ -196,6 +274,13 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (now.imageBlob === imageBlob && now.saved?.id === saved?.id) {
store.set({ saved: toRef(card), savedConfig: config, imageChanged: false, cardName: card.name });
}
// Gespeichert heißt veröffentlicht: die Karte geht direkt (oder in neuer Fassung) in die Community-Galerie.
publishError = null;
try {
await autoPublish(card);
} catch (err) {
publishError = publishFailureText(err);
}
}
const saveCard = createSaveAction({
@@ -205,7 +290,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onStatus: onSaveStatus,
});
// Karte öffnen (/?karte=<id>): nur die zuletzt angeforderte zählt.
// Karte öffnen (/gestalten?karte=<id>): nur die zuletzt angeforderte zählt.
let loadToken = 0;
function cancelCardLoad(): void {
loadToken += 1;
@@ -217,8 +302,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 +347,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 +359,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 +372,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 +406,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 +419,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 +448,65 @@ 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);
// Die Logos der früheren Auswahl gehören nicht zu diesem Zustand, auch nicht ihr Hinweis „nicht mehr verfügbar“.
if (logos?.key !== key) {
logos = null;
showWarning();
}
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 +519,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 +534,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 +542,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 +574,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('Veröffentlicht');
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('Speichern veröffentlicht die Karte direkt in der Community. Nur eigene Bilder, keine echten Kartendaten.'));
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;
}
+110 -2
View File
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG, DEFAULT_CROP } from '../card/config';
import type { CardImage } from '../card/render';
import { createStore, initialEditorState, setImage } from './state';
import { setLang } from '../i18n/i18n';
import { createStore, defaultCardName, followLanguage, initialEditorState, setImage, type SavedCardRef } from './state';
const fakeImage = (w = 2000, h = 1000): CardImage => ({ source: {} as CanvasImageSource, w, h });
const fakeBlob = () => new Blob(['x'], { type: 'image/png' });
@@ -119,3 +120,110 @@ describe('setImage', () => {
expect(fn).toHaveBeenCalledTimes(1);
});
});
describe('defaultCardName', () => {
it('der Standardname in der Sprache der Seite; initialEditorState trägt ihn', () => {
expect(defaultCardName()).toBe('Meine Karte');
expect(initialEditorState().cardName).toBe('Meine Karte');
setLang('en', { persist: false });
expect(defaultCardName()).toBe('My card');
expect(initialEditorState().cardName).toBe('My card');
});
});
describe('followLanguage: der Standardname einer neuen Karte folgt der Sprache', () => {
/** Abmeldungen der Hörer dieses Tests; sie werden nach jedem Test aufgehoben (die Hörerliste von i18n.ts lebt so lange wie die Datei). */
const stops: Array<() => void> = [];
afterEach(() => {
for (const stop of stops.splice(0)) stop();
});
const bound = (patch: Partial<ReturnType<typeof initialEditorState>> = {}) => {
const store = createStore({ ...initialEditorState(), ...patch });
stops.push(followLanguage(store));
return store;
};
const ref = (name: string): SavedCardRef => ({ id: 'k1', name, imageFileId: 'i', previewFileId: 'p', updatedAt: '2026-10-05T10:00:00.000Z' });
it('der unberührte Standardname wechselt mit der Sprache, hin und zurück', () => {
const store = bound();
expect(store.get().cardName).toBe('Meine Karte');
setLang('en', { persist: false });
expect(store.get().cardName).toBe('My card');
setLang('de', { persist: false });
expect(store.get().cardName).toBe('Meine Karte');
});
it('auch wenn der Editor auf Englisch beginnt', () => {
setLang('en', { persist: false });
const store = bound();
expect(store.get().cardName).toBe('My card');
setLang('de', { persist: false });
expect(store.get().cardName).toBe('Meine Karte');
});
it('ein selbst gewählter Name bleibt, in jeder Richtung', () => {
const store = bound({ cardName: 'Mein Urlaub' });
setLang('en', { persist: false });
expect(store.get().cardName).toBe('Mein Urlaub');
store.set({ cardName: 'Holiday' });
setLang('de', { persist: false });
expect(store.get().cardName).toBe('Holiday');
});
it('der Name wurde geändert und wieder auf den Standardtext zurückgesetzt: er gilt als unberührt', () => {
const store = bound();
store.set({ cardName: 'Urlaub' });
store.set({ cardName: 'Meine Karte' });
setLang('en', { persist: false });
expect(store.get().cardName).toBe('My card');
});
it('eine gespeicherte Karte behält ihren Namen, auch wenn er dem Standard gleicht', () => {
const store = bound({ saved: ref('Meine Karte'), cardName: 'Meine Karte' });
setLang('en', { persist: false });
expect(store.get().cardName).toBe('Meine Karte');
});
it('wird die Karte gespeichert, bevor die Sprache wechselt, bleibt der Name', () => {
const store = bound();
store.set({ saved: ref('Meine Karte') }); // Speichern unter dem Standardnamen
setLang('en', { persist: false });
expect(store.get().cardName).toBe('Meine Karte');
});
it('ein leerer oder fremder Name wird nicht angefasst', () => {
const store = bound({ cardName: '' });
setLang('en', { persist: false });
expect(store.get().cardName).toBe('');
});
it('der Rest des Zustands bleibt unberührt (Bild, Konfiguration), und es gibt genau eine Benachrichtigung', () => {
const store = bound();
const image = fakeImage();
store.set({ image, imageBlob: fakeBlob(), imageChanged: true });
const before = store.get();
const fn = vi.fn();
store.subscribe(fn);
setLang('en', { persist: false });
expect(fn).toHaveBeenCalledTimes(1);
expect(store.get()).toEqual({ ...before, cardName: 'My card' });
expect(store.get().image).toBe(image);
});
it('derselbe Wechsel ohne Wirkung: gleiche Sprache noch einmal setzen ändert nichts und benachrichtigt nicht', () => {
const store = bound();
const fn = vi.fn();
store.subscribe(fn);
setLang('de', { persist: false });
expect(fn).not.toHaveBeenCalled();
});
it('nach der Abmeldung des Hörers folgt der Name nicht mehr', () => {
const store = createStore(initialEditorState());
const stop = followLanguage(store);
stop();
setLang('en', { persist: false });
expect(store.get().cardName).toBe('Meine Karte');
});
});
+23 -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 { onLangChange, t } from '../i18n/i18n';
export interface SavedCardRef {
id: string;
@@ -49,7 +50,12 @@ export function createStore(initial: EditorState) {
export type EditorStore = ReturnType<typeof createStore>;
/** Frischer Anfangszustand; DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */
/** Der Standardname einer neuen Karte in der Sprache der Seite. */
export function defaultCardName(): string {
return t('Meine Karte');
}
/** 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,11 +64,26 @@ export function initialEditorState(): EditorState {
imageChanged: false,
saved: null,
savedConfig: null,
cardName: 'Meine Karte',
cardName: defaultCardName(),
saving: false,
};
}
/**
* Der Standardname einer neuen Karte folgt der Sprache der Seite, solange ihn niemand geändert hat: Nach einem Sprachwechsel bekommt eine
* Karte, die noch nicht gespeichert ist und den unberührten Standardnamen der bisherigen Sprache trägt, den der neuen. Ein selbst gewählter
* Name bleibt, ebenso der Name einer gespeicherten Karte. Gibt die Abmeldung zurück.
*/
export function followLanguage(store: EditorStore): () => void {
let known = defaultCardName(); // der Standardname in der Sprache, die bis zum nächsten Wechsel gilt
return onLangChange(() => {
const previous = known;
known = defaultCardName();
const { saved, cardName } = store.get();
if (saved === null && cardName === previous && previous !== known) store.set({ cardName: known });
});
}
/** Übernimmt ein neues Bild und setzt den Ausschnitt zurück (der alte passt nicht zum neuen Bild). */
export function setImage(store: EditorStore, image: CardImage, blob: Blob): void {
store.set({
+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 };
}
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more