docs: AirCard-Implementierungsplan + Spec-Korrekturen aus Plan-Review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0cd12c1ed3
commit
ba93d8f8c5
2 files changed
+415
-9
No files matched your search
@@ -0,0 +1,406 @@
|
||||
# AirCard-Website – 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:** Kartenmotiv-Editor unter https://aircard-ios.org (Bild hochladen, Vorlage/Logo/Hell-Dunkel wählen, PNG 1536 × 969 herunterladen) mit Konten und gespeicherten Karten über ein eigenes Appwrite-Projekt.
|
||||
|
||||
**Architecture:** Statische Vite-+-TypeScript-Seite ohne Framework im Unterordner `web/` von `knso/AirCardIOS`. Reine Kartenlogik (`web/src/card/*`) ist von UI und Appwrite getrennt und mit Vitest getestet. Konten/Speichern über das Appwrite-Web-SDK gegen `https://aircard-ios.org/v1`, das Traefik per Header-Matcher an Appwrite weiterreicht (first-party Cookie). Deploy nach dem Vorbild ticket.webklar.com: Gitea-Push → Webhook → Build-Container → `out/` → nginx.
|
||||
|
||||
**Tech Stack:** TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom 20.14.5 (Tests), appwrite (Web-SDK) 22.4.1, @fontsource-variable/nunito-sans 5.3.0, Node 22 (Build-Container `node:22-alpine`, lokal 22.23.3), nginx:alpine, Traefik v3.6 (vorhanden), Appwrite 1.8.1 (vorhanden).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-04-aircard-website-design.md`. Jeder Task liest die Spec-Abschnitte, die er umsetzt.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitskopie: `/home/webklar/apps/aircard-ios.org/dev` (Branch `main`, Remote `https://git.webklar.com/knso/AirCardIOS.git`). Pfade unten relativ dazu, außer sie beginnen mit `/`.
|
||||
- Versionen **exakt** pinnen (kein `^`): `vite 8.3.2`, `vitest 5.0.3`, `typescript 6.0.3`, `happy-dom 20.14.5`, `appwrite 22.4.1`, `@fontsource-variable/nunito-sans 5.3.0`.
|
||||
- Kein UI-Framework, keine externen CDNs/Fonts zur Laufzeit.
|
||||
- Alle sichtbaren Texte auf Deutsch; Wortlaut aus der Spec bzw. aus diesem Plan.
|
||||
- Karte: `CARD_W = 1536`, `CARD_H = 969`, Rand `56`, Vorschau-Radius `57` (nur Vorschau, nie im Export).
|
||||
- Upload: nur `image/png`, `image/jpeg`, `image/webp`, max. `5_242_880` Byte.
|
||||
- Details-Farben: Hell `#FFFFFF`, Dunkel `#111111`. Kartengrund ohne Bild bzw. unter transparenten Bildern: `#1B2738`.
|
||||
- Appwrite-IDs: Projekt `aircard`, Datenbank `aircard`, Tabelle `cards`, Bucket `card-images`.
|
||||
- Frontend-Endpoint: `import.meta.env.VITE_APPWRITE_ENDPOINT ?? location.origin + '/v1'`, Projekt `import.meta.env.VITE_APPWRITE_PROJECT_ID ?? 'aircard'`.
|
||||
- Dateien aus Appwrite **nie** über `getFileView`/`getFilePreview`-URLs laden (Projekt als Query, kein Header → trifft die Traefik-Route nicht), sondern über `fetchFile` (Task 9).
|
||||
- Produktion: vor jeder Änderung an `/home/webklar/apps/docker-compose.yml` Backup `docker-compose.yml.bak-2026-10-04-aircard`; **nie** nacktes `docker compose up -d`; `webklar-appwrite*`, `webklar-traefik` und `preview-portal` nicht neu starten oder ändern; keine Secrets in Ausgaben, Logs oder Commits.
|
||||
- Konten anlegen und Passwörter auf der Live-Seite eingeben macht **Kenso**, nicht Claude. Claude arbeitet danach in Kensos angemeldeter Chrome-Sitzung weiter.
|
||||
- Tests: `cd web && npx vitest run` (Host-Node 24); Typprüfung `cd web && npx tsc --noEmit`; Deploy-Tests `node --test deploy/webhook-server.test.js`.
|
||||
- Zahlen mit Nachkommastellen in Tests mit `toBeCloseTo(wert, 1)` vergleichen.
|
||||
- Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Subagents: Modell `sonnet` (Implementierung/Review), `haiku` für kleine Nachprüfungen.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- **Transparentes PNG hochgeladen** → exportiertes PNG ist deckend (Grund `#1B2738`, Canvas ohne Alpha). Test in Task 4.
|
||||
- **iPhone-Foto als HEIC oder > 5 MiB** → klare deutsche Meldung, Editor unverändert. Test in Task 5.
|
||||
- **Sitzung läuft zwischen Seitenaufruf und Speichern/Laden ab (401)** → Login-Dialog, danach wird die Aktion genau einmal wiederholt. Tests in Task 9.
|
||||
- **Doppelklick/-tipp auf „Speichern“** → genau eine Karte. Test in Task 9.
|
||||
- **Neues Bild nach Verschieben/Zoomen eines anderen Bildes** → Ausschnitt wird zurückgesetzt bzw. begrenzt, keine leeren Streifen im PNG. Tests in Task 4 und Task 5.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Projektgerüst `web/`
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitignore`, `README.md` (Kurzfassung, wird in Task 10 ergänzt), `web/package.json`, `web/package-lock.json`, `web/tsconfig.json`, `web/vite.config.ts`, `web/index.html`, `web/public/favicon.svg` (Platzhalter, Task 3 ersetzt ihn), `web/src/main.ts`, `web/src/styles.css`, `web/src/pages/router.ts`, `web/src/vite-env.d.ts`
|
||||
- Test: `web/src/pages/router.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `type RouteId = 'editor' | 'cards' | 'reset'`; `matchRoute(pathname: string): RouteId`; `navigate(path: string): void`; `startRouter(render: (route: RouteId, root: HTMLElement) => void | (() => void)): void` (ruft den Cleanup der vorherigen Seite, fängt Klicks auf `a[data-link]` ab, reagiert auf `popstate`). DOM-Gerüst in `index.html`: `<header id="app-header">`, `<main id="app-root">`, `<footer id="app-footer">`, darin `#account-slot` in der Kopfzeile.
|
||||
|
||||
- [ ] **Step 1: Test schreiben** – `matchRoute('/')` → `'editor'`; `'/karten'` und `'/karten/'` → `'cards'`; `'/passwort-neu'` → `'reset'`; `'/irgendwas'` → `'editor'`.
|
||||
- [ ] **Step 2: Gerüst anlegen** – `package.json` (`"type": "module"`, Scripts `dev`, `build` = `tsc --noEmit && vite build`, `test` = `vitest run`, `preview`), exakte Versionen, `npm install` erzeugt den Lockfile. `vite.config.ts` mit Vitest `environment: 'happy-dom'`. `.gitignore`: `node_modules/`, `dist/`, `.env*`. `tsconfig.json`: `strict`, `target`/`lib` ES2022 + DOM, `types: ["vite/client"]`.
|
||||
- [ ] **Step 3: Layout und Stil** – `index.html` (`lang="de"`, Titel „AirCard – Kartenmotiv gestalten“, Favicon `/favicon.svg`). `styles.css` mit Tokens auf `:root`: `--bg #0F1828; --panel #162133; --panel-2 #1B2738; --line #2A3A52; --line-strong #3D5576; --sel-bg #1F3150; --sel-line #8FB4E8; --text #E8EEF7; --muted #9DB0C8; --primary #2A4566; --primary-hover #34557C; --danger #E5484D; --radius 14px`; Schrift `"Nunito Sans Variable"` über `import '@fontsource-variable/nunito-sans'` in `main.ts`. Kopfzeile: Wortmarke „AirCard“ (Link `/`), Link „Meine Karten“ (`/karten`, `data-link`), rechts `#account-slot`. Fußzeile exakt: „Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.“ Seiten zunächst Platzhalter mit Überschrift.
|
||||
- [ ] **Step 4: Prüfen** – `npx vitest run` → PASS; `npm run build` → `dist/index.html`, keine Fehler.
|
||||
- [ ] **Step 5: Commit** – `git add -A && git commit -m "feat(web): Projektgerüst, Router, Grundlayout"`
|
||||
|
||||
### Task 2: Kartenkonfiguration, Geometrie, Bildausschnitt
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/card/config.ts`, `web/src/card/geometry.ts`, `web/src/card/crop.ts`
|
||||
- Test: `web/src/card/config.test.ts`, `web/src/card/geometry.test.ts`, `web/src/card/crop.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`config.ts`):
|
||||
- `type TemplateId = 'image' | 'minimal' | 'classic' | 'signature'`; `type DetailsTone = 'light' | 'dark'`; `type BrandId = 'aircards-symbol' | 'aircards-text' | 'apple' | 'none'`
|
||||
- `type NetworkId` und `NETWORK_IDS: readonly NetworkId[]` in dieser Reihenfolge: `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`
|
||||
- `interface Crop { x: number; y: number; zoom: number }` – x/y = Versatz der Bildmitte zur Kartenmitte in Karten-px, zoom ≥ 1 relativ zum Cover-Maßstab
|
||||
- `interface CardConfig { v: 1; template: TemplateId; network: NetworkId; details: DetailsTone; brand: BrandId; crop: Crop }`
|
||||
- `DEFAULT_CONFIG = { v: 1, template: 'signature', network: 'visa-auto', details: 'light', brand: 'aircards-symbol', crop: { x: 0, y: 0, zoom: 1 } }`; `DEFAULT_CROP`
|
||||
- `parseConfig(json: string | null | undefined): CardConfig` (feldweise validiert, Unbekanntes → Standard des Feldes), `serializeConfig(c: CardConfig): string`, `TONE_COLOR: Record<DetailsTone, string>`
|
||||
- Produces (`geometry.ts`): `CARD_W`, `CARD_H`, `MARGIN = 56`, `PREVIEW_RADIUS = 57`, `interface Box { x: number; y: number; w: number; h: number }`, `WALLET_KEEP_CLEAR: Box = {x:0,y:779,w:620,h:190}`, `fitNetwork(aspect: number, maxH: number): Box` (w = min(360, maxH·aspect), h = w/aspect, rechte Kante 1480, Unterkante 913), `fitBrand(aspect: number, maxH = 100): Box` (Box 56,56 bis max. 360 × maxH, links oben), `layoutFor(t: TemplateId): Layout`:
|
||||
```ts
|
||||
interface Layout {
|
||||
network: boolean; brand: boolean;
|
||||
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
|
||||
chip?: { x: number; y: number; w: number; h: number; r: number; hLines: number[]; vLines: number[]; padY: [number, number] };
|
||||
contactless?: { path: string; lineWidth: number; box: Box };
|
||||
number?: { startX: number; pitch: number; baseline: number; size: number; bulletY: number; bulletR: number; text: string };
|
||||
}
|
||||
```
|
||||
Werte: Klassisch-PLATINUM `{x:1480, baseline:123, size:46, trackingPx:9, align:'right'}`; Signature-PLATINUM `{x:60, baseline:226, size:44, trackingPx:8, align:'left'}`; Chip `{x:157, y:326, w:226, h:205, r:32, hLines:[383,429,474], vLines:[234,307], padY:[383,474]}` (waagerechte Linien über die volle Chipbreite 157–383, auf das abgerundete Rechteck beschnitten; senkrechte Linien von `padY[0]` bis `padY[1]`); Kontaktlos `{path:'M548.75 379.89a98.21 98.21 0 0 1 0 98.21M527.14 390.7a76.61 76.61 0 0 1 0 76.61M506.52 400.61a55.39 55.98 0 0 1 0 55.98M485.89 411.32a35.36 33.39 0 0 1 0 35.36', lineWidth:11.8, box:{x:480,y:374,w:88,h:110}}`; Nummer `{startX:157, pitch:45.6, baseline:650, size:72, bulletY:624, bulletR:8, text:'•••• •••• •••• 8888'}`. `image`: `network`/`brand` false, keine optionalen Felder; `minimal`: nur `network`; `classic`: alles; `signature`: `network`, `brand`, `platinum`.
|
||||
- Produces (`crop.ts`): `coverScale(imgW: number, imgH: number): number` (= max(1536/w, 969/h)), `imageRect(img: {w:number;h:number}, crop: Crop): Box`, `clampCrop(img, crop): Crop` (zoom auf [1, 4], Versatz so, dass das Bild die Karte ganz bedeckt), `panBy(img, crop, dx, dy): Crop`, `zoomTo(img, crop, zoom): Crop`.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben**
|
||||
- config: Rundlauf `parseConfig(serializeConfig(c))` = `c`; `parseConfig(null)` und `parseConfig('kaputt{')` → `DEFAULT_CONFIG`; `network: 'paypal'` → nur dieses Feld Standard; `crop.zoom: 99` → 4, `crop.x: NaN` → 0; `serializeConfig(DEFAULT_CONFIG).length < 2000`; `NETWORK_IDS.length === 18`.
|
||||
- geometry: `fitNetwork(330/109, 109)` ≈ `{x:1150, y:804, w:330, h:109}`; `fitNetwork(6, 109)` → `w 360`, x+w = 1480, y+h = 913; `fitBrand(1)` → `{x:56,y:56,w:100,h:100}`; für jede Vorlage liegen folgende Boxen im Canvas und schneiden `WALLET_KEEP_CLEAR` nicht: Chip, Kontaktlos-Box, Nummernzeile `{x:157,y:572,w:867,h:78}`, PLATINUM-Zeile (feste Worst-Case-Boxen: Klassisch `{x:1120,y:77,w:360,h:58}`, Signature `{x:60,y:182,w:360,h:55}`), `fitNetwork(1, 125)`, `fitNetwork(6, 109)`, `fitBrand(3.6)`; `layoutFor('image')` hat `network === false` und keine optionalen Felder.
|
||||
- crop: `coverScale(3000, 1000)` ≈ 0.969; 3000×1000, `clampCrop(img, {x: 9999, y: 0, zoom: 1}).x` ≈ 685.5; 1000×3000: x-Grenze 0, y > 0; Panorama 4000×200 deckt bei zoom 1 die volle Höhe (`imageRect.h` ≥ 969); `zoomTo(…, 0.5).zoom` → 1, `zoomTo(…, 7).zoom` → 4; `imageRect(img, DEFAULT_CROP)` ist mittig und deckt 0..1536 × 0..969 ab; `panBy` über den Rand hinaus bleibt begrenzt.
|
||||
- [ ] **Step 2: Tests laufen lassen** – `npx vitest run src/card` → FAIL (Module fehlen).
|
||||
- [ ] **Step 3: Implementieren** (Signaturen oben).
|
||||
- [ ] **Step 4: Tests laufen lassen** – PASS; `npx tsc --noEmit` sauber.
|
||||
- [ ] **Step 5: Commit** – `git commit -m "feat(card): Konfiguration, Geometrie und Bildausschnitt"`
|
||||
|
||||
### Task 3: Logos (SVG-Dateien und Register)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/assets/marks/<id>.svg` für alle 18 `NetworkId` sowie `aircards-symbol.svg`, `aircards-text.svg`, `apple.svg`; `web/src/card/marks.ts`
|
||||
- Modify: `web/public/favicon.svg` (eigene Kopie des AirCards-Symbols mit fester Farbe `#E8EEF7` auf abgerundetem Quadrat `#0F1828`, kein `currentColor`)
|
||||
- Test: `web/src/card/marks.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `NetworkId`, `BrandId`, `DetailsTone`, `TONE_COLOR` (Task 2)
|
||||
- Produces:
|
||||
```ts
|
||||
type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo'
|
||||
interface MarkDef { id: NetworkId | Exclude<BrandId, 'none'>; group: MarkGroup; label: string; svg: string; tint: boolean; maxH: number }
|
||||
NETWORK_MARKS: readonly MarkDef[] // 18, Reihenfolge = NETWORK_IDS
|
||||
BRAND_MARKS: readonly MarkDef[] // aircards-symbol, aircards-text, apple (group 'Logo', maxH 100)
|
||||
getNetworkMark(id: NetworkId): MarkDef
|
||||
getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef
|
||||
svgAspect(svg: string): number // aus viewBox
|
||||
tintSvg(svg: string, tone: DetailsTone): string
|
||||
loadMarkImage(def: MarkDef, tone: DetailsTone): Promise<HTMLImageElement> // Blob-URL, Cache je (id, tone)
|
||||
```
|
||||
SVGs per `import x from '../assets/marks/x.svg?raw'`. `group` dient im Optionsfeld als `aria-label`/`title` („Visa – Blau“).
|
||||
|
||||
- [ ] **Step 1: Test schreiben** – `NETWORK_MARKS.map(m => m.id)` = `NETWORK_IDS`; Labels exakt in Reihenfolge (Wortlaut Screenshot): `Automatisch, Blau, Marineblau, Metallisches Silber, Farbe, Metallisches Silber, Wortmarke, Silber + Text, Klassisch, Farbe, Metallisches Silber, Weißer Rahmen, Englisch, Blau, Umriss, Select, JCB, Discover`; `BRAND_MARKS` Labels `AirCards · Symbol, AirCards · Text, Apple`; jedes `svg` beginnt mit `<svg`, hat `viewBox`, enthält weder `<image` noch `<script`; `svgAspect` endlich > 0; `tint: true` genau für `visa-auto`, `amex-outline`, `aircards-symbol`, `aircards-text`, `apple`, diese enthalten `currentColor`, und `tintSvg(svg, 'dark')` enthält `#111111` und kein `currentColor`; `maxH` 109 für `visa-*`, `mc-wordmark`, `mc-silver-text`, `up-english`, `discover`; 125 für `mc-color`, `mc-silver`, `mc-classic`, `up-color`, `up-silver`, `up-white-frame`, `amex-blue`, `amex-outline`, `jcb`; 90 für `amex-select`; 100 für alle `BRAND_MARKS`.
|
||||
- [ ] **Step 2: Test laufen lassen** → FAIL.
|
||||
- [ ] **Step 3: SVG-Dateien erstellen** – Quellen (Wikimedia: PD-textlogo + Markenhinweis):
|
||||
- Visa: Pfad aus `https://upload.wikimedia.org/wikipedia/commons/5/5c/Visa_Inc._logo_%282021%E2%80%93present%29.svg`; `visa-auto` fill `currentColor`, `visa-blue` `#1434CB`, `visa-navy` `#1A1F71`, `visa-silver` linearer Verlauf `#F4F4F4 → #A8A8A8 → #E2E2E2`.
|
||||
- Mastercard: `mc-color` aus Commons `File:Mastercard_2019_logo.svg`; `mc-silver` = gleiche Kreise mit Silber-Verlauf, Überlappung `#8C8C8C`; `mc-wordmark` aus `https://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg` (Schriftzug weiß); `mc-silver-text` = Silber-Kreise + weißer Schriftzug; `mc-classic` aus `https://upload.wikimedia.org/wikipedia/commons/b/b7/MasterCard_Logo.svg`.
|
||||
- UnionPay: `up-color` aus Commons `File:UnionPay_logo.svg`; `up-silver` = gleiche Form mit Silber-Verläufen; `up-white-frame` = `up-color` mit weißer Kontur (Strichbreite 6 % der Höhe); `up-english` = Farblogo ohne chinesische Zeichen, Schriftzug „UnionPay“ weiß.
|
||||
- Amex: `https://upload.wikimedia.org/wikipedia/commons/f/fa/American_Express_logo_%282018%29.svg`; `amex-blue` original `#016FD0`; `amex-outline` Box transparent, Schrift/Rahmen `currentColor`; `amex-select` = `amex-blue`.
|
||||
- JCB: `https://upload.wikimedia.org/wikipedia/commons/4/40/JCB_logo.svg`.
|
||||
- Discover: `https://upload.wikimedia.org/wikipedia/commons/5/57/Discover_Card_logo.svg`, eingebettetes PNG des Balls durch `<circle>` mit Verlauf `#FFB000 → #E5600F` ersetzen, Schrift weiß.
|
||||
- Apple: Pfad aus Commons `File:Apple_logo_black.svg`, fill `currentColor`.
|
||||
- `aircards-symbol`: eigenes SVG, schräg gestellte abgerundete Karte (−12°) mit zwei kurzen Linien, fill `currentColor`; `aircards-text`: „AirCards“ als Pfade (Nunito Sans 800, einmalig mit `opentype.js` im Scratchpad umgewandelt), fill `currentColor`.
|
||||
- Ist `/root/reports/aircard-marks-research/` lesbar, dürfen die dortigen Kandidaten statt Neu-Download benutzt werden.
|
||||
- Alle Dateien mit `npx svgo@4 --multipass` bereinigen; keine eingebetteten Bilder oder Schriftreferenzen.
|
||||
- [ ] **Step 4: `marks.ts` implementieren.**
|
||||
- [ ] **Step 5: Prüfen** – Tests PASS; Kontaktbogen aller 21 SVGs (+ Favicon) im Scratchpad als PNG rendern (z. B. `npx @resvg/resvg-js-cli`) und ansehen: nichts abgeschnitten, Farben stimmen.
|
||||
- [ ] **Step 6: Commit** – `git commit -m "feat(card): Netzwerk- und Marken-Logos"`
|
||||
|
||||
### Task 4: Renderer
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/card/render.ts`
|
||||
- Test: `web/src/card/render.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 (`CardConfig`, `layoutFor`, `fitNetwork`, `fitBrand`, `imageRect`, `clampCrop`, `TONE_COLOR`, `CARD_W/H`), Task 3 (`getNetworkMark`, `getBrandMark`, `svgAspect`, `loadMarkImage`)
|
||||
- Produces:
|
||||
```ts
|
||||
type CardImage = { source: CanvasImageSource; w: number; h: number }
|
||||
interface CardAssets { image: CardImage | null; network: CanvasImageSource | null; brand: CanvasImageSource | null }
|
||||
type Ctx = Pick<CanvasRenderingContext2D, 'save'|'restore'|'scale'|'setTransform'|'fillRect'|'drawImage'|'fillText'|'measureText'|'beginPath'|'arc'|'fill'|'stroke'|'roundRect'|'clip'|'moveTo'|'lineTo'|'createLinearGradient'> & { fillStyle: unknown; strokeStyle: unknown; lineWidth: number; lineCap: CanvasLineCap; font: string; textAlign: CanvasTextAlign; textBaseline: CanvasTextBaseline }
|
||||
renderCard(ctx: Ctx, config: CardConfig, assets: CardAssets, strokePath?: (ctx: Ctx, d: string) => void): void // zeichnet in 1536×969-Koordinaten
|
||||
drawCard(canvas: HTMLCanvasElement | OffscreenCanvas, config: CardConfig, assets: CardAssets): void // getContext('2d', { alpha: false }), setTransform(1,0,0,1,0,0), scale(canvas.width/1536, canvas.height/969), renderCard
|
||||
loadCardAssets(config: CardConfig, image: CardImage | null): Promise<CardAssets> // wartet auf document.fonts.load('700 46px "Nunito Sans Variable"'); lädt Netzwerk- und Brand-Logo immer (außer brand 'none'), unabhängig von der Vorlage, in Details-Farbe
|
||||
exportCard(config: CardConfig, assets: CardAssets, opts: { width: number; type: 'image/png' | 'image/webp'; quality?: number }): Promise<Blob> // Canvas width × round(width·969/1536), drawCard, toBlob
|
||||
```
|
||||
`strokePath` Standard: `ctx.stroke(new Path2D(d))`. Vorschau, Vorlagen-Miniaturen und Export benutzen alle `drawCard`.
|
||||
- Zeichenreihenfolge (fest):
|
||||
1. `fillRect(0,0,1536,969)` mit `#1B2738`.
|
||||
2. Bild mit `imageRect(image, clampCrop(image, config.crop))` (defensiv begrenzt).
|
||||
3. Chip: abgerundetes Rechteck mit Verlauf hell `#F2F2F2 → #A9A9A9`, Linien `#7A7A7A`; dunkel `#4A4A4A → #1C1C1C`, Linien `#0A0A0A`; Linienbreite 3, auf das Rechteck beschnitten.
|
||||
4. Kontaktlos-Bögen in Details-Farbe, `lineCap 'round'`.
|
||||
5. Nummer: Punkte als Kreise (`arc`) an `startX + pitch·i + pitch/2` für i = 0–3, 5–8, 10–13 auf `bulletY`; Ziffern `8` für i = 15–18 mit `textAlign 'center'` auf `baseline`.
|
||||
6. PLATINUM: zeichenweise mit `textAlign 'left'`; Gesamtbreite = Σ `measureText(Buchstabe).width` + (n−1)·`trackingPx` (keine Laufweite nach dem letzten Buchstaben); bei `align 'right'` Start = x − Gesamtbreite.
|
||||
7. Brand-Logo in `fitBrand(svgAspect(getBrandMark(id).svg), getBrandMark(id).maxH)`.
|
||||
8. Netzwerk-Logo in `fitNetwork(svgAspect(getNetworkMark(id).svg), getNetworkMark(id).maxH)`.
|
||||
Schrift `` `700 ${size}px "Nunito Sans Variable"` ``, Text in `TONE_COLOR[config.details]`.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben** – aufzeichnender Fake-`Ctx` (`measureText` → `{ width: text.length * 20 }`); `vi.mock('./marks', …)`: `getNetworkMark` → `{ svg: '<svg viewBox="0 0 330 109"></svg>', maxH: 109, … }`, `getBrandMark` → viewBox `0 0 100 100`, `maxH 100`, `svgAspect` echt oder aus der viewBox:
|
||||
- erster Aufruf `fillRect(0,0,1536,969)` mit `fillStyle '#1B2738'`, auch mit Bild (transparentes PNG).
|
||||
- `image` mit Bild: genau ein `drawImage` (das Bild), kein `fillText`.
|
||||
- Bild 3000×1000 mit `crop.x = 9999`: Bild-`drawImage`-Rechteck deckt 0..1536 × 0..969 ab.
|
||||
- `minimal`: `drawImage(network, 1150, 804, 330, 109)` (≈).
|
||||
- `classic`, `details 'dark'`: alle `fillText` mit `fillStyle '#111111'`; letzter PLATINUM-Buchstabe endet bei x ≈ 1480; vier `fillText('8', x, 650)` mit x ≈ 863.8 / 909.4 / 955 / 1000.6; zwölf `arc` mit y 624.
|
||||
- `signature`, `brand 'none'`: kein Brand-`drawImage`; erster PLATINUM-Buchstabe bei x 60.
|
||||
- ohne Bild: kein Bild-`drawImage`, Grund gefüllt.
|
||||
- [ ] **Step 2: Tests laufen lassen** → FAIL.
|
||||
- [ ] **Step 3: Implementieren.**
|
||||
- [ ] **Step 4: Tests laufen lassen** → PASS; `npx tsc --noEmit` sauber.
|
||||
- [ ] **Step 5: Commit** – `git commit -m "feat(card): Canvas-Renderer und Export"`
|
||||
|
||||
### Task 5: Editor-Oberfläche
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/editor/state.ts`, `web/src/editor/upload.ts`, `web/src/editor/editor-page.ts`, `web/src/editor/templates-panel.ts`, `web/src/editor/preview.ts`, `web/src/editor/options-panel.ts`, `web/src/editor/download.ts`, `web/src/editor/editor.css`
|
||||
- Modify: `web/src/main.ts` (Store einmal erzeugen; Route `editor` → `mountEditor`)
|
||||
- Test: `web/src/editor/state.test.ts`, `web/src/editor/upload.test.ts`, `web/src/editor/download.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 2–4.
|
||||
- Produces:
|
||||
```ts
|
||||
interface SavedCardRef { id: string; name: string; imageFileId: string; previewFileId: string; updatedAt: string }
|
||||
interface EditorState { config: CardConfig; image: CardImage | null; imageBlob: Blob | null; imageChanged: boolean; saved: SavedCardRef | null; cardName: string; saving: boolean }
|
||||
createStore(initial: EditorState): EditorStore // { get(): EditorState; set(patch: Partial<EditorState>): void; subscribe(fn: (s: EditorState) => void): () => void }
|
||||
initialEditorState(): EditorState // DEFAULT_CONFIG, kein Bild, saved null, cardName 'Meine Karte'
|
||||
setImage(store: EditorStore, image: CardImage, blob: Blob): void // setzt image/imageBlob, imageChanged true, crop = DEFAULT_CROP
|
||||
validateImageFile(f: { type: string; size: number }): { ok: true } | { ok: false; message: string }
|
||||
decodeImage(blob: Blob): Promise<CardImage> // source = HTMLImageElement; reject mit Meldung „Das Bild konnte nicht gelesen werden.“
|
||||
downloadFileName(cardName: string, date: Date): string
|
||||
mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSave(): void }): () => void
|
||||
```
|
||||
`hooks.onSave` ist bis Task 9 ein No-op.
|
||||
- UI (Spec §4): drei Spalten (`grid-template-columns: 300px 1fr 400px`), unter 900 px einspaltig (Vorschau, Vorlagen als waagerechte Scroll-Reihe, Optionen). Vorlagenkacheln mit Live-Miniatur (Canvas 384 × 242 über `drawCard`), Titel/Untertitel: *Nur Bild* / „Bild unverändert lassen“, *Minimalistisch* / „Dein Bild steht im Mittelpunkt“, *Klassisch* / „Die vertrauten Kartendetails“, *Signature* / „Ein bisschen mehr Persönlichkeit“; Auswahl = Rahmen `--sel-line` + Häkchen. Mitte ohne Bild: gestrichelte Ablagefläche (Icon, „Ziehe deine Idee hierher“, Knopf „Bild auswählen“, „PNG / JPG / WebP · Max. 5 MiB“, Drag-and-drop + Dateiauswahl); mit Bild: Canvas 1536 × 969 per CSS skaliert, `border-radius: 3.711% / 5.882%`, Ziehen per Pointer-Events (`panBy`, CSS-Delta × 1536 / clientWidth), Zoom-Regler 1–4 (Schritt 0.01). Darunter „1536 × 969 PNG“, „Bild ersetzen“, **Herunterladen**, **Speichern** – beide ohne Bild deaktiviert (`title` „Erst ein Bild wählen“); Hinweis „Dein Bild bleibt auf deinem Gerät, bis du speicherst.“ Rechts „Individuell gestalten“: „Kartennetzwerk“ (3-spaltiges Raster, Knopf = Logo + Label, `aria-label` „<group> – <label>“), „Details“ (Hell/Dunkel als Radio-Knöpfe), „Logo“ (`AirCards · Symbol`, `AirCards · Text`, `Apple`, `Kein Logo`).
|
||||
- Download: `exportCard(config, assets, { width: 1536, type: 'image/png' })` über `<a download>`; Blob > 4 000 000 Byte → Hinweis „Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.“
|
||||
- Ungültige Datei: Meldung an Ablagefläche bzw. unter der Karte, Zustand bleibt.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben**
|
||||
- state: `set` mischt ein und benachrichtigt; nach `unsubscribe` keine Aufrufe; `setImage` nach `crop {x: 300, y: 0, zoom: 2}` → `crop` = `DEFAULT_CROP`, `imageChanged` true.
|
||||
- upload: PNG 1 000 Byte → ok; `image/heic` → `'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'`; PNG 5 242 881 Byte → `'Das Bild ist größer als 5 MiB.'`; genau 5 242 880 → ok; leerer Typ → Typ-Meldung.
|
||||
- download: `downloadFileName('Mein Urlaub!', d)` → `'aircard-mein-urlaub.png'`; `downloadFileName(' ', new Date('2026-10-04T10:00:00Z'))` → `'aircard-2026-10-04.png'`; `'Grüße'` → `'aircard-gruesse.png'`.
|
||||
- [ ] **Step 2: Tests laufen lassen** → FAIL.
|
||||
- [ ] **Step 3: Implementieren.**
|
||||
- [ ] **Step 4: Tests + Build** – `npx vitest run` → PASS; `npm run build` → ok.
|
||||
- [ ] **Step 5: Commit** – `git commit -m "feat(editor): Editor-Oberfläche mit Upload, Vorschau und Download"` (Sichtprüfung im Browser folgt in Task 6 Step 9).
|
||||
|
||||
### Task 6: Deployment auf aircard-ios.org
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/deploy.sh`, `deploy/webhook-server.js`, `deploy/webhook-server.test.js`, `deploy/nginx/aircard-ios.org.conf`, `deploy/traefik/aircard.yml`, `deploy/compose-snippet.yml`, `deploy/README.md`
|
||||
- Server (außerhalb des Repos): `/home/webklar/apps/aircard-ios.org/{build/,out/,deploy.sh,webhook-server.js,nginx/aircard-ios.org.conf}`, `/home/webklar/apps/traefik/dynamic/aircard.yml`, `/home/webklar/apps/docker-compose.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- `webhook-server.js` (CommonJS, Node 22, ohne Abhängigkeiten):
|
||||
- `verifySignature(raw: Buffer, signatureHex: string | undefined, secret: string): boolean` – Header `x-gitea-signature` (kleingeschriebenes Hex von HMAC-SHA256 über den Rohbody, ohne Präfix); `false` bei leerem Secret, fehlender Signatur oder ungleicher Länge (vor `timingSafeEqual` prüfen); keine anderen Header-Fallbacks.
|
||||
- `branchFromPayload(body: unknown): string | null` – `refs/heads/<x>` → `<x>`.
|
||||
- `createServer({ secret, branch, runDeploy }): http.Server` – `POST /webhook/deploy`: 401 bei falscher Signatur; 202 `{"queued":true}` bei passendem Branch; 200 `{"ignored":true}` sonst; alles andere 404. Läufe nacheinander; höchstens ein Lauf wartet zusätzlich zum laufenden.
|
||||
- Start nur bei `require.main === module`: Ist `AIRCARD_WEBHOOK_TOKEN` leer → Fehlermeldung ohne Wert, `process.exit(1)`. Port `AIRCARD_WEBHOOK_PORT` (8087), Branch `AIRCARD_DEPLOY_BRANCH` (main), Skript `AIRCARD_DEPLOY_SCRIPT` (/deploy.sh) mit `DEPLOY_TRIGGER` im Env; beim Start `initial`, dann alle `POLL_INTERVAL_MINUTES` (15) `poll`.
|
||||
- `deploy.sh` (POSIX sh, `set -e`): Variablen `REPO_DIR` (/app), `OUT_DIR` (/out), `GITEA_URL`, `GITEA_USER` (knso), `GITEA_REPO` (AirCardIOS), `GITEA_TOKEN` (optional), `APP_SUBDIR` (web), `DEPLOY_BRANCH` (main), `DEPLOY_TRIGGER`. Ablauf: klonen bzw. `fetch` + `reset --hard origin/$DEPLOY_BRANCH` + `clean -fd`; `case "${DEPLOY_TRIGGER:-manual}" in poll|initial)` → ist Marker `$REPO_DIR/.git/aircard-last-deployed-commit` = HEAD und `$OUT_DIR/index.html` vorhanden → „unverändert“, Exit 0`;; esac` (webhook/manual/leer bauen immer); Marker = HEAD **vor** dem Build schreiben (zuletzt versuchter Commit – ein kaputter Build wird nicht alle 15 Minuten wiederholt; alte Seite bleibt online); `cd $APP_SUBDIR && npm ci --no-audit --no-fund && npm run build`; `dist/index.html` prüfen; `rm -rf $OUT_DIR/* && cp -r dist/. $OUT_DIR/`; letzte Zeile `echo "Deployment abgeschlossen"`; Fehlerzeilen beginnen mit `ERROR:`. Manuell: `docker exec webklar-aircard-build sh /deploy.sh`.
|
||||
- nginx: Server-Header `X-Frame-Options SAMEORIGIN`, `X-Content-Type-Options nosniff`, `Referrer-Policy strict-origin-when-cross-origin`; gzip; `location ^~ /assets/ { try_files $uri =404; expires 1y; access_log off; }`; `location ^~ /v1/ { return 404; }`; `location = /index.html { expires -1; }`; Bild-/Font-Regex 7 Tage; `location / { try_files $uri $uri/ /index.html; expires -1; }`; `location ~ /\.(?!well-known) { deny all; }`.
|
||||
- `traefik/aircard.yml`: Router `aircard-api`, rule ``Host(`aircard-ios.org`) && PathPrefix(`/v1`) && Header(`X-Appwrite-Project`, `aircard`)``, entryPoints `[websecure]`, service `appwrite@docker`, priority 150, `tls.certResolver: letsencrypt`.
|
||||
- `compose-snippet.yml` (an den eship-Block anhängen): `aircard-build` = `node:22-alpine`, `container_name webklar-aircard-build`, `restart unless-stopped`, `init: true`, `mem_limit 768m`, `working_dir /app`, Env `GITEA_URL=https://git.webklar.com`, `GITEA_USER=knso`, `GITEA_REPO=AirCardIOS`, `GITEA_TOKEN=${GITEA_DEPLOY_TOKEN}`, `APP_SUBDIR=web`, `DEPLOY_BRANCH=main`, `AIRCARD_DEPLOY_BRANCH=main`, `AIRCARD_WEBHOOK_TOKEN=${GITEA_DEPLOY_WEBHOOK_SECRET}`, `AIRCARD_WEBHOOK_PORT=8087`, `AIRCARD_DEPLOY_SCRIPT=/deploy.sh`, `POLL_INTERVAL_MINUTES=15`, `NODE_OPTIONS=--max-old-space-size=512`, `VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1`, `VITE_APPWRITE_PROJECT_ID=aircard`; Volumes `./aircard-ios.org/build:/app`, `./aircard-ios.org/out:/out`, `./aircard-ios.org/deploy.sh:/deploy.sh:ro`, `./aircard-ios.org/webhook-server.js:/webhook/server.js:ro`; Netz `webklar-network`; Command `/bin/sh -c "apk add --no-cache git && node /webhook/server.js"`; Labels Router `aircard-webhook` ``Host(`aircard-ios.org`) && Path(`/webhook/deploy`)``, websecure, letsencrypt, priority 200, Service-Port 8087. `aircard` = `nginx:alpine`, `container_name webklar-aircard`, `restart unless-stopped`, `mem_limit 64m`, Volumes `out` (ro) + nginx-Konfig (ro), Netz `webklar-network`; Labels Router `aircard` ``Host(`aircard-ios.org`)`` websecure/letsencrypt, Service-Port 80; Router `aircard-http` (entrypoint `web`) mit Middleware `aircard-https` (redirectscheme https, permanent). Zusätzlich **auskommentiert** die www-Labels für Step 10.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben** – `deploy/webhook-server.test.js` (`node:test`): Signatur gültig; ungültig (`'deadbeef'`, kürzer) → false ohne Exception; leeres Secret → false; fehlender Header → false; `branchFromPayload({ref:'refs/heads/main'})` → `'main'`, `{}` → `null`; Server auf Port 0: unsignierter POST → 401; signierter Push auf main → 202 und `runDeploy('webhook')`; Push auf `dev` → 200 ignoriert; `GET /` → 404; drei schnelle signierte Pushes bei blockiertem `runDeploy` → alle 202, nach Freigabe genau 2 Läufe.
|
||||
- [ ] **Step 2: Tests laufen lassen** – `node --test deploy/webhook-server.test.js` → FAIL.
|
||||
- [ ] **Step 3: Dateien in `deploy/` schreiben**; `deploy/README.md` nennt Kopierziele, Start, manuellen Deploy und Webhook.
|
||||
- [ ] **Step 4: Prüfen** – `node --test deploy/webhook-server.test.js` → PASS; `sh -n deploy/deploy.sh` → ok; `python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" deploy/traefik/aircard.yml` → ok.
|
||||
- [ ] **Step 5: Commit + erster Push** – `git commit -m "feat(deploy): Webhook, Build-Skript, nginx, Traefik"`. Push-Remote wie bei den anderen knso-Arbeitskopien (Token-URL aus einer bestehenden knso-Arbeitskopie übernehmen, nie ausgeben), `git push -u origin main`. Blockiert der Auto-Modus den Push: Kenso bitten, `git -C /home/webklar/apps/aircard-ios.org/dev push -u origin main` auszuführen, und warten.
|
||||
- [ ] **Step 6: Server einrichten** – Ordner `build/`, `out/`, `nginx/` unter `/home/webklar/apps/aircard-ios.org/`; `deploy.sh` (chmod +x), `webhook-server.js`, nginx-Konfig kopieren; `cp docker-compose.yml docker-compose.yml.bak-2026-10-04-aircard`; Services aus `deploy/compose-snippet.yml` nach dem eship-Block einfügen; `docker compose config -q; echo $?` → `0`, einzige erlaubte Ausgabe ist die bestehende Warnung „attribute `version` is obsolete“. Traefik-Datei atomar: `cp deploy/traefik/aircard.yml /home/webklar/apps/traefik/dynamic/aircard.yml.tmp && mv /home/webklar/apps/traefik/dynamic/aircard.yml.tmp /home/webklar/apps/traefik/dynamic/aircard.yml`; danach `docker logs --since 2m webklar-traefik 2>&1 | grep -iE 'error|aircard'` → kein Fehler des File-Providers (bekannte ACME-Fehler anderer Hosts ignorieren).
|
||||
- [ ] **Step 7: Starten** – `cd /home/webklar/apps && docker compose up --dry-run -d aircard-build aircard` → nur `webklar-aircard-build` und `webklar-aircard` werden angelegt; dann ohne `--dry-run`. Auf das Ende warten: `for i in $(seq 1 60); do docker logs webklar-aircard-build 2>&1 | grep -qE 'Deployment abgeschlossen|ERROR:' && break; sleep 5; done; docker logs --tail 40 webklar-aircard-build` → „Deployment abgeschlossen“.
|
||||
- [ ] **Step 8: Gitea-Webhook** – an `knso/AirCardIOS`: Typ gitea, URL `https://aircard-ios.org/webhook/deploy`, Content-Type json, Secret = Wert von `GITEA_DEPLOY_WEBHOOK_SECRET` aus `/home/webklar/apps/.env`, nur Push-Events, Branch-Filter `main`. Per Gitea-API (`POST /api/v1/repos/knso/AirCardIOS/hooks`) mit `GITEA_SYSTEM_TOKEN` aus `.env`, beide Werte nur innerhalb des Befehls, nie ausgeben. Blockiert der Auto-Modus das: Kenso trägt den Hook in der Gitea-Oberfläche ein und kopiert das Secret selbst aus `.env` (Zeile `GITEA_DEPLOY_WEBHOOK_SECRET=…`, volle 64 Zeichen); Claude nennt nur URL, Content-Type, Events, Branch-Filter. Prüfen mit „Test delivery“ → Antwort 202 (401 = Secret falsch), Log zeigt Branch `main`.
|
||||
- [ ] **Step 9: Prüfen**
|
||||
- `curl -sI http://aircard-ios.org` → 301 auf https; `curl -sI https://aircard-ios.org` → 200 mit gültigem Zertifikat (ohne `-k`).
|
||||
- `curl -s -o /dev/null -w '%{http_code}' https://aircard-ios.org/karten` → 200, Body enthält `id="app-root"`.
|
||||
- `curl -sI https://aircard-ios.org/assets/<eine .js-Datei aus out/assets>` → `Cache-Control: max-age=31536000`.
|
||||
- `curl -s -o /dev/null -w '%{http_code}' -X POST https://aircard-ios.org/webhook/deploy` → 401.
|
||||
- `curl -s -i -H 'X-Appwrite-Project: aircard' https://aircard-ios.org/v1/health/version` → solange das Projekt noch nicht existiert: Header `Server: Appwrite` und Body mit `"type":"project_not_found"` (beweist die Route); ohne Header → 404 von nginx.
|
||||
- nach 2 Minuten Leerlauf `docker stats --no-stream webklar-aircard webklar-aircard-build` → nginx < 20 MB, Build-Container < 250 MB.
|
||||
- Editor im Browser (Claude in Chrome): Bild hochladen, alle vier Vorlagen, Stichprobe Logos, Hell/Dunkel, Verschieben/Zoom, Download → PNG 1536 × 969; HEIC-Datei → Meldung.
|
||||
- [ ] **Step 10: www (nur wenn DNS da)** – liefert `dig +short www.aircard-ios.org @8.8.8.8` `91.99.156.85`: die auskommentierten www-Labels am Service `aircard` aktivieren (Router `aircard-www` websecure/letsencrypt und `aircard-www-http` web, Middleware `aircard-www` = redirectregex `^https?://www\\.aircard-ios\\.org(.*)` → `https://aircard-ios.org$${1}`, permanent), Backup, `docker compose config -q`, `docker compose up -d aircard`; `curl -sI 'https://www.aircard-ios.org/x?y=1'` → 301 auf `https://aircard-ios.org/x?y=1`. Ohne DNS überspringen und im Bericht nennen.
|
||||
- [ ] **Step 11: Commit** (falls Anpassungen); Memory-Notiz aktualisieren.
|
||||
|
||||
### Task 7: Appwrite-Projekt und Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `web/scripts/appwrite-setup.mjs`, `docs/appwrite-console.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Ergebnis: Datenbank `aircard` (Name „AirCard“); Tabelle `cards` (Name „Cards“, Rechte `['create("users")']`, `rowSecurity: true`, Spalten `name` string 80, `config` string 2000, `imageFileId` string 36, `previewFileId` string 36, alle `required: true`); Bucket `card-images` (Name „Card Images“, Rechte `['create("users")']`, `fileSecurity: true`, `maximumFileSize: 5242880`, `allowedFileExtensions: ['png','jpg','jpeg','webp']`, `compression: 'none'`, `antivirus: false`; Verschlüsselung/Transformationen Standard). Keine eigenen Indizes – `orderDesc('$updatedAt')` nutzt den Systemindex.
|
||||
- Aufruf: `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]`. Endpoint `AIRCARD_APPWRITE_ENDPOINT` (Standard `https://appwrite.webklar.com/v1`), Projekt `aircard`, Key `AIRCARD_APPWRITE_API_KEY`. Jede Anfrage mit `Content-Type: application/json`, `X-Appwrite-Project`, `X-Appwrite-Key`. Bodies: `POST /tablesdb {databaseId:'aircard', name:'AirCard'}`; `POST /tablesdb/aircard/tables {tableId:'cards', name:'Cards', permissions:['create("users")'], rowSecurity:true}`; `POST /tablesdb/aircard/tables/cards/columns/string {key, size, required:true}` (Antwort 202, `status: 'processing'`); `POST /storage/buckets {bucketId:'card-images', name:'Card Images', permissions, fileSecurity, maximumFileSize, allowedFileExtensions, compression, antivirus}`.
|
||||
- Logik: je Objekt erst GET; 404 mit `type !== 'project_not_found'` = fehlt → anlegen; jeder andere Fehler (401, 404 `project_not_found`, 5xx) → Abbruch mit klarer Meldung, ohne den Key auszugeben. Spalten nacheinander anlegen, danach bis 30 s per GET auf `status: 'available'` warten (bereit vor dem ersten Schreibzugriff). Vergleichen und Abweichungen melden (nicht ändern): `$permissions`, `rowSecurity`, Spalten `size`/`required`/`status`, Bucket `fileSecurity`, `maximumFileSize`, `allowedFileExtensions` (sortiert). `--check` legt nichts an, meldet Fehlendes/Abweichendes und endet dann mit Exit 1, sonst „OK: Schema vollständig“ und Exit 0.
|
||||
|
||||
- [ ] **Step 1: Klickliste schreiben** – `docs/appwrite-console.md` für Kenso: Projekt „AirCard“ mit ID `aircard` in der bestehenden Organisation; Plattform Web `aircard-ios.org` und `localhost`; Auth: nur E-Mail/Passwort an (Magic URL, E-Mail-OTP, Telefon, Anonym, JWT aus), Sitzungsdauer 30 Tage, max. 5 Sitzungen, Nutzerlimit 2000, Passwort-Wörterbuch an; Dienste aus: Functions, Sites, Messaging, Teams, GraphQL; API-Key `aircard-provision` mit `databases.read/write`, `tables.read/write`, `columns.read/write`, `buckets.read/write`; Zeile `AIRCARD_APPWRITE_API_KEY=…` in `/home/webklar/apps/.env` eintragen. Commit.
|
||||
- [ ] **Step 2: Kenso bitten**, die Klickliste abzuarbeiten; warten, bis er Bescheid gibt.
|
||||
- [ ] **Step 3: Skript schreiben.**
|
||||
- [ ] **Step 4: Vorher prüfen** – `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` → Exit 1, listet Datenbank, Tabelle, 4 Spalten, Bucket als fehlend.
|
||||
- [ ] **Step 5: Anlegen** – ohne `--check` → alles angelegt.
|
||||
- [ ] **Step 6: Nachher prüfen** – `--check` → „OK: Schema vollständig“, Exit 0; erneuter Lauf ohne `--check` legt nichts doppelt an; `curl -s -H 'X-Appwrite-Project: aircard' https://aircard-ios.org/v1/health/version` → `{"version":"1.8.1"}`.
|
||||
- [ ] **Step 7: Commit** – `git commit -m "feat(appwrite): Setup-Skript und Console-Anleitung"`
|
||||
|
||||
### Task 8: Konten (Login, Registrierung, Passwort)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/account/appwrite.ts`, `web/src/account/auth.ts`, `web/src/account/errors.ts`, `web/src/account/login-dialog.ts`, `web/src/account/account-menu.ts`, `web/src/pages/reset-page.ts`, `web/src/account/account.css`
|
||||
- Modify: `web/src/main.ts` (Konto-Menü in `#account-slot`, Route `reset`)
|
||||
- Test: `web/src/account/errors.test.ts`, `web/src/account/auth.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```ts
|
||||
// appwrite.ts
|
||||
client: Client; account: Account; tables: TablesDB; storage: Storage
|
||||
APPWRITE_ENDPOINT: string; APPWRITE_PROJECT: string
|
||||
IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const
|
||||
// auth.ts
|
||||
type User = { $id: string; email: string }
|
||||
getCurrentUser(): Promise<User | null> // 401 → null; Antwort ohne string-$id/email → throw Error('Unerwartete Antwort')
|
||||
currentUserId(): string | null // Cache aus dem letzten getCurrentUser/login/register/logout
|
||||
register(email: string, password: string): Promise<User> // account.create({ userId: ID.unique(), email, password, name: email.split('@')[0].slice(0,128) }), dann login
|
||||
login(email: string, password: string): Promise<User>
|
||||
logout(): Promise<void> // 401 ignorieren, Cache leeren, onAuthChange(null)
|
||||
requestPasswordReset(email: string): Promise<void> // URL `${location.origin}/passwort-neu`
|
||||
completePasswordReset(userId: string, secret: string, password: string): Promise<void>
|
||||
onAuthChange(fn: (u: User | null) => void): () => void
|
||||
validatePassword(pw: string): string | null // 8–256 Zeichen, sonst Meldung
|
||||
// errors.ts
|
||||
type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load'
|
||||
errorMessage(err: unknown, ctx: ErrorContext): string
|
||||
isUnauthorized(err: unknown): boolean // code 401
|
||||
// login-dialog.ts
|
||||
requireLogin(view?: 'login' | 'register' | 'forgot'): Promise<boolean> // <dialog>; true nach Login/Registrierung, false bei Abbruch
|
||||
```
|
||||
SDK-Aufrufe im Objekt-Parameter-Stil von appwrite 22.4.1.
|
||||
- `errorMessage` – zuerst `type`, dann `code`; bei `general_argument_invalid` den Parameter per `` /`(\w+)` param/ `` aus `err.message` lesen:
|
||||
- `user_invalid_credentials` → „E-Mail oder Passwort ist falsch.“
|
||||
- `user_already_exists` → „Diese E-Mail ist schon registriert. Melde dich an.“
|
||||
- `user_invalid_token` → „Der Link ist ungültig oder abgelaufen.“
|
||||
- `user_not_found`: ctx `recover` → „Zu dieser E-Mail gibt es kein Konto.“; ctx `reset` → „Der Link ist ungültig oder abgelaufen.“
|
||||
- `general_argument_invalid` + Parameter `email` → „Bitte gib eine gültige E-Mail-Adresse ein.“; `password` + ctx `login` → „E-Mail oder Passwort ist falsch.“; `password` sonst: Nachricht enthält „commonly used“ → „Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.“, sonst „Das Passwort muss 8 bis 256 Zeichen lang sein.“; `userId`/`secret` → „Der Link ist ungültig oder abgelaufen.“
|
||||
- `row_not_found` → „Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.“
|
||||
- `storage_file_not_found` → „Das Bild dieser Karte fehlt.“
|
||||
- `general_unknown_origin` → „Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.“
|
||||
- `user_count_exceeded` → „Zurzeit sind keine neuen Konten möglich.“
|
||||
- Code 429 → „Zu viele Versuche – bitte kurz warten.“
|
||||
- `TypeError` (Netz) → „Keine Verbindung zum Server.“
|
||||
- sonst → „Etwas ist schiefgelaufen. Bitte nochmal versuchen.“
|
||||
- UI: Dialog mit Reitern „Anmelden“ / „Registrieren“ (E-Mail + Passwort; Registrieren mit zweitem Passwortfeld, Abgleich „Die Passwörter stimmen nicht überein.“ und `validatePassword` vor dem Senden), Link „Passwort vergessen?“ → E-Mail-Feld, Erfolg „Wir haben dir einen Link geschickt. Er gilt eine Stunde.“; bei `user_already_exists` Link „Anmelden“ zum Reiter. Konto-Menü: abgemeldet „Anmelden“; angemeldet E-Mail + „Abmelden“. `/passwort-neu`: liest `userId`, `secret`; zwei Passwortfelder; Erfolg → „Passwort geändert. Du kannst dich jetzt anmelden.“ + „Anmelden“. „Der Link ist ungültig oder abgelaufen.“ + Knopf „Neuen Link anfordern“ (öffnet `requireLogin('forgot')`) bei fehlenden Parametern, `user_invalid_token`, `user_not_found` und `general_argument_invalid` mit Parameter `userId`/`secret`; nur `general_argument_invalid` mit `password` zeigt den Hinweis am Feld.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben** – `errors.test.ts`: jede Zeile oben mit Fake-Fehlern `{ code, type, message }`, dabei echte Server-Texte: `'Invalid \`password\` param: Password must be between 8 and 256 characters long.'` (ctx `login` → falsches Passwort, ctx `register` → 8–256), `'Invalid \`email\` param: Value must be a valid email address'`, `'Invalid \`password\` param: Password must be at least 8 characters and should not be one of the commonly used password.'` (register → „zu verbreitet“), `'Invalid \`userId\` param: …'` (reset → Link ungültig); `new TypeError('Failed to fetch')`; `isUnauthorized({code:401})` true, `{code:404}` false. `auth.test.ts` mit `vi.mock('./appwrite')`: `getCurrentUser` bei Antwort `{ message: '<!doctype html>' }` → wirft; bei 401 → `null` und `currentUserId()` → `null`; bei `{ $id: 'u1', email: 'a@b.de' }` → User und `currentUserId()` → `'u1'`; `logout` bei 401 löst auf und ruft `onAuthChange(null)`; `validatePassword('kurz')` → Meldung, 8 Zeichen → `null`.
|
||||
- [ ] **Step 2: Tests laufen lassen** → FAIL.
|
||||
- [ ] **Step 3: Implementieren.**
|
||||
- [ ] **Step 4: Tests + Build** – PASS; `npm run build` ok.
|
||||
- [ ] **Step 5: Commit + Push** – `git commit -m "feat(account): Login, Registrierung, Passwort zurücksetzen"`; Push nach `main` (wie Task 6 Step 5); Webhook-Build abwarten (Log „Deployment abgeschlossen“).
|
||||
- [ ] **Step 6: Live prüfen** – Kenso registriert auf https://aircard-ios.org ein Testkonto, meldet sich ab und wieder an, probiert einmal ein falsches Passwort und „Passwort vergessen“ mit seinem Postfach (Mail kommt an, Text beginnt mit „Hallo <Name>,“, Link setzt neues Passwort). Danach prüft Claude in Kensos angemeldetem Chrome: `localStorage.getItem('cookieFallback')` → `null`; Konto-Menü zeigt die E-Mail; falls `read_network_requests` die Login-Antwort zeigt: `Set-Cookie … Domain=.aircard-ios.org; Secure; HttpOnly`. Scheitert das Cookie, Fallback aus Spec §3 (Endpoint `https://appwrite.webklar.com/v1`) umsetzen und Kenso informieren.
|
||||
|
||||
### Task 9: Karten speichern und „Meine Karten“
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/account/cards.ts`, `web/src/account/with-login.ts`, `web/src/editor/save.ts`, `web/src/pages/cards-page.ts`, `web/src/pages/cards.css`
|
||||
- Modify: `web/src/editor/editor-page.ts` (Speichern an `createSaveAction`, Laden über `?karte=<id>`), `web/src/main.ts` (Route `cards`)
|
||||
- Test: `web/src/account/cards.test.ts`, `web/src/account/with-login.test.ts`, `web/src/editor/save.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 (`parseConfig`, `serializeConfig`, `clampCrop`), Task 4 (`exportCard`, `loadCardAssets`, `drawCard`), Task 5 (`EditorStore`, `SavedCardRef`, `setImage`, `decodeImage`, `downloadFileName`), Task 8 (`client`, `IDS`, `tables`, `storage`, `getCurrentUser`, `currentUserId`, `requireLogin`, `errorMessage`, `isUnauthorized`).
|
||||
- Produces:
|
||||
```ts
|
||||
interface CardRowData { name: string; config: string; imageFileId: string; previewFileId: string }
|
||||
interface CardRow extends CardRowData { $id: string; $updatedAt: string }
|
||||
interface CardsApi {
|
||||
createFile(file: File, permissions: string[]): Promise<{ $id: string }>
|
||||
deleteFile(fileId: string): Promise<void>
|
||||
fetchFile(fileId: string): Promise<Blob>
|
||||
createRow(data: CardRowData, permissions: string[]): Promise<CardRow>
|
||||
updateRow(id: string, data: Partial<CardRowData>): Promise<CardRow>
|
||||
getRow(id: string): Promise<CardRow>
|
||||
deleteRow(id: string): Promise<void>
|
||||
listRows(): Promise<CardRow[]>
|
||||
}
|
||||
interface SavedCard extends SavedCardRef { config: CardConfig }
|
||||
interface SaveInput { name: string; config: CardConfig; image: Blob; imageChanged: boolean; preview: Blob; previous: SavedCardRef | null }
|
||||
normalizeName(name: string): string // trim, Leerraum zusammenfassen, max. 80 Codepoints, leer → 'Meine Karte'
|
||||
ownerPermissions(userId: string): string[] // ['read("user:<id>")','update("user:<id>")','delete("user:<id>")']
|
||||
toUploadFile(blob: Blob, base: 'image' | 'preview'): File // Name base + '.' + Endung aus blob.type: png/jpg/webp; anderer Typ → Error
|
||||
createCardsService(api: CardsApi, userId: () => string): {
|
||||
list(): Promise<SavedCard[]>; get(id: string): Promise<SavedCard>; save(input: SaveInput): Promise<SavedCard>;
|
||||
rename(id: string, name: string): Promise<SavedCard>; remove(card: SavedCardRef): Promise<void>;
|
||||
loadImage(card: SavedCardRef): Promise<Blob>; loadPreview(card: SavedCardRef): Promise<Blob>
|
||||
}
|
||||
appwriteCardsApi(): CardsApi
|
||||
// with-login.ts
|
||||
withLogin<T>(fn: () => Promise<T>, requireLoginFn?: typeof requireLogin): Promise<T> // bei isUnauthorized einmal requireLogin, dann fn erneut; Abbruch → throw LoginCancelled
|
||||
class LoginCancelled extends Error {}
|
||||
// save.ts
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
createSaveAction(deps: { getUser(): Promise<User | null>; requireLogin(): Promise<boolean>; doSave(): Promise<void>; onStatus(s: SaveStatus, message?: string): void }): () => Promise<void>
|
||||
```
|
||||
- `appwriteCardsApi`:
|
||||
- `createFile` → `storage.createFile({ bucketId: IDS.bucket, fileId: ID.unique(), file, permissions })`.
|
||||
- `createRow` → `tables.createRow({ databaseId: IDS.database, tableId: IDS.table, rowId: ID.unique(), data, permissions })`; `updateRow` ohne `permissions`.
|
||||
- `listRows` → `queries: [Query.orderDesc('$updatedAt'), Query.limit(100)]`, Ergebnis `.rows`.
|
||||
- `fetchFile`: `const url = new URL(\`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${id}/view\`); const { uri, options } = client.prepareRequest('GET', url); const res = await fetch(uri, options)`; bei `!res.ok` JSON lesen und `throw new AppwriteException(j.message ?? res.statusText, res.status, j.type ?? '', text)`; Blob ohne `image/`-Typ → `AppwriteException('Unerwartete Antwort', 502, 'general_unknown', '')`. Kommentar: kein `getFileView` (Projekt als Query, trifft die Traefik-Route nicht).
|
||||
- `userId`-Thunk im Editor: `() => { const id = currentUserId(); if (!id) throw Object.assign(new Error('unauthorized'), { code: 401 }); return id }`.
|
||||
- Regeln für `save`:
|
||||
- neu (`previous === null`): `createFile(toUploadFile(image,'image'))` → `createFile(toUploadFile(preview,'preview'))` → `createRow` mit `name: normalizeName(name)`, `config: serializeConfig(config)`; scheitert ein Schritt, bereits hochgeladene Dateien löschen, Fehler weiterwerfen.
|
||||
- bestehend: Vorschau immer neu; Bild nur wenn `imageChanged`; `updateRow`; scheitert das Update, nur die neuen Dateien löschen. Danach alte Vorschau (und ggf. altes Bild) löschen – **best effort**: Fehler (auch 404) verschlucken, `save` löst mit der neuen `SavedCard` auf.
|
||||
- Dateien und Zeilen immer mit `ownerPermissions(userId())`; wirft `userId()`, wird nichts hochgeladen.
|
||||
- `remove`: Zeile löschen, dann beide Dateien; Datei-Fehler ignorieren.
|
||||
- `createSaveAction`: läuft schon ein Speichern → sofort zurück; ohne Nutzer → `requireLogin()`, bei `false` Status `idle`, Ende; `doSave()`; bei `isUnauthorized` einmal `requireLogin()` (bei `false` → `idle`) und erneut `doSave()`; Erfolg → `saved`; Fehler → `error` mit „Speichern fehlgeschlagen – “ + `errorMessage(err, 'save')`.
|
||||
- Editor `doSave`: ohne `imageBlob` → `onStatus('error', 'Bitte zuerst ein Bild wählen.')` und Ende; erst `await getCurrentUser()` (Cache warm); Vorschau per `exportCard(config, assets, { width: 480, type: 'image/webp', quality: 0.85 })`; `previous: store.get().saved`; nach Erfolg `saved` = Ergebnis, `imageChanged: false`. Knopf zeigt „Speichert …“ (deaktiviert), danach 2 s „Gespeichert“.
|
||||
- Laden `/?karte=<id>` über `withLogin`: `service.get` → `loadImage` → `decodeImage` → `setImage`, danach `config.crop = clampCrop(image, config.crop)`, `imageChanged: false`, `saved` = Karte; `storage_file_not_found` → Karte ohne Bild + Hinweis „Das Bild dieser Karte fehlt.“; `row_not_found` → Meldung aus `errorMessage` und leerer Editor; `LoginCancelled` → Hinweis „Melde dich an, um diese Karte zu öffnen.“
|
||||
- „Meine Karten“ (`/karten`): abgemeldet → „Melde dich an, um deine Karten zu sehen.“ + Knopf „Anmelden“; leer → „Noch keine Karten gespeichert.“ + Link zum Editor; sonst Kachelraster (Vorschau per `loadPreview` → Object-URL, beim Verlassen freigeben; Name; Datum `dd.mm.yyyy`). Aktionen: Öffnen (`navigate('/?karte=<id>')`), Herunterladen (Bild laden → `drawCard` → PNG mit `downloadFileName`), Umbenennen (Inline-Eingabe, Enter speichert, Esc bricht ab), Löschen (eigener Dialog „Karte „<Name>“ löschen?“, kein `window.confirm`). `list`, `rename`, `remove`, `loadImage`, `loadPreview` laufen über `withLogin`.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben**
|
||||
- `cards.test.ts` mit Fake-`CardsApi` (zeichnet Aufrufe auf, pro Methode einmal fehlschlagen lassen):
|
||||
- neu: zwei `createFile` mit `ownerPermissions('u1')`, Dateinamen `image.png`/`preview.webp` (je nach Blob-Typ; JPEG → `image.jpg`); `createRow` mit `serializeConfig(c)`; Ergebnis mit geparster Config.
|
||||
- neu, `createRow` wirft → beide neuen Dateien gelöscht, Fehler kommt an.
|
||||
- neu, `userId` wirft → kein `createFile`.
|
||||
- bestehend ohne Bildwechsel: ein `createFile` (Vorschau), `updateRow` mit neuer `previewFileId`, danach `deleteFile(alteVorschau)`, altes Bild bleibt.
|
||||
- bestehend mit Bildwechsel: zwei `createFile`, nach `updateRow` alte Dateien gelöscht.
|
||||
- bestehend, `updateRow` wirft → nur neue Dateien gelöscht.
|
||||
- bestehend, `deleteFile(alteVorschau)` wirft → `save` löst trotzdem auf.
|
||||
- `remove`: `deleteRow` vor `deleteFile`; `deleteFile` wirft → löst auf.
|
||||
- `list`: Zeile mit kaputtem `config` → `DEFAULT_CONFIG`.
|
||||
- `toUploadFile(new Blob([], {type:'image/gif'}), 'image')` → wirft.
|
||||
- `normalizeName`: `' Mein Urlaub '` → `'Mein Urlaub'`; `' '` → `'Meine Karte'`; 100 × `'😀'` → 80 Codepoints; 81 ASCII-Zeichen → 80.
|
||||
- `with-login.test.ts`: `fn` wirft `{code:401}`, `requireLogin → true` → `fn` zweimal, Ergebnis kommt an; `requireLogin → false` → `LoginCancelled`; anderer Fehler → durchgereicht, kein `requireLogin`.
|
||||
- `save.test.ts`: zwei schnelle Aufrufe → `doSave` einmal; ohne Nutzer + `requireLogin → false` → `doSave` nie, letzter Status `idle`; `doSave` wirft `{code:401}`, `requireLogin → true` → `requireLogin` einmal, `doSave` zweimal, Status `saved`; 401 und `requireLogin → false` → `idle`; anderer Fehler → `error` mit Präfix „Speichern fehlgeschlagen – “.
|
||||
- [ ] **Step 2: Tests laufen lassen** → FAIL.
|
||||
- [ ] **Step 3: Implementieren.**
|
||||
- [ ] **Step 4: Tests + Build** – PASS; `npm run build` ok.
|
||||
- [ ] **Step 5: Commit + Push** – `git commit -m "feat(cards): Karten speichern und Meine Karten"`; Push nach `main`, Build abwarten.
|
||||
- [ ] **Step 6: Live prüfen** (Claude in Chrome in Kensos angemeldeter Testsitzung): Karte gestalten → Speichern → neu laden → `/karten` zeigt Kachel → Öffnen lädt Bild und Einstellungen → Bild ersetzen + Speichern → Umbenennen → Herunterladen → eine eigens angelegte Testkarte löschen. Kenso meldet sich einmal ab, klickt Speichern und meldet sich im Dialog an → Karte gespeichert. **Pflicht – zweites Konto:** Kenso legt in einem privaten Fenster ein zweites Testkonto an und meldet sich dort an; Claude prüft in diesem Fenster `fetch('/v1/tablesdb/aircard/tables/cards/rows/<ID Konto 1>', {headers: {'X-Appwrite-Project': 'aircard'}})` → 404 `row_not_found`, `fetch('/v1/storage/buckets/card-images/files/<Datei-ID Konto 1>/view', {headers: {'X-Appwrite-Project': 'aircard'}})` → 404, `/?karte=<ID Konto 1>` zeigt „Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.“, `/karten` zeigt „Noch keine Karten gespeichert.“
|
||||
|
||||
### Task 10: Abnahme und Abschluss
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (Zweck, lokale Entwicklung, Tests, Deploy, Appwrite-Setup, Verweis auf Spec/Plan)
|
||||
|
||||
- [ ] **Step 1: Gesamtprüfung nach Spec §8** – jeden Punkt der Abnahmeliste einzeln abhaken (Befehle aus Task 6 Step 9 – jetzt mit `{"version":"1.8.1"}` für den Header-Check –, Task 8 Step 6, Task 9 Step 6 inkl. zweitem Konto). Zusätzlich: jede Vorlage × Hell/Dunkel einmal heruntergeladen und angesehen (1536 × 969, eckig, Freibereich unten links nur Bild); im Scratchpad die PNG-Kopfdaten prüfen: Bytes 16–23 = Breite 1536, Höhe 969; Farbtyp (Byte 25) 2 = RGB erwartet – ist er 6 (RGBA), im Bericht als „deckendes RGBA“ vermerken; transparentes PNG als Quelle → deckendes Ergebnis; HEIC → Meldung; Handy-Breite 390 px (`resize_window`): kein waagerechtes Scrollen, Vorschau oben, Vorlagenreihe scrollt waagerecht.
|
||||
- [ ] **Step 2: Testkonten** – Kenso entscheidet, ob er sie behält oder in der Appwrite-Console löscht (Claude löscht keine Konten).
|
||||
- [ ] **Step 3: README + Commit + Push** – `git commit -m "docs: README für AirCard"`; Push.
|
||||
- [ ] **Step 4: Bericht** an Kenso: was live ist, Links, offene Punkte (www-DNS, Projekt-SMTP optional, Backup der Appwrite-Uploads separat); Memory `aircard-website.md` aktualisieren.
|
||||
Reference in new issue
Block a user