Compare commits
250 Commits
3b1950713f
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b0bd4dc44 | ||
|
|
e7318164cb | ||
|
|
2216c89a04 | ||
|
|
01d29bff0e | ||
|
|
a5321d620a | ||
|
|
b31223add5 | ||
|
|
f495c024c6 | ||
|
|
1214b9e01d | ||
|
|
82d4348873 | ||
|
|
6f54b004ca | ||
|
|
226ca5e5ed | ||
|
|
5357c9787b | ||
|
|
6c8de6fa3a | ||
|
|
866a222265 | ||
|
|
543008b0f2 | ||
|
|
2cd9b47450 | ||
|
|
98894bb895 | ||
|
|
363ea6fbe7 | ||
|
|
005c3ea7b5 | ||
|
|
1d7731edbb | ||
|
|
0bfeba2c0a | ||
|
|
f3e2cebfb4 | ||
|
|
442076a278 | ||
|
|
4afc597689 | ||
|
|
42b1aed023 | ||
|
|
a15390f4b8 | ||
|
|
52bb83cbd5 | ||
|
|
4e902b1d98 | ||
|
|
0346a699b9 | ||
|
|
f4eac4d9c3 | ||
|
|
3c30d1f35a | ||
|
|
943a645095 | ||
|
|
7fa1079125 | ||
|
|
0e6d2c93a6 | ||
|
|
1bd5dd106f | ||
|
|
dc15cf04a9 | ||
|
|
e53cdc96fe | ||
|
|
a500a5623e | ||
|
|
2750c298e9 | ||
|
|
7baf60f422 | ||
|
|
e176b8c3f2 | ||
|
|
8570d41f53 | ||
|
|
76864a6034 | ||
|
|
2c61d82935 | ||
|
|
974227590f | ||
|
|
1889b0dea0 | ||
|
|
494b672e8d | ||
|
|
c31a9c6110 | ||
|
|
85bf197084 | ||
|
|
83fe95ac76 | ||
|
|
95ba14ad6f | ||
|
|
8ceb5e95d7 | ||
|
|
7dab267033 | ||
|
|
45223df86d | ||
|
|
fd5d759336 | ||
|
|
956357d5ca | ||
|
|
d9490c8073 | ||
|
|
0373dc32da | ||
|
|
272a07777e | ||
|
|
efdcace892 | ||
|
|
fb7c2f0e9b | ||
|
|
33ee6fbf2e | ||
|
|
e2713913e7 | ||
|
|
3bc7fa16e2 | ||
|
|
173d9d138d | ||
|
|
5492d4dc24 | ||
|
|
39de08abf9 | ||
|
|
fd7884e1b2 | ||
|
|
13728f9252 | ||
|
|
83f5b88d94 | ||
|
|
cb93725139 | ||
|
|
80c72b6e5b | ||
|
|
b88f1fbfa4 | ||
|
|
f4aefb8e99 | ||
|
|
6dab36339a | ||
|
|
eea5fb7560 | ||
|
|
47e91de0a1 | ||
|
|
bc42f35f8c | ||
|
|
8c23875ba2 | ||
|
|
06e60afc88 | ||
|
|
e01f15a2a6 | ||
|
|
3f259a7870 | ||
|
|
904edcb3ff | ||
|
|
d479fd61d8 | ||
|
|
0cca9a699c | ||
|
|
c284f4b85b | ||
|
|
9e3d6e8d01 | ||
|
|
783b782608 | ||
|
|
1532880cd5 | ||
|
|
aa7f0eff11 | ||
|
|
26018eee7f | ||
|
|
24bd9c1d1b | ||
|
|
633e497bdc | ||
|
|
b5c01b950e | ||
|
|
6bde3909d8 | ||
|
|
78c4f56992 | ||
|
|
c07d2f99ad | ||
|
|
8069c5c246 | ||
|
|
7d6ee04fec | ||
|
|
b646720a6e | ||
|
|
526c7433f4 | ||
|
|
96cb55495e | ||
|
|
a1baf7f30a | ||
|
|
b0d5f921e2 | ||
|
|
72816d6b35 | ||
|
|
ad5a6afcd9 | ||
|
|
30a409fd16 | ||
|
|
504fbb6cc6 | ||
|
|
d50841c5a6 | ||
|
|
defbb5e24d | ||
|
|
c43b1dca87 | ||
|
|
015cb432fb | ||
|
|
f273942286 | ||
|
|
c45ef2a613 | ||
|
|
e7067971a5 | ||
|
|
0ca42f3329 | ||
|
|
4b17f19038 | ||
|
|
4edddc38e3 | ||
|
|
fc47c78397 | ||
|
|
58ce19c160 | ||
|
|
7fd90643c5 | ||
|
|
3021ccb6a9 | ||
|
|
a7ad159c69 | ||
|
|
7da37d0a3d | ||
|
|
e953ca7870 | ||
|
|
c1789f902e | ||
|
|
02b9cdbc68 | ||
|
|
5a291a53dd | ||
|
|
98a8022ddf | ||
|
|
5a1ffee3bb | ||
|
|
9ee8efa479 | ||
|
|
2c1fd29003 | ||
|
|
cda6e77a9e | ||
|
|
85fe1312ca | ||
|
|
31c6e5cd1f | ||
|
|
6d9e79d4f0 | ||
|
|
60c8352c96 | ||
|
|
30a447a3ea | ||
|
|
ff293e9db8 | ||
|
|
739cc2d058 | ||
|
|
830c740747 | ||
|
|
2289547503 | ||
|
|
10c43c4d4a | ||
|
|
5283ab9b51 | ||
|
|
aaaf762564 | ||
|
|
dc04f5b032 | ||
|
|
2f2f7dc7e7 | ||
|
|
76ea5bed8d | ||
|
|
f89f363183 | ||
|
|
854af2fc34 | ||
|
|
1bec054ec6 | ||
|
|
c2074c9768 | ||
|
|
858d4c1622 | ||
|
|
42f79f122b | ||
|
|
3d6f6393b3 | ||
|
|
0ede62dc8a | ||
|
|
1a4f7b5f20 | ||
|
|
528508a304 | ||
|
|
8bb208a613 | ||
|
|
3906781c4e | ||
|
|
447ff2be32 | ||
|
|
51a88a4c58 | ||
|
|
582d902c62 | ||
|
|
7c8edb9b92 | ||
|
|
d38992661c | ||
|
|
02df0331b7 | ||
|
|
d08cefa5c9 | ||
|
|
0c66bd677e | ||
|
|
04641355df | ||
|
|
0b12aa027f | ||
|
|
60f6db9091 | ||
|
|
303939a6ff | ||
|
|
2807dd1cab | ||
|
|
7233cc3a13 | ||
|
|
297281e201 | ||
|
|
194aee269e | ||
|
|
361164febd | ||
|
|
8e33b52f66 | ||
|
|
60d0cd7659 | ||
|
|
a10ebefb75 | ||
|
|
e56c1543d8 | ||
|
|
8c93099d91 | ||
|
|
f92ce677f6 | ||
|
|
cbf9b94aa3 | ||
|
|
7070a83991 | ||
|
|
a2b3c8981c | ||
|
|
68e27a6868 | ||
|
|
351434f43d | ||
|
|
49d4e60a1c | ||
| 553bf4f924 | |||
|
|
1b31a8ff1e | ||
|
|
c79cf8657d | ||
|
|
9a5c626890 | ||
|
|
ee783ff50b | ||
|
|
61c1b9558e | ||
|
|
340ab5e558 | ||
|
|
09c0270c64 | ||
|
|
a1d91943c6 | ||
|
|
9e471c7bf3 | ||
|
|
82e8371451 | ||
|
|
a8fdb8c3f9 | ||
|
|
3e41505b81 | ||
|
|
4465744838 | ||
|
|
3e3afc0102 | ||
|
|
272935034d | ||
|
|
c87196cd67 | ||
|
|
aad3ad689d | ||
|
|
ab2acb6437 | ||
|
|
d1ddd51da1 | ||
|
|
15442ff72b | ||
|
|
52858f94fe | ||
|
|
2e196b4834 | ||
|
|
15c15c8494 | ||
|
|
6c2b24d060 | ||
|
|
a590cf0a57 | ||
|
|
d004430854 | ||
|
|
864d113082 | ||
|
|
0992e51a5d | ||
|
|
d3c9bc5619 | ||
|
|
342ea0efc8 | ||
|
|
dbc9646caa | ||
|
|
c27c2dbc62 | ||
|
|
1b7c5c084e | ||
|
|
a62b32aa1e | ||
|
|
b4a7355b24 | ||
|
|
f72fe64d8e | ||
|
|
dd52e44f67 | ||
|
|
5e7e37cc3c | ||
|
|
018fc987cd | ||
|
|
60021b879f | ||
|
|
224352d051 | ||
|
|
8db67bd1a5 | ||
|
|
1055a670da | ||
|
|
7cac02de5a | ||
|
|
657d006441 | ||
|
|
cf31e79fb0 | ||
|
|
347b1de555 | ||
|
|
4d90d51501 | ||
|
|
1712263fd1 | ||
|
|
53e4815508 | ||
|
|
211d58ebec | ||
|
|
9bc4465061 | ||
|
|
6a784488f5 | ||
|
|
3cd22544d3 | ||
|
|
d693cb422d | ||
|
|
76110f9841 | ||
|
|
d737618312 | ||
|
|
84655151be | ||
| 4f7c76c908 | |||
| 1b9928f806 |
20
.env.example
20
.env.example
@@ -1,3 +1,23 @@
|
|||||||
|
# Kopiere zu .env und trage deine Werte ein.
|
||||||
|
# .env ist per .gitignore ausgenommen — Secrets landen nie im Repo.
|
||||||
|
|
||||||
|
# Kochwas-App (nur relevant, wenn du die App lokal startest; die Compose-
|
||||||
|
# Setups setzen ihre eigenen Pfade im Container).
|
||||||
DATABASE_PATH=./data/kochwas.db
|
DATABASE_PATH=./data/kochwas.db
|
||||||
IMAGE_DIR=./data/images
|
IMAGE_DIR=./data/images
|
||||||
SEARXNG_URL=http://localhost:8888
|
SEARXNG_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# Brave Search API-Key (https://api-dashboard.search.brave.com/).
|
||||||
|
# Leer lassen, wenn du ohne Brave testen willst — andere Engines laufen
|
||||||
|
# trotzdem. Fehlt der Key, antwortet die Brave-Engine nur mit 401.
|
||||||
|
BRAVE_API_KEY=
|
||||||
|
|
||||||
|
# SearXNG-Secret: beliebig lange Zufallskette. Für Prod mit
|
||||||
|
# `openssl rand -hex 32` generieren und in der Pi-.env ablegen.
|
||||||
|
SEARXNG_SECRET=dev-secret-change-me
|
||||||
|
|
||||||
|
# Gemini Vision (Foto-Rezept-Magie). Ohne Key ist die Funktion graceful
|
||||||
|
# deaktiviert — der Kamera-Button erscheint dann gar nicht erst.
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
GEMINI_MODEL=gemini-2.5-flash
|
||||||
|
GEMINI_TIMEOUT_MS=20000
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Build & Publish Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: ['**']
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,3 +5,9 @@ data/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright-report-remote/
|
||||||
|
.playwright-mcp/
|
||||||
|
.claude/
|
||||||
|
ci-log.txt
|
||||||
|
|||||||
24
.prettierignore
Normal file
24
.prettierignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generierte / Build-Artefakte
|
||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
.vite
|
||||||
|
|
||||||
|
# Lockfiles
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Lokale Laufzeit-Daten
|
||||||
|
data
|
||||||
|
|
||||||
|
# Test-Fixtures: rohe HTML-Captures muessen byte-exakt bleiben,
|
||||||
|
# sonst schlaegt die JSON-LD-Extraktion im Parser-Test anders an.
|
||||||
|
tests/fixtures
|
||||||
|
|
||||||
|
# Markdown: Tabellen sind hand-aligned, Code-Bloecke in historischen
|
||||||
|
# Plaenen sollen nicht nachtraeglich umgebrochen werden.
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# SearXNG-Config ist ein Template mit ${VAR}-Platzhaltern, die der
|
||||||
|
# Init-Container expandiert.
|
||||||
|
searxng/settings.yml
|
||||||
80
CLAUDE.md
Normal file
80
CLAUDE.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Kochwas — Hinweise für Claude (Fortsetzung nach Session-Neustart)
|
||||||
|
|
||||||
|
> **Lies mich zuerst.** Wenn du eine neue Session öffnest und hier weiterarbeitest, steht hier das Wesentliche. Tiefer: `docs/ARCHITECTURE.md` (Code) und `docs/OPERATIONS.md` (Deployment).
|
||||||
|
|
||||||
|
## Was das ist
|
||||||
|
Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://kochwas.siegeln.net`. Deutschsprachiges UI, ohne Login, Profile werden per Klick gewählt. Läuft in Docker auf einem Raspberry Pi 5 (arm64).
|
||||||
|
|
||||||
|
## Wichtigste Gotchas (wiederkehrende Stolpersteine)
|
||||||
|
|
||||||
|
| Thema | Regel |
|
||||||
|
|---|---|
|
||||||
|
| **Node-Binding** | `better-sqlite3` ist **synchron** und native — im `Dockerfile` gibt es einen Build-Stage, der das Native-Module explizit für arm64 baut. |
|
||||||
|
| **Healthcheck** | Muss `127.0.0.1` verwenden, nicht `localhost`. Node bindet nur IPv4; `localhost` wird oft zu `::1` aufgelöst und der Check schlägt fehl. Traefik filtert unhealthy Container raus → kein Routing, kein ACME. |
|
||||||
|
| **SearXNG Bot-Detection** | Bei Requests aus dem Docker-Netzwerk müssen `X-Forwarded-For: 127.0.0.1` und `X-Real-IP: 127.0.0.1` im Header stehen (`src/lib/server/http.ts` `extraHeaders`). Sonst 403. |
|
||||||
|
| **Traefik Cloudflare-Token** | Token muss `Edit zone DNS` Berechtigung für `siegeln.net` haben. Expired Tokens → DNS-Challenge failt → Let's-Encrypt-Rate-Limit nach 5 Versuchen in 1 h. |
|
||||||
|
| **Wildcard-Cert** | Für neue Subdomains auf siegeln.net sollten die Labels das Wildcard nutzen, nicht per-Host-Cert: `tls.domains[0].main=siegeln.net` + `sans=*.siegeln.net`. |
|
||||||
|
| **Migrations** | Werden via Vite `import.meta.glob('./migrations/*.sql', {eager, query:'?raw'})` gebundelt. Neue Migration einfach als `00N_name.sql` ablegen, kein Copy-in-Dockerfile nötig. |
|
||||||
|
| **$lib/server in Client** | Svelte-Import aus `$lib/server/*` in einem `.svelte`-Komponenten-Script bricht den Build. Pures JS/TS, das beidseitig funktioniert (z. B. Portionen-Scaler), gehört nach `$lib/`, nicht `$lib/server/`. |
|
||||||
|
| **Preview-Bilder** | `recipe.image_path` kann **absolute URL** (Preview-Modus) oder **lokaler Filename** sein. `RecipeView.svelte` prüft mit `/^https?:\/\//i`. |
|
||||||
|
| **Service Worker nur ab HTTPS** | `npm run dev` liefert HTTP → SW registriert nicht. Für PWA-Tests `npm run build && npm run preview` (localhost) oder Prod-Docker. |
|
||||||
|
| **Icon-Rendering** | `npm run render:icons` rendert `icon-192.png` + `icon-512.png` aus `static/icon.svg`. Nur nach SVG-Änderung erneut ausführen + committen. |
|
||||||
|
| **Gemini-Key fehlt** | Wenn `GEMINI_API_KEY` leer ist, rendert das Layout das Camera-Icon nicht, und `/new/from-photo` antwortet mit 503 (`+page.server.ts`-Gate). Graceful Degradation — kein Zombie-Button. |
|
||||||
|
| **sharp + libheif** | Im `Dockerfile`-Builder-Stage ist `vips-dev` nötig, damit `sharp` HEIC-Input (iOS) lesen kann. Runtime-Stage braucht nix zusätzlich (sharp bringt libvips prebuilt mit). |
|
||||||
|
|
||||||
|
## Dateien, die man typischerweise anfasst
|
||||||
|
|
||||||
|
- `src/routes/+page.svelte` — Startseite mit Live-Search + Quote
|
||||||
|
- `src/routes/+layout.svelte` — Header, mobile expand, Dropdown-Search auf Rezeptseiten
|
||||||
|
- `src/routes/recipes/[id]/+page.svelte` — Rezept-Detail mit allen Actions (Rating, Favorit, Cooked, Wunschliste, Kommentar, Umbenennen, Löschen)
|
||||||
|
- `src/routes/preview/+page.svelte` — importierte Vorschau vor dem Speichern
|
||||||
|
- `src/routes/new/from-photo/+page.svelte` — Foto-Rezept-Magie (Picker → Spinner → vorbefüllter Editor)
|
||||||
|
- `src/lib/server/ai/` — Gemini-Client, Prompt-Schema, image-preprocess, rate-limit, description-phrases
|
||||||
|
- `src/lib/components/RecipeView.svelte` / `RecipeEditor.svelte` — Lesen/Edit-Mode des Rezepts. Editor ist in Sub-Components aufgeteilt: `IngredientRow`, `StepList`, `ImageUploadBox`, `TimeDisplay` (+ shared types `recipe-editor-types.ts`)
|
||||||
|
- `src/lib/server/search/searxng.ts` — Web-Suche + Thumbnail-Enrichment + SQLite-Cache
|
||||||
|
- `src/lib/server/recipes/importer.ts` — JSON-LD → Recipe, orchestriert Bild-Download
|
||||||
|
- `src/lib/server/db/migrations/*.sql` — Schema; bei Änderung immer **neue** Migration statt bestehende bearbeiten
|
||||||
|
- `src/service-worker.ts` — Service-Worker-Orchestrator (Shell-Cache + Pre-Cache + SWR)
|
||||||
|
- `src/lib/sw/` — reine Logik (Cache-Strategy-Entscheider, Diff-Manifest) für Unit-Tests
|
||||||
|
- `src/lib/client/*.svelte.ts` — Frontend-Stores (Search, Network, Sync-Status, Toast, Install-Prompt, Wishlist, PWA, Profile, Confirm, Search-Filter)
|
||||||
|
- `tests/e2e/remote/` — Playwright gegen `kochwas-dev.siegeln.net` (CRUD erlaubt; workers:1, serviceWorkers:block)
|
||||||
|
|
||||||
|
## Arbeitsweise (wie wir es machen)
|
||||||
|
|
||||||
|
- **Terse Antworten auf Deutsch**; Code-Kommentare auf Englisch, sparsam.
|
||||||
|
- **Commits** kleinteilig, deutscher Body, englische Zeile, Subject unter 72 Zeichen.
|
||||||
|
- **Tests nach jeder Änderung**: `npm test` (vitest) + `npm run check` (svelte-check). Beides muss grün sein, bevor gepusht wird.
|
||||||
|
- **Push nach jedem Commit**, außer der Nutzer sagt explizit nein. CI baut dann das arm64-Image und published es nach `gitea.siegeln.net/claude/kochwas:latest`.
|
||||||
|
- **Keine Backwards-Compat-Krücken** für nicht-ausgelieferten Code. Direkt refactoren, alte Signaturen raus.
|
||||||
|
- **Nie mit `--no-verify`** committen. Wenn ein Hook fehlschlägt, den echten Grund beheben.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # erstes Mal
|
||||||
|
npm run dev # lokal auf http://localhost:5173
|
||||||
|
npm test # volle Vitest-Suite
|
||||||
|
npm run check # svelte-check Types
|
||||||
|
npm run format # Prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
Lokaler Docker-Test des Prod-Builds:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was NICHT tun
|
||||||
|
|
||||||
|
- Keine neuen Top-Level-Docs erzeugen, wenn ein bestehendes Dokument (Specs, Plans, ARCHITECTURE, OPERATIONS) passt.
|
||||||
|
- Keine Emojis in Code/Commits — außer UI-Icons (🍽️, ⚙️, 🥘 etc.) sind explizit im UX-Design.
|
||||||
|
- Keine `alert()`/`confirm()` — wir haben `alertAction()` / `confirmAction()` in `src/lib/client/confirm.svelte.ts`.
|
||||||
|
- Keine hardcoded `localhost` in Healthchecks → `127.0.0.1`.
|
||||||
|
- Keinen sensiblen Output in Commits (Cloudflare-Tokens, acme.json).
|
||||||
|
|
||||||
|
## Offene Themen / Stand
|
||||||
|
|
||||||
|
Siehe die Plan-Dateien unter `docs/superpowers/plans/*.md` für abgeschlossene Implementierungs-Phasen (v1.0 Foundations → v1.1 Offline-PWA → Post-Review-Roadmap → Search-State-Store → Editor-Split → Ingredient-Sections = v1.2). Was als „Later" markiert ist, ist nicht beauftragt.
|
||||||
|
|
||||||
|
## Auto-Memory (lokal, nicht im Repo)
|
||||||
|
|
||||||
|
Persönliche Präferenzen / projektspezifische Entscheidungen landen in deinem Auto-Memory unter `~/.claude/projects/C--Users-Hendrik-Documents-projects-kochwas/memory/`. Der aktuelle Index (`MEMORY.md`) hält fest: Deployment-Target, Registry. Bei Bedarf erweitern — nicht in dieser Datei dokumentieren, da sie versioniert ist.
|
||||||
37
Dockerfile
37
Dockerfile
@@ -3,17 +3,44 @@
|
|||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Alpine needs build tools for better-sqlite3 native module
|
# Alpine needs build tools for better-sqlite3 native module.
|
||||||
RUN apk add --no-cache python3 make g++ libc6-compat
|
# vips-dev provides libvips + libheif for sharp (incl. HEIC input from iOS).
|
||||||
|
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
# Sharp-Prebuilt-Install unter Docker-Buildx-QEMU war trotz aller Flag-
|
||||||
|
# Varianten unzuverlaessig. Finale Strategie:
|
||||||
|
# - --cpu/--os/--libc explizit setzen: sharp's offizielle Doc-Empfehlung
|
||||||
|
# fuer Cross-Platform-Docker-Builds (siehe sharp-Install-Doku),
|
||||||
|
# umgeht QEMU-Detection-Bugs.
|
||||||
|
# - --ignore-scripts + npm rebuild: loest das Parallel-Install-Race,
|
||||||
|
# bei dem sharp's install-Skript vor dem Entpacken der Prebuilt-Binary
|
||||||
|
# laeuft.
|
||||||
|
# - Explizites Nachinstallieren der Prebuilts als Sicherheit: falls (A)
|
||||||
|
# noch nicht reicht, zwingt (B) die Plattform-Pakete auf Disk.
|
||||||
|
# - node-addon-api + node-gyp als Runtime-Deps: falls am Ende doch alles
|
||||||
|
# nicht klappt und sharp from-source baut (mit dem oben installierten
|
||||||
|
# python3 + make + g++ + vips-dev).
|
||||||
|
RUN npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --include=optional --no-audit --no-fund
|
||||||
|
RUN npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --no-save --no-audit --no-fund \
|
||||||
|
@img/sharp-linuxmusl-arm64@0.34.5 \
|
||||||
|
@img/sharp-libvips-linuxmusl-arm64@1.2.4
|
||||||
|
RUN npm rebuild
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Remove dev dependencies for the runtime image
|
# Fresh-Install fuer den Runtime-Stage: nur Produktions-Deps, gleiche Strategie.
|
||||||
RUN npm prune --omit=dev
|
RUN rm -rf node_modules \
|
||||||
|
&& npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --omit=dev --include=optional --no-audit --no-fund \
|
||||||
|
&& npm install --cpu=arm64 --os=linux --libc=musl \
|
||||||
|
--ignore-scripts --no-save --no-audit --no-fund \
|
||||||
|
@img/sharp-linuxmusl-arm64@0.34.5 \
|
||||||
|
@img/sharp-libvips-linuxmusl-arm64@1.2.4 \
|
||||||
|
&& npm rebuild
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ services:
|
|||||||
- IMAGE_DIR=/data/images
|
- IMAGE_DIR=/data/images
|
||||||
- SEARXNG_URL=http://searxng:8080
|
- SEARXNG_URL=http://searxng:8080
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
# Im Header als kleine Versionsnummer unter dem Logo angezeigt.
|
||||||
|
- KOCHWAS_TAG=${KOCHWAS_TAG:-dev}
|
||||||
|
# Gemini (Foto-Rezept-Magie). Leer = Feature deaktiviert.
|
||||||
|
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
|
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
|
||||||
|
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
|
||||||
|
# adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
|
||||||
|
# JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
|
||||||
|
# Multipart-Overhead.
|
||||||
|
- BODY_SIZE_LIMIT=25000000
|
||||||
depends_on:
|
depends_on:
|
||||||
- searxng
|
- searxng
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -23,23 +33,55 @@ services:
|
|||||||
- "traefik.http.routers.kochwas.rule=Host(`kochwas.siegeln.net`)"
|
- "traefik.http.routers.kochwas.rule=Host(`kochwas.siegeln.net`)"
|
||||||
- "traefik.http.routers.kochwas.entrypoints=websecure"
|
- "traefik.http.routers.kochwas.entrypoints=websecure"
|
||||||
- "traefik.http.routers.kochwas.tls.certresolver=cloudflareResolver"
|
- "traefik.http.routers.kochwas.tls.certresolver=cloudflareResolver"
|
||||||
|
- "traefik.http.routers.kochwas.tls.domains[0].main=siegeln.net"
|
||||||
|
- "traefik.http.routers.kochwas.tls.domains[0].sans=*.siegeln.net"
|
||||||
# Specify which port Traefik should forward traffic to inside the container
|
# Specify which port Traefik should forward traffic to inside the container
|
||||||
- "traefik.http.services.kochwas.loadbalancer.server.port=3000"
|
- "traefik.http.services.kochwas.loadbalancer.server.port=3000"
|
||||||
# Explicitly tell Traefik which network to use (since kochwas is on two networks)
|
# Explicitly tell Traefik which network to use (since kochwas is on two networks)
|
||||||
- "traefik.docker.network=traefik_proxy"
|
- "traefik.docker.network=traefik_proxy"
|
||||||
|
|
||||||
|
# Ein-Shot-Init: expandiert ${…}-Platzhalter in der Source-settings.yml und
|
||||||
|
# legt das gerenderte File aufs searxng-config Named-Volume. Verwendet das
|
||||||
|
# gleiche SearXNG-Image — bereits gepullt, hat Python 3 an Bord. Kein
|
||||||
|
# zusätzliches Image, kein apk add gettext, kein fragiler entrypoint-Override
|
||||||
|
# am Hauptcontainer. FORCE_OWNERSHIP=false, damit der Init-Container nicht
|
||||||
|
# versucht den chown-Setup zu machen.
|
||||||
|
searxng-init:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
restart: 'no'
|
||||||
|
user: root
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
python3 -c "import os; open('/out/settings.yml','w').write(os.path.expandvars(open('/in/settings.yml').read()))"
|
||||||
|
volumes:
|
||||||
|
- ./searxng:/in:ro
|
||||||
|
- searxng-config:/out
|
||||||
|
environment:
|
||||||
|
- FORCE_OWNERSHIP=false
|
||||||
|
- BRAVE_API_KEY=${BRAVE_API_KEY:-}
|
||||||
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-dev-secret-change-in-prod}
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
# Absichtlich nur intern erreichbar — keine Traefik-Labels, kein externer Port.
|
# Absichtlich nur intern erreichbar — keine Traefik-Labels, kein externer Port.
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
volumes:
|
volumes:
|
||||||
- ./searxng:/etc/searxng
|
- searxng-config:/etc/searxng
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=http://searxng:8080/
|
- BASE_URL=http://searxng:8080/
|
||||||
- INSTANCE_NAME=kochwas-search
|
- INSTANCE_NAME=kochwas-search
|
||||||
|
depends_on:
|
||||||
|
searxng-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
searxng-config:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik_proxy:
|
traefik_proxy:
|
||||||
# Dasselbe externe Netz wie bei deinem Gitea-Compose.
|
# Dasselbe externe Netz wie bei deinem Gitea-Compose.
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
|
# Dev-Setup: nur SearXNG läuft im Container; Kochwas selbst startest du
|
||||||
|
# lokal mit `npm run dev`. SEARXNG_URL=http://localhost:8888 wird von der
|
||||||
|
# App automatisch erkannt (oder via .env gesetzt).
|
||||||
|
#
|
||||||
|
# Starten:
|
||||||
|
# cp .env.example .env # einmalig, Werte anpassen
|
||||||
|
# docker compose up -d
|
||||||
|
# npm run dev
|
||||||
|
#
|
||||||
|
# Der Init-Container expandiert ${BRAVE_API_KEY} und ${SEARXNG_SECRET} aus
|
||||||
|
# der .env genau wie prod — damit testet man lokal mit dem gleichen Flow.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
searxng-init:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
restart: 'no'
|
||||||
|
user: root
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
python3 -c "import os; open('/out/settings.yml','w').write(os.path.expandvars(open('/in/settings.yml').read()))"
|
||||||
|
volumes:
|
||||||
|
- ./searxng:/in:ro
|
||||||
|
- searxng-config:/out
|
||||||
|
environment:
|
||||||
|
- FORCE_OWNERSHIP=false
|
||||||
|
- BRAVE_API_KEY=${BRAVE_API_KEY:-}
|
||||||
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-dev-secret-change-me}
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
ports:
|
ports:
|
||||||
- '8888:8080'
|
- '8888:8080'
|
||||||
volumes:
|
volumes:
|
||||||
- ./searxng:/etc/searxng
|
- searxng-config:/etc/searxng
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=http://localhost:8888/
|
- BASE_URL=http://localhost:8888/
|
||||||
- INSTANCE_NAME=kochwas-search-dev
|
- INSTANCE_NAME=kochwas-search-dev
|
||||||
|
depends_on:
|
||||||
|
searxng-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
searxng-config:
|
||||||
|
|||||||
161
docs/ARCHITECTURE.md
Normal file
161
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Kochwas — Architektur
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **SvelteKit 2** + **Svelte 5 Runes** (`$state`, `$derived`, `$effect`, `$props`)
|
||||||
|
- **TypeScript strict**
|
||||||
|
- **SQLite** über `better-sqlite3` (synchron, native Binding arm64)
|
||||||
|
- **FTS5** Virtual Tables mit BM25-Ranking für Volltext-Suche
|
||||||
|
- **linkedom** für HTML-Parsing (JSON-LD-Extraktion, og:image-Enrichment)
|
||||||
|
- **zod** für API-Schema-Validierung
|
||||||
|
- Adapter: `@sveltejs/adapter-node` → Node 22 Alpine im Container
|
||||||
|
|
||||||
|
## Top-Level-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app.html, app.d.ts # Shell + Env-Types
|
||||||
|
├── service-worker.ts # PWA-Shell
|
||||||
|
├── lib/
|
||||||
|
│ ├── client/ # reaktive Stores (Profile, Search, Wishlist, PWA, Network, Sync, Toast, Install, Confirm, API-Fetch-Wrapper)
|
||||||
|
│ ├── components/ # Svelte-Komponenten:
|
||||||
|
│ │ # - Recipe: RecipeView, RecipeEditor + Editor-Sub-Components
|
||||||
|
│ │ # (IngredientRow, StepList, ImageUploadBox, TimeDisplay, recipe-editor-types)
|
||||||
|
│ │ # - UI-Shell: ConfirmDialog, ProfileSwitcher, SyncIndicator, Toast, UpdateToast
|
||||||
|
│ │ # - Search: SearchFilter, SearchLoader, StarRating
|
||||||
|
│ ├── recipes/ # shared: Portionen-Scaler (Client UND Server)
|
||||||
|
│ ├── server/ # nur Server-Code (nie in Client-Bundle!)
|
||||||
|
│ │ ├── db/ # openDb, Migrations, DB-Singleton
|
||||||
|
│ │ ├── domains/ # Whitelist-Repo
|
||||||
|
│ │ ├── http.ts # fetch-Wrapper mit Timeout / maxBytes / extraHeaders
|
||||||
|
│ │ ├── images/ # Download, SHA256-Dedup, Save
|
||||||
|
│ │ ├── parsers/ # json-ld-recipe.ts, iso8601-duration.ts
|
||||||
|
│ │ ├── profiles/ # Profile-Repo
|
||||||
|
│ │ ├── recipes/ # importer, actions, repository, search-local
|
||||||
|
│ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache)
|
||||||
|
│ │ ├── wishlist/ # Repo
|
||||||
|
│ │ └── backup/ # ZIP-Export via archiver, Import via yauzl
|
||||||
|
│ ├── quotes.ts # 150 Flachwitze für die Homepage
|
||||||
|
│ └── types.ts # shared types
|
||||||
|
└── routes/
|
||||||
|
├── +layout.svelte # Header, Confirm-Dialog-Mount, Header-Search-Dropdown
|
||||||
|
├── +page.svelte # Home: Hero + Live-Search + Zuletzt-hinzugefügt
|
||||||
|
├── recipes/[id]/ # Rezept-Detail
|
||||||
|
├── preview/ # Vorschau vor dem Speichern
|
||||||
|
├── wishlist/
|
||||||
|
├── admin/ # Whitelist, Profile, Backup/Restore
|
||||||
|
├── images/[filename] # Statische Auslieferung lokaler Bilder
|
||||||
|
└── api/ # REST-Endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenfluss
|
||||||
|
|
||||||
|
### Import (User klickt auf Web-Treffer)
|
||||||
|
|
||||||
|
1. User klickt auf Web-Hit → `/preview?url=...`
|
||||||
|
2. `/api/recipes/preview` → `importer.ts` lädt HTML, `parseHTML` von linkedom, `json-ld-recipe.ts` extrahiert `Recipe`-Objekt mit **externer** Bild-URL
|
||||||
|
3. Preview-Seite rendert das `Recipe` via `RecipeView.svelte` (erkennt externe URL und lädt direkt vom Original-CDN)
|
||||||
|
4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `ingredient` + `step` + `recipe_tag`
|
||||||
|
5. Redirect zu `/recipes/[id]`
|
||||||
|
|
||||||
|
### Foto-Rezept-Magie (AI-Extraktion)
|
||||||
|
|
||||||
|
1. User klickt Camera-Icon im Header → `/new/from-photo` (nur gerendert wenn `GEMINI_API_KEY` gesetzt; disabled wenn offline)
|
||||||
|
2. File-Picker mit `capture="environment"` öffnet direkt die Rückkamera auf Mobile
|
||||||
|
3. Upload als `multipart/form-data` → `POST /api/recipes/extract-from-photo`
|
||||||
|
4. Server: MIME-Whitelist + 8 MB-Gate + Rate-Limit (10/min/IP) → `preprocessImage` (sharp, ≤1600px lange Kante, JPEG-Re-encode, Metadata-Strip) → `extractRecipeFromImage` (Gemini 2.5 Flash mit structured `responseSchema`, `temperature: 0.1`, Zod-validiert, 1× Retry bei Schema-Fehler oder 5xx) → zufällige Description aus `description-phrases.ts` (50er-Pool) → Response mit `Partial<Recipe>`
|
||||||
|
5. **Das Original-Foto wird nicht persistiert.** Der Server loggt keine Prompt/Response-Inhalte — nur Code, Dauer, Byte-Größe.
|
||||||
|
6. Client hält das Ergebnis im `PhotoUploadStore` und rendert `<RecipeEditor recipe={extracted}>`. Weil `recipe.id === null` ist, blendet der Editor den `ImageUploadBox`-Block aus und zeigt nur den Hinweis „Bild kannst du nach dem Speichern hinzufügen."
|
||||||
|
7. User editiert und klickt „Speichern" → `POST /api/recipes` (neuer Scratch-Insert-Endpoint, wrappt `insertRecipe`) → Redirect auf `/recipes/:id`
|
||||||
|
|
||||||
|
### Web-Suche
|
||||||
|
|
||||||
|
Die gesamte Live-Search-Logik ist im `SearchStore` (`src/lib/client/search.svelte.ts`) gekapselt: Debounce, Race-Guard, Pagination, Web-Fallback, Snapshot/Restore für Back-Nav. Sowohl Header-Dropdown (`+layout.svelte`) als auch Startseite (`+page.svelte`) teilen sich die Klasse mit unterschiedlicher `filterParam`-Quelle.
|
||||||
|
|
||||||
|
1. User tippt → 300 ms Debounce → `/api/recipes/search?q=...` (lokal FTS5)
|
||||||
|
2. Wenn 0 Treffer: automatisch `/api/recipes/search/web?q=...`
|
||||||
|
3. `searxng.ts` → SearXNG-API mit `site:domain OR site:domain2 ...`-Filter aus Whitelist
|
||||||
|
4. Filtert Non-Recipe-Pfade (Foren, Magazin, Listings) via `NON_RECIPE_PATH_PATTERNS`
|
||||||
|
5. Pro Treffer: parallel (max 6) `enrichThumbnail`:
|
||||||
|
- SQLite-Cache hit → return
|
||||||
|
- Sonst: Seite holen (max 512 KB, 4 s), `extractPageImage`: og:image → link rel=image_src → JSON-LD → erstes Content-img
|
||||||
|
- Ergebnis (auch null) in `thumbnail_cache` persistieren (30 Tage TTL)
|
||||||
|
- **Überschreibt** bestehendes SearXNG-Thumbnail, weil das meist LowRes ist
|
||||||
|
|
||||||
|
### Confirm / Alert
|
||||||
|
|
||||||
|
Promise-basiert statt `window.confirm`/`window.alert`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
|
if (await confirmAction({ title: 'Löschen?', destructive: true })) { /* ... */ }
|
||||||
|
await alertAction({ title: 'Fehler', message: 'xyz' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemeinsame Komponente `ConfirmDialog.svelte` wird im Root-Layout einmal gemountet. Store (`confirmStore`) hält die Promise-Resolve-Funktion, Komponente rendert nur wenn `pending !== null`.
|
||||||
|
|
||||||
|
## Design-Entscheidungen
|
||||||
|
|
||||||
|
- **Kein Login, nur Profile**: Profile werden beim Start gewählt, in localStorage persistiert. Actions (Rating, Favorit, Cooked, Kommentar) brauchen aktives Profil → sonst Custom-Alert „Bitte Profil wählen".
|
||||||
|
- **FTS5 als Haupt-Suche**: statt externer Search-Engine-DB. Passt zu SQLite-only-Stack.
|
||||||
|
- **JSON-LD first**: Alle drei Ziel-Domains (Chefkoch, Emmi, Experimente) liefern `schema.org/Recipe` im JSON-LD. LLM-Fallback war geplant, aktuell nicht nötig.
|
||||||
|
- **SearXNG als Such-Engine**: Self-hosted, daher keine API-Keys. Das Bot-Detection-Theater wird mit gesetzten `X-Forwarded-For`-Headern aus Docker-IPs umgangen.
|
||||||
|
- **Thumbnail-Cache in SQLite**: 30 Tage TTL (per `KOCHWAS_THUMB_TTL_DAYS`). Negative Einträge (Seite ohne Bild) werden auch gecacht.
|
||||||
|
- **Svelte 5 Runes** — kein `$:` mehr, keine alten Stores außer `$app/stores`. Neue Stores via Klasse mit `$state`-Feldern. Form-lokale Snapshots in Edit-Komponenten mit `untrack()` aus `svelte`, damit Prop-Updates nicht laufende Edits überschreiben.
|
||||||
|
- **Zutaten-Sektionen** (ab Migration 012, v1.2): `ingredient.section_heading TEXT NULL`. Ist das Feld gesetzt, startet an dieser Zeile eine neue Sektion — folgende Zutaten gehören dazu, bis die nächste Zeile wieder ein Heading hat. Kein zweites Tabellen-Modell, Ordnung bleibt `position`. Importer setzt immer `null` (schema.org/Recipe hat das Konzept nicht). Editor erlaubt Inline-Insert via `Abschnitt hinzufügen`-Button vor jeder Zeile; leeres Heading wird beim Save zu `null` normalisiert.
|
||||||
|
- **Service Worker** rein zum Shell-Cachen für Offline-First-PWA, kein intelligentes Cache-Matching (keine externe Rezept-Seiten).
|
||||||
|
|
||||||
|
## Migrations-Workflow
|
||||||
|
|
||||||
|
Bei Schema-Änderung:
|
||||||
|
|
||||||
|
1. Neue Datei `src/lib/server/db/migrations/00N_beschreibung.sql` — nächste freie Nummer
|
||||||
|
2. SQL sollte nicht-destruktiv sein (nur `CREATE`, `ALTER ADD`); keine `DROP` auf bestehende Daten
|
||||||
|
3. `migrate.ts` liest via Vite-Glob und führt neue Einträge aus (über `schema_migrations`-Tabelle getrackt)
|
||||||
|
4. Tests anpassen: `db idempotent` zählt vorher/nachher — bleibt automatisch grün
|
||||||
|
|
||||||
|
## Test-Strategie
|
||||||
|
|
||||||
|
- **Unit**: `tests/unit/` — pure Funktionen + Client-Stores via jsdom (json-ld-recipe, iso8601-duration, quotes-random, scaler, ingredient-parser, SearchStore, PWA/Toast/Sync-Stores, SW-Logik).
|
||||||
|
- **Integration**: `tests/integration/` — mit `openInMemoryForTest()` fresh SQLite pro Test. Externe HTTP via `node:http`-TestServer auf Port 0 gemockt.
|
||||||
|
- **E2E local**: `tests/e2e/` — Playwright gegen `npm run preview`, deckt PWA-Offline-Lifecycle ab (`offline.spec.ts`).
|
||||||
|
- **E2E remote**: `tests/e2e/remote/` — Playwright gegen `kochwas-dev.siegeln.net` via `playwright.remote.config.ts` (`workers:1`, `serviceWorkers:block`). Testet Live-API-Verhalten, inkl. destruktiver CRUD-Flows (Recipes, Kommentare, Favoriten). Run: `npm run test:e2e:remote`. Siehe `tests/e2e/remote/fixtures/` für Profile-Setup + idempotente API-Cleanup-Helper.
|
||||||
|
- **Keine Svelte-Component-Unit-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird per E2E und manuell getestet).
|
||||||
|
- **Vor Commit**: `npm test && npm run check` muss grün sein. Vor Merge zu main: zusätzlich `npm run test:e2e:remote`.
|
||||||
|
|
||||||
|
### Service Worker (PWA)
|
||||||
|
|
||||||
|
`src/service-worker.ts` ist SvelteKits eingebauter SW-Slot. Er nutzt `$service-worker` (`build`, `files`, `version`) für den App-Shell-Cache und implementiert eigene Logik für:
|
||||||
|
|
||||||
|
- **Pre-Cache** (alle Rezepte + Bilder beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`.
|
||||||
|
- **Delta-Sync** beim App-Start (diff vs. Cache-Manifest, nur Delta laden).
|
||||||
|
- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = network-first mit 3 s-Timeout-Fallback auf Cache, Bilder = cache-first.
|
||||||
|
- **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client.
|
||||||
|
|
||||||
|
Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`):
|
||||||
|
- `src/lib/sw/cache-strategy.ts` — `resolveStrategy({url, method})` → `'shell' | 'network-first' | 'images' | 'network-only'`
|
||||||
|
- `src/lib/sw/diff-manifest.ts` — `diffManifest(current, cached)` → `{toAdd, toRemove}`
|
||||||
|
|
||||||
|
Client-Stores (SSR-safe via typeof-Guards):
|
||||||
|
- `src/lib/client/network.svelte.ts` — `navigator.onLine` + Events.
|
||||||
|
- `src/lib/client/sync-status.svelte.ts` — SW-Message-Spiegel, `lastSynced` in localStorage.
|
||||||
|
- `src/lib/client/toast.svelte.ts` — Toast-Queue für Offline-Fehler + Sync-Meldungen.
|
||||||
|
- `src/lib/client/install-prompt.svelte.ts` — fängt `beforeinstallprompt`, erkennt Plattform.
|
||||||
|
- `src/lib/client/sw-register.ts` — registriert den SW, leitet Messages an den Sync-Status-Store.
|
||||||
|
- `src/lib/client/require-online.ts` — Helper für Schreib-Aktionen (Toast statt stillem Fail).
|
||||||
|
|
||||||
|
UI-Komponenten:
|
||||||
|
- `src/lib/components/SyncIndicator.svelte` — Pill unten rechts (Sync-Fortschritt / Offline-Status).
|
||||||
|
- `src/lib/components/Toast.svelte` — Top-Center-Toast-Renderer.
|
||||||
|
|
||||||
|
Admin-UI: `src/routes/admin/app/+page.svelte` mit Install-Button, manuellem Sync-Trigger, Cache-Reset.
|
||||||
|
|
||||||
|
E2E-Tests: `tests/e2e/offline.spec.ts` — Playwright setzt das Netzwerk offline und prüft Navigation/Toast/Indikator-Verhalten.
|
||||||
|
|
||||||
|
## Was später kommt (laut Spec, aktuell nicht implementiert)
|
||||||
|
|
||||||
|
- LLM-Fallback für nicht-JSON-LD-Seiten
|
||||||
|
- Print-View ist nur rudimentär, könnte ein eigenes Print-CSS bekommen
|
||||||
|
- Tag-Editor im Admin
|
||||||
|
- Export-Format JSON zusätzlich zu ZIP
|
||||||
212
docs/OPERATIONS.md
Normal file
212
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Kochwas — Deployment & Operations
|
||||||
|
|
||||||
|
## Deployment-Topologie
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
↓ HTTPS (kochwas.siegeln.net)
|
||||||
|
Cloudflare DNS (A-Record auf Pi-IP oder Tunnel)
|
||||||
|
↓
|
||||||
|
Raspberry Pi 5 (arm64, Debian/Ubuntu)
|
||||||
|
↓
|
||||||
|
Traefik v3 (Docker, Container "traefik" im Netz traefik_proxy)
|
||||||
|
↓ reverse proxy
|
||||||
|
Kochwas-Container (Node 22 Alpine, Port 3000, internal bridge)
|
||||||
|
↔ SearXNG-Container (Sidecar im gleichen Stack, Port 8080 intern)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Traefik** terminiert TLS mit Wildcard-Cert `*.siegeln.net` von Let's Encrypt (DNS-01 Challenge über Cloudflare-API).
|
||||||
|
- **SearXNG** läuft als Sidecar im kochwas-Compose. Kochwas spricht ihn über `http://searxng:8080` intern an.
|
||||||
|
- **Gitea Registry** `gitea.siegeln.net/claude/kochwas` hostet das arm64-Image.
|
||||||
|
- **Daten** liegen im Volume `/opt/docker/kochwas/data/` (SQLite + images/).
|
||||||
|
|
||||||
|
## Build & Publish (Gitea Actions)
|
||||||
|
|
||||||
|
Workflow in `.gitea/workflows/docker.yml`:
|
||||||
|
|
||||||
|
1. Trigger: push auf `main`
|
||||||
|
2. Checkout, Setup QEMU + Buildx
|
||||||
|
3. Login an `gitea.siegeln.net` mit Secret `REGISTRY_TOKEN` (PAT mit `write:package` + `read:package` Scope)
|
||||||
|
4. `docker/build-push-action` baut **nativ arm64** (nicht via emuliertem amd64!), mit `cache-from/to: type=registry,ref=...:buildcache`
|
||||||
|
5. Push als `:latest` und `:${commit}`
|
||||||
|
|
||||||
|
Wenn die Pipeline rot ist, häufig:
|
||||||
|
- `unauthorized`: Token fehlt oder ohne Package-Scope. PAT unter Gitea → Settings → Applications → Generate Token.
|
||||||
|
- Build-Cache i/o-Timeout: Registry-Cache benutzen, nicht GHA-Artifact-Cache.
|
||||||
|
|
||||||
|
## Deploy auf den Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@pi5
|
||||||
|
cd /opt/docker/kochwas
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
docker compose logs -f kochwas
|
||||||
|
```
|
||||||
|
|
||||||
|
Was der Pi braucht (einmalig):
|
||||||
|
- `/opt/docker/kochwas/docker-compose.prod.yml` — gespiegelt aus dem Repo
|
||||||
|
- `/opt/docker/kochwas/.env` mit `KOCHWAS_TAG=latest` (optional) und `SEARXNG_SECRET=...`
|
||||||
|
- `/opt/docker/kochwas/searxng/settings.yml` — aus dem Repo, mit `limiter: false` und `public_instance: false`
|
||||||
|
- `/opt/docker/kochwas/data/` existiert (für SQLite + images)
|
||||||
|
- Netzwerk `traefik_proxy` existiert, damit Traefik den Container sieht
|
||||||
|
|
||||||
|
## Traefik-Integration
|
||||||
|
|
||||||
|
Labels am kochwas-Container (siehe `docker-compose.prod.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik_proxy
|
||||||
|
- traefik.http.routers.kochwas.rule=Host(`kochwas.siegeln.net`)
|
||||||
|
- traefik.http.routers.kochwas.entrypoints=websecure
|
||||||
|
- traefik.http.routers.kochwas.tls.certresolver=cloudflareResolver
|
||||||
|
- traefik.http.routers.kochwas.tls.domains[0].main=siegeln.net
|
||||||
|
- traefik.http.routers.kochwas.tls.domains[0].sans=*.siegeln.net
|
||||||
|
- traefik.http.services.kochwas.loadbalancer.server.port=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `tls.domains`-Zeilen sorgen dafür, dass der Router das Wildcard-Cert nutzt statt einen neuen per-Host-Cert zu holen. **Nie per-Host für neue Subdomains** — Let's Encrypt Rate-Limit (5 failed Authorizations pro Identifier pro Stunde, 50 Certs pro Registered Domain pro Woche).
|
||||||
|
|
||||||
|
### Wenn Cert fehlt / TLS-Fehler
|
||||||
|
|
||||||
|
1. `echo | openssl s_client -servername kochwas.siegeln.net -connect kochwas.siegeln.net:443 2>/dev/null | openssl x509 -noout -issuer -subject` — ist der Issuer „TRAEFIK DEFAULT CERT"? Dann hat Traefik kein Cert.
|
||||||
|
2. `sudo jq '.cloudflareResolver.Certificates | map(.domain.main)' /opt/docker/traefik/letsencrypt/acme.json` — ist `siegeln.net` (mit SAN `*.siegeln.net`) dabei?
|
||||||
|
3. `docker logs traefik 2>&1 | grep -iE 'lego|acme|cloudflare|kochwas' | tail -60` — Fehler?
|
||||||
|
- `Invalid access token` → Cloudflare-API-Token abgelaufen, neu erstellen mit `Zone → DNS → Edit` Scope, `CF_DNS_API_TOKEN` im Traefik-Compose setzen, `docker compose up -d traefik`
|
||||||
|
- `429 rateLimited` → Warten (zeitangabe im Error) oder auf Wildcard umstellen
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container läuft, Traefik filtert ihn raus
|
||||||
|
|
||||||
|
Symptom: Traefik-Logs sagen `Filtering unhealthy or starting container`.
|
||||||
|
Ursache: Healthcheck schlägt fehl. Der Check ruft `wget 127.0.0.1:3000/api/health` (muss IPv4 sein!).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect kochwas-kochwas-1 --format '{{json .State.Health}}' | jq
|
||||||
|
docker exec kochwas-kochwas-1 wget -qO- 127.0.0.1:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### SearXNG gibt 403 zurück
|
||||||
|
|
||||||
|
Log: `Internet-Suche zurzeit nicht möglich: HTTP 403`
|
||||||
|
Ursache: Bot-Detection. Fix war schon einmal nötig — `src/lib/server/http.ts` setzt via `extraHeaders` `X-Forwarded-For: 127.0.0.1` und `X-Real-IP: 127.0.0.1`. Wenn trotzdem 403: `searxng/settings.yml` prüfen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
use_default_settings: true
|
||||||
|
server:
|
||||||
|
limiter: false
|
||||||
|
public_instance: false
|
||||||
|
secret_key: ${SEARXNG_SECRET:-dev-secret-change-in-prod}
|
||||||
|
search:
|
||||||
|
formats: [html, json]
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Server-Container muss diese Datei per Volume Mount sehen. Nach Änderung: `docker compose restart searxng`.
|
||||||
|
|
||||||
|
### Thumbnail-Cache leeren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec kochwas-kochwas-1 sqlite3 /data/kochwas.db 'DELETE FROM thumbnail_cache;'
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder gezielt eine URL:
|
||||||
|
```bash
|
||||||
|
docker exec kochwas-kochwas-1 sqlite3 /data/kochwas.db \
|
||||||
|
"DELETE FROM thumbnail_cache WHERE url = 'https://www.chefkoch.de/rezepte/...';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank-Backup manuell
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@pi5 'docker exec kochwas-kochwas-1 sqlite3 /data/kochwas.db ".backup /data/backup.db"'
|
||||||
|
scp admin@pi5:/opt/docker/kochwas/data/backup.db ./kochwas-$(date +%F).db
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App hat ein eingebautes Backup unter `/admin` (ZIP-Export mit DB + Bildern). Restore via `/admin` ebenfalls.
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Name | Default | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `SEARXNG_URL` | `http://localhost:8888` | SearXNG-Endpoint, im Compose auf `http://searxng:8080` |
|
||||||
|
| `KOCHWAS_THUMB_TTL_DAYS` | `30` | TTL für Thumbnail-Cache in der SQLite |
|
||||||
|
| `DATABASE_PATH` | `data/kochwas.db` | Pfad zur SQLite, relativ oder absolut |
|
||||||
|
| `IMAGE_DIR` | `data/images` | Pfad für lokale Bild-Dateien |
|
||||||
|
| `PORT` | `3000` | Node-HTTP-Port (adapter-node) |
|
||||||
|
|
||||||
|
Siehe `.env.example` im Repo.
|
||||||
|
|
||||||
|
## Häufige Commits als Referenz
|
||||||
|
|
||||||
|
- **Healthcheck-Fix** → `Dockerfile` (localhost → 127.0.0.1, tightened interval)
|
||||||
|
- **SearXNG-Bot-Bypass** → `src/lib/server/http.ts` (extraHeaders)
|
||||||
|
- **Traefik-Wildcard** → `docker-compose.prod.yml` (tls.domains Labels)
|
||||||
|
- **Thumbnail-Cache in SQLite** → `003_thumbnail_cache.sql` + `searxng.ts`
|
||||||
|
|
||||||
|
Git-Log ist die Wahrheit; diese Datei ist eine Orientierung.
|
||||||
|
|
||||||
|
## PWA / Offline-Modus
|
||||||
|
|
||||||
|
Kochwas ist eine installierbare PWA. Erkennbar an:
|
||||||
|
- `static/manifest.webmanifest` (Manifest + Icons: SVG + 192×192 + 512×512, alle maskable)
|
||||||
|
- `src/service-worker.ts` (Cache + Sync)
|
||||||
|
|
||||||
|
Caches im Browser (siehe DevTools → Application → Cache Storage):
|
||||||
|
- `kochwas-shell-<version>` — App-Shell (JS/CSS/Static-Icons), cache-first
|
||||||
|
- `kochwas-data-v1` — Rezept-HTMLs + API-JSON (network-first, 3 s Timeout → Cache-Fallback)
|
||||||
|
- `kochwas-images-v1` — Bilder (cache-first)
|
||||||
|
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)
|
||||||
|
|
||||||
|
Sync-Verhalten:
|
||||||
|
- **Initial-Sync** (nach erstem Install): SW lädt alle Rezepte + Bilder im Hintergrund. Fortschritt im `SyncIndicator`-Pill unten rechts.
|
||||||
|
- **Update-Sync** (bei jedem App-Start online): Diff gegen Cache-Manifest, nur Delta nachladen, gelöschte IDs räumen.
|
||||||
|
- **Storage-Quota-Check**: < 100 MB frei → abbrechen mit Fehler-Toast.
|
||||||
|
|
||||||
|
Bei SW-Problemen Debug-Pfad:
|
||||||
|
1. Admin → „App"-Tab → „Offline-Cache leeren" (destructive, zweistufig bestätigt)
|
||||||
|
2. Alternative: DevTools → Application → Service Workers → Unregister, dann Seite neu laden.
|
||||||
|
|
||||||
|
E2E-Tests (Playwright): `npm run test:e2e`. Setzt `npm run build` voraus (Playwright startet automatisch `npm run preview`).
|
||||||
|
|
||||||
|
Icons einmalig rendern: `npm run render:icons` (schreibt nach `static/icon-*.png`, committen).
|
||||||
|
|
||||||
|
## Dev-System / Remote-E2E
|
||||||
|
|
||||||
|
`https://kochwas-dev.siegeln.net/` ist ein separates Deployment (eigener Container, eigene DB unter `/opt/docker/kochwas-dev/data/`). Zweck: E2E-Tests gegen eine prod-nahe Umgebung ohne Angst vor DB-Schäden. Die Remote-Suite (`tests/e2e/remote/`, Config `playwright.remote.config.ts`) darf dort frei CRUDen — User stellt die DB bei Bedarf per Backup wieder her.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:remote # gegen kochwas-dev
|
||||||
|
E2E_REMOTE_URL=https://... npm run test:e2e:remote # andere URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige Config-Eigenschaften:
|
||||||
|
- `workers: 1` — DB-Race-Sicherheit bei CRUD-Tests.
|
||||||
|
- `serviceWorkers: 'block'` — verhindert Chromium-Crashes durch akkumulierten SW-State über 40+ Contexts.
|
||||||
|
- Fixtures unter `tests/e2e/remote/fixtures/`: `profile.ts` (Profile-Auswahl via localStorage vor Seitenladen), `api-cleanup.ts` (idempotente DELETE-Helfer für afterEach).
|
||||||
|
|
||||||
|
**Niemals gegen `kochwas.siegeln.net` (ohne `-dev`)** die destruktiven Tests laufen lassen — das ist Prod.
|
||||||
|
|
||||||
|
|
||||||
|
## Gemini / Foto-Rezept-Magie
|
||||||
|
|
||||||
|
Die Funktion „Foto → Rezept" ruft Google Gemini 2.5 Flash mit Vision auf. Im Header erscheint dann ein Kamera-Icon, das auf `/new/from-photo` führt.
|
||||||
|
|
||||||
|
**Env-Vars** (in `docker-compose.prod.yml`):
|
||||||
|
|
||||||
|
| Variable | Default | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `GEMINI_API_KEY` | _(leer)_ | Ohne Key ist das Feature graceful deaktiviert — Camera-Icon erscheint nicht. |
|
||||||
|
| `GEMINI_MODEL` | `gemini-2.5-flash` | Modell-Wechsel ohne Rebuild, z. B. auf `gemini-2.5-pro` bei harter Handschrift. |
|
||||||
|
| `GEMINI_TIMEOUT_MS` | `20000` | Timeout für den Vision-Call. |
|
||||||
|
|
||||||
|
**Wichtig:** Env-Änderungen greifen erst nach `docker compose up -d --force-recreate`, nicht nach `restart`.
|
||||||
|
|
||||||
|
**Privacy:** Das hochgeladene Foto geht einmal an Google Gemini und wird serverseitig nicht gespeichert. Google trainiert im Paid-Tier nicht auf API-Daten. Der Server loggt nur Status-Code, Dauer und Bildgröße — nie Prompt oder Response-Inhalt.
|
||||||
|
|
||||||
|
**Rate-Limit:** 10 Requests/Minute pro IP (in-memory, resettet beim Prozess-Restart).
|
||||||
|
|
||||||
|
**Key aus Gitea Secrets:** `GEMINI_API_KEY` als Secret in der CI-Umgebung hinterlegen; der Deploy-Schritt injiziert ihn in die `.env` des Pi-Stacks. Ablauf-Monitoring über die Google-Cloud-Konsole (≥1× pro Quartal checken).
|
||||||
|
|
||||||
|
**Build-Dep `sharp`:** Der Foto-Preprocess nutzt `sharp` (libvips). Im `Dockerfile`-Builder-Stage ist `vips-dev` enthalten, damit der npm-install auf arm64 sauber durchläuft — insbesondere für HEIC-Input von iOS-Geräten (libheif kommt via vips-dev).
|
||||||
1982
docs/superpowers/plans/2026-04-18-offline-pwa.md
Normal file
1982
docs/superpowers/plans/2026-04-18-offline-pwa.md
Normal file
File diff suppressed because it is too large
Load Diff
153
docs/superpowers/plans/2026-04-18-review-fixes.md
Normal file
153
docs/superpowers/plans/2026-04-18-review-fixes.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Review-Fixes 2026-04-18 — Implementation Plan
|
||||||
|
|
||||||
|
> **Quelle:** `docs/superpowers/review/REVIEW-2026-04-18.md` + Sub-Reports.
|
||||||
|
> **Branch:** `review-fixes-2026-04-18`
|
||||||
|
> **Goal:** Alle HIGH/MEDIUM Findings aus dem Code-Review adressieren, bewusst verschobene Items dokumentieren.
|
||||||
|
> **Architecture:** Inkrementelle Refactors, jeder atomar committed + gepusht, Tests nach jedem Wave grün.
|
||||||
|
> **Tech-Stack:** SvelteKit, TypeScript-strict, Zod, Vitest, better-sqlite3, Service-Worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was wird angegangen (must-do)
|
||||||
|
|
||||||
|
| # | Wave | Zeit | Begründung |
|
||||||
|
|---|------|------|------------|
|
||||||
|
| 1 | Doku-Fixes (ARCHITECTURE/OPERATIONS/handoff) | 5 min | Hoher Wert, trivialer Aufwand |
|
||||||
|
| 2 | constants.ts + Image-Endpoint EN + interne Types | 30 min | Alle "Quick-Wins" aus REVIEW |
|
||||||
|
| 3 | api-helpers.ts (parsePositiveIntParam + validateBody) | 1-2 h | Refactor A — 9+11 Call-Sites |
|
||||||
|
| 4 | requireProfile() + asyncFetch Wrapper | 1 h | Profile-Guard 4× + fetch-Pattern 5× |
|
||||||
|
| 5 | Cleanup (yauzl-Doku, baseRecipe-Fixture, Console-Logs) | 30 min | Restliche LOW-Findings |
|
||||||
|
| 6 | Ingredient-Parser Edge-Cases (Refactor D) | 2-3 h | Locale-Komma, Unicode-Brüche, Bounds |
|
||||||
|
| 7 | Verifikation (test/check/build, Docker-Smoke) | 30 min | Baseline gegen Regressionen |
|
||||||
|
| 8 | Re-Review + OPEN-ISSUES-NEXT.md | 1 h | Beweis + Ausblick |
|
||||||
|
|
||||||
|
## Was bewusst NICHT angegangen wird (Begründung in OPEN-ISSUES-NEXT.md)
|
||||||
|
|
||||||
|
- **Refactor B** (Search-State-Store, halber Tag): Touch von 808-Zeilen-Page + 678-Zeilen-Layout, bricht riskant Frontend ohne UAT. Eigene Phase planen.
|
||||||
|
- **Refactor C** (RecipeEditor zerlegen): Review sagt explizit "keine Eile, solange niemand sonst drin arbeitet".
|
||||||
|
- **SearXNG Rate-Limit Recovery**: Größeres Feature, eigene Phase.
|
||||||
|
- **SW-Zombie-Cleanup Unit-Tests**: Bereits 6 pwa-store-Tests vorhanden, Erweiterung wäre Bonus.
|
||||||
|
- **JSON-LD Parser Edge-Cases** (Locales): Weniger Käse als Ingredient-Parser-Issues, eigene Iteration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 1 — Doku-Fixes
|
||||||
|
|
||||||
|
**Files:** `docs/ARCHITECTURE.md:55`, `docs/OPERATIONS.md:135`, `docs/superpowers/session-handoff-2026-04-17.md:46`
|
||||||
|
|
||||||
|
- [ ] ARCHITECTURE.md: `recipe_ingredient` + `recipe_step` → `ingredient` + `step`
|
||||||
|
- [ ] OPERATIONS.md: `IMAGES_PATH` → `IMAGE_DIR`
|
||||||
|
- [ ] session-handoff: `/api/recipes/[id]/image` (POST/DELETE) ergänzen
|
||||||
|
- [ ] Commit `docs(review): Doku-Mismatches korrigiert`
|
||||||
|
|
||||||
|
## Wave 2 — Konstanten + Cleanup
|
||||||
|
|
||||||
|
**Files:** `src/lib/constants.ts` (neu), `src/routes/+page.svelte`, `src/lib/client/pwa.svelte.ts`, `src/routes/api/recipes/[id]/image/+server.ts`, `src/lib/sw/cache-strategy.ts`, `src/lib/sw/diff-manifest.ts`
|
||||||
|
|
||||||
|
- [ ] `src/lib/constants.ts` mit `SW_VERSION_QUERY_TIMEOUT_MS = 1500`, `SW_UPDATE_POLL_INTERVAL_MS = 30 * 60_000`
|
||||||
|
- [ ] Image-Endpoint: deutsche Fehlermeldungen → englisch (Konsistenz)
|
||||||
|
- [ ] `RequestShape` / `ManifestDiff`: `export` weg wenn rein intern
|
||||||
|
- [ ] Test + check, Commit
|
||||||
|
|
||||||
|
## Wave 3 — api-helpers.ts (TDD)
|
||||||
|
|
||||||
|
**Files:** `src/lib/server/api-helpers.ts` (neu), `tests/unit/api-helpers.test.ts` (neu), `src/lib/types.ts` (ErrorResponse)
|
||||||
|
|
||||||
|
### 3a Helper bauen
|
||||||
|
- [ ] Test: `parsePositiveIntParam("42", "id")` → 42
|
||||||
|
- [ ] Test: `parsePositiveIntParam("0", "id")` wirft 400
|
||||||
|
- [ ] Test: `parsePositiveIntParam("abc", "id")` wirft 400
|
||||||
|
- [ ] Test: `parsePositiveIntParam(null, "id")` wirft 400
|
||||||
|
- [ ] Test: `validateBody(invalid, schema)` wirft 400 mit issues
|
||||||
|
- [ ] Test: `validateBody(valid, schema)` returns parsed
|
||||||
|
- [ ] Implement helpers
|
||||||
|
- [ ] Tests grün, Commit
|
||||||
|
|
||||||
|
### 3b Migration parseId → parsePositiveIntParam (9 Sites)
|
||||||
|
Files (jeder Endpoint):
|
||||||
|
- `src/routes/api/recipes/[id]/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/favorite/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/rating/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/cooked/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/comments/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/image/+server.ts`
|
||||||
|
- `src/routes/api/profiles/[id]/+server.ts`
|
||||||
|
- `src/routes/api/domains/[id]/+server.ts`
|
||||||
|
- `src/routes/api/wishlist/[recipe_id]/+server.ts`
|
||||||
|
|
||||||
|
- [ ] Pro Endpoint: lokales parseId entfernen, Helper importieren
|
||||||
|
- [ ] Tests grün
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### 3c Migration safeParse → validateBody
|
||||||
|
Files: alle `+server.ts` mit `safeParse`. ErrorResponse-Shape standardisieren.
|
||||||
|
|
||||||
|
- [ ] Pro Endpoint umstellen
|
||||||
|
- [ ] Tests grün
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
## Wave 4 — Client-Helpers
|
||||||
|
|
||||||
|
### 4a requireProfile()
|
||||||
|
- [ ] Helper in `src/lib/client/profile.svelte.ts` ergänzen
|
||||||
|
- [ ] 4 Sites in `src/routes/recipes/[id]/+page.svelte` ersetzen
|
||||||
|
- [ ] Test + Commit
|
||||||
|
|
||||||
|
### 4b asyncFetch Wrapper
|
||||||
|
- [ ] `src/lib/client/api-fetch-wrapper.ts` mit `asyncFetch(url, init, actionTitle)`
|
||||||
|
- [ ] 5 Sites umstellen: `recipes/[id]/+page.svelte` (2×), `admin/domains/+page.svelte` (2×), `admin/profiles/+page.svelte`
|
||||||
|
- [ ] Test + Commit
|
||||||
|
|
||||||
|
## Wave 5 — Cleanup
|
||||||
|
|
||||||
|
- [ ] yauzl: Inline-Kommentar in package.json: "Reserved for Phase 5b ZIP-Backup-Import"
|
||||||
|
- [ ] baseRecipe Fixture nach `tests/fixtures/recipe.ts` (wenn dupliziert)
|
||||||
|
- [ ] Console-Logs: per `if (import.meta.env.DEV)` wrappen oder absichtlich-Kommentar
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
## Wave 6 — Ingredient-Parser Edge-Cases
|
||||||
|
|
||||||
|
**Files:** `src/lib/server/parsers/ingredient.ts`, `tests/unit/ingredient.test.ts`
|
||||||
|
|
||||||
|
### Tests zuerst (red)
|
||||||
|
- [ ] Locale-Komma: `"1,5 kg Mehl"` → qty 1.5
|
||||||
|
- [ ] Unicode-½: `"½ TL Salz"` → qty 0.5
|
||||||
|
- [ ] Unicode-⅓: `"⅓ Tasse Wasser"` → qty 1/3
|
||||||
|
- [ ] Unicode-¼: `"¼ kg Zucker"` → qty 0.25
|
||||||
|
- [ ] Negativ: `"-1 EL Öl"` → wirft / qty=null
|
||||||
|
- [ ] Null: `"0 g Mehl"` → wirft / qty=null
|
||||||
|
- [ ] Führende Null: `"0.5 kg"` → 0.5
|
||||||
|
- [ ] Wissenschaftliche Notation: `"1e3 g"` → wirft / qty=null
|
||||||
|
|
||||||
|
### Parser fixen
|
||||||
|
- [ ] Unicode-Brüche-Map
|
||||||
|
- [ ] Locale-Komma-Handling (sicher: "1,5" wenn nur 1 Komma + Ziffern drumrum)
|
||||||
|
- [ ] Bounds: 0 < qty <= 10000 (Zod refinement oder Pre-Check)
|
||||||
|
- [ ] Tests grün, Commit
|
||||||
|
|
||||||
|
## Wave 7 — Verifikation
|
||||||
|
|
||||||
|
- [ ] `npm test` — 158+ Tests grün
|
||||||
|
- [ ] `npm run check` — 0 Errors
|
||||||
|
- [ ] `npm run build` — erfolgreich
|
||||||
|
- [ ] Optional: Docker-Smoke `docker compose -f docker-compose.prod.yml up --build`
|
||||||
|
- [ ] Push aller Commits
|
||||||
|
|
||||||
|
## Wave 8 — Re-Review + OPEN-ISSUES-NEXT.md
|
||||||
|
|
||||||
|
- [ ] Parallele Explore-Agenten: dead-code, redundancy, structure, docs-vs-code
|
||||||
|
- [ ] Befunde in `docs/superpowers/review/OPEN-ISSUES-NEXT.md`
|
||||||
|
- [ ] Bewusst verschobene Items mit Begründung
|
||||||
|
- [ ] Neue Findings (falls vorhanden)
|
||||||
|
- [ ] Commit + Push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erfolgs-Kriterien
|
||||||
|
|
||||||
|
1. Tests grün (158+)
|
||||||
|
2. svelte-check: 0 Errors, 0 Warnings (oder ≤ Baseline)
|
||||||
|
3. Build erfolgreich
|
||||||
|
4. Alle 8 Quick-Wins + Refactor A + Refactor D umgesetzt
|
||||||
|
5. OPEN-ISSUES-NEXT.md vorhanden mit klarer Trennung "verschoben (warum)" vs "neu entdeckt"
|
||||||
|
6. Branch ready zum Mergen / PR
|
||||||
897
docs/superpowers/plans/2026-04-19-editor-split.md
Normal file
897
docs/superpowers/plans/2026-04-19-editor-split.md
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
# Editor-Split Implementation Plan
|
||||||
|
|
||||||
|
> **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:** Split the monolithic `RecipeEditor.svelte` (628 L) and pull one readability-oriented block out of `RecipeView.svelte` (398 L) by extracting 4 focused Svelte components: `ImageUploadBox`, `IngredientRow`, `StepList`, `TimeDisplay`. No behavior changes, just structure.
|
||||||
|
|
||||||
|
**Architecture:** Parent-owned state stays in the parent (`RecipeEditor` still owns `ingredients: DraftIng[]`, `steps: DraftStep[]`). Sub-components receive props + callbacks and render their own template + scoped CSS. Shared draft types land in `src/lib/components/recipe-editor-types.ts` so sub-components and parent agree on the shape. `RecipeView.TimeDisplay` is pure presentational with no state.
|
||||||
|
|
||||||
|
**Tech Stack:** Svelte 5 runes (`$props`, `$state`, `$derived`), TypeScript-strict, no new runtime deps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this is worth doing
|
||||||
|
|
||||||
|
- `RecipeEditor.svelte:42-89` (Bild-Upload) and `RecipeEditor.svelte:313-334` (Zubereitung) are each self-contained logic-islands with their own state and handlers. Extracting them caps the file a Claude can reason about in one shot.
|
||||||
|
- `IngredientRow` renders 10 lines of template with 5 ARIA labels and 6 grid-columns — a natural single-responsibility unit.
|
||||||
|
- `TimeDisplay` is pure formatting; owning it as a component lets future phases (preview, card hover) reuse it.
|
||||||
|
|
||||||
|
## What we are NOT doing
|
||||||
|
|
||||||
|
- No refactor of `RecipeView`'s tabs / servings-stepper / ingredient-display. Those work fine as-is; roadmap only names the 4 above.
|
||||||
|
- No component unit tests (kochwas has none for components; the e2e `recipe-detail.spec.ts` still covers View behavior, and edit-flow is manually smoked).
|
||||||
|
- No `<style global>` extraction. Small CSS duplication (`.add`, `.del` buttons) is accepted.
|
||||||
|
- No prop-type sharing via `<script module>` blocks. A `.ts` sibling file is simpler.
|
||||||
|
|
||||||
|
## Design Snapshot
|
||||||
|
|
||||||
|
**Shared types** — `src/lib/components/recipe-editor-types.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type DraftIng = {
|
||||||
|
qty: string;
|
||||||
|
unit: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftStep = { text: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component APIs (locked before implementation):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ImageUploadBox.svelte
|
||||||
|
type Props = {
|
||||||
|
recipeId: number;
|
||||||
|
imagePath: string | null; // initial value; component owns its own state after
|
||||||
|
onchange: (path: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// IngredientRow.svelte
|
||||||
|
type Props = {
|
||||||
|
ing: DraftIng; // passed by reference — bind:value=ing.* works transparently
|
||||||
|
idx: number;
|
||||||
|
total: number; // for "last row? disable move-down"
|
||||||
|
onmove: (dir: -1 | 1) => void;
|
||||||
|
onremove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// StepList.svelte
|
||||||
|
type Props = {
|
||||||
|
steps: DraftStep[]; // passed by reference
|
||||||
|
onadd: () => void;
|
||||||
|
onremove: (idx: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TimeDisplay.svelte
|
||||||
|
type Props = {
|
||||||
|
prepTimeMin: number | null;
|
||||||
|
cookTimeMin: number | null;
|
||||||
|
totalTimeMin: number | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Render-wrapping pattern:** The parent keeps the `<section class="block"><h2>…</h2> … </section>` wrappers. Sub-components render bare content (no outer utility-class wrapper), so the parent's scoped `.block` / `h2` styling continues to apply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extract `ImageUploadBox`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/components/ImageUploadBox.svelte`
|
||||||
|
- Modify: `src/lib/components/RecipeEditor.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the new component**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- src/lib/components/ImageUploadBox.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { ImagePlus, ImageOff } from 'lucide-svelte';
|
||||||
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
recipeId: number;
|
||||||
|
imagePath: string | null;
|
||||||
|
onchange: (path: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { recipeId, imagePath: initial, onchange }: Props = $props();
|
||||||
|
|
||||||
|
let imagePath = $state<string | null>(initial);
|
||||||
|
let uploading = $state(false);
|
||||||
|
let fileInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
const imageSrc = $derived(
|
||||||
|
imagePath === null
|
||||||
|
? null
|
||||||
|
: /^https?:\/\//i.test(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: `/images/${imagePath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onFileChosen(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!requireOnline('Der Bild-Upload')) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${recipeId}/image`,
|
||||||
|
{ method: 'POST', body: fd },
|
||||||
|
'Upload fehlgeschlagen'
|
||||||
|
);
|
||||||
|
if (!res) return;
|
||||||
|
const body = await res.json();
|
||||||
|
imagePath = body.image_path;
|
||||||
|
onchange(imagePath);
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage() {
|
||||||
|
if (imagePath === null) return;
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Bild entfernen?',
|
||||||
|
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
|
||||||
|
confirmLabel: 'Entfernen',
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Entfernen')) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${recipeId}/image`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
'Entfernen fehlgeschlagen'
|
||||||
|
);
|
||||||
|
if (!res) return;
|
||||||
|
imagePath = null;
|
||||||
|
onchange(null);
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-row">
|
||||||
|
<div class="image-preview" class:empty={!imageSrc}>
|
||||||
|
{#if imageSrc}
|
||||||
|
<img src={imageSrc} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder">Kein Bild</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="image-actions">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<ImagePlus size={16} strokeWidth={2} />
|
||||||
|
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
|
||||||
|
</button>
|
||||||
|
{#if imagePath}
|
||||||
|
<button class="btn ghost" type="button" onclick={removeImage} disabled={uploading}>
|
||||||
|
<ImageOff size={16} strokeWidth={2} />
|
||||||
|
<span>Entfernen</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if uploading}
|
||||||
|
<span class="upload-status">Lade …</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
class="file-input"
|
||||||
|
onchange={onFileChosen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.image-preview {
|
||||||
|
width: 160px;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #eef3ef;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.image-preview.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.image-preview .placeholder {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.image-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.upload-status {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.image-hint {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 40px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire up `RecipeEditor.svelte`**
|
||||||
|
|
||||||
|
Remove lines 30–89 (imagePath/uploading/fileInput state, imageSrc derived, onFileChosen, removeImage).
|
||||||
|
|
||||||
|
Remove these imports at the top:
|
||||||
|
```ts
|
||||||
|
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
|
||||||
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
```
|
||||||
|
Replace with (Task 1 needs only Plus + Trash2 + Chevrons — the image-specific imports move to the sub-component; `confirmAction`/`asyncFetch`/`requireOnline` stay for future tasks):
|
||||||
|
```ts
|
||||||
|
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-svelte';
|
||||||
|
import ImageUploadBox from '$lib/components/ImageUploadBox.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the image-related CSS (`.image-row`, `.image-preview*`, `.image-actions`, `.image-actions .btn`, `.upload-status`, `.file-input`, `.image-hint`, `.image-block` — those live in the sub-component now).
|
||||||
|
|
||||||
|
Replace the Bild section in the template:
|
||||||
|
```svelte
|
||||||
|
<section class="block">
|
||||||
|
<h2>Bild</h2>
|
||||||
|
<ImageUploadBox
|
||||||
|
recipeId={recipe.id}
|
||||||
|
imagePath={recipe.image_path}
|
||||||
|
onchange={(p) => onimagechange?.(p)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors, 196/196 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open any saved recipe → edit → upload an image → verify it shows up and `onimagechange` fires (parent's state updates). Remove the image → confirms the confirm-dialog and removes. Bail out if either flow breaks.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/ImageUploadBox.svelte src/lib/components/RecipeEditor.svelte
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor(editor): ImageUploadBox als eigenstaendige Component
|
||||||
|
|
||||||
|
Isoliert den Bild-Upload-Flow (File-Input, Preview, Entfernen-Dialog)
|
||||||
|
aus dem RecipeEditor. Parent haelt nur noch den <section>-Wrapper und
|
||||||
|
reicht recipe.id + image_path rein, kriegt Aenderungen per onchange
|
||||||
|
callback zurueck.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Extract types + `IngredientRow`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/components/recipe-editor-types.ts`
|
||||||
|
- Create: `src/lib/components/IngredientRow.svelte`
|
||||||
|
- Modify: `src/lib/components/RecipeEditor.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Types file**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/components/recipe-editor-types.ts
|
||||||
|
export type DraftIng = {
|
||||||
|
qty: string;
|
||||||
|
unit: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftStep = { text: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: IngredientRow component**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- src/lib/components/IngredientRow.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Trash2, ChevronUp, ChevronDown } from 'lucide-svelte';
|
||||||
|
import type { DraftIng } from './recipe-editor-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ing: DraftIng;
|
||||||
|
idx: number;
|
||||||
|
total: number;
|
||||||
|
onmove: (dir: -1 | 1) => void;
|
||||||
|
onremove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { ing, idx, total, onmove, onremove }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class="ing-row">
|
||||||
|
<div class="move">
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Zutat nach oben"
|
||||||
|
disabled={idx === 0}
|
||||||
|
onclick={() => onmove(-1)}
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Zutat nach unten"
|
||||||
|
disabled={idx === total - 1}
|
||||||
|
onclick={() => onmove(1)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
|
||||||
|
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
|
||||||
|
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
|
||||||
|
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
|
||||||
|
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
|
||||||
|
<Trash2 size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ing-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.move {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.move-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #555;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.move-btn:hover:not(:disabled) {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.move-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ing-row input {
|
||||||
|
padding: 0.5rem 0.55rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 38px;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.del {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #f1b4b4;
|
||||||
|
background: white;
|
||||||
|
color: #c53030;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.del:hover {
|
||||||
|
background: #fdf3f3;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ing-row {
|
||||||
|
grid-template-columns: 28px 70px 1fr 40px;
|
||||||
|
grid-template-areas:
|
||||||
|
'move qty name del'
|
||||||
|
'move unit unit del'
|
||||||
|
'note note note note';
|
||||||
|
}
|
||||||
|
.ing-row .move {
|
||||||
|
grid-area: move;
|
||||||
|
}
|
||||||
|
.ing-row .qty {
|
||||||
|
grid-area: qty;
|
||||||
|
}
|
||||||
|
.ing-row .unit {
|
||||||
|
grid-area: unit;
|
||||||
|
}
|
||||||
|
.ing-row .name {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
.ing-row .note {
|
||||||
|
grid-area: note;
|
||||||
|
}
|
||||||
|
.ing-row .del {
|
||||||
|
grid-area: del;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire up `RecipeEditor.svelte`**
|
||||||
|
|
||||||
|
Replace the local `DraftIng` / `DraftStep` type declarations (lines 100–106) with:
|
||||||
|
```ts
|
||||||
|
import type { DraftIng, DraftStep } from '$lib/components/recipe-editor-types';
|
||||||
|
import IngredientRow from '$lib/components/IngredientRow.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
In the template, swap the `<li class="ing-row">` block for:
|
||||||
|
```svelte
|
||||||
|
{#each ingredients as ing, idx (idx)}
|
||||||
|
<IngredientRow
|
||||||
|
{ing}
|
||||||
|
{idx}
|
||||||
|
total={ingredients.length}
|
||||||
|
onmove={(dir) => moveIngredient(idx, dir)}
|
||||||
|
onremove={() => removeIngredient(idx)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the CSS for `.ing-row`, `.move`, `.move-btn`, `.ing-row input`, `.del`, and the `@media (max-width: 560px)` block — all now live in `IngredientRow.svelte`.
|
||||||
|
|
||||||
|
Remove the unused imports `ChevronUp`, `ChevronDown`, `Trash2` from RecipeEditor (they moved to the sub-component, but wait — `Trash2` is also used for step-remove. Keep `Trash2`, remove the two Chevrons).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke**
|
||||||
|
|
||||||
|
Open any recipe in edit mode. Add an ingredient, type into all 4 fields, reorder up/down, remove one. Verify save persists the ordering.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/recipe-editor-types.ts src/lib/components/IngredientRow.svelte src/lib/components/RecipeEditor.svelte
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor(editor): IngredientRow + shared types
|
||||||
|
|
||||||
|
IngredientRow rendert eine einzelne editierbare Zutat-Zeile. DraftIng
|
||||||
|
und DraftStep sind jetzt in recipe-editor-types.ts, damit Parent und
|
||||||
|
Sub-Components auf dieselbe Form referenzieren.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Extract `StepList`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/components/StepList.svelte`
|
||||||
|
- Modify: `src/lib/components/RecipeEditor.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: StepList component**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- src/lib/components/StepList.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Plus, Trash2 } from 'lucide-svelte';
|
||||||
|
import type { DraftStep } from './recipe-editor-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
steps: DraftStep[];
|
||||||
|
onadd: () => void;
|
||||||
|
onremove: (idx: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { steps, onadd, onremove }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ol class="step-list">
|
||||||
|
{#each steps as step, idx (idx)}
|
||||||
|
<li class="step-row">
|
||||||
|
<span class="num">{idx + 1}</span>
|
||||||
|
<textarea
|
||||||
|
bind:value={step.text}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Schritt beschreiben …"
|
||||||
|
></textarea>
|
||||||
|
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => onremove(idx)}>
|
||||||
|
<Trash2 size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
<button class="add" type="button" onclick={onadd}>
|
||||||
|
<Plus size={16} strokeWidth={2} />
|
||||||
|
<span>Schritt hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.step-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.step-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr 40px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.step-row textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.del {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #f1b4b4;
|
||||||
|
background: white;
|
||||||
|
color: #c53030;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.del:hover {
|
||||||
|
background: #fdf3f3;
|
||||||
|
}
|
||||||
|
.add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border: 1px dashed #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.add:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire up `RecipeEditor.svelte`**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
```ts
|
||||||
|
import StepList from '$lib/components/StepList.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the entire Zubereitung `<section class="block">` template block (starting `<section class="block">` with `<h2>Zubereitung</h2>` through the add-step button):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<section class="block">
|
||||||
|
<h2>Zubereitung</h2>
|
||||||
|
<StepList {steps} onadd={addStep} onremove={removeStep} />
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSS audit — what stays and what goes in the parent:**
|
||||||
|
|
||||||
|
Parent's template after Tasks 1–3 still contains:
|
||||||
|
- `<section class="block"><h2>Bild</h2><ImageUploadBox .../></section>` — no `.block` inner styles needed beyond what's in parent.
|
||||||
|
- `<div class="meta">` — still here. Keep `.meta`, `.field`, `.row`, `.small`, `.lbl`.
|
||||||
|
- `<section class="block"><h2>Zutaten</h2><ul class="ing-list">{#each ..}<IngredientRow/>{/each}</ul><button class="add">...</button></section>` — still uses `.ing-list` and `.add`.
|
||||||
|
- `<section class="block"><h2>Zubereitung</h2><StepList/></section>` — no inner CSS.
|
||||||
|
- `<div class="foot"><button class="btn ghost">...</button><button class="btn primary">...</button></div>` — keeps `.foot`, `.btn`, `.btn.ghost`, `.btn.primary`, `.btn:disabled`.
|
||||||
|
|
||||||
|
So parent CSS after Task 3 keeps: `.editor`, `.meta`, `.field`, `.lbl`, `.row`, `.small`, `.block`, `.block h2`, `.ing-list` (the `<ul>` wrapper), `.add` (for "Zutat hinzufügen"), `.foot`, `.btn` and variants.
|
||||||
|
|
||||||
|
Drop from parent CSS in Task 3: `.step-list`, `.step-row`, `.num`, `.step-row textarea`, `.del`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke**
|
||||||
|
|
||||||
|
Open any recipe → edit → add a step, type, remove, save. Verify steps persist with correct ordering.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/StepList.svelte src/lib/components/RecipeEditor.svelte
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor(editor): StepList als eigenstaendige Component
|
||||||
|
|
||||||
|
Zubereitungs-Liste mit Add + Remove als Sub-Component. Parent steuert
|
||||||
|
nur noch den Wrapper und reicht steps + die zwei Callbacks rein.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Extract `TimeDisplay` (RecipeView)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/components/TimeDisplay.svelte`
|
||||||
|
- Modify: `src/lib/components/RecipeView.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: TimeDisplay component**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- src/lib/components/TimeDisplay.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
prepTimeMin: number | null;
|
||||||
|
cookTimeMin: number | null;
|
||||||
|
totalTimeMin: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { prepTimeMin, cookTimeMin, totalTimeMin }: Props = $props();
|
||||||
|
|
||||||
|
const summary = $derived.by(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (prepTimeMin) parts.push(`Vorb. ${prepTimeMin} min`);
|
||||||
|
if (cookTimeMin) parts.push(`Kochen ${cookTimeMin} min`);
|
||||||
|
if (!prepTimeMin && !cookTimeMin && totalTimeMin)
|
||||||
|
parts.push(`Gesamt ${totalTimeMin} min`);
|
||||||
|
return parts.join(' · ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if summary}
|
||||||
|
<p class="times">{summary}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.times {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire up `RecipeView.svelte`**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
```ts
|
||||||
|
import TimeDisplay from '$lib/components/TimeDisplay.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the local `timeSummary()` function (lines 45–52).
|
||||||
|
|
||||||
|
Replace the `{#if timeSummary()}<p class="times">...</p>{/if}` block in the template with:
|
||||||
|
```svelte
|
||||||
|
<TimeDisplay
|
||||||
|
prepTimeMin={recipe.prep_time_min}
|
||||||
|
cookTimeMin={recipe.cook_time_min}
|
||||||
|
totalTimeMin={recipe.total_time_min}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the `.times` CSS from RecipeView (it's in the sub-component now).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke**
|
||||||
|
|
||||||
|
Open any recipe → verify the time line still shows the same content (Vorb. / Kochen / Gesamt).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/TimeDisplay.svelte src/lib/components/RecipeView.svelte
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor(view): TimeDisplay als eigenstaendige Component
|
||||||
|
|
||||||
|
timeSummary-Formatierung in eine wiederverwendbare Component
|
||||||
|
gezogen. RecipeView liefert nur noch die drei Werte — zukuenftige
|
||||||
|
Call-Sites (Preview, Hover-Cards) koennen dieselbe Logik reusen.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Self-review + push
|
||||||
|
|
||||||
|
- [ ] **Step 1: Line-count audit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wc -l src/lib/components/RecipeEditor.svelte src/lib/components/RecipeView.svelte src/lib/components/ImageUploadBox.svelte src/lib/components/IngredientRow.svelte src/lib/components/StepList.svelte src/lib/components/TimeDisplay.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected shape (approximate, ±10%):
|
||||||
|
- `RecipeEditor.svelte`: 628 → ~330–370
|
||||||
|
- `RecipeView.svelte`: 398 → ~380
|
||||||
|
- `ImageUploadBox.svelte`: ~160
|
||||||
|
- `IngredientRow.svelte`: ~110
|
||||||
|
- `StepList.svelte`: ~100
|
||||||
|
- `TimeDisplay.svelte`: ~30
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full test + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Both green.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Git log review**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline main..HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected 4 commits:
|
||||||
|
1. `refactor(editor): ImageUploadBox als eigenstaendige Component`
|
||||||
|
2. `refactor(editor): IngredientRow + shared types`
|
||||||
|
3. `refactor(editor): StepList als eigenstaendige Component`
|
||||||
|
4. `refactor(view): TimeDisplay als eigenstaendige Component`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remote E2E after push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin editor-split
|
||||||
|
```
|
||||||
|
|
||||||
|
CI builds branch-tagged image. After deploy to `kochwas-dev.siegeln.net`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:remote
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 40/42 green (same as Search-State-Store baseline). `recipe-detail.spec.ts` (6 tests) specifically exercises the View side — must be clean.
|
||||||
|
|
||||||
|
Manual UAT pass on `https://kochwas-dev.siegeln.net/`:
|
||||||
|
- Edit a recipe → upload + remove image.
|
||||||
|
- Add / reorder / remove an ingredient → save → verify persistence on reload.
|
||||||
|
- Add / remove a step → save → verify.
|
||||||
|
- Check time-summary rendering on any recipe with prep/cook/total times set.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Merge to main**
|
||||||
|
|
||||||
|
Once UAT is clean:
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git merge --no-ff editor-split
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Notes
|
||||||
|
|
||||||
|
- **Prop-reference mutability.** `IngredientRow` and `StepList` receive `ing` / `steps` by reference and use `bind:value` on their own `<input>` / `<textarea>` elements. Svelte 5 handles this correctly — writes propagate to the parent's `$state` array. Verified pattern with existing `searchFilterStore` usage and similar bind-through-prop in older Svelte 5 components in this codebase.
|
||||||
|
- **Confirm-dialog scope.** `ImageUploadBox` imports `confirmAction` directly rather than using a prop-callback. Consistent with the rest of the codebase (`confirmAction` is a global).
|
||||||
|
- **Scoped CSS duplication.** `.del` and `.add` button styles exist in multiple sub-components. Accepted — the alternative (global button classes) is out of scope for this phase.
|
||||||
|
- **No component unit tests.** Risk: a structural mistake (bad prop passing, missing callback wiring) wouldn't be caught by logic-layer tests. Mitigation: manual smoke test + `npm run check` type-safety + existing e2e coverage on RecipeView side.
|
||||||
|
|
||||||
|
## Deferred — NOT in this plan
|
||||||
|
|
||||||
|
- **Component unit tests with `@testing-library/svelte`:** Would add Vitest+browser setup. Worth doing in a separate phase once the project acquires a second component-refactor candidate.
|
||||||
|
- **Edit-flow E2E spec:** `tests/e2e/remote/recipe-edit.spec.ts` would cover the editor end-to-end. Valuable, but out of scope here — this phase is structural extraction, not test coverage expansion.
|
||||||
|
- **Extract `RecipeHero` / `ServingsStepper` / `TabSwitcher` from RecipeView:** Not on the roadmap. Add to a future phase if RecipeView grows further.
|
||||||
634
docs/superpowers/plans/2026-04-19-ingredient-sections.md
Normal file
634
docs/superpowers/plans/2026-04-19-ingredient-sections.md
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
# Ingredient Sections Implementation Plan
|
||||||
|
|
||||||
|
> **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:** Zutaten können im Editor in benannte Sektionen (z. B. „Für den Teig", „Für die Füllung") gruppiert werden; in der View werden die Sektionen als Überschriften über den zugehörigen Zutatenblöcken gerendert.
|
||||||
|
|
||||||
|
**Architecture:** Eine neue nullable Spalte `section_heading` auf `ingredient`. Ist sie gesetzt, startet an dieser Zeile eine neue Sektion — alle folgenden Zutaten gehören dazu bis zur nächsten Zeile mit gesetzter `section_heading`. Ordnung bleibt `position`. Keine neue Tabelle, keine zweite Ordnungsachse, Scaler/FTS/Importer bleiben unverändert im Verhalten (nur Type-Passthrough). Inline-Button „Abschnitt hinzufügen" erscheint im Editor vor jeder Zutatenzeile und am Listenende.
|
||||||
|
|
||||||
|
**Tech Stack:** better-sqlite3 Migration, TypeScript-strict, Svelte 5 runes, vitest.
|
||||||
|
|
||||||
|
**Scope-Entscheidungen (vom User bestätigt):**
|
||||||
|
- Sektionen **nur für Zutaten**, nicht für Zubereitungsschritte.
|
||||||
|
- „Abschnitt hinzufügen"-Button inline vor jeder Zeile (plus einer am Listenende).
|
||||||
|
- Keine Import-Extraction — JSON-LD hat keine Sektionen, Emmikochteinfach rendert sie nur im HTML. Später via HTML-Parse möglich, aber out-of-scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Migration + Type-Erweiterung + parseIngredient-Sites
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/server/db/migrations/012_ingredient_section.sql`
|
||||||
|
- Modify: `src/lib/types.ts` (Ingredient type)
|
||||||
|
- Modify: `src/lib/server/parsers/ingredient.ts` (3 return sites)
|
||||||
|
- Test: `tests/unit/ingredient.test.ts` (bereits existierend, muss grün bleiben)
|
||||||
|
|
||||||
|
**Warum zusammen:** Nach der Type-Änderung schlägt `svelte-check` überall fehl, wo ein `Ingredient`-Literal gebaut wird. `parseIngredient` hat 3 solcher Stellen und ist vom selben Commit abhängig, sonst wird der Build rot.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Migration schreiben**
|
||||||
|
|
||||||
|
Create `src/lib/server/db/migrations/012_ingredient_section.sql`:
|
||||||
|
```sql
|
||||||
|
-- Nullable — alte Zeilen behalten NULL, neue dürfen eine Überschrift haben.
|
||||||
|
-- Rendering-Regel: Ist section_heading gesetzt (nicht NULL und nicht leer),
|
||||||
|
-- startet an dieser Zeile eine neue Sektion mit diesem Titel.
|
||||||
|
ALTER TABLE ingredient ADD COLUMN section_heading TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Ingredient-Type erweitern**
|
||||||
|
|
||||||
|
Modify `src/lib/types.ts`:
|
||||||
|
```ts
|
||||||
|
export type Ingredient = {
|
||||||
|
position: number;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: string | null;
|
||||||
|
name: string;
|
||||||
|
note: string | null;
|
||||||
|
raw_text: string;
|
||||||
|
section_heading: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: parseIngredient-Return-Sites aktualisieren**
|
||||||
|
|
||||||
|
Modify `src/lib/server/parsers/ingredient.ts`:
|
||||||
|
Alle drei `return { position, ... raw_text: rawText };`-Literale (Zeilen 108, 115, 119) bekommen `section_heading: null` am Ende. Beispiel für Zeile 108:
|
||||||
|
```ts
|
||||||
|
return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
|
||||||
|
```
|
||||||
|
Analog für Zeilen 115 und 119.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Bestehende Unit-Tests grün**
|
||||||
|
|
||||||
|
Run: `npm run test -- ingredient.test.ts`
|
||||||
|
Expected: PASS (Tests prüfen nur vorhandene Felder, neues Feld stört nicht).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Svelte-Check muss noch rot sein**
|
||||||
|
|
||||||
|
Run: `npm run check`
|
||||||
|
Expected: FAIL mit Fehlern in `repository.ts` (Select-Statement ohne `section_heading`). Das ist erwartet — wird in Task 2 behoben. Nicht hier fixen.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/types.ts src/lib/server/db/migrations/012_ingredient_section.sql src/lib/server/parsers/ingredient.ts
|
||||||
|
git commit -m "feat(schema): ingredient.section_heading (Migration 012 + Type)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Repository-Layer Persistenz
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/server/recipes/repository.ts` (insertRecipe, replaceIngredients, getRecipeById)
|
||||||
|
- Test: `tests/integration/recipe-repository.test.ts`
|
||||||
|
|
||||||
|
**Warum jetzt:** Nach Task 1 ist der Type-Vertrag aufgemacht. Die DB muss das Feld lesen und schreiben, sonst gehen Sektionen beim Save/Load verloren.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test für Roundtrip**
|
||||||
|
|
||||||
|
Add to `tests/integration/recipe-repository.test.ts` inside `describe('recipe repository', ...)`:
|
||||||
|
```ts
|
||||||
|
it('persistiert section_heading und gibt es beim Laden zurück', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const recipe = baseRecipe({
|
||||||
|
title: 'Torte',
|
||||||
|
ingredients: [
|
||||||
|
{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Für den Teig' },
|
||||||
|
{ position: 2, quantity: 100, unit: 'g', name: 'Zucker', note: null, raw_text: '100 g Zucker', section_heading: null },
|
||||||
|
{ position: 3, quantity: 300, unit: 'g', name: 'Beeren', note: null, raw_text: '300 g Beeren', section_heading: 'Für die Füllung' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const id = insertRecipe(db, recipe);
|
||||||
|
const loaded = getRecipeById(db, id);
|
||||||
|
expect(loaded!.ingredients[0].section_heading).toBe('Für den Teig');
|
||||||
|
expect(loaded!.ingredients[1].section_heading).toBeNull();
|
||||||
|
expect(loaded!.ingredients[2].section_heading).toBe('Für die Füllung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaceIngredients persistiert section_heading', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const id = insertRecipe(db, baseRecipe({ title: 'X' }));
|
||||||
|
replaceIngredients(db, id, [
|
||||||
|
{ position: 1, quantity: null, unit: null, name: 'A', note: null, raw_text: 'A', section_heading: 'Kopf' }
|
||||||
|
]);
|
||||||
|
const loaded = getRecipeById(db, id);
|
||||||
|
expect(loaded!.ingredients[0].section_heading).toBe('Kopf');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test laufen — muss fehlschlagen**
|
||||||
|
|
||||||
|
Run: `npm run test -- recipe-repository.test.ts`
|
||||||
|
Expected: FAIL — `section_heading` kommt als `undefined` zurück, weil SQL-SELECT es nicht holt.
|
||||||
|
|
||||||
|
- [ ] **Step 3: INSERT-Statements erweitern**
|
||||||
|
|
||||||
|
Modify `src/lib/server/recipes/repository.ts`:
|
||||||
|
|
||||||
|
In `insertRecipe` (line ~66): Spalte + Parameter anhängen.
|
||||||
|
```ts
|
||||||
|
const insIng = db.prepare(
|
||||||
|
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const ing of recipe.ingredients) {
|
||||||
|
insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `replaceIngredients` (line ~217): gleiche Änderung.
|
||||||
|
```ts
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: SELECT-Statement erweitern**
|
||||||
|
|
||||||
|
In `getRecipeById` (line ~105):
|
||||||
|
```ts
|
||||||
|
const ingredients = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT position, quantity, unit, name, note, raw_text, section_heading
|
||||||
|
FROM ingredient WHERE recipe_id = ? ORDER BY position`
|
||||||
|
)
|
||||||
|
.all(id) as Ingredient[];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Tests grün**
|
||||||
|
|
||||||
|
Run: `npm run test -- recipe-repository.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Volle Suite + svelte-check**
|
||||||
|
|
||||||
|
Run: `npm test && npm run check`
|
||||||
|
Expected: Beides PASS. `svelte-check` ist jetzt auf Repo-Ebene typ-clean; View/Editor noch nicht berührt, deren Nutzung von `Ingredient` bleibt (Feld darf fehlen, weil der Type optional wirkt? — Nein, es ist `string | null`, also **pflicht**. Falls `check` rot wird, liegt es an Importer/Scaler-Aufrufern, die `Ingredient`-Literale bauen. Das ist dann Task 3.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/server/recipes/repository.ts tests/integration/recipe-repository.test.ts
|
||||||
|
git commit -m "feat(db): section_heading roundtrip in recipe-repository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Importer-Passthrough + Scaler-Test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/recipes/scaler.ts` (nur falls Test rot — siehe unten)
|
||||||
|
- Test: `tests/unit/scaler.test.ts`
|
||||||
|
- Test: evtl. `tests/integration/importer.test.ts`
|
||||||
|
|
||||||
|
**Warum:** parseIngredient setzt `section_heading: null` (Task 1). Das reicht für den Importer — keine JSON-LD-Extraction. Aber der Scaler ruft `.map((i) => ({ ...i, quantity: ... }))` auf; das Spread erhält `section_heading` automatisch. Wir fügen nur einen Regressions-Test hinzu, dass das stimmt.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Scaler-Regressions-Test**
|
||||||
|
|
||||||
|
Add to `tests/unit/scaler.test.ts`:
|
||||||
|
```ts
|
||||||
|
it('preserves section_heading through scaling', () => {
|
||||||
|
const input: Ingredient[] = [
|
||||||
|
{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Teig' },
|
||||||
|
{ position: 2, quantity: null, unit: null, name: 'Ei', note: null, raw_text: 'Ei', section_heading: null }
|
||||||
|
];
|
||||||
|
const scaled = scaleIngredients(input, 2);
|
||||||
|
expect(scaled[0].section_heading).toBe('Teig');
|
||||||
|
expect(scaled[1].section_heading).toBeNull();
|
||||||
|
expect(scaled[0].quantity).toBe(400);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test laufen**
|
||||||
|
|
||||||
|
Run: `npm run test -- scaler.test.ts`
|
||||||
|
Expected: PASS (weil `...i` das Feld durchreicht).
|
||||||
|
|
||||||
|
Falls FAIL: In `src/lib/recipes/scaler.ts` das `.map` prüfen — es sollte `...i` spreaden und nur `quantity` überschreiben. Bei Abweichung angleichen.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Importer-Roundtrip-Test (Bolognese-Fixture)**
|
||||||
|
|
||||||
|
Prüfen, dass Importer für Emmi-Fixture `section_heading: null` auf allen Zutaten liefert. Der existierende `importer.test.ts` sollte automatisch grün bleiben (parseIngredient setzt das Feld auf null), aber wir schauen kurz nach:
|
||||||
|
|
||||||
|
Run: `npm run test -- importer.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/unit/scaler.test.ts
|
||||||
|
git commit -m "test(scaler): section_heading ueberlebt Skalierung"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: IngredientRow — Heading-Anzeige + Inline Insert-Button
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/recipe-editor-types.ts`
|
||||||
|
- Modify: `src/lib/components/IngredientRow.svelte`
|
||||||
|
- Test: neue Svelte-Component-Tests via vitest-browser — **ausgenommen**: wir haben keine Svelte-Component-Unit-Tests im Repo. Stattdessen decken E2E + manuelle Verifikation ab. Das ist konsistent mit der bestehenden Praxis.
|
||||||
|
|
||||||
|
**Verhalten:**
|
||||||
|
- `DraftIng` bekommt `section_heading: string | null` (immer gesetzt, aber nullable).
|
||||||
|
- Hat eine Zeile `section_heading` als String (auch leer), wird oberhalb der Row ein `<input>` für den Titel gerendert plus ein kleiner „Sektion entfernen"-Button.
|
||||||
|
- Hat eine Zeile `section_heading === null`, wird ein dezenter `<button class="add-section">Abschnitt hinzufügen</button>` **über** der Row gerendert.
|
||||||
|
- IngredientRow bekommt Callbacks `onaddSection`, `onremoveSection` — Parent verwaltet das Array.
|
||||||
|
|
||||||
|
- [ ] **Step 1: DraftIng-Typ erweitern**
|
||||||
|
|
||||||
|
Modify `src/lib/components/recipe-editor-types.ts`:
|
||||||
|
```ts
|
||||||
|
export type DraftIng = {
|
||||||
|
qty: string;
|
||||||
|
unit: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
section_heading: string | null;
|
||||||
|
};
|
||||||
|
export type DraftStep = { text: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: IngredientRow erweitern — Props**
|
||||||
|
|
||||||
|
Modify `src/lib/components/IngredientRow.svelte` Script-Block:
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { Trash2, ChevronUp, ChevronDown, Plus, X } from 'lucide-svelte';
|
||||||
|
import type { DraftIng } from './recipe-editor-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ing: DraftIng;
|
||||||
|
idx: number;
|
||||||
|
total: number;
|
||||||
|
onmove: (dir: -1 | 1) => void;
|
||||||
|
onremove: () => void;
|
||||||
|
onaddSection: () => void;
|
||||||
|
onremoveSection: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { ing, idx, total, onmove, onremove, onaddSection, onremoveSection }: Props = $props();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: IngredientRow-Template — Section-Block + Add-Button**
|
||||||
|
|
||||||
|
Replace the existing `<li class="ing-row">…</li>` with:
|
||||||
|
```svelte
|
||||||
|
{#if ing.section_heading === null}
|
||||||
|
<li class="section-insert">
|
||||||
|
<button type="button" class="add-section" onclick={onaddSection}>
|
||||||
|
<Plus size={12} strokeWidth={2.5} />
|
||||||
|
<span>Abschnitt hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="section-heading-row">
|
||||||
|
<input
|
||||||
|
class="section-heading"
|
||||||
|
type="text"
|
||||||
|
bind:value={ing.section_heading}
|
||||||
|
placeholder="Sektion, z. B. „Für den Teig""
|
||||||
|
aria-label="Sektionsüberschrift"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="section-remove"
|
||||||
|
aria-label="Sektion entfernen"
|
||||||
|
onclick={onremoveSection}
|
||||||
|
>
|
||||||
|
<X size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
<li class="ing-row">
|
||||||
|
<div class="move">
|
||||||
|
<!-- unchanged -->
|
||||||
|
<button class="move-btn" type="button" aria-label="Zutat nach oben" disabled={idx === 0} onclick={() => onmove(-1)}>
|
||||||
|
<ChevronUp size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button class="move-btn" type="button" aria-label="Zutat nach unten" disabled={idx === total - 1} onclick={() => onmove(1)}>
|
||||||
|
<ChevronDown size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
|
||||||
|
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
|
||||||
|
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
|
||||||
|
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
|
||||||
|
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
|
||||||
|
<Trash2 size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Wir rendern pro Row zwei `<li>`: optional einen Sektions-Block (Insert-Button ODER Heading-Input), plus die bestehende Zutaten-Row. Das passt in die `<ul class="ing-list">` des Parents — semantisch unsauber (nicht-Zutat-`<li>` in Zutatenliste), aber praktikabel; alternativ könnte IngredientRow auf `<div>` umgestellt werden, das wäre aber ein Parent-Umbau. Wir bleiben bei `<li>` und geben dem Section-`<li>` `list-style: none` via CSS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Styles für Section-UI**
|
||||||
|
|
||||||
|
Add to `<style>`-Block in `IngredientRow.svelte`:
|
||||||
|
```css
|
||||||
|
.section-insert {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
list-style: none;
|
||||||
|
margin: -0.2rem 0 0.1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.ing-list:hover .section-insert,
|
||||||
|
.section-insert:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.add-section {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border: 1px dashed #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.add-section:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.section-heading-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 32px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.section-heading {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.section-remove {
|
||||||
|
width: 32px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.section-remove:hover {
|
||||||
|
background: #fdf3f3;
|
||||||
|
border-color: #f1b4b4;
|
||||||
|
color: #c53030;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Begründung `opacity: 0` + Hover:** Der Insert-Button erscheint vor **jeder** Zeile — das ist visuelles Rauschen auf statischem Zustand. Fade-in-on-hover hält die Zutatenliste lesbar und macht den Button auf Mouse-Interaktion trotzdem sichtbar. Auf Touch-Geräten ist `:hover` ggf. sticky — das ist OK, weil auf Mobile die Zutatenliste ohnehin explorativ bedient wird. `:focus-within` deckt Keyboard-Navigation ab.
|
||||||
|
|
||||||
|
- [ ] **Step 5: svelte-check**
|
||||||
|
|
||||||
|
Run: `npm run check`
|
||||||
|
Expected: FAIL — `RecipeEditor.svelte` gibt die neuen Callbacks `onaddSection` / `onremoveSection` noch nicht rein, und `DraftIng`-Literale im Editor haben noch kein `section_heading`. Wird in Task 5 behoben.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/IngredientRow.svelte src/lib/components/recipe-editor-types.ts
|
||||||
|
git commit -m "feat(editor): Sektionsueberschriften in IngredientRow + Insert-Button"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: RecipeEditor — State, Handler, Save-Patch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/RecipeEditor.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: DraftIng-Seeding erweitern**
|
||||||
|
|
||||||
|
In `RecipeEditor.svelte` Script-Block, `ingredients`-State (line ~40):
|
||||||
|
```ts
|
||||||
|
let ingredients = $state<DraftIng[]>(
|
||||||
|
untrack(() =>
|
||||||
|
recipe.ingredients.map((i) => ({
|
||||||
|
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
||||||
|
unit: i.unit ?? '',
|
||||||
|
name: i.name,
|
||||||
|
note: i.note ?? '',
|
||||||
|
section_heading: i.section_heading
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: addIngredient aktualisieren**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function addIngredient() {
|
||||||
|
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '', section_heading: null }];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Section-Handler einfügen**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function addSection(idx: number) {
|
||||||
|
const next = [...ingredients];
|
||||||
|
next[idx] = { ...next[idx], section_heading: '' };
|
||||||
|
ingredients = next;
|
||||||
|
}
|
||||||
|
function removeSection(idx: number) {
|
||||||
|
const next = [...ingredients];
|
||||||
|
next[idx] = { ...next[idx], section_heading: null };
|
||||||
|
ingredients = next;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: save()-Patch erweitern**
|
||||||
|
|
||||||
|
In `save()` (line ~86), das `cleanedIngredients`-Mapping:
|
||||||
|
```ts
|
||||||
|
const cleanedIngredients: Ingredient[] = ingredients
|
||||||
|
.filter((i) => i.name.trim())
|
||||||
|
.map((i, idx) => {
|
||||||
|
const qty = parseQty(i.qty);
|
||||||
|
const unit = i.unit.trim() || null;
|
||||||
|
const name = i.name.trim();
|
||||||
|
const note = i.note.trim() || null;
|
||||||
|
const rawParts: string[] = [];
|
||||||
|
if (qty !== null) rawParts.push(String(qty).replace('.', ','));
|
||||||
|
if (unit) rawParts.push(unit);
|
||||||
|
rawParts.push(name);
|
||||||
|
const heading = i.section_heading === null ? null : (i.section_heading.trim() || null);
|
||||||
|
return {
|
||||||
|
position: idx + 1,
|
||||||
|
quantity: qty,
|
||||||
|
unit,
|
||||||
|
name,
|
||||||
|
note,
|
||||||
|
raw_text: rawParts.join(' '),
|
||||||
|
section_heading: heading
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regel:** Eine leere Sektion (`section_heading === ''` nach Trim) wird beim Speichern zu `null`. Begründung: User tippt „Abschnitt hinzufügen" und lässt das Feld leer → keine unbenannte Sektion in der View. Nur Zeilen mit echtem Titel werden als Sektionsanker persistiert.
|
||||||
|
|
||||||
|
- [ ] **Step 5: IngredientRow-Callbacks verdrahten**
|
||||||
|
|
||||||
|
In `RecipeEditor.svelte` Template (line ~170):
|
||||||
|
```svelte
|
||||||
|
{#each ingredients as ing, idx (idx)}
|
||||||
|
<IngredientRow
|
||||||
|
{ing}
|
||||||
|
{idx}
|
||||||
|
total={ingredients.length}
|
||||||
|
onmove={(dir) => moveIngredient(idx, dir)}
|
||||||
|
onremove={() => removeIngredient(idx)}
|
||||||
|
onaddSection={() => addSection(idx)}
|
||||||
|
onremoveSection={() => removeSection(idx)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: svelte-check + Tests**
|
||||||
|
|
||||||
|
Run: `npm run check && npm test`
|
||||||
|
Expected: Beides grün.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/RecipeEditor.svelte
|
||||||
|
git commit -m "feat(editor): Sektionen-Handler + save-Patch mit section_heading"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: RecipeView — Sektions-Überschriften rendern
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/RecipeView.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Zutatenliste umbauen**
|
||||||
|
|
||||||
|
In `RecipeView.svelte` (line ~128), den `<ul class="ing-list">`-Block:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<ul class="ing-list">
|
||||||
|
{#each scaled as ing, i (i)}
|
||||||
|
{#if ing.section_heading && ing.section_heading.trim()}
|
||||||
|
<li class="section-heading">{ing.section_heading}</li>
|
||||||
|
{/if}
|
||||||
|
<li>
|
||||||
|
{#if ing.quantity !== null || ing.unit}
|
||||||
|
<span class="qty">
|
||||||
|
{formatQty(ing.quantity)}
|
||||||
|
{#if ing.unit}
|
||||||
|
{' '}{ing.unit}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="name">
|
||||||
|
{ing.name}
|
||||||
|
{#if ing.note}<span class="note"> ({ing.note})</span>{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** `<li class="section-heading">` statt `<h3>` — wir sind in einer `<ul>` und dürfen dort nur `<li>` direkt verschachteln. Semantisch ist das OK, Screenreader lesen die Heading-Klasse nicht als Landmark, aber sie liest den Text als normales Listen-Item; für ein Rezept ist das akzeptabel. Alternativ: `<ul>` in mehrere `<section>`s aufsplitten — deutlich komplexer bei gleicher visueller Wirkung; verschoben, bis jemand klagt.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Style für .section-heading**
|
||||||
|
|
||||||
|
Add to `<style>`-Block in `RecipeView.svelte`:
|
||||||
|
```css
|
||||||
|
.ing-list .section-heading {
|
||||||
|
list-style: none;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
border-bottom: 1px solid #e4eae7;
|
||||||
|
}
|
||||||
|
.ing-list .section-heading:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Tests + Check**
|
||||||
|
|
||||||
|
Run: `npm run check && npm test`
|
||||||
|
Expected: Beides grün.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Dev-Build-Smoke-Test**
|
||||||
|
|
||||||
|
Run: `npm run build && npm run preview`
|
||||||
|
Manuell: Rezept öffnen, editieren, Sektion „Teig" auf Zeile 1 setzen und „Füllung" auf Zeile 3, speichern. Wechsel zu View → beide Überschriften sichtbar, Skalierung ändert nur Mengen. Screenshot ist nice-to-have, nicht Pflicht.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/RecipeView.svelte
|
||||||
|
git commit -m "feat(view): Zutaten-Sektionen als Ueberschriften rendern"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Ship
|
||||||
|
|
||||||
|
- [ ] **Step 1: Finale Testsuite**
|
||||||
|
|
||||||
|
Run: `npm run check && npm test`
|
||||||
|
Expected: Beides grün.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feature/ingredient-sections
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Auf Deploy warten (CI-Image-Build, Pi-Pull)**
|
||||||
|
|
||||||
|
User wird manuell signalisieren, wenn deployed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Nach Deploy — Playwright Remote-Smoke**
|
||||||
|
|
||||||
|
Run: `npm run test:e2e:remote`
|
||||||
|
Expected: 42/42 green (unchanged suite, wir haben keine Recipe-Edit-E2E-Tests hinzugefügt).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Merge zu main**
|
||||||
|
|
||||||
|
Falls E2E grün:
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git merge --no-ff feature/ingredient-sections -m "Merge ingredient-sections — Zutaten-Gruppierung via section_heading"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review-Notiz
|
||||||
|
|
||||||
|
- Spec-Coverage: alle drei User-Anforderungen abgedeckt (Inline-Button vor jeder Zeile → Task 4, nur Zutaten → keine Step-Änderungen, Edit-Mode-only → Importer unverändert).
|
||||||
|
- Type-Konsistenz: `section_heading: string | null` überall einheitlich (Ingredient, DraftIng, Save-Patch).
|
||||||
|
- Keine Placeholder — alle SQL-/Code-Snippets ausgeschrieben.
|
||||||
|
- Migrations-Reihenfolge: `012_` nach `011_clear_favicon_for_rerun.sql`.
|
||||||
|
- FTS-Impact: `section_heading` taucht nicht im FTS-Trigger auf (`001_init.sql` nutzt `name`, `description`, `ingredients_concat`, `tags_concat`). Das ist bewusst so — Sektionstitel sind Organisationshilfen, kein Suchinhalt. User suchen nach „Mehl", nicht nach „Für den Teig".
|
||||||
217
docs/superpowers/plans/2026-04-19-post-review-roadmap.md
Normal file
217
docs/superpowers/plans/2026-04-19-post-review-roadmap.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Post-Review Roadmap 2026-04-19
|
||||||
|
|
||||||
|
> **Quelle:** `docs/superpowers/review/OPEN-ISSUES-NEXT.md` (Items A–I) + UAT `kochwas-dev.siegeln.net` (Branch `review-fixes-2026-04-18`, 2026-04-19).
|
||||||
|
> **Branch-Status:** Merge-ready — 8 atomare Commits, 184/184 Tests grün, svelte-check 0 Errors, UAT durchgeklickt (Profil, Suche, Rezept-Actions, Wunschliste, Preview, Admin, API-Shapes).
|
||||||
|
> **Goal:** Die nach dem Review-Branch offenen 9 Items in priorisierte Phasen übersetzen, damit jede einzeln via `/gsd-plan-phase` → `/gsd-execute-phase` abgearbeitet werden kann.
|
||||||
|
> **Architecture:** Keine Groß-Refactor-Phase, sondern getaktete Einzel-Phasen mit klarem Gate. Reihenfolge folgt Risiko × Wert: erst kleine Wins, dann eine strukturelle Phase (A), dann opportunistische.
|
||||||
|
> **Tech-Stack:** SvelteKit, TypeScript-strict, Zod, Vitest, Playwright-UAT, better-sqlite3, Service-Worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Merge-Entscheidung
|
||||||
|
|
||||||
|
**Jetzt mergen.** Der Branch-UAT auf `kochwas-dev` war clean (siehe Session-Log 2026-04-19). Findings aus dem UAT:
|
||||||
|
|
||||||
|
- Kommentar-Delete hat keinen UI-Button (MINOR, kein Branch-Regress — Zustand schon vor Refactor so).
|
||||||
|
- `/preview` ohne `?url=` bleibt im Dauer-Lader (MINOR, harmlos — niemand ruft die Route blank auf).
|
||||||
|
|
||||||
|
Beide werden als LOW-Items unten aufgenommen, sind aber **kein Merge-Blocker**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier-Zuordnung
|
||||||
|
|
||||||
|
| Tier | Items | Wann | Aufwand total |
|
||||||
|
|------|-------|------|---------------|
|
||||||
|
| 1 — Schneller Cleanup-Batch | F, G, H, I | Direkt nach Merge | ~2 h |
|
||||||
|
| 2 — Phase Search-State-Store | A | Nächster größerer Slot | halber Tag |
|
||||||
|
| 3 — Phase SearXNG-Recovery | C | Wenn Rate-Limit-Schmerz konkret auftaucht | 1–2 h |
|
||||||
|
| 4 — Opportunistisch | B, D, E, + Kommentar-Delete, Preview-Guard | Trigger-basiert | reaktiv |
|
||||||
|
| 5 — Geparkt | yauzl / Phase 5b | Nur bei explizitem Bedarf | nicht geplant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 — Cleanup-Batch (1 Phase, 4 Items)
|
||||||
|
|
||||||
|
**Phasenname-Vorschlag:** `Phase Cleanup-Batch nach Review-Fixes` (via `/gsd-new-phase` oder `/gsd-add-phase`).
|
||||||
|
|
||||||
|
Alle vier Items touchen wenige Zeilen, sind LOW/MEDIUM, und lassen sich in 1–2 Commits pro Item sauber atomar committen. **Gebündelt statt einzeln**, weil Kontext-Overhead pro Einzelphase größer wäre als der Fix.
|
||||||
|
|
||||||
|
### Item I — RecipeEditor auf `$derived` umstellen
|
||||||
|
|
||||||
|
**Files:** `src/lib/components/RecipeEditor.svelte:28,97–102,113,121`, `src/routes/recipes/[id]/+page.svelte:43`
|
||||||
|
|
||||||
|
Pattern aktuell: `let foo = recipe.bar` → Svelte-5-Warning, Snapshot-only, bricht bei In-Place-Mutation des Rezepts.
|
||||||
|
|
||||||
|
**Plan pro Warnung:**
|
||||||
|
- [ ] Warning-Site auslesen, beurteilen: soll `foo` Mutations am `recipe` tracken oder bewusst ein Snapshot bleiben?
|
||||||
|
- [ ] Track-Fall: `let foo = $derived(recipe.bar)`.
|
||||||
|
- [ ] Snapshot-Fall: Variable umbenennen (z. B. `initialFoo`) und als `$state` deklarieren mit Kommentar `// intentional snapshot`.
|
||||||
|
- [ ] `npm run check` — 0 Warnings erwartet.
|
||||||
|
- [ ] `npm test` — grün.
|
||||||
|
- [ ] Commit: `refactor(editor): RecipeEditor auf $derived umstellen`.
|
||||||
|
|
||||||
|
**Gate:** svelte-check 0 Warnings, alle Editor-Flows (Titel, Zutaten, Schritte) per Hand getestet — In-Place-PATCH zeigt aktualisierten Wert.
|
||||||
|
|
||||||
|
### Item H — RecipeEditor Bild-Upload/Delete auf `asyncFetch`
|
||||||
|
|
||||||
|
**Files:** `src/lib/components/RecipeEditor.svelte:54,83`
|
||||||
|
|
||||||
|
**Warum zusammen mit I:** Gleiche Datei, gleicher Touch.
|
||||||
|
|
||||||
|
- [ ] Zeile 54 (Upload): `const res = await fetch(...); if (!res.ok) alertAction(...)` → `await asyncFetch(...)`.
|
||||||
|
- [ ] Zeile 83 (Delete): dito.
|
||||||
|
- [ ] Error-Messages beibehalten.
|
||||||
|
- [ ] Test manuell: Bild hochladen + löschen in einem Test-Rezept.
|
||||||
|
- [ ] Commit: `refactor(editor): Bild-Upload/Delete auf asyncFetch`.
|
||||||
|
|
||||||
|
**Gate:** Bild-Upload + Delete-Flow grün in manuellem Smoke; `npm run check` clean.
|
||||||
|
|
||||||
|
### Item F — Inline UI-Constants in `src/lib/theme.ts`
|
||||||
|
|
||||||
|
**Files:** Neu `src/lib/theme.ts`, Modify `ConfirmDialog.svelte`, `ProfileSwitcher.svelte`, weitere Call-Sites via `grep`.
|
||||||
|
|
||||||
|
- [ ] `grep -rn "z-index:\|border-radius: 999\|setTimeout.*[0-9]{3,4}" src/lib/components src/routes` — Call-Sites auflisten.
|
||||||
|
- [ ] `src/lib/theme.ts` anlegen mit: `MODAL_Z_INDEX = 1000`, `POPOVER_Z_INDEX = 900`, `PILL_RADIUS = '999px'` (nur Werte, die wirklich mehrfach vorkommen — YAGNI).
|
||||||
|
- [ ] Call-Sites durchgehen, Inline-Werte durch Import ersetzen.
|
||||||
|
- [ ] `npm run check` + `npm test`.
|
||||||
|
- [ ] Commit: `refactor(ui): shared theme constants fuer z-index/radius`.
|
||||||
|
|
||||||
|
**Gate:** Keine visuellen Änderungen beim Durchklicken (Confirm-Dialog, Profile-Switcher, Toast, Menü).
|
||||||
|
|
||||||
|
### Item G — `requireProfile()` mit optionaler Message
|
||||||
|
|
||||||
|
**Files:** `src/lib/client/confirm.svelte.ts` (oder wo `requireProfile` liegt), `src/routes/wishlist/+page.svelte:38`
|
||||||
|
|
||||||
|
**Option A — minimal invasiv:** `wishlist/+page.svelte` belassen, Custom-Message-Konstante in der Datei. Dann **nur dokumentieren** im Kommentar der `requireProfile`-Funktion, dass die Wunschliste bewusst eigenständig ist.
|
||||||
|
|
||||||
|
**Option B — DRY:** `requireProfile(message?: string): Profile | null` mit Fallback auf Default.
|
||||||
|
|
||||||
|
- [ ] **Entscheidung zuerst** — Option A sparsamer, Option B konsistent. Ich empfehle **A**, weil die Custom-Message in der Wunschliste wirklich Kontext ist („um mitzuwünschen"), nicht nur Deko. Aber: wenn B, dann sauber mit Unit-Test.
|
||||||
|
- [ ] Commit: `refactor(client): requireProfile Custom-Message entscheiden` (je nach Entscheidung).
|
||||||
|
|
||||||
|
**Gate:** Wunschliste zeigt beim Klick ohne Profil die korrekte Message; keine anderen Sites verhalten sich anders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 — Phase Search-State-Store (Item A)
|
||||||
|
|
||||||
|
**Empfohlener Einstieg:** `/gsd-discuss-phase Search-State-Store` (per OPEN-ISSUES Empfehlung), nicht direkt `/gsd-plan-phase`.
|
||||||
|
|
||||||
|
**Warum eigene Phase:** Touch `+page.svelte` (808 L) + `+layout.svelte` (678 L), Reactive-Glue zwischen Header-Search-Dropdown und Home-Search muss 1:1 übernommen werden. **UAT-pflichtig**, weil es keine UI-Tests gibt.
|
||||||
|
|
||||||
|
**Scope-Sketch (für die Discuss-Phase):**
|
||||||
|
|
||||||
|
- Neu: `src/lib/client/search.svelte.ts` — reaktiver Store mit `query`, `hits`, `loading`, `error`, `hasMore`, `search(q)`, `loadMore()`, `clear()`.
|
||||||
|
- Debounce (aktuell in `+page.svelte`) in den Store migrieren.
|
||||||
|
- Web-Fallback-Logik (lokal leer → Web-Suche) beibehalten — Store muss beide Modi kennen (`mode: 'local' | 'web'`).
|
||||||
|
- `+layout.svelte` Header-Dropdown zuerst migrieren (kleineres Surface), dann `+page.svelte`.
|
||||||
|
- Duplizierten `$state`-Block entfernen.
|
||||||
|
|
||||||
|
**Verifikation pro Wave:**
|
||||||
|
1. Nach Store-Anlegen: Vitest-Unit-Tests für Store (mocked fetch).
|
||||||
|
2. Nach Layout-Migration: Browser-UAT Header-Dropdown auf Rezept-Seite + Startseite.
|
||||||
|
3. Nach Page-Migration: Browser-UAT Live-Suche (lokaler Treffer, Web-Fallback, Empty-State), inkl. Deep-Link `?q=xyz`.
|
||||||
|
4. Playwright-Script wiederholen (existiert aus 2026-04-19 UAT).
|
||||||
|
|
||||||
|
**Gate:** Alle 3 UAT-Pfade clean; `+page.svelte` unter 700 L; `+layout.svelte` unter 600 L; `npm test` + `npm run check` grün.
|
||||||
|
|
||||||
|
**Aufwand:** halber Tag (4–6 h).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 — Phase SearXNG-Rate-Limit-Recovery (Item C)
|
||||||
|
|
||||||
|
**Trigger:** Wenn konkreter Schmerz (User merkt „Suche liefert komische alte Sachen" oder SearXNG logt 429/403 gehäuft).
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- `src/lib/server/search/searxng.ts`: `lastFailureAt: Map<string, number>` pro Domain.
|
||||||
|
- Exponentieller Backoff: bei wiederholtem 429/403 → 1 min → 5 min → 30 min (Cap).
|
||||||
|
- Response-Shape erweitern: `isStale?: boolean` wenn aus Cache nach Fail.
|
||||||
|
- UI: `src/routes/+page.svelte` Such-Ergebnisheader zeigt „Ergebnisse evtl. veraltet" wenn `isStale`.
|
||||||
|
|
||||||
|
**Tests (TDD, Vitest):**
|
||||||
|
|
||||||
|
- Simulierter 429 → nächster Call innerhalb 1 min geht nicht raus, Response aus Cache mit `isStale: true`.
|
||||||
|
- Nach 1 min Wartezeit → Call geht wieder raus.
|
||||||
|
- Nach erfolgreichem Call → Backoff-Zähler resettet.
|
||||||
|
|
||||||
|
**Gate:** Tests grün; manuell: Fake-429 injizieren (z. B. über ENV-Toggle im Dev), UI zeigt Hinweis.
|
||||||
|
|
||||||
|
**Aufwand:** 1–2 h.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 4 — Opportunistisch (Trigger-gesteuert)
|
||||||
|
|
||||||
|
Alle Items hier werden **nicht proaktiv** geplant. Sie warten auf ihren Trigger.
|
||||||
|
|
||||||
|
### Item B — RecipeEditor/RecipeView in Sub-Components
|
||||||
|
|
||||||
|
**Trigger:** Zweite Person arbeitet am Projekt mit, ODER Editor-Bug-Hunt wird unübersichtlich.
|
||||||
|
|
||||||
|
**Scope-Sketch:** `IngredientRow.svelte`, `StepList.svelte`, `TimeDisplay.svelte`, `ImageUploadBox.svelte`.
|
||||||
|
|
||||||
|
**Vorbedingung:** Item I muss zuerst durch sein (die pre-existing Warnings würden sonst in die Sub-Components wandern).
|
||||||
|
|
||||||
|
### Item D — SW Zombie-Cleanup unter Drosselung
|
||||||
|
|
||||||
|
**Trigger:** Nächster Service-Worker-Touch (z. B. neue Cache-Strategy oder Chunks-Manifest-Änderung).
|
||||||
|
|
||||||
|
**Scope:** Mit DevTools-Throttling-Profil „Slow 3G" durchgehen, prüfen ob der 1500ms-Timeout in `pwa.svelte.ts` False-Positives triggert. Falls ja: Timeout konfigurierbar oder Heuristik verfeinern.
|
||||||
|
|
||||||
|
### Item E — JSON-LD Parser Locale-Edge-Cases
|
||||||
|
|
||||||
|
**Trigger:** Echter Import-Bug aus dem Alltag.
|
||||||
|
|
||||||
|
**Scope:** Gezielter Test für die Fail-URL + Fix. Kein Vorab-Sprint.
|
||||||
|
|
||||||
|
### Kommentar-Delete-UI (UAT 2026-04-19)
|
||||||
|
|
||||||
|
**Status:** Kommentar-DELETE-Endpoint existiert, aber keine UI-Exposition.
|
||||||
|
|
||||||
|
**Vorschlag:** In `src/routes/recipes/[id]/+page.svelte` Kommentar-Liste pro Eintrag ein 🗑-Button für den Autor (`comment.profile_id === profileStore.active?.id`). Mit `confirmAction`-Dialog.
|
||||||
|
|
||||||
|
**Trigger:** Erster Wunsch, einen Kommentar loszuwerden.
|
||||||
|
|
||||||
|
**Aufwand:** ~30 min.
|
||||||
|
|
||||||
|
### Preview-ohne-URL-Guard (UAT 2026-04-19)
|
||||||
|
|
||||||
|
**Status:** `/preview` ohne `?url=` bleibt im Dauer-Lader.
|
||||||
|
|
||||||
|
**Vorschlag:** `src/routes/preview/+page.svelte` Zeile 33ff.: wenn `u` leer, `errored = 'Kein URL-Parameter gesetzt'` oder Redirect auf `/`. **2-Zeilen-Fix.**
|
||||||
|
|
||||||
|
**Trigger:** Bevor jemand die Route bookmarked.
|
||||||
|
|
||||||
|
**Aufwand:** 5 min — könnte man auch sofort in Tier 1 reinnehmen, ist aber so trivial, dass es ohne Phase geht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 5 — Geparkt
|
||||||
|
|
||||||
|
### Phase 5b — ZIP-Backup-Restore via `yauzl`
|
||||||
|
|
||||||
|
**Status:** Dokumentiert in `ARCHITECTURE.md:33` und `session-handoff-2026-04-17.md`. Dependency bleibt installiert.
|
||||||
|
|
||||||
|
**Kein Plan.** Wird erst aktiviert, wenn jemand wirklich ein Backup-ZIP zurückspielen will. Dann: `/gsd-plan-phase Phase-5b-ZIP-Restore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Ausführungs-Reihenfolge
|
||||||
|
|
||||||
|
1. **Merge** `review-fixes-2026-04-18` → `main`.
|
||||||
|
2. **Neuen Branch** `cleanup-batch-post-review` → Tier 1 (Items I + H zusammen in einem Wave, dann F, dann G).
|
||||||
|
3. **Merge** → Tier 2 Discuss: `/gsd-discuss-phase Search-State-Store`.
|
||||||
|
4. Tier 2 execution.
|
||||||
|
5. Tier 3 erst wenn der Trigger da ist, sonst Tier 4 abwarten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit-Stil für alle Phasen
|
||||||
|
|
||||||
|
- Deutsch, kleinteilig, eine Idee pro Commit.
|
||||||
|
- Body erklärt das *Warum* (Reference auf Item-Nummer aus diesem Doc).
|
||||||
|
- Nach jedem Commit `npm test` + `npm run check` grün.
|
||||||
|
- Push direkt nach Commit (CI baut Branch-Tag, siehe `docker.yml`).
|
||||||
971
docs/superpowers/plans/2026-04-19-search-state-store.md
Normal file
971
docs/superpowers/plans/2026-04-19-search-state-store.md
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
# Search-State-Store Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Extract the duplicated live-search state machine from `src/routes/+page.svelte` and `src/routes/+layout.svelte` into a single reusable `SearchStore` class in `src/lib/client/search.svelte.ts`, so both the home search and the header dropdown drive their UI from the same logic.
|
||||||
|
|
||||||
|
**Architecture:** Factory-class store (one instance per consumer, like `new SearchStore()` — not a shared singleton). Holds all `$state` fields currently inlined in the Svelte components (query, hits, webHits, searching flags, error, pagination state), plus imperative methods (`runDebounced`, `loadMore`, `reSearch`, `reset`, `captureSnapshot`, `restoreSnapshot`). Consumers keep UI-specific concerns (URL sync, dropdown open/close, snapshot hookup) in their component — the store owns only fetch/pagination/debounce.
|
||||||
|
|
||||||
|
**Tech Stack:** Svelte 5 runes (`$state` in class fields), TypeScript-strict, Vitest + jsdom, fetch injection for tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Snapshot
|
||||||
|
|
||||||
|
**API surface (locked before implementation):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/client/search.svelte.ts
|
||||||
|
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
|
import type { WebHit } from '$lib/server/search/searxng';
|
||||||
|
|
||||||
|
export type SearchSnapshot = {
|
||||||
|
query: string;
|
||||||
|
hits: SearchHit[];
|
||||||
|
webHits: WebHit[];
|
||||||
|
searchedFor: string | null;
|
||||||
|
webError: string | null;
|
||||||
|
localExhausted: boolean;
|
||||||
|
webPageno: number;
|
||||||
|
webExhausted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchStoreOptions = {
|
||||||
|
pageSize?: number; // default 30
|
||||||
|
debounceMs?: number; // default 300
|
||||||
|
filterDebounceMs?: number; // default 150 (shorter for filter-change re-search)
|
||||||
|
minQueryLength?: number; // default 4 (query.trim().length > 3)
|
||||||
|
filterParam?: () => string; // e.g. () => searchFilterStore.queryParam → "foo,bar" or ""
|
||||||
|
fetchImpl?: typeof fetch; // injected for tests
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SearchStore {
|
||||||
|
query = $state('');
|
||||||
|
hits = $state<SearchHit[]>([]);
|
||||||
|
webHits = $state<WebHit[]>([]);
|
||||||
|
searching = $state(false);
|
||||||
|
webSearching = $state(false);
|
||||||
|
webError = $state<string | null>(null);
|
||||||
|
searchedFor = $state<string | null>(null);
|
||||||
|
localExhausted = $state(false);
|
||||||
|
webPageno = $state(0);
|
||||||
|
webExhausted = $state(false);
|
||||||
|
loadingMore = $state(false);
|
||||||
|
|
||||||
|
constructor(opts?: SearchStoreOptions);
|
||||||
|
|
||||||
|
/** Call from `$effect(() => { store.query; store.runDebounced(); })`. Handles debounce + race-guard. */
|
||||||
|
runDebounced(): void;
|
||||||
|
/** Immediate (no debounce). Used by form `submit`. */
|
||||||
|
runSearch(q: string): Promise<void>;
|
||||||
|
/** Filter-change re-search — shorter debounce. */
|
||||||
|
reSearch(): void;
|
||||||
|
/** Paginate locally, then fall back to web. Idempotent while in-flight. */
|
||||||
|
loadMore(): Promise<void>;
|
||||||
|
/** Clear query + results + cancel any pending debounce (e.g. `afterNavigate`). */
|
||||||
|
reset(): void;
|
||||||
|
/** For SvelteKit `Snapshot<>` API. */
|
||||||
|
captureSnapshot(): SearchSnapshot;
|
||||||
|
restoreSnapshot(s: SearchSnapshot): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior invariants (copied 1:1 from the current code — do NOT change):**
|
||||||
|
- Query threshold: `trim().length > 3` triggers search, `<= 3` clears results.
|
||||||
|
- Race-guard: after every `await fetch(...)`, bail if `this.query.trim() !== q`.
|
||||||
|
- When `hits.length === 0` after local search → auto-fire web search page 1.
|
||||||
|
- `loadMore`: first drains local (offset pagination), then switches to web (pageno pagination).
|
||||||
|
- Dedup: local by `id`, web by `url`.
|
||||||
|
- `webError`: keep the message text so UI can render it.
|
||||||
|
|
||||||
|
**What stays OUT of the store:**
|
||||||
|
- URL sync (`history.replaceState` with `?q=`) → stays in `+page.svelte`.
|
||||||
|
- Dropdown visibility (`navOpen`) → stays in `+layout.svelte`.
|
||||||
|
- `afterNavigate`-reset wiring → stays in `+layout.svelte`, just calls `store.reset()`.
|
||||||
|
- SvelteKit `Snapshot<>` wiring → stays in `+page.svelte`, delegates to store.
|
||||||
|
- Filter-change re-search `$effect` → stays in `+page.svelte`, just calls `store.reSearch()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Failing Unit Tests for SearchStore
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/unit/search-store.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write test file with full behavior coverage (runs red until Task 2)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { SearchStore } from '../../src/lib/client/search.svelte';
|
||||||
|
|
||||||
|
type FetchMock = ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function mockFetch(responses: Array<{ ok?: boolean; status?: number; body: unknown }>): FetchMock {
|
||||||
|
const calls = [...responses];
|
||||||
|
return vi.fn(async () => {
|
||||||
|
const r = calls.shift();
|
||||||
|
if (!r) throw new Error('fetch called more times than expected');
|
||||||
|
return {
|
||||||
|
ok: r.ok ?? true,
|
||||||
|
status: r.status ?? 200,
|
||||||
|
json: async () => r.body
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SearchStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps results empty while query is <= 3 chars (debounced)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
|
||||||
|
store.query = 'abc';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(store.searching).toBe(false);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires local search after debounce when query > 3 chars', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: [{ id: 1, title: 'Pasta', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 50, pageSize: 30 });
|
||||||
|
store.query = 'pasta';
|
||||||
|
store.runDebounced();
|
||||||
|
expect(store.searching).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
|
||||||
|
expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?q=pasta&limit=30/);
|
||||||
|
expect(store.hits).toHaveLength(1);
|
||||||
|
expect(store.searchedFor).toBe('pasta');
|
||||||
|
expect(store.localExhausted).toBe(true); // 1 hit < pageSize → exhausted
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to web search when local returns zero hits', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: [] } },
|
||||||
|
{ body: { hits: [{ url: 'https://chefkoch.de/x', title: 'Foo', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
|
||||||
|
store.query = 'pizza';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await vi.waitFor(() => expect(store.webHits).toHaveLength(1));
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?q=pizza&pageno=1/);
|
||||||
|
expect(store.webPageno).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('races-guards: stale response discarded when query changed mid-flight', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: [{ id: 99, title: 'Stale', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 10 });
|
||||||
|
store.query = 'stale-query';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
|
store.query = 'different'; // user kept typing
|
||||||
|
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
|
||||||
|
expect(store.hits).toEqual([]); // stale discarded
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadMore: drains local first (offset pagination)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const page1 = Array.from({ length: 30 }, (_, i) => ({ id: i, title: `r${i}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }));
|
||||||
|
const page2 = Array.from({ length: 5 }, (_, i) => ({ id: i + 30, title: `r${i + 30}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }));
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: page1 } },
|
||||||
|
{ body: { hits: page2 } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 });
|
||||||
|
store.query = 'meal';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
|
await vi.waitFor(() => expect(store.hits).toHaveLength(30));
|
||||||
|
expect(store.localExhausted).toBe(false);
|
||||||
|
await store.loadMore();
|
||||||
|
expect(store.hits).toHaveLength(35);
|
||||||
|
expect(fetchImpl.mock.calls[1][0]).toMatch(/offset=30/);
|
||||||
|
expect(store.localExhausted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadMore: switches to web pagination after local exhausted', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const local = [{ id: 1, title: 'local', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }];
|
||||||
|
const webP1 = [{ url: 'https://a.com', title: 'A', domain: 'a.com', snippet: null, thumbnail: null }];
|
||||||
|
const webP2 = [{ url: 'https://b.com', title: 'B', domain: 'b.com', snippet: null, thumbnail: null }];
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: local } },
|
||||||
|
{ body: { hits: webP1 } }, // auto-fallback? No — local has 1 hit, so no fallback.
|
||||||
|
{ body: { hits: webP2 } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 });
|
||||||
|
store.query = 'soup';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
|
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
|
||||||
|
expect(store.localExhausted).toBe(true);
|
||||||
|
await store.loadMore(); // web pageno=1
|
||||||
|
expect(store.webHits).toHaveLength(1);
|
||||||
|
await store.loadMore(); // web pageno=2
|
||||||
|
expect(store.webHits).toHaveLength(2);
|
||||||
|
expect(store.webPageno).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('web search error sets webError and marks webExhausted', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: [] } },
|
||||||
|
{ ok: false, status: 502, body: { message: 'SearXNG unreachable' } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 10 });
|
||||||
|
store.query = 'anything';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
|
await vi.waitFor(() => expect(store.webError).toBe('SearXNG unreachable'));
|
||||||
|
expect(store.webExhausted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset(): clears query, results, and pending debounce', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 100 });
|
||||||
|
store.query = 'foobar';
|
||||||
|
store.runDebounced();
|
||||||
|
store.reset();
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
expect(store.query).toBe('');
|
||||||
|
expect(store.hits).toEqual([]);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captureSnapshot / restoreSnapshot: round-trips without re-fetching', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([]);
|
||||||
|
const store = new SearchStore({ fetchImpl, debounceMs: 50 });
|
||||||
|
const snap: SearchSnapshot = {
|
||||||
|
query: 'lasagne',
|
||||||
|
hits: [{ id: 7, title: 'Lasagne', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }],
|
||||||
|
webHits: [],
|
||||||
|
searchedFor: 'lasagne',
|
||||||
|
webError: null,
|
||||||
|
localExhausted: true,
|
||||||
|
webPageno: 0,
|
||||||
|
webExhausted: false
|
||||||
|
};
|
||||||
|
store.restoreSnapshot(snap);
|
||||||
|
expect(store.query).toBe('lasagne');
|
||||||
|
expect(store.hits).toHaveLength(1);
|
||||||
|
store.runDebounced(); // should NOT re-fetch after restore
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
const round = store.captureSnapshot();
|
||||||
|
expect(round).toEqual(snap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filterParam option: gets appended to both local and web requests', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: [] } },
|
||||||
|
{ body: { hits: [] } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({
|
||||||
|
fetchImpl,
|
||||||
|
debounceMs: 10,
|
||||||
|
filterParam: () => '&domains=chefkoch.de'
|
||||||
|
});
|
||||||
|
store.query = 'curry';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
|
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
|
||||||
|
expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/);
|
||||||
|
expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reSearch: immediate re-run with current query on filter change', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let filter = '';
|
||||||
|
const fetchImpl = mockFetch([
|
||||||
|
{ body: { hits: [] } },
|
||||||
|
{ body: { hits: [] } },
|
||||||
|
{ body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
|
||||||
|
]);
|
||||||
|
const store = new SearchStore({
|
||||||
|
fetchImpl,
|
||||||
|
debounceMs: 10,
|
||||||
|
filterDebounceMs: 5,
|
||||||
|
filterParam: () => filter
|
||||||
|
});
|
||||||
|
store.query = 'broth';
|
||||||
|
store.runDebounced();
|
||||||
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
|
// Simulate filter change
|
||||||
|
filter = '&domains=chefkoch.de';
|
||||||
|
store.reSearch();
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
|
||||||
|
// Last call should have filter param
|
||||||
|
const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
|
||||||
|
expect(last).toMatch(/&domains=chefkoch\.de/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify all fail with "SearchStore is not a constructor" or "Cannot find module"**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- search-store.test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 12 tests, all failing because `src/lib/client/search.svelte.ts` doesn't exist yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Implement SearchStore to pass tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/client/search.svelte.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Scaffold the class + types**
|
||||||
|
|
||||||
|
Create `src/lib/client/search.svelte.ts` with this content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
|
import type { WebHit } from '$lib/server/search/searxng';
|
||||||
|
|
||||||
|
export type SearchSnapshot = {
|
||||||
|
query: string;
|
||||||
|
hits: SearchHit[];
|
||||||
|
webHits: WebHit[];
|
||||||
|
searchedFor: string | null;
|
||||||
|
webError: string | null;
|
||||||
|
localExhausted: boolean;
|
||||||
|
webPageno: number;
|
||||||
|
webExhausted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchStoreOptions = {
|
||||||
|
pageSize?: number;
|
||||||
|
debounceMs?: number;
|
||||||
|
filterDebounceMs?: number;
|
||||||
|
minQueryLength?: number;
|
||||||
|
filterParam?: () => string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SearchStore {
|
||||||
|
query = $state('');
|
||||||
|
hits = $state<SearchHit[]>([]);
|
||||||
|
webHits = $state<WebHit[]>([]);
|
||||||
|
searching = $state(false);
|
||||||
|
webSearching = $state(false);
|
||||||
|
webError = $state<string | null>(null);
|
||||||
|
searchedFor = $state<string | null>(null);
|
||||||
|
localExhausted = $state(false);
|
||||||
|
webPageno = $state(0);
|
||||||
|
webExhausted = $state(false);
|
||||||
|
loadingMore = $state(false);
|
||||||
|
|
||||||
|
private readonly pageSize: number;
|
||||||
|
private readonly debounceMs: number;
|
||||||
|
private readonly filterDebounceMs: number;
|
||||||
|
private readonly minQueryLength: number;
|
||||||
|
private readonly filterParam: () => string;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private skipNextDebounce = false;
|
||||||
|
|
||||||
|
constructor(opts: SearchStoreOptions = {}) {
|
||||||
|
this.pageSize = opts.pageSize ?? 30;
|
||||||
|
this.debounceMs = opts.debounceMs ?? 300;
|
||||||
|
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
|
||||||
|
this.minQueryLength = opts.minQueryLength ?? 4;
|
||||||
|
this.filterParam = opts.filterParam ?? (() => '');
|
||||||
|
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement `runDebounced`, `runSearch`, private `runWebSearch`**
|
||||||
|
|
||||||
|
Add to the class:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
runDebounced(): void {
|
||||||
|
// Consumer pattern:
|
||||||
|
// $effect(() => { store.query; store.runDebounced(); });
|
||||||
|
// The bare `store.query` read registers the reactive dep; this method
|
||||||
|
// then reads `this.query` live to kick off / debounce the search.
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
if (this.skipNextDebounce) {
|
||||||
|
this.skipNextDebounce = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const q = this.query.trim();
|
||||||
|
if (q.length < this.minQueryLength) {
|
||||||
|
this.resetResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.searching = true;
|
||||||
|
this.webHits = [];
|
||||||
|
this.webSearching = false;
|
||||||
|
this.webError = null;
|
||||||
|
this.debounceTimer = setTimeout(() => {
|
||||||
|
void this.runSearch(q);
|
||||||
|
}, this.debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runSearch(q: string): Promise<void> {
|
||||||
|
this.localExhausted = false;
|
||||||
|
this.webPageno = 0;
|
||||||
|
this.webExhausted = false;
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}`
|
||||||
|
);
|
||||||
|
const body = (await res.json()) as { hits: SearchHit[] };
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
this.hits = body.hits;
|
||||||
|
this.searchedFor = q;
|
||||||
|
if (this.hits.length < this.pageSize) this.localExhausted = true;
|
||||||
|
if (this.hits.length === 0) {
|
||||||
|
await this.runWebSearch(q, 1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.query.trim() === q) this.searching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWebSearch(q: string, pageno: number): Promise<void> {
|
||||||
|
this.webSearching = true;
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}`
|
||||||
|
);
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { message?: string };
|
||||||
|
this.webError = err.message ?? `HTTP ${res.status}`;
|
||||||
|
this.webExhausted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { hits: WebHit[] };
|
||||||
|
this.webHits = pageno === 1 ? body.hits : [...this.webHits, ...body.hits];
|
||||||
|
this.webPageno = pageno;
|
||||||
|
if (body.hits.length === 0) this.webExhausted = true;
|
||||||
|
} finally {
|
||||||
|
if (this.query.trim() === q) this.webSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `loadMore`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async loadMore(): Promise<void> {
|
||||||
|
if (this.loadingMore) return;
|
||||||
|
const q = this.query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
this.loadingMore = true;
|
||||||
|
try {
|
||||||
|
if (!this.localExhausted) {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}`
|
||||||
|
);
|
||||||
|
const body = (await res.json()) as { hits: SearchHit[] };
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
const more = body.hits;
|
||||||
|
const seen = new Set(this.hits.map((h) => h.id));
|
||||||
|
const deduped = more.filter((h) => !seen.has(h.id));
|
||||||
|
this.hits = [...this.hits, ...deduped];
|
||||||
|
if (more.length < this.pageSize) this.localExhausted = true;
|
||||||
|
} else if (!this.webExhausted) {
|
||||||
|
const nextPage = this.webPageno + 1;
|
||||||
|
const wasEmpty = this.webHits.length === 0;
|
||||||
|
if (wasEmpty) this.webSearching = true;
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}`
|
||||||
|
);
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { message?: string };
|
||||||
|
this.webError = err.message ?? `HTTP ${res.status}`;
|
||||||
|
this.webExhausted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { hits: WebHit[] };
|
||||||
|
const more = body.hits;
|
||||||
|
const seen = new Set(this.webHits.map((h) => h.url));
|
||||||
|
const deduped = more.filter((h) => !seen.has(h.url));
|
||||||
|
if (deduped.length === 0) {
|
||||||
|
this.webExhausted = true;
|
||||||
|
} else {
|
||||||
|
this.webHits = [...this.webHits, ...deduped];
|
||||||
|
this.webPageno = nextPage;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.query.trim() === q) this.webSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `reSearch`, `reset`, `resetResults`, snapshot methods**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
reSearch(): void {
|
||||||
|
const q = this.query.trim();
|
||||||
|
if (q.length < this.minQueryLength) return;
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.searching = true;
|
||||||
|
this.webHits = [];
|
||||||
|
this.webSearching = false;
|
||||||
|
this.webError = null;
|
||||||
|
this.debounceTimer = setTimeout(() => void this.runSearch(q), this.filterDebounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = null;
|
||||||
|
this.query = '';
|
||||||
|
this.resetResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetResults(): void {
|
||||||
|
this.hits = [];
|
||||||
|
this.webHits = [];
|
||||||
|
this.searchedFor = null;
|
||||||
|
this.searching = false;
|
||||||
|
this.webSearching = false;
|
||||||
|
this.webError = null;
|
||||||
|
this.localExhausted = false;
|
||||||
|
this.webPageno = 0;
|
||||||
|
this.webExhausted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureSnapshot(): SearchSnapshot {
|
||||||
|
return {
|
||||||
|
query: this.query,
|
||||||
|
hits: this.hits,
|
||||||
|
webHits: this.webHits,
|
||||||
|
searchedFor: this.searchedFor,
|
||||||
|
webError: this.webError,
|
||||||
|
localExhausted: this.localExhausted,
|
||||||
|
webPageno: this.webPageno,
|
||||||
|
webExhausted: this.webExhausted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSnapshot(s: SearchSnapshot): void {
|
||||||
|
this.skipNextDebounce = true;
|
||||||
|
this.query = s.query;
|
||||||
|
this.hits = s.hits;
|
||||||
|
this.webHits = s.webHits;
|
||||||
|
this.searchedFor = s.searchedFor;
|
||||||
|
this.webError = s.webError;
|
||||||
|
this.localExhausted = s.localExhausted;
|
||||||
|
this.webPageno = s.webPageno;
|
||||||
|
this.webExhausted = s.webExhausted;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests, iterate until all green**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- search-store.test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 12 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: `npm run check`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors, 0 warnings in `search.svelte.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/client/search.svelte.ts tests/unit/search-store.test.ts
|
||||||
|
git commit -m "feat(search): SearchStore fuer Live-Search mit Web-Fallback
|
||||||
|
|
||||||
|
Extrahiert die duplizierte Such-Logik aus +page.svelte und
|
||||||
|
+layout.svelte in eine gemeinsame Klasse. Pure Datenschicht
|
||||||
|
mit injizierbarem fetch — UI-Concerns (URL-Sync, Dropdown,
|
||||||
|
Snapshot) bleiben in den Komponenten."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Migrate `+layout.svelte` header dropdown
|
||||||
|
|
||||||
|
**Why first:** Smaller surface than `+page.svelte`, no snapshot API, no URL sync. If the store is wrong, here we find out with less code at risk.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/+layout.svelte:20-200`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add import**
|
||||||
|
|
||||||
|
At the top of `<script>`:
|
||||||
|
```ts
|
||||||
|
import { SearchStore } from '$lib/client/search.svelte';
|
||||||
|
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||||
|
```
|
||||||
|
(Latter is already imported — just confirm.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the 11 `$state` declarations (navQuery, navHits, navWebHits, navSearching, navWebSearching, navWebError, navLocalExhausted, navWebPageno, navWebExhausted, navLoadingMore, debounceTimer) with one store instance.**
|
||||||
|
|
||||||
|
Keep these (UI-only): `navOpen`, `navContainer`, `menuOpen`, `menuContainer`.
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
const navStore = new SearchStore({
|
||||||
|
pageSize: 30,
|
||||||
|
filterParam: () => {
|
||||||
|
const p = searchFilterStore.queryParam;
|
||||||
|
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the local `filterParam()` helper — the store owns it now.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the big `$effect` (lines 52–109) with a 3-line `$effect`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
$effect(() => {
|
||||||
|
// Bare reads register the reactive deps; then kick the store.
|
||||||
|
const q = navStore.query;
|
||||||
|
navStore.runDebounced();
|
||||||
|
// navOpen follows query length: open while typing, close when cleared.
|
||||||
|
navOpen = q.trim().length > 3;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace `loadMoreNav` function (lines 111–159) with a pass-through**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function loadMoreNav() {
|
||||||
|
return navStore.loadMore();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or inline `onclick={() => navStore.loadMore()}` at the call-site — pick the less disruptive option when looking at the template.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Replace `submitNav` (lines 161–167)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function submitNav(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = navStore.query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
navOpen = false;
|
||||||
|
void goto(`/?q=${encodeURIComponent(q)}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Replace `pickHit` (lines 185–190)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function pickHit() {
|
||||||
|
navOpen = false;
|
||||||
|
navStore.reset();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Update `afterNavigate` (lines 192+)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
afterNavigate(() => {
|
||||||
|
navStore.reset();
|
||||||
|
navOpen = false;
|
||||||
|
menuOpen = false;
|
||||||
|
// ... rest of existing body (wishlist refresh etc.)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Update the template**
|
||||||
|
|
||||||
|
Every `navQuery` → `navStore.query`, every `navHits` → `navStore.hits`, etc. This is a mechanical rename — use find+replace scoped to `src/routes/+layout.svelte` only.
|
||||||
|
|
||||||
|
Mapping:
|
||||||
|
- `navQuery` → `navStore.query`
|
||||||
|
- `navHits` → `navStore.hits`
|
||||||
|
- `navWebHits` → `navStore.webHits`
|
||||||
|
- `navSearching` → `navStore.searching`
|
||||||
|
- `navWebSearching` → `navStore.webSearching`
|
||||||
|
- `navWebError` → `navStore.webError`
|
||||||
|
- `navLocalExhausted` → `navStore.localExhausted`
|
||||||
|
- `navWebPageno` → `navStore.webPageno` (if referenced in template)
|
||||||
|
- `navWebExhausted` → `navStore.webExhausted`
|
||||||
|
- `navLoadingMore` → `navStore.loadingMore`
|
||||||
|
|
||||||
|
`bind:value={navQuery}` on the `<input>` → `bind:value={navStore.query}`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Run checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Both must be clean.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Smoke-test dev server manually**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open a recipe page → type in header dropdown → verify: dropdown opens, shows local hits, falls back to web for unknown query, "+ weitere Ergebnisse" paginates, clicking a hit closes the dropdown, navigating back/forward clears the dropdown.
|
||||||
|
|
||||||
|
- [ ] **Step 11: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/+layout.svelte
|
||||||
|
git commit -m "refactor(layout): Header-Dropdown nutzt SearchStore
|
||||||
|
|
||||||
|
Ersetzt die 11 lokalen \$state und den Debounce-Effect durch
|
||||||
|
eine SearchStore-Instanz. Nav-Open-Toggle bleibt lokal, weil
|
||||||
|
UI-Concern."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Migrate `+page.svelte` home
|
||||||
|
|
||||||
|
**Why after Task 3:** The store is now field-tested. Home adds snapshot + URL sync + filter-change re-search on top.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/+page.svelte:1-371`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SearchStore, type SearchSnapshot } from '$lib/client/search.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the duplicated `$state` block (lines 17–32)**
|
||||||
|
|
||||||
|
Delete: `query`, `hits`, `webHits`, `searching`, `webSearching`, `webError`, `searchedFor`, `localExhausted`, `webPageno`, `webExhausted`, `loadingMore`, `skipNextSearch`, `debounceTimer`.
|
||||||
|
|
||||||
|
Keep: `quote`, `recent`, `favorites` (not search-related), and all `all*` state (All-Recipes listing — unrelated to search).
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```ts
|
||||||
|
const store = new SearchStore({
|
||||||
|
pageSize: LOCAL_PAGE,
|
||||||
|
filterParam: () => {
|
||||||
|
const p = searchFilterStore.queryParam;
|
||||||
|
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the local `filterParam()` helper (lines 224–227).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewire the `Snapshot<>` API (lines 50–83)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const snapshot: Snapshot<SearchSnapshot> = {
|
||||||
|
capture: () => store.captureSnapshot(),
|
||||||
|
restore: (s) => store.restoreSnapshot(s)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete the old `SearchSnapshot` local type alias (it's now imported).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace the two search `$effect`s (filter-change + query-change) with two one-liners**
|
||||||
|
|
||||||
|
Remove lines 188–199 (filter-change effect) and lines 322–347 (query-change effect).
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```ts
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
store.query; // register reactive dep
|
||||||
|
store.runDebounced();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
searchFilterStore.active;
|
||||||
|
store.reSearch();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Keep the URL-sync `$effect` as-is, but read from `store.query`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const q = store.query.trim();
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const current = url.searchParams.get('q') ?? '';
|
||||||
|
if (q === current) return;
|
||||||
|
if (q) url.searchParams.set('q', q);
|
||||||
|
else url.searchParams.delete('q');
|
||||||
|
history.replaceState(history.state, '', url.toString());
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update `onMount` URL-restore**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
|
||||||
|
if (urlQ) store.query = urlQ;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Delete `runSearch` and `loadMore` local functions (lines 229–320)**
|
||||||
|
|
||||||
|
The store provides both. Template references `loadMore` → change to `store.loadMore()`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Update `submit`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function submit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = store.query.trim();
|
||||||
|
if (q.length <= 3) return;
|
||||||
|
void store.runSearch(q);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Update the template (same mechanical rename as Task 3)**
|
||||||
|
|
||||||
|
`query` → `store.query`, `hits` → `store.hits`, etc. for all 11 fields.
|
||||||
|
|
||||||
|
`bind:value={query}` → `bind:value={store.query}`.
|
||||||
|
|
||||||
|
`activeSearch` derived stays: `const activeSearch = $derived(store.query.trim().length > 3);`
|
||||||
|
|
||||||
|
- [ ] **Step 10: Run checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 11: Verify file is shorter than before**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wc -l src/routes/+page.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: under 700 lines (was 808). Target from roadmap: under 700 L.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wc -l src/routes/+layout.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: under 600 lines (was 681). Target from roadmap: under 600 L.
|
||||||
|
|
||||||
|
- [ ] **Step 12: Smoke-test dev manually**
|
||||||
|
|
||||||
|
- Type "lasagne" in home → local hits appear.
|
||||||
|
- Type "pizza margherita" → web fallback.
|
||||||
|
- Deep-link `/?q=lasagne` → query restored, results visible.
|
||||||
|
- Navigate to recipe → back → home query + results preserved (snapshot).
|
||||||
|
- Change domain filter while query is active → results re-fetch with new filter.
|
||||||
|
|
||||||
|
- [ ] **Step 13: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/+page.svelte
|
||||||
|
git commit -m "refactor(home): Live-Search auf SearchStore migriert
|
||||||
|
|
||||||
|
Entfernt 11 duplizierte \$state, runSearch, loadMore und beide
|
||||||
|
Debounce-Effekte. URL-Sync, Snapshot und Filter-Re-Search bleiben
|
||||||
|
hier — aber alle delegieren an den Store."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Remote E2E smoke (optional — only if CI deploy happens)
|
||||||
|
|
||||||
|
**Trigger:** Only run this task if CI builds the `search-state-store` branch and deploys to `kochwas-dev.siegeln.net`. Otherwise skip to Task 6.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Run: existing `tests/e2e/remote/search.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run remote suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:remote -- search.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4/4 pass (existing coverage is sufficient — no new specs needed for a pure refactor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Self-review + merge prep
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Review: all changed files
|
||||||
|
|
||||||
|
- [ ] **Step 1: `npm test` full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass (previous count + 12 new SearchStore tests).
|
||||||
|
|
||||||
|
- [ ] **Step 2: `npm run check` full repo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors, 0 warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `git diff main...HEAD` review**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff main...HEAD --stat
|
||||||
|
git log main..HEAD --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected commits:
|
||||||
|
1. `feat(search): SearchStore fuer Live-Search mit Web-Fallback`
|
||||||
|
2. `refactor(layout): Header-Dropdown nutzt SearchStore`
|
||||||
|
3. `refactor(home): Live-Search auf SearchStore migriert`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push branch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin search-state-store
|
||||||
|
```
|
||||||
|
|
||||||
|
CI builds branch-tagged image → user tests on `kochwas-dev.siegeln.net` → merges to main when clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Notes
|
||||||
|
|
||||||
|
- **Svelte 5 `$state` in classes:** Standard pattern in this repo (`SearchFilterStore`, `PWAStore`). Works.
|
||||||
|
- **Two instances of `SearchStore` simultaneously:** Each has its own timer + state. No shared mutable state between them — verified because the store has no static fields.
|
||||||
|
- **Snapshot restore racing with `runDebounced`:** Handled via `skipNextDebounce` flag. Same mechanism as the current `skipNextSearch` in `+page.svelte`.
|
||||||
|
- **Filter change on home while query is empty:** `reSearch()` early-exits when `q.length < minQueryLength`. Safe.
|
||||||
|
- **`afterNavigate` firing during an in-flight search:** `reset()` clears timer and mutates `query`. Any in-flight fetch will race-guard-fail on the next `if (this.query.trim() !== q) return;`. Results get dropped, which is the desired behavior.
|
||||||
|
|
||||||
|
## Deferred — NOT in this plan
|
||||||
|
|
||||||
|
- **Search-Store-Tests mit echtem Browser-`$effect`:** Would need `@sveltejs/vite-plugin-svelte` test setup with component mount. Current Vitest setup is Node-only. Skip — the injected-fetch unit tests cover the state machine.
|
||||||
|
- **Shared store instance (singleton) instead of per-consumer:** Rejected during design — would couple home and header search semantically.
|
||||||
|
- **Web-Hit-Cache im Store:** Out of scope. The roadmap explicitly scopes this phase to state extraction, not perf work.
|
||||||
2237
docs/superpowers/plans/2026-04-21-photo-recipe-magic.md
Normal file
2237
docs/superpowers/plans/2026-04-21-photo-recipe-magic.md
Normal file
File diff suppressed because it is too large
Load Diff
2293
docs/superpowers/plans/2026-04-21-shopping-list.md
Normal file
2293
docs/superpowers/plans/2026-04-21-shopping-list.md
Normal file
File diff suppressed because it is too large
Load Diff
1241
docs/superpowers/plans/2026-04-22-views-and-collapsibles.md
Normal file
1241
docs/superpowers/plans/2026-04-22-views-and-collapsibles.md
Normal file
File diff suppressed because it is too large
Load Diff
166
docs/superpowers/review/OPEN-ISSUES-NEXT.md
Normal file
166
docs/superpowers/review/OPEN-ISSUES-NEXT.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Open Issues — Stand nach Review-Fixes
|
||||||
|
|
||||||
|
**Datum:** 2026-04-18 (Nacht-Session)
|
||||||
|
**Branch:** `review-fixes-2026-04-18`
|
||||||
|
**Baseline:** REVIEW-2026-04-18.md + 4 Sub-Reports vom Morgen
|
||||||
|
**Tests:** 184/184 grün (Baseline waren 158, +26 neue Tests)
|
||||||
|
**svelte-check:** 0 Errors, 10 Warnings (alle pre-existing in `RecipeEditor.svelte` / `recipes/[id]/+page.svelte`)
|
||||||
|
**Build:** `npm run build` erfolgreich
|
||||||
|
**Smoke-Test:** `npm run preview` + curl auf `/api/health`, `/api/profiles`, `/api/recipes/abc` (400), `/api/wishlist` mit invalider Body (400 + issues) — alle Endpunkte verhalten sich korrekt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was wurde gemacht (8 Commits)
|
||||||
|
|
||||||
|
| Commit | Inhalt | Verifikation |
|
||||||
|
|---|---|---|
|
||||||
|
| `2289547` | docs(review): table names, IMAGE_DIR, image endpoints | grep auf alte Namen → 0 |
|
||||||
|
| `830c740` | refactor(constants): SW-Timing-Konstanten, RequestShape/ManifestDiff intern, Image-Endpoint EN | tests + check grün |
|
||||||
|
| `739cc2d` | feat(server): api-helpers.ts (parsePositiveIntParam, validateBody, ErrorResponse) | 13 neue Tests |
|
||||||
|
| `ff293e9` | refactor(api): 13 +server.ts handler auf api-helpers (-67 Zeilen netto) | 171/171 |
|
||||||
|
| `30a447a` | refactor(client): requireProfile() + asyncFetch wrapper | 5 + 4 Sites umgestellt |
|
||||||
|
| `60c8352` | docs(searxng): Intent-Kommentar fuer Prod-Logs | — |
|
||||||
|
| `6d9e79d` | feat(parser): Unicode-Brueche + Mengen-Plausibilitaet | 13 neue Tests |
|
||||||
|
| `31c6e5c` | refactor(server): IMAGE_DIR/DATABASE_PATH zentralisieren + Doku-Drift | grep auf alte Pattern → 0 |
|
||||||
|
|
||||||
|
Net: 31 Files, +626/-272.
|
||||||
|
|
||||||
|
### Re-Review per 4 paralleler Explore-Agenten — Beweis
|
||||||
|
|
||||||
|
**Dead-Code (HIGH-Confidence):** Alle vorherigen Findings resolved. RequestShape + ManifestDiff sind nur noch interne Types. yauzl ist explizit als Phase 5b markiert (in `session-handoff-2026-04-17.md` und `ARCHITECTURE.md:33`). Kein neuer toter Code durch die Refactors.
|
||||||
|
|
||||||
|
**Redundancy (HIGH-Confidence):** 0 verbleibende `function parseId`/`parsePositiveInt`-Definitionen in `src/routes/api/`. 0 verbleibende `safeParse(...) + manueller error(400)`-Blöcke. Der gerade behobene `IMAGE_DIR`-Drift war 6× im Code und 1× in `db/index.ts`. Verbleibende kleine Pattern siehe unten.
|
||||||
|
|
||||||
|
**Structure:** Constants-Extraktion + API-Error-Shape-Standardisierung erledigt. Ingredient-Parser-Edge-Cases mit 13 Tests abgesichert. Große Pages bleiben groß (siehe „Bewusst verschoben").
|
||||||
|
|
||||||
|
**Docs-vs-Code:** Alle drei Original-Findings behoben. Zwei kleine zusätzliche Mismatches (149→150 Quote-Count, search/-Route gar nicht existent) heute gleich mitgenommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ Verbleibende Items — bewusst verschoben mit Begründung
|
||||||
|
|
||||||
|
### A. Refactor B — Search-State-Store extrahieren (HIGH, halber Tag)
|
||||||
|
**Wo:** `src/routes/+page.svelte` (808 Zeilen, 20+ `$state`-Vars), `src/routes/+layout.svelte` (678 Zeilen, dupliziert das Header-Search-Dropdown).
|
||||||
|
|
||||||
|
**Vorschlag:** `src/lib/client/search.svelte.ts` mit `search()`, `loadMore()`, `clear()` und reaktivem `query / hits / loading / error`-Zustand.
|
||||||
|
|
||||||
|
**Warum nicht heute:**
|
||||||
|
1. Touch in zwei der drei größten Files der Codebase (808L + 678L)
|
||||||
|
2. Bricht Frontend-Verhalten subtil, wenn Reactive-Glue zwischen Layout-Search und Page-Search nicht 1:1 übernommen wird
|
||||||
|
3. UAT-pflichtig (Live-Suche, Empty-State, Web-Suche-Fallback) — ohne UAT-Slot zu riskant
|
||||||
|
4. Kein automatisches Test-Sicherheitsnetz für die UI-Layer
|
||||||
|
|
||||||
|
**Empfehlung:** Eigene Phase mit `/gsd-discuss-phase` und Smoke-UAT vor dem Mergen. Anschließend `/gsd-execute-phase` mit Browser-Check pro Wave.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Refactor C — RecipeEditor / RecipeView in Sub-Components zerlegen (MEDIUM, halber Tag)
|
||||||
|
**Wo:** `src/lib/components/RecipeEditor.svelte` (630L), `RecipeView.svelte` (398L).
|
||||||
|
|
||||||
|
**Kandidaten:** `IngredientRow.svelte`, `StepList.svelte`, `TimeDisplay.svelte`, `ImageUploadBox.svelte`.
|
||||||
|
|
||||||
|
**Warum nicht heute:**
|
||||||
|
- REVIEW-2026-04-18.md sagt explizit: *"Aber: keine Eile, solange niemand sonst drin arbeitet."*
|
||||||
|
- Solange der Owner allein entwickelt, ist 630L pro Komponente kein Blocker.
|
||||||
|
- Tests gibt es nur indirekt (über Importer-Tests und Unit-Tests der Parser).
|
||||||
|
|
||||||
|
**Empfehlung:** Spätere Phase, falls eine zweite Person mitarbeitet oder wenn Editor-Bug-Hunting zu schwierig wird. Vorher zumindest die 10 pre-existing svelte-check WARNINGs in `RecipeEditor.svelte` fixen — die sind schon flackrige Reactive-Patterns (`$derived` statt `$state` für abgeleitete Werte).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C. SearXNG Rate-Limit Recovery (MEDIUM, 1-2 h)
|
||||||
|
**Wo:** `src/lib/server/search/searxng.ts`.
|
||||||
|
|
||||||
|
**Was fehlt:** Bei 429/403 wird zwar geloggt, aber kein Backoff oder `isStale`-Flag. Folgesuchen liefern alten Cache, der User merkt nichts.
|
||||||
|
|
||||||
|
**Empfehlung:** Eigene Phase. Drei mögliche Zutaten: (1) `lastFailureAt`-Map per Domain, (2) exponentieller Backoff, (3) `isStale: boolean` im Response, das die UI als „Ergebnisse evtl. veraltet" anzeigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D. Service-Worker Zombie-Cleanup unter Last testen (MEDIUM, 2-3 h)
|
||||||
|
**Wo:** `src/lib/client/pwa.svelte.ts` Zombie-Heuristik.
|
||||||
|
|
||||||
|
**Status:** 6 Unit-Tests existieren bereits (`tests/unit/pwa-store.test.ts`), die beide Pfade abdecken.
|
||||||
|
|
||||||
|
**Was offen ist:** Verhalten unter sehr langsamen Netzen (1500ms-Timeout könnte False-Positive triggern). Sehr edge-case, aber im REVIEW-Original als MEDIUM gelistet.
|
||||||
|
|
||||||
|
**Empfehlung:** Beim nächsten Service-Worker-Touch mit Throttling-DevTools-Profil testen. Kein eigener Sprint nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E. JSON-LD Parser Edge-Cases (MEDIUM, halbe Phase)
|
||||||
|
**Wo:** `src/lib/server/parsers/json-ld-recipe.ts` (402L).
|
||||||
|
|
||||||
|
**Was abgesichert ist:** Ingredient-Parser-Käfer (Unicode-Brüche, Bounds, Komma-Dezimal) sind heute mit 13 neuen Tests dicht.
|
||||||
|
|
||||||
|
**Was offen ist:** JSON-LD selbst hat Edge-Cases — null-Servings, Locale-spezifische Number-Formats, defekte `recipeIngredient`-Arrays.
|
||||||
|
|
||||||
|
**Empfehlung:** Wenn beim Importieren ein Bug auftaucht, gezielt einen Test schreiben. Kein Vorab-Sprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F. Inline UI-Constants (LOW, 30 min)
|
||||||
|
**Wo:** `ConfirmDialog.svelte`, `ProfileSwitcher.svelte` etc. mit Hardcoded `z-index`, `border-radius: 999px`, kleinen Timeouts.
|
||||||
|
|
||||||
|
**Vorschlag:** `src/lib/theme.ts` mit `MODAL_Z_INDEX`, `POPOVER_Z_INDEX`, `PILL_RADIUS`.
|
||||||
|
|
||||||
|
**Warum nicht heute:** LOW-Severity, kein konkreter Bug damit verbunden, betrifft viele Files punktuell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### G. wishlist/+page.svelte:38 — Profil-Guard mit individueller Message (LOW)
|
||||||
|
**Was:** Eine 7. Stelle hat das Profil-Guard-Pattern, aber mit eigenem Text („um mitzuwünschen"). `requireProfile()` akzeptiert aktuell keine Custom-Message.
|
||||||
|
|
||||||
|
**Empfehlung:** Entweder `requireProfile(message?)`-Variante einführen oder das Site so lassen — die Custom-Message ist dort wirklich Kontext-Information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H. RecipeEditor.svelte:54 + :83 — Bild-Upload/Delete mit inline `if (!res.ok)` (LOW)
|
||||||
|
**Was:** Image-Upload und -Delete im Editor nutzen noch das Pattern, das `asyncFetch` ersetzen sollte. Der Aufwand wäre 5 Minuten, aber RecipeEditor steckt in den 10 svelte-check-WARNINGs (siehe Refactor B-Notiz) — beim nächsten Touch der Datei mitnehmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I. Pre-Existing svelte-check Warnings (10 Stück)
|
||||||
|
**Wo:** `RecipeEditor.svelte` (9× Zeilen 28, 97-102, 113, 121) + `recipes/[id]/+page.svelte` (1× Zeile 43).
|
||||||
|
|
||||||
|
**Was:** Pattern `let foo = recipe.bar` im Top-Level-Script — Svelte 5 will `$derived(recipe.bar)`. Aktuell snapshot-only.
|
||||||
|
|
||||||
|
**Risiko:** Bei In-Place-Mutation des Rezepts (z. B. nach PATCH) zeigt der Editor ggf. den alten Wert. **Tests fangen das nicht.**
|
||||||
|
|
||||||
|
**Empfehlung:** Kleine Phase „RecipeEditor auf $derived umstellen" — passt gut zur RecipeEditor-Subkomponentenphase (B oben), oder vorab alleine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 Neu entdeckt in der zweiten Runde — alle behoben
|
||||||
|
|
||||||
|
| # | Fund | Severity | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `IMAGE_DIR` 6× dupliziert + `DATABASE_PATH` 2× | HIGH | ✅ `src/lib/server/paths.ts` |
|
||||||
|
| 2 | `ARCHITECTURE.md:34` — „49 Flachwitze" | MEDIUM | ✅ → 150 |
|
||||||
|
| 3 | `ARCHITECTURE.md:41` — `search/`-Route existiert nicht | LOW | ✅ entfernt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene nächste Schritte
|
||||||
|
|
||||||
|
1. **PR mergen** sobald lokal abgenickt — der Branch enthält 8 atomische Commits, jeder einzeln revert-bar.
|
||||||
|
2. **Falls UAT erwünscht:** `npm run build && npm run preview`, dann manuell Profile-Switching, Rezept-Edit, Favoriten-Toggle, Wunschliste, Bild-Upload, Such-Pfade durchklicken. Erwartung: keine Verhaltensänderung gegenüber `main`.
|
||||||
|
3. **Phase „RecipeEditor reactive cleanup"** für die 10 svelte-check-Warnings (klein) — schließt Item I.
|
||||||
|
4. **Phase „Search-State-Store"** als nächste größere Phase — schließt Item A und drückt das größte Page-File spürbar runter.
|
||||||
|
5. yauzl/Phase 5b (ZIP-Backup-Restore) bleibt als ungeplant bis explizit gebraucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code-Quality Snapshot
|
||||||
|
|
||||||
|
| Metrik | Vorher | Nachher | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Tests gesamt | 158 | 184 | +26 |
|
||||||
|
| Tests Files | 23 | 24 | +1 (api-helpers) |
|
||||||
|
| svelte-check Errors | 0 | 0 | — |
|
||||||
|
| svelte-check Warnings | 10 | 10 | — (alle pre-existing) |
|
||||||
|
| Build | ✓ | ✓ | — |
|
||||||
|
| Größte Datei (recipes/[id]/+page.svelte) | 757 | 725 | -32 |
|
||||||
|
| Größte Datei (+page.svelte) | 808 | 808 | — |
|
||||||
|
| API +server.ts Boilerplate | ca. 11 Zeilen pro Handler | ca. 4 Zeilen pro Handler | -64% |
|
||||||
|
| Duplizierte ENV-Defaults | 8 Sites | 1 Site | -7 |
|
||||||
140
docs/superpowers/review/REVIEW-2026-04-18.md
Normal file
140
docs/superpowers/review/REVIEW-2026-04-18.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Deep Code Review — Kochwas
|
||||||
|
|
||||||
|
**Datum:** 2026-04-18
|
||||||
|
**Stand:** commit `5283ab9` auf `main`
|
||||||
|
**Testsuite beim Start:** 158/158 grün
|
||||||
|
**Scope:** `src/` (~97 Dateien), Migrations, Tests, Docker-Setup, alle Docs unter `docs/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Der Code ist **gesund**. Keine toten Pfade, keine broken Features, keine strukturellen Fehlentscheidungen. Die vier auffälligsten Themen sind alle **Natural-Growth-Pressure** aus der v1.x-Phase, keine Fehler:
|
||||||
|
|
||||||
|
1. **Ein echter Doku-Bug:** `docs/ARCHITECTURE.md:55` sagt `recipe_ingredient` + `recipe_step` — die Tabellen heißen in Wirklichkeit `ingredient` / `step` (siehe `001_init.sql`). 5-Minuten-Fix.
|
||||||
|
2. **API-Handler duplizieren `parseId`** neunmal. Kandidat #1 für einen `src/lib/server/api-helpers.ts`.
|
||||||
|
3. **Page-Komponenten sind groß** geworden (`+page.svelte` 808 Zeilen, `recipes/[id]/+page.svelte` 757 Zeilen). Solange du allein dran arbeitest: akzeptabel. Sobald jemand mitprogrammiert: refactor.
|
||||||
|
4. **`yauzl` / `@types/yauzl` sind installiert, aber nicht importiert.** Reserviert für den noch fehlenden ZIP-Backup-Import. Entweder im Session-Handoff verankert lassen oder als Phase ziehen.
|
||||||
|
|
||||||
|
Keine Sicherheits- oder Performance-Probleme im Code-Review aufgetaucht. Keine Reviewer-Korrekturen an der Architektur-Grundlinie (Server/Client-Trennung, Repository-Pattern, Runes-Stores).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick-Wins (≤ 30 min pro Stück)
|
||||||
|
|
||||||
|
| # | Titel | Aufwand | Wert |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `ARCHITECTURE.md:55` auf `ingredient` + `step` korrigieren | 2 min | hoch (sonst debuggt jemand Geisterschema) |
|
||||||
|
| 2 | `OPERATIONS.md:135` `IMAGES_PATH` → `IMAGE_DIR` | 2 min | niedrig, aber trivial |
|
||||||
|
| 3 | `parseId` zentralisieren (`src/lib/server/api-helpers.ts`) | 20 min | hoch — 9 Call-Sites |
|
||||||
|
| 4 | Unit-Test für `parseId`-Helper | 10 min | hoch — fängt zukünftige Regressionen |
|
||||||
|
| 5 | `requireProfile()`-Helper in `recipes/[id]/+page.svelte` (Zeilen 124/143/166/188 räumen 4×7 Zeilen weg) | 15 min | mittel |
|
||||||
|
| 6 | Timeout-Magic-Numbers nach `src/lib/constants.ts` (1500 ms, 30-min SW-Poll) | 10 min | mittel |
|
||||||
|
| 7 | Deutsche Fehler-Texte in `api/recipes/[id]/image/+server.ts` englisch ziehen (Konsistenz) | 5 min | kosmetisch |
|
||||||
|
| 8 | Im Session-Handoff `/api/recipes/[id]/image` (POST/DELETE) nachtragen | 5 min | niedrig |
|
||||||
|
|
||||||
|
Summe: unter 90 Minuten — und du hast den Großteil der Haut-Irritationen unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Größere Refactor-Kandidaten
|
||||||
|
|
||||||
|
### A. API-Endpoints entkoppeln (HIGH, 1–2 Std)
|
||||||
|
Extrahiere `src/lib/server/api-helpers.ts` mit:
|
||||||
|
- `parsePositiveIntParam(raw: string, field: string): number` — wirft via SvelteKit `error(400, …)`
|
||||||
|
- `validateBody<T>(body: unknown, schema: ZodSchema<T>): T` — ersetzt die `safeParse()`-Loops in 8+ Handlern
|
||||||
|
- gemeinsame `ErrorResponse`-Shape (aktuell mal `{message}`, mal `{message, issues}`)
|
||||||
|
|
||||||
|
Nach dem Helper-Refactor sollten die Handler nur noch echtes Business-Logik enthalten und je 30–50 Zeilen kürzer werden.
|
||||||
|
|
||||||
|
### B. Search-State aus `+page.svelte` ziehen (HIGH, halber Tag)
|
||||||
|
`+page.svelte` trägt 20+ `$state`-Variablen (`query`, `hits`, `webHits`, `searching`, `webError` …) und duplizierte Search-UI in `+layout.svelte`. Vorschlag: `src/lib/client/search.svelte.ts` mit `search()`, `loadMore()`, `clear()`. Danach ist das Page-File halbiert und der Layout-Nav-Search nutzt denselben Store.
|
||||||
|
|
||||||
|
### C. `RecipeEditor` / `RecipeView` in Sub-Components zerlegen (MEDIUM, halber Tag)
|
||||||
|
Kandidaten: `IngredientRow.svelte`, `StepList.svelte`, `TimeDisplay.svelte`, `ImageUploadBox.svelte`. Vorteile: isoliert testbar, wiederverwendbar in Preview-Seite. **Aber:** keine Eile, solange niemand sonst drin arbeitet.
|
||||||
|
|
||||||
|
### D. Ingredient-Parser-Edge-Cases (HIGH, 2–3 Std)
|
||||||
|
Der Parser (`src/lib/server/parsers/ingredient.ts`) und seine Tests decken ASCII-Ganzzahlen + Dezimal + Brüche ab. Fehlt:
|
||||||
|
- Unicode-Brüche (½, ⅓, ¼)
|
||||||
|
- führende Nullen, wissenschaftliche Notation
|
||||||
|
- Locale-Kommadezimal (deutsche Rezepte!)
|
||||||
|
- 0-Portionen, negative Mengen
|
||||||
|
|
||||||
|
Parametrisierte Tests anlegen, dann Parser ggf. mit Zod-Refinement absichern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einzelbefunde im Detail
|
||||||
|
|
||||||
|
### Dead Code
|
||||||
|
- Unused Deps: `yauzl`, `@types/yauzl` (absichtlich für Phase 5b; Entscheidung treffen: behalten oder entfernen bis Phase kommt).
|
||||||
|
- `RequestShape` (`src/lib/sw/cache-strategy.ts:3`) und `ManifestDiff` (`src/lib/sw/diff-manifest.ts:4`) sind exportiert, aber nur intern benutzt — `export` weg oder im Test importieren.
|
||||||
|
- Alle 97 Source-Files erreichbar, keine orphan-Assets, keine TODO/FIXME/HACK-Marker, keine großen auskommentierten Blöcke.
|
||||||
|
|
||||||
|
### Redundanzen
|
||||||
|
- `parseId`/`parsePositiveInt` — 9 Sites: `api/recipes/[id]/`, `…/favorite`, `…/rating`, `…/cooked`, `…/comments`, `…/image`, `api/profiles/[id]/`, `api/domains/[id]/`, `api/wishlist/[recipe_id]/`
|
||||||
|
- Fetch-try/catch-alert-Pattern in 5 Svelte-Komponenten: `recipes/[id]/+page.svelte` (2×), `admin/domains/+page.svelte` (2×), `admin/profiles/+page.svelte`
|
||||||
|
- Zod-`safeParse` + gleicher Error-Throw in 12+ Endpoints
|
||||||
|
- `parseQty` + Zutat-Reassembly in `RecipeEditor` dupliziert Logik aus `parseIngredient` — könnte über `src/lib/shared/` geteilt werden
|
||||||
|
- Profile-Guard (`if (!profile.active) alert(…)`) 4× identisch in `recipes/[id]/+page.svelte`
|
||||||
|
|
||||||
|
### Struktur
|
||||||
|
- Große Dateien: `+page.svelte` (808), `recipes/[id]/+page.svelte` (757), `+layout.svelte` (678), `RecipeEditor` (630), `recipes/+page.svelte` (539). Keine davon ist kaputt; alle sind Wachstum unter Last.
|
||||||
|
- API-Error-Shape: mehrheitlich `{message}`, `profiles/+server.ts` gibt zusätzlich `{issues}` aus (Zod-Details). Festschreiben.
|
||||||
|
- Store-Init-Races: `profile.svelte.ts` und `search-filter.svelte.ts` laden bei erstem Zugriff. Komponenten sehen ggf. Leer-State vor Fetch. Optional `loading`-Flag.
|
||||||
|
- Konsolen-Logs: 6 Stück in Prod-Build (`service-worker.ts` 2×, `searxng.ts` 3×, `sw-register.ts` 1×). Vermutlich Absicht; als Dok-Kommentar festhalten oder in `if (DEV)`-Guards packen.
|
||||||
|
- Svelte-5-Runes-Stores sind konsistent, keine God-Stores.
|
||||||
|
- TypeScript: `strict` an, 0× `any`, 0× Server-Import-in-Client — bestätigt die CLAUDE.md-Regel.
|
||||||
|
|
||||||
|
### Docs-vs-Code-Mismatches
|
||||||
|
| Fundstelle | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `ARCHITECTURE.md:55` — `recipe_ingredient` + `recipe_step` | `ingredient` + `step` |
|
||||||
|
| `OPERATIONS.md:135` — `IMAGES_PATH` | `IMAGE_DIR` |
|
||||||
|
| `session-handoff-2026-04-17.md:46` — fehlt `/api/recipes/[id]/image` (POST/DELETE) | ergänzen |
|
||||||
|
| Alle Gotchas in `CLAUDE.md` | ✓ verifiziert, stimmen |
|
||||||
|
| Alle Claims im offline-PWA-Spec | ✓ verifiziert, alle in Code vorhanden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was bleibt wie es ist
|
||||||
|
|
||||||
|
- **Migrationen:** 001–011 sind historisch sauber. 008 + 010 löschen beide den Thumbnail-Cache — Feature-Iteration, kein Bug. **Keine** bestehende Migration anfassen (das ist ohnehin die dokumentierte Regel).
|
||||||
|
- **Service-Worker:** Zombie-Cleanup-Logik (`pwa.svelte.ts`) ist Kunst, aber funktioniert und ist kommentiert. Unit-Tests decken beide Zweige (Zombie vs alter SW) ab.
|
||||||
|
- **Repository-Pattern:** Cleane Schichtung. Nicht refactoren.
|
||||||
|
- **Test-Suite:** 23 Dateien, 158 Tests, volle Integration inkl. DB/HTTP/Import/SearXNG. Leichte Lücken bei Parser-Edge-Cases (siehe oben).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ampel
|
||||||
|
|
||||||
|
| Dimension | Status |
|
||||||
|
|---|---|
|
||||||
|
| Architektur & Schichten | 🟢 gesund |
|
||||||
|
| Dead Code | 🟢 minimal |
|
||||||
|
| Redundanzen | 🟡 adressierbar, nicht dringend |
|
||||||
|
| Datei-/Komponenten-Größen | 🟡 zwei Pages ≥ 750L |
|
||||||
|
| Tests | 🟢 stark, Edge-Cases ausbaufähig |
|
||||||
|
| Doku | 🟡 1 inhaltlicher Fehler + 1 ENV-Tippfehler, sonst stabil |
|
||||||
|
| Sicherheit/Perf | 🟢 keine Funde im statischen Review |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorgeschlagene Reihenfolge
|
||||||
|
|
||||||
|
1. Heute (10 min): Quick-Wins 1 + 2 (ARCHITECTURE-Tabellen, OPERATIONS-ENV).
|
||||||
|
2. Nächste Session (2 h): `api-helpers.ts` + `parseId`-Consolidation + Tests (Refactor A).
|
||||||
|
3. Bei Zeit: Search-State-Store (Refactor B) — bringt beim nächsten Feature sofort Dividende.
|
||||||
|
4. Phase-5b: `yauzl` einsetzen ODER Deps entfernen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teilreports
|
||||||
|
|
||||||
|
Die vollständigen Agent-Befunde liegen daneben:
|
||||||
|
- `docs/superpowers/review/dead-code.md`
|
||||||
|
- `docs/superpowers/review/redundancy.md`
|
||||||
|
- `docs/superpowers/review/structure.md`
|
||||||
|
- `docs/superpowers/review/docs-vs-code.md`
|
||||||
|
|
||||||
|
Review-Metadaten: 4 parallele Explore-Agenten, jeweils read-only, Summen manuell gegen Code verifiziert (Line-Counts, Tabellen-Namen, ENV-Namen, `parseId`-Sites).
|
||||||
42
docs/superpowers/review/dead-code.md
Normal file
42
docs/superpowers/review/dead-code.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Dead-Code Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Kochwas codebase is remarkably clean with minimal dead code. Primary finding: **yauzl dependency is unused** (reserved for future backup-restore feature). All exports are active, files are properly structured, and no unreachable code paths detected.
|
||||||
|
|
||||||
|
## HIGH confidence findings
|
||||||
|
|
||||||
|
### Unused Dependencies
|
||||||
|
- **package.json: yauzl, @types/yauzl** — Declared in `dependencies` but never imported in source code. Added in commit for future backup ZIP import feature (currently only export via archiver is implemented). See `docs/superpowers/session-handoff-2026-04-17.md` which notes: "Import aus ZIP ist noch manueller DB-Copy. yauzl ist bereits als Dependency da, Phase 5b kann das in 10 Minuten nachziehen."
|
||||||
|
|
||||||
|
### Exported Types Not Imported Elsewhere
|
||||||
|
- **src/lib/sw/cache-strategy.ts:3** — `RequestShape` — Exported type only used within the same file as a function parameter. Not imported anywhere (type is passed inline at call site in service-worker.ts). Candidates for internal-only marking.
|
||||||
|
- **src/lib/sw/diff-manifest.ts:4** — `ManifestDiff` — Exported type only used within same file as a return type of `diffManifest()`. Not imported by any other module.
|
||||||
|
|
||||||
|
## MEDIUM confidence findings
|
||||||
|
|
||||||
|
None identified. All functions, types, and stores are actively used. All 85 source files are reachable through proper route conventions (+page.svelte, +server.ts, +layout.svelte are auto-routed by SvelteKit).
|
||||||
|
|
||||||
|
## LOW confidence / worth double-checking
|
||||||
|
|
||||||
|
### Conditional Dead Code in Service Worker (Low risk)
|
||||||
|
- **src/service-worker.ts:99-110** — The `GET_VERSION` message handler for zombie-SW cleanup is only triggered by `pwaStore` when specific conditions match (bit-identical versions detected after SKIP_WAITING). Works correctly but only fires on edge-case deployments (Chromium race condition). Verified it's needed—comments explain the scenario thoroughly.
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
- **src/lib/server/db/migrations/007-011** — Recent migrations (thumbnail_cache rerun, favicon resets) are cleanup/maintenance operations. Verified they're applied in sequence and read by code (e.g., searxng.ts queries thumbnail_cache). No orphaned migration tables.
|
||||||
|
|
||||||
|
## Non-findings (places I checked and confirmed alive)
|
||||||
|
|
||||||
|
- **All client stores** (confirm, install-prompt, network, profile, pwa, search-filter, sync-status, toast, wishlist) — Every export used in components
|
||||||
|
- **All server repositories** (domains, profiles, recipes, wishlist) — All functions imported by API routes
|
||||||
|
- **All parsers** (ingredient, iso8601-duration, json-ld-recipe) — Used by recipe importer and web search
|
||||||
|
- **All API routes** — All 27 route handlers are reachable and handler import the functions they need
|
||||||
|
- **All Svelte components** — No orphaned .svelte files; all imported by routes or other components
|
||||||
|
- **Static assets** (/manifest.webmanifest, /icon.svg, /icon-192.png, /icon-512.png) — Referenced in app.html, cache-strategy.ts, and manifest
|
||||||
|
- **Service worker** — All functions in service-worker.ts are called; no dead branches
|
||||||
|
- **Commented code** — Only legitimate documentation comments (German docs explaining design decisions); no large disabled code blocks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Review Scope:** src/ (~85 files), package.json dependencies, tests/
|
||||||
|
**Tools used:** Grep (regex + pattern matching), Read (file inspection), Bash (git log)
|
||||||
|
**Confidence Threshold:** HIGH = 100% sure, MEDIUM = 95%+, LOW = contextual
|
||||||
130
docs/superpowers/review/docs-vs-code.md
Normal file
130
docs/superpowers/review/docs-vs-code.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Docs vs Code Audit
|
||||||
|
|
||||||
|
**Date:** 2026-04-18 | **Scope:** Full Documentation Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The documentation is **80% accurate and well-structured**, with most claims verifiable against the code. However, there are several discrete mismatches in table naming, missing API endpoints, and one environment variable discrepancy. Core concepts (architecture, deployment, gotchas) are reliable. No critical blockers found — all mismatches are either naming inconsistencies or minor omissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLAUDE.md Findings
|
||||||
|
|
||||||
|
### ✅ All gotchas verified
|
||||||
|
- **Healthcheck rule:** Confirmed in `Dockerfile` line 37: uses `http://127.0.0.1:3000/api/health` ✓
|
||||||
|
- **SearXNG headers:** Confirmed in `src/lib/server/search/searxng.ts` — sets `X-Forwarded-For: 127.0.0.1` and `X-Real-IP: 127.0.0.1` ✓
|
||||||
|
- **Icon rendering:** Confirmed — `scripts/render-icons.mjs` renders 192 + 512 PNG icons from `static/icon.svg` via `npm run render:icons` ✓
|
||||||
|
- **better-sqlite3 native build:** Confirmed in `Dockerfile` lines 6–7: multi-stage build with Python + make + g++ for ARM64 ✓
|
||||||
|
- **Service Worker HTTPS-only:** Confirmed in `src/service-worker.ts` and offline-pwa-design.md specs ✓
|
||||||
|
- **Migration workflow:** Confirmed in `src/lib/server/db/migrations/` — 11 migrations exist, Vite glob bundled ✓
|
||||||
|
|
||||||
|
### ⚠ Minor: Environment Variable Name
|
||||||
|
- **Claim in doc:** OPERATIONS.md mentions `IMAGES_PATH` in the env var table (line 135) as an example env var
|
||||||
|
- **Reality in code:**
|
||||||
|
- Code uses: `process.env.IMAGE_DIR` (not `IMAGES_PATH`) — see `src/lib/server/db/index.ts`
|
||||||
|
- `.env.example` and `Dockerfile` both use `IMAGE_DIR`
|
||||||
|
- `.env.example` does NOT list `IMAGES_PATH`
|
||||||
|
- **Severity:** LOW (internal inconsistency in docs; code is correct)
|
||||||
|
- **Fix:** Update OPERATIONS.md line 135 to use `IMAGE_DIR` instead of `IMAGES_PATH`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## docs/ARCHITECTURE.md Findings
|
||||||
|
|
||||||
|
### ❌ CRITICAL: Incorrect Table Names
|
||||||
|
|
||||||
|
**Claim in doc (line 55):**
|
||||||
|
> "INSERT in `recipe` + `recipe_ingredient` + `recipe_step` + `recipe_tag`"
|
||||||
|
|
||||||
|
**Reality in code:**
|
||||||
|
- Actual table names in `src/lib/server/db/migrations/001_init.sql`:
|
||||||
|
- Line 29: `CREATE TABLE IF NOT EXISTS ingredient` (NOT `recipe_ingredient`)
|
||||||
|
- Line 41: `CREATE TABLE IF NOT EXISTS step` (NOT `recipe_step`)
|
||||||
|
- Line 54: `CREATE TABLE IF NOT EXISTS recipe_tag` (this one is correct ✓)
|
||||||
|
|
||||||
|
**Severity:** HIGH
|
||||||
|
- **Impact:** Anyone reading docs will search for `recipe_ingredient` table and not find it; confuses debugging
|
||||||
|
- **Fix:** Update ARCHITECTURE.md line 55 from `recipe_ingredient` + `recipe_step` to `ingredient` + `step`
|
||||||
|
|
||||||
|
Also verify the same claim doesn't appear in design specs (section 8.8 of 2026-04-17-kochwas-design.md is correct — it already lists `ingredient` and `step` without the prefix).
|
||||||
|
|
||||||
|
### ✅ All other architecture claims verified
|
||||||
|
- **Module structure:** Confirmed (`src/lib/server/db`, `src/lib/server/parsers`, `src/lib/server/recipes`, etc.) ✓
|
||||||
|
- **FTS5 virtual table:** Confirmed in `001_init.sql` with BM25 ranking ✓
|
||||||
|
- **API endpoints:** All listed endpoints exist as route files ✓
|
||||||
|
- **Cache strategies:** Confirmed in `src/lib/sw/cache-strategy.ts` ✓
|
||||||
|
- **Service Worker behavior:** Confirmed in `src/service-worker.ts` ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## docs/OPERATIONS.md Findings
|
||||||
|
|
||||||
|
### ⚠ MEDIUM: Environment Variable Discrepancy
|
||||||
|
- **Same as CLAUDE.md issue:** `IMAGES_PATH` vs `IMAGE_DIR` in line 135
|
||||||
|
- **Also affects:** docker-compose.prod.yml example in section "Umgebungsvariablen" — doc doesn't show it being set, but it's not needed (code defaults to `./data/images`)
|
||||||
|
|
||||||
|
### ✅ All deployment claims verified
|
||||||
|
- **Healthcheck interval/timeout:** Confirmed in Dockerfile ✓
|
||||||
|
- **SearXNG configuration:** Confirmed `searxng/settings.yml` with `limiter: false` and `secret_key` env injection ✓
|
||||||
|
- **Traefik wildcard cert labels:** Confirmed in `docker-compose.prod.yml` lines 26–27 ✓
|
||||||
|
- **PWA offline behavior:** Confirmed in spec and code ✓
|
||||||
|
- **Backup/restore UI:** Confirmed routes exist `/admin/backup` and `/api/admin/backup` ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## docs/superpowers/ Findings
|
||||||
|
|
||||||
|
### ✅ Session Handoff (2026-04-17)
|
||||||
|
|
||||||
|
**Routes listed (line 46):**
|
||||||
|
- Session-handoff lists: `/images/[filename]` endpoint
|
||||||
|
- **Actual code:** Route exists at `src/routes/images/[filename]/+server.ts` ✓
|
||||||
|
- **Verification:** All other endpoints match (`/api/recipes/all`, `/api/recipes/blank`, `/api/recipes/favorites`, `/api/wishlist` etc.) ✓
|
||||||
|
|
||||||
|
**Note:** Session-handoff does NOT mention `/api/recipes/[id]/image` (POST/DELETE for profile-specific image updates), which exists in code. This is not a *mismatch* but an **omission** (minor).
|
||||||
|
|
||||||
|
### ✅ Design Spec (2026-04-17)
|
||||||
|
|
||||||
|
**Section 8 (Datenmodell):**
|
||||||
|
- Lists `ingredient` and `step` tables correctly (no prefix) ✓
|
||||||
|
- This contradicts ARCHITECTURE.md (which says `recipe_ingredient` + `recipe_step`), but ARCHITECTURE.md is wrong
|
||||||
|
- Design spec is the source of truth here ✓
|
||||||
|
|
||||||
|
### ✅ Offline PWA Design (2026-04-18)
|
||||||
|
|
||||||
|
**All claims verified:**
|
||||||
|
- `src/service-worker.ts` implements the three cache buckets (shell, data, images) ✓
|
||||||
|
- `src/lib/sw/cache-strategy.ts` implements the strategy dispatcher ✓
|
||||||
|
- `src/lib/client/sync-status.svelte.ts` exists with message handler ✓
|
||||||
|
- `src/lib/client/network.svelte.ts` exists with online-status tracking ✓
|
||||||
|
- `src/lib/components/SyncIndicator.svelte` exists ✓
|
||||||
|
- `src/lib/components/Toast.svelte` exists ✓
|
||||||
|
- `/admin/app/+page.svelte` exists (confirmed in route listing) ✓
|
||||||
|
- Icon rendering script confirmed ✓
|
||||||
|
- PWA manifest with PNG icons confirmed ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Docs Get Right
|
||||||
|
|
||||||
|
1. **Architecture & Code Structure:** Clearly explained with accurate module boundaries
|
||||||
|
2. **Deployment workflow:** Gitea Actions, Docker multi-stage build, Traefik integration all correct
|
||||||
|
3. **Database & migrations:** Vite glob bundling, idempotent migrations, schema evolution strategy sound
|
||||||
|
4. **PWA offline-first design:** Well thought out, faithfully implemented
|
||||||
|
5. **All API endpoints:** Comprehensive listing in session-handoff; all routes exist
|
||||||
|
6. **Gotchas table:** Invaluable reference, 100% correct across Healthcheck, SearXNG, better-sqlite3, icons, etc.
|
||||||
|
7. **Test strategy:** Vitest + Playwright mentioned; `npm test` and `npm run test:e2e` exist in package.json
|
||||||
|
8. **Icon rendering:** Accurately documented; `npm run render:icons` works as described
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Findings
|
||||||
|
|
||||||
|
| Finding | Severity | File | Line | Action |
|
||||||
|
|---------|----------|------|------|--------|
|
||||||
|
| Table names: `recipe_ingredient` → should be `ingredient` | HIGH | ARCHITECTURE.md | 55 | Update table names in claim |
|
||||||
|
| Table names: `recipe_step` → should be `step` | HIGH | ARCHITECTURE.md | 55 | Update table names in claim |
|
||||||
|
| Env var: `IMAGES_PATH` → should be `IMAGE_DIR` | LOW | OPERATIONS.md | 135 | Update to match code |
|
||||||
|
| Endpoint omission: `/api/recipes/[id]/image` not listed | LOW | session-handoff-2026-04-17.md | 46 | Add to routes list (optional) |
|
||||||
|
|
||||||
|
**Total issues found:** 4 (1 HIGH, 2 MEDIUM, 1 LOW) | **Blocker for development:** None
|
||||||
61
docs/superpowers/review/redundancy.md
Normal file
61
docs/superpowers/review/redundancy.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Redundancy Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Kochwas exhibits significant duplication in API endpoint handlers across 22 endpoints, with copy-pasted parameter parsing (parseId/parsePositiveInt) in 8+ files, repeated error-handling patterns in fetch wrappers across 5+ Svelte components, and schema validation blocks that could be consolidated.
|
||||||
|
|
||||||
|
## HIGH severity
|
||||||
|
|
||||||
|
### Duplicated parseId / parsePositiveInt helpers across API endpoints
|
||||||
|
- **Sites**: /api/recipes/[id]/+server.ts:51-54, /api/recipes/[id]/favorite/+server.ts:9-13, /api/recipes/[id]/rating/+server.ts:14-18, /api/recipes/[id]/cooked/+server.ts:10-14, /api/recipes/[id]/comments/+server.ts:14-18, /api/profiles/[id]/+server.ts:9-13, /api/domains/[id]/+server.ts:19-23, /api/wishlist/[recipe_id]/+server.ts:9-13
|
||||||
|
- **Pattern**: Eight API endpoints independently define nearly identical parameter-parsing functions that validate positive integers from route params.
|
||||||
|
- **Suggestion**: Extract to src/lib/server/api-helpers.ts with parsePositiveIntParam(raw, field) returning the number or throwing via SvelteKit error().
|
||||||
|
|
||||||
|
### Copy-pasted fetch error-handling + alert pattern in Svelte components
|
||||||
|
- **Sites**: /recipes/[id]/+page.svelte:76-87, /recipes/[id]/+page.svelte:253-265, /admin/domains/+page.svelte:31-48, /admin/domains/+page.svelte:67-87, /admin/profiles/+page.svelte:30-44
|
||||||
|
- **Pattern**: Five component functions repeat identical 6-line blocks: await fetch(); if not ok, parse JSON, show alert with body.message or HTTP status.
|
||||||
|
- **Suggestion**: Create src/lib/client/api-fetch-wrapper.ts with asyncFetch(url, init, actionTitle) that wraps fetch, error handling, and alertAction.
|
||||||
|
|
||||||
|
## MEDIUM severity
|
||||||
|
|
||||||
|
### Repeated Zod schema validation + error pattern across API endpoints
|
||||||
|
- **Sites**: /api/recipes/[id]/+server.ts, /api/recipes/[id]/favorite/+server.ts, /api/recipes/[id]/rating/+server.ts, /api/recipes/[id]/cooked/+server.ts, /api/recipes/[id]/comments/+server.ts, /api/profiles/+server.ts, /api/domains/[id]/+server.ts, /api/wishlist/+server.ts
|
||||||
|
- **Pattern**: Every endpoint defines schemas locally with safeParse() followed by identical error handling: if not success, error 400 Invalid body. 12+ endpoints repeat this 3-4 line pattern.
|
||||||
|
- **Suggestion**: Create src/lib/server/schemas.ts with common validators and validateBody<T>(body, schema) helper that centralizes the error throw.
|
||||||
|
|
||||||
|
### Recipe scaling / ingredient manipulation scattered without consolidation
|
||||||
|
- **Sites**: /lib/recipes/scaler.ts:10-16, /lib/server/parsers/ingredient.ts:42-68, /lib/components/RecipeEditor.svelte:144-149, /lib/components/RecipeEditor.svelte:156-175
|
||||||
|
- **Pattern**: RecipeEditor re-implements parseQty and ingredient assembly (raw_text building) instead of importing parseIngredient from server parser. Logic is nearly identical in two places.
|
||||||
|
- **Suggestion**: Expose parseIngredient as shared client code or create src/lib/shared/ingredient-utils.ts; import into component to avoid duplication.
|
||||||
|
|
||||||
|
### Profile not-selected alert pattern duplicated 4x in same component
|
||||||
|
- **Sites**: /recipes/[id]/+page.svelte:124-131, :143-150, :166-173, :188-195
|
||||||
|
- **Pattern**: Four action functions (setRating, toggleFavorite, logCooked, addComment) all open with identical 7-line guard checking active profile and showing same alert message.
|
||||||
|
- **Suggestion**: Extract requireProfile() helper in src/lib/client/profile.svelte that performs the alert and returns boolean; replace all four guard clauses.
|
||||||
|
|
||||||
|
## LOW severity
|
||||||
|
|
||||||
|
### WakeLock error handling try-catch pattern
|
||||||
|
- **Sites**: /recipes/[id]/+page.svelte:318-327, :332-338
|
||||||
|
- **Pattern**: Both functions independently have try-catch that silently swallows errors. Identical empty catch pattern duplicated.
|
||||||
|
- **Suggestion**: Cosmetic - document once or combine into manageWakeLock(action) wrapper.
|
||||||
|
|
||||||
|
### Migration cache-clearing pattern (historical only)
|
||||||
|
- **Sites**: /db/migrations/008_thumbnail_cache_drop_unknown.sql, /db/migrations/010_thumbnail_cache_rerun_negatives.sql
|
||||||
|
- **Pattern**: Two consecutive migrations both DELETE from thumbnail_cache due to feature iteration. Not a bug, just historical stacking.
|
||||||
|
- **Note**: Safe to leave as-is.
|
||||||
|
|
||||||
|
### Test fixture duplication: baseRecipe helper
|
||||||
|
- **Sites**: /tests/integration/recipe-repository.test.ts:22-42
|
||||||
|
- **Pattern**: baseRecipe factory defined locally; likely duplicated in other tests.
|
||||||
|
- **Suggestion**: Move to tests/fixtures/recipe.ts and import everywhere.
|
||||||
|
|
||||||
|
### API error message language inconsistency
|
||||||
|
- **Sites**: /api/recipes/[id]/image/+server.ts (German), all others (English)
|
||||||
|
- **Pattern**: Image endpoint uses German error messages; all other endpoints use English.
|
||||||
|
- **Suggestion**: Standardize to German or English for consistent UX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Strong separation of concerns observed in repositories and parsers. Type definitions well-centralized in src/lib/types.ts. No major SQL redundancy beyond historical migrations. Primary improvement opportunities are parameter validation, error handling, and component fetch logic consolidation.
|
||||||
146
docs/superpowers/review/structure.md
Normal file
146
docs/superpowers/review/structure.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Structure / Design / Maintainability Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Kochwas has a healthy, maintainable codebase with strong architectural boundaries between server and client, comprehensive test coverage (integration + e2e), and disciplined use of TypeScript. The main pressure points are large page components (+700 lines) and some high-complexity features (search orchestration, image import pipeline) that could benefit from further decomposition.
|
||||||
|
|
||||||
|
## Big-picture observations
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
1. **Clean architectural layers**: No server code bleeding into client. Strict separation of $lib/server/*, $lib/client/*.svelte.ts, and components.
|
||||||
|
2. **Comprehensive testing**: 17+ integration tests, 4+ unit tests, 2 e2e suites covering recipes, images, parsers, search.
|
||||||
|
3. **Type-safe API**: Domain types in src/lib/types.ts are exhaustive; Zod schemas match; no shadow types.
|
||||||
|
4. **Consistent error handling**: Custom ImporterError with codes, mapped through mapImporterError().
|
||||||
|
5. **Smart runes stores**: Separate concerns (profile, network, pwa, sync-status, toast, wishlist, search-filter). No god-stores.
|
||||||
|
6. **Well-documented gotchas**: CLAUDE.md clearly marks traps (SW HTTPS-only, healthcheck IPv4, native module arm64).
|
||||||
|
|
||||||
|
### Concerns
|
||||||
|
1. **Large page components**: +page.svelte (808L), recipes/[id]/+page.svelte (757L), +layout.svelte (678L).
|
||||||
|
2. **Dense components**: RecipeEditor (630L), RecipeView (398L), SearchFilter (360L) hard to unit-test.
|
||||||
|
3. **Complex parsers**: json-ld-recipe.ts (402L) and searxng.ts (389L) lack edge-case validation.
|
||||||
|
4. **State synchronization**: 20+ local state variables in search page; duplication in +layout.svelte.
|
||||||
|
5. **Magic numbers**: Timeout constants (1500ms, 30min) and z-index values are inline.
|
||||||
|
|
||||||
|
## HIGH severity findings
|
||||||
|
|
||||||
|
### Large page components
|
||||||
|
- **Where**: src/routes/+page.svelte (808L), src/routes/recipes/[id]/+page.svelte (757L), src/routes/+layout.svelte (678L)
|
||||||
|
- **What**: Pages bundle view + component orchestration + state management (20+ $state vars) + fetch logic. Hard to test individual behaviors without mounting entire page.
|
||||||
|
- **Suggestion**: Extract orchestration into composables/stores (e.g., usePageSearch()). Break out visual widgets as sub-components. Move fetch logic to +page.server.ts.
|
||||||
|
|
||||||
|
### State density: 20+ variables in search page
|
||||||
|
- **Where**: src/routes/+page.svelte lines 17-48
|
||||||
|
- **What**: Local state controls search (query, hits, webHits, searching, webError, etc.). Duplication in +layout.svelte nav search. Risk of stale state.
|
||||||
|
- **Suggestion**: Create useSearchState() rune or dedicated store with methods: .search(q), .loadMore(), .clear().
|
||||||
|
|
||||||
|
### JSON-LD parser edge cases
|
||||||
|
- **Where**: src/lib/server/parsers/json-ld-recipe.ts (402L)
|
||||||
|
- **What**: Parser assumes well-formed JSON-LD. Tests only cover ASCII digits; no coverage for non-ASCII numerals, fraction chars, or 0 servings.
|
||||||
|
- **Suggestion**: Add Zod refinement for quantity validation. Test against real recipes from different locales. Document assumptions.
|
||||||
|
|
||||||
|
### Ingredient parsing gaps
|
||||||
|
- **Where**: tests/unit/ingredient.test.ts
|
||||||
|
- **What**: Tests cover integers/decimals/fractions but not: leading zeros, scientific notation, Unicode fractions, unusual separators, null ingredients.
|
||||||
|
- **Suggestion**: Parametrized tests for edge cases. Clamp quantity range (0-1000) at parser level.
|
||||||
|
|
||||||
|
### Unnamed timeout constants
|
||||||
|
- **Where**: src/routes/+page.svelte, src/lib/client/pwa.svelte.ts
|
||||||
|
- **What**: 1500ms (PWA version query), 30*60_000ms (SW update poll), implicit debounce. Hard to find all call sites.
|
||||||
|
- **Suggestion**: Export to src/lib/constants.ts: SW_VERSION_QUERY_TIMEOUT_MS, SW_UPDATE_POLL_INTERVAL_MS.
|
||||||
|
|
||||||
|
## MEDIUM severity findings
|
||||||
|
|
||||||
|
### RecipeEditor/RecipeView component size
|
||||||
|
- **Where**: src/lib/components/RecipeEditor.svelte (630L), src/lib/components/RecipeView.svelte (398L)
|
||||||
|
- **What**: Feature-complete but dense; hard to test rendering in isolation (e.g., ingredient scaling).
|
||||||
|
- **Suggestion**: Extract sub-components: IngredientRow.svelte, StepList.svelte, TimeDisplay.svelte, ImageUploadBox.svelte.
|
||||||
|
|
||||||
|
### API error shape inconsistency
|
||||||
|
- **Where**: src/routes/api/**/*.ts
|
||||||
|
- **What**: Most return {message}. But profiles/+server.ts POST returns {message, issues} (Zod details). Implicit schema.
|
||||||
|
- **Suggestion**: Standardize or define shared ErrorResponse type in src/lib/types.ts. Document in docs/API.md.
|
||||||
|
|
||||||
|
### Service Worker zombie cleanup untested
|
||||||
|
- **Where**: src/lib/client/pwa.svelte.ts (lines 1-72)
|
||||||
|
- **What**: Clever but untested heuristic. 1500ms timeout may cause false positives on slow networks.
|
||||||
|
- **Suggestion**: Unit test timeout scenario. Document 1500ms rationale in comments.
|
||||||
|
|
||||||
|
### Searxng rate-limit recovery
|
||||||
|
- **Where**: src/lib/server/search/searxng.ts (389L)
|
||||||
|
- **What**: Caches per-query. On 429/403, logs but doesn't backoff. Second search returns stale cache with no signal.
|
||||||
|
- **Suggestion**: Add isStale flag. Show "results may be outdated" banner or implement exponential backoff.
|
||||||
|
|
||||||
|
### Store initialization races
|
||||||
|
- **Where**: src/lib/client/profile.svelte.ts, src/lib/client/search-filter.svelte.ts
|
||||||
|
- **What**: Load data on first access. If component mounts before fetch completes, shows stale state. No loading signal.
|
||||||
|
- **Suggestion**: Add loading property. Load in +page.server.ts instead or await store.init() in onMount().
|
||||||
|
|
||||||
|
## LOW severity findings
|
||||||
|
|
||||||
|
### Missing named constants
|
||||||
|
- **Where**: ConfirmDialog.svelte, ProfileSwitcher.svelte (z-index, border-radius, timeouts inline)
|
||||||
|
- **What**: Z-index (100, 200), border-radius (999px), timeouts (1500ms) hardcoded.
|
||||||
|
- **Suggestion**: Create src/lib/theme.ts: MODAL_Z_INDEX, POPOVER_Z_INDEX, etc.
|
||||||
|
|
||||||
|
### console logging in production
|
||||||
|
- **Where**: src/service-worker.ts (2), src/lib/server/search/searxng.ts (3), src/lib/client/sw-register.ts (1)
|
||||||
|
- **What**: Likely intentional (production diagnostics) but unfiltered by log level.
|
||||||
|
- **Suggestion**: Document intent. If not intentional, wrap in if (DEV) guards.
|
||||||
|
|
||||||
|
### Unhandled DB errors
|
||||||
|
- **Where**: src/routes/api/recipes/all/+server.ts
|
||||||
|
- **What**: If DB query fails, error propagates as 500.
|
||||||
|
- **Suggestion**: Wrap in try-catch for consistency (unlikely with local SQLite).
|
||||||
|
|
||||||
|
### Migration ordering
|
||||||
|
- **Where**: Tests don't verify migration sequence
|
||||||
|
- **What**: Migrations autodiscovered via glob; out-of-order filenames won't cause build error.
|
||||||
|
- **Suggestion**: CI check verifying 00X_* sequence.
|
||||||
|
|
||||||
|
### Incomplete image downloader errors
|
||||||
|
- **Where**: src/lib/server/images/image-downloader.ts
|
||||||
|
- **What**: Generic error message; can't distinguish "URL wrong" from "network down."
|
||||||
|
- **Suggestion**: Add error codes (NOT_FOUND, TIMEOUT, NETWORK).
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
### Lines per file (top 15)
|
||||||
|
```
|
||||||
|
808 src/routes/+page.svelte
|
||||||
|
757 src/routes/recipes/[id]/+page.svelte
|
||||||
|
678 src/routes/+layout.svelte
|
||||||
|
630 src/lib/components/RecipeEditor.svelte
|
||||||
|
539 src/routes/recipes/+page.svelte
|
||||||
|
402 src/lib/server/parsers/json-ld-recipe.ts
|
||||||
|
398 src/lib/components/RecipeView.svelte
|
||||||
|
389 src/lib/server/search/searxng.ts
|
||||||
|
360 src/lib/components/SearchFilter.svelte
|
||||||
|
321 src/routes/wishlist/+page.svelte
|
||||||
|
318 src/routes/admin/domains/+page.svelte
|
||||||
|
259 src/service-worker.ts
|
||||||
|
244 src/lib/server/recipes/repository.ts
|
||||||
|
218 src/lib/components/ProfileSwitcher.svelte
|
||||||
|
216 src/routes/preview/+page.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality metrics
|
||||||
|
| Metric | Value | Status |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| Test suites (integration) | 17 | Good |
|
||||||
|
| Test suites (unit) | 5+ | Adequate |
|
||||||
|
| Zod validation endpoints | 11 | Excellent |
|
||||||
|
| TypeScript strict | Yes | Excellent |
|
||||||
|
| Any types found | 0 | Excellent |
|
||||||
|
| Server code in client | 0 | Excellent |
|
||||||
|
| Console logging | 6 instances | Minor |
|
||||||
|
|
||||||
|
## Recommendations (priority)
|
||||||
|
|
||||||
|
1. **Extract page state to stores** (HIGH, medium effort): Reduce +page.svelte by ~200L; enable isolated testing.
|
||||||
|
2. **Split large components** (HIGH, medium effort): RecipeEditor/RecipeView sub-components.
|
||||||
|
3. **Add ingredient validation** (HIGH, low effort): Zod refinement + edge-case tests.
|
||||||
|
4. **Define named constants** (MEDIUM, low effort): src/lib/constants.ts for timeouts/z-index.
|
||||||
|
5. **Standardize API errors** (MEDIUM, low effort): docs/API.md + shared ErrorResponse type.
|
||||||
|
6. **Test SW zombie cleanup** (MEDIUM, medium effort): Unit tests + comments.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
Healthy, maintainable codebase. Main pressure: large page/component sizes (natural scaling). With recommendations above, ready for continued development and easy to onboard new developers.
|
||||||
@@ -43,7 +43,7 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
### Server-Seite
|
### Server-Seite
|
||||||
- **DB:** SQLite mit FTS5, Migrationen (`./migrations/*.sql`) werden von Vite gebündelt und beim ersten DB-Zugriff angewendet. Auto-mkdir für `data/` und `data/images/`.
|
- **DB:** SQLite mit FTS5, Migrationen (`./migrations/*.sql`) werden von Vite gebündelt und beim ersten DB-Zugriff angewendet. Auto-mkdir für `data/` und `data/images/`.
|
||||||
- **Module:** `parsers/` (iso8601, ingredient, json-ld-recipe), `recipes/` (scaler + repository + actions + importer + search-local), `domains/` (repository + whitelist), `profiles/`, `images/image-downloader`, `search/searxng`, `backup/export`, `http`.
|
- **Module:** `parsers/` (iso8601, ingredient, json-ld-recipe), `recipes/` (scaler + repository + actions + importer + search-local), `domains/` (repository + whitelist), `profiles/`, `images/image-downloader`, `search/searxng`, `backup/export`, `http`.
|
||||||
- **Routes:** `/api/health`, `/api/profiles`, `/api/profiles/[id]`, `/api/domains`, `/api/domains/[id]`, `/api/recipes/search`, `/api/recipes/search/web`, `/api/recipes/preview`, `/api/recipes/import`, `/api/recipes/[id]`, `/api/recipes/[id]/rating`, `/api/recipes/[id]/favorite`, `/api/recipes/[id]/cooked`, `/api/recipes/[id]/comments`, `/api/admin/backup`, `/images/[filename]`.
|
- **Routes:** `/api/health`, `/api/profiles`, `/api/profiles/[id]`, `/api/domains`, `/api/domains/[id]`, `/api/recipes/search`, `/api/recipes/search/web`, `/api/recipes/preview`, `/api/recipes/import`, `/api/recipes/[id]`, `/api/recipes/[id]/rating`, `/api/recipes/[id]/favorite`, `/api/recipes/[id]/cooked`, `/api/recipes/[id]/comments`, `/api/recipes/[id]/image` (POST/DELETE), `/api/admin/backup`, `/images/[filename]`.
|
||||||
|
|
||||||
### Client-Seite (Svelte 5 Runes)
|
### Client-Seite (Svelte 5 Runes)
|
||||||
- **Layout** mit Profil-Chip und Zahnrad zu Admin.
|
- **Layout** mit Profil-Chip und Zahnrad zu Admin.
|
||||||
|
|||||||
235
docs/superpowers/specs/2026-04-18-offline-pwa-design.md
Normal file
235
docs/superpowers/specs/2026-04-18-offline-pwa-design.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Offline-PWA v1.1 — Design-Spec
|
||||||
|
|
||||||
|
> **Stand**: 2026-04-18 — Brainstorming-Ergebnis. Vor der Plan-Erstellung vom Nutzer zu bestätigen.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Kochwas als installierbare PWA mit vollständigem Lese-Offline-Modus. Alle Rezepte (bei ~200 erwartet: ca. 60 MB inkl. Bilder) werden automatisch lokal synchronisiert. Schreib-Aktionen bleiben online-only. Keine Backend-Änderungen.
|
||||||
|
|
||||||
|
## Design-Entscheidungen (aus Brainstorming)
|
||||||
|
|
||||||
|
| Entscheidung | Gewähltes Vorgehen |
|
||||||
|
|---|---|
|
||||||
|
| Sync-Umfang | **Alle Rezepte + alle Bilder** (nicht nur Favoriten/Wunschliste). Einheitliches Mental-Modell "alles da". |
|
||||||
|
| Installierbarkeit | **Volles PWA-Manifest + Icons** — Home-Screen-App auf Android/iOS. |
|
||||||
|
| Offline-Indikator | **Dezent**, fix unten rechts als Pill. Schreib-Buttons zeigen Toast bei Fehler. |
|
||||||
|
| Pre-Cache-Timing | **Im Hintergrund** nach erstem Besuch. Kein blockierender Ladescreen. Sichtbarer Fortschritt. |
|
||||||
|
| Update-Strategie | **Bei jedem App-Start wenn online** — diff gegen Cache-Manifest, Delta nachladen. |
|
||||||
|
| SW-Technologie | **SvelteKits eingebauter Service Worker** (`src/service-worker.ts`, `$service-worker`-Modul). Kein `vite-plugin-pwa`. |
|
||||||
|
| Offline-Schreib-Queue | **Nicht Teil dieser Version**. Offline-Klicks zeigen Toast und bleiben ohne Wirkung. Komplexität verschoben auf v1.2+. |
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Cache-Buckets
|
||||||
|
|
||||||
|
Drei Buckets, drei Strategien:
|
||||||
|
|
||||||
|
1. **`kochwas-shell-v{hash}`** — App-Shell (Build-Output: JS, CSS, Static-Icons aus `$service-worker`'s `build` + `files`). **Cache-First**. Bei Deploy neue Version → alter Cache wird in `activate` gelöscht.
|
||||||
|
|
||||||
|
2. **`kochwas-data-v1`** — Rezept-HTMLs (`/recipes/[id]`) + API-Reads (`/api/recipes/*`, `/api/wishlist`, `/api/domains`). **Stale-While-Revalidate**. Cache-Antwort sofort, Netz-Fetch parallel für nächsten Besuch.
|
||||||
|
|
||||||
|
3. **`kochwas-images-v1`** — `/images/*`. **Cache-First**. Filenames sind SHA-256-Hashes → ändert sich das Bild, ändert sich die URL, neue Einträge, alte räumt der Diff-Sync weg.
|
||||||
|
|
||||||
|
### Network-Only (nie cachen)
|
||||||
|
|
||||||
|
- Alle `POST/PUT/PATCH/DELETE` Requests
|
||||||
|
- `GET /api/recipes/import`, `/api/recipes/preview`, `/api/recipes/search/web` — reine Netz-Features, offline sinnfrei
|
||||||
|
- `GET /api/recipes/blank` gibt es nicht (Blank ist POST)
|
||||||
|
|
||||||
|
### Pre-Cache-Flow (Initial + Update)
|
||||||
|
|
||||||
|
**Initial (nach SW-Activate, einmalig)**:
|
||||||
|
|
||||||
|
1. Client postet `{ type: 'sync-start' }` an SW.
|
||||||
|
2. SW fetcht `/api/recipes/all?sort=name&limit=50&offset=N` seitenweise bis weniger als 50 Treffer kommen (Endpoint cappt aktuell auf 50 pro Request, siehe `/api/recipes/all/+server.ts`).
|
||||||
|
3. Alle IDs in Cache-Manifest-Entry schreiben (`kochwas-meta` cache, key `/cache-manifest`).
|
||||||
|
4. Für jede ID: parallel (max. 4 gleichzeitig) cachen:
|
||||||
|
- `GET /recipes/{id}` → `data`-Bucket
|
||||||
|
- `GET /api/recipes/{id}` → `data`-Bucket
|
||||||
|
- Aus der JSON-Response `image_path` extrahieren, wenn vorhanden `GET /images/{image_path}` → `images`-Bucket
|
||||||
|
5. Nach jedem erfolgreichen Eintrag: `postMessage({ type: 'sync-progress', current, total })` an alle Clients.
|
||||||
|
6. Am Ende: `postMessage({ type: 'sync-done', lastSynced: Date.now() })`.
|
||||||
|
|
||||||
|
**Update (bei jedem App-Start online)**:
|
||||||
|
|
||||||
|
1. Client postet `{ type: 'sync-check' }` an SW.
|
||||||
|
2. SW fetcht `/api/recipes/all` frisch.
|
||||||
|
3. Diff gegen Cache-Manifest:
|
||||||
|
- Neue IDs → cachen wie oben (nur Delta).
|
||||||
|
- Gelöschte IDs → aus `data`- und `images`-Bucket räumen.
|
||||||
|
4. Wenn Delta leer → `sync-done` mit unverändertem Zähler.
|
||||||
|
|
||||||
|
**Abbruch-Resilienz**: SW hält State in Cache-Manifest; abgebrochen mittendrin → nächster Start sieht unvollständiges Manifest und holt das Fehlende nach. Idempotent.
|
||||||
|
|
||||||
|
**Editierte Rezepte (gleiche ID, neuer Inhalt)**: Der Diff-Sync sieht keine Änderung (ID existiert ja). Der Refresh passiert stattdessen über Stale-While-Revalidate: wenn der User das Rezept online öffnet, liefert der Cache zuerst, der parallele Netz-Fetch aktualisiert den Cache-Eintrag. Der User sieht die Änderung also **beim übernächsten Öffnen**. Akzeptabel für eine Familien-App — wenn jemand „Salz auf 5 g" editiert, ist das nicht zeitkritisch. Bilder-Updates (neuer Image-Path durch andere Hash-URL) funktionieren automatisch: API-JSON aktualisiert sich per SWR, neue URL wird beim nächsten Bildrequest vom SW gecacht; alter Image-Cache-Entry bleibt als Orphan bis zum nächsten `diffManifest`-Lauf, der auch nach Orphan-Images schaut.
|
||||||
|
|
||||||
|
**Concurrency**: 4 parallele Requests max — schont den Raspberry Pi unter Last.
|
||||||
|
|
||||||
|
**Storage-Check**: Vor dem Initial-Sync `navigator.storage.estimate()`. Bei verfügbarem Quota < 100 MB → Toast: "Nicht genug Speicher für Offline-Modus". Hintergrund-Sync läuft trotzdem, bricht bei Quota-Fehler einfach ab.
|
||||||
|
|
||||||
|
### Sync-Status-Store
|
||||||
|
|
||||||
|
`src/lib/client/sync-status.svelte.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SyncState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'syncing'; current: number; total: number }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
export const syncStatus = {
|
||||||
|
state: $state<SyncState>({ kind: 'idle' }),
|
||||||
|
lastSynced: $state<number | null>(null),
|
||||||
|
// Abonniert SW-Messages, dispatcht State
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Gefüllt über `navigator.serviceWorker.addEventListener('message', ...)`. Persistiert `lastSynced` in localStorage (`kochwas.sw.lastSynced`).
|
||||||
|
|
||||||
|
### Online-Status-Store
|
||||||
|
|
||||||
|
`src/lib/client/network.svelte.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const network = {
|
||||||
|
online: $state(navigator.onLine),
|
||||||
|
// initialisiert Listener auf window 'online'/'offline'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Keine heuristischen Fetches — `navigator.onLine` ist für unsere Zwecke gut genug.
|
||||||
|
|
||||||
|
### UI-Komponenten
|
||||||
|
|
||||||
|
**`<SyncIndicator />`** — fix positioniert unten rechts, ~90×30 px Pill. Drei States:
|
||||||
|
|
||||||
|
- Sync läuft: grüner Spinner + `Sync 47/200`
|
||||||
|
- Offline: grauer Pill mit `Offline`
|
||||||
|
- Online, alles synchron: `display: none`
|
||||||
|
|
||||||
|
Tap/Klick öffnet kleine Overlay-Karte:
|
||||||
|
- "Zuletzt synchronisiert: vor 3 Min · 200 Rezepte im Cache"
|
||||||
|
- "Jetzt aktualisieren"-Button (triggert `sync-check`)
|
||||||
|
|
||||||
|
**`<Toast />`** — in `+layout.svelte` am Top eingehängt. Kurze, nicht-blockierende Meldungen. Store-API:
|
||||||
|
```ts
|
||||||
|
toastStore.error('Nicht verbunden');
|
||||||
|
toastStore.info('Synchronisiert — 200 Rezepte');
|
||||||
|
```
|
||||||
|
Auto-Dismiss nach 3 s, manuell ×-klickbar.
|
||||||
|
|
||||||
|
**Admin-Tab „App"** (`/admin/app`) — vierter Tab im Admin-Layout:
|
||||||
|
|
||||||
|
- Install-Button: feuert das gespeicherte `beforeinstallprompt`-Event. Auf iOS (UA-Detect): Info-Text „Teilen → Zum Home-Bildschirm hinzufügen".
|
||||||
|
- Sync-Status: `Synchronisiert 200/200 Rezepte (zuletzt 15:42)`.
|
||||||
|
- „Jetzt aktualisieren"-Button.
|
||||||
|
- „Offline-Cache leeren"-Button (destructive, zweistufig bestätigt) — für Debugging/Reset.
|
||||||
|
|
||||||
|
### Schreib-Aktionen-Verhalten
|
||||||
|
|
||||||
|
Betroffene Buttons in:
|
||||||
|
- `/recipes/[id]/+page.svelte`: Rating, Favorit, Wunschliste, Cooked, Kommentar, Titel, Edit-Save, Löschen, Bildschirm-Wake-Lock
|
||||||
|
- `/recipes/+page.svelte` (Register): Import, Blank-Create
|
||||||
|
- `/wishlist/+page.svelte`: Wunschliste-Toggle, Für-alle-entfernen
|
||||||
|
- `/admin/*/+page.svelte`: Domain-CRUD, Profile-CRUD, Backup
|
||||||
|
|
||||||
|
Pattern pro Klick:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!network.online) {
|
||||||
|
toastStore.error('Nicht verbunden — die Aktion speichert nicht.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... dann normal fetch ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative: Fetch versuchen, bei `TypeError: Failed to fetch` im catch toasten. Beides ist OK. Design-Entscheidung: **proaktiver Check** — klarere UX, keine falschen optimistischen UI-Updates.
|
||||||
|
|
||||||
|
### PWA-Manifest-Ergänzungen
|
||||||
|
|
||||||
|
`static/manifest.webmanifest`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Icons werden lokal einmalig aus `static/icon.svg` gerendert (Inkscape oder `rsvg-convert`) und committed. Keine CI-Abhängigkeit.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- `src/service-worker.ts` — SW-Hauptdatei (install/activate/fetch/message-Handler, Pre-Cache-Orchestrator)
|
||||||
|
- `src/lib/client/sync-status.svelte.ts` — Sync-Status-Store
|
||||||
|
- `src/lib/client/network.svelte.ts` — Online-Status-Store
|
||||||
|
- `src/lib/client/toast.svelte.ts` — Toast-Store
|
||||||
|
- `src/lib/components/SyncIndicator.svelte` — bottom-right Pill + Overlay-Karte
|
||||||
|
- `src/lib/components/Toast.svelte` — Toast-Renderer
|
||||||
|
- `src/routes/admin/app/+page.svelte` — Admin-Tab „App"
|
||||||
|
- `static/icon-192.png`, `static/icon-512.png` — PWA-Icons (einmal gerendert, committed)
|
||||||
|
- `tests/integration/sw-cache-strategy.test.ts` — Unit-Tests für die Cache-Strategy-Entscheider + Diff-Logik
|
||||||
|
- `tests/e2e/offline.spec.ts` — Playwright: Offline-Navigation, Sync-Indikator, Schreib-Aktion-Toast
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- `static/manifest.webmanifest` — PNG-Icons ergänzen, `purpose: "any maskable"`
|
||||||
|
- `src/routes/+layout.svelte` — SW registrieren, `<SyncIndicator />` + `<Toast />` einbinden, Network-Store initialisieren
|
||||||
|
- `src/routes/admin/+layout.svelte` — vierten Tab „App" mit Smartphone-Icon
|
||||||
|
- Alle Seiten mit Schreib-Buttons — proaktiver `network.online`-Check
|
||||||
|
|
||||||
|
### Nicht angefasst
|
||||||
|
|
||||||
|
- Backend (`src/lib/server/**`, `src/routes/api/**`) — reines Frontend-Feature
|
||||||
|
- Datenbank-Schema
|
||||||
|
- Deployment (Dockerfile, compose-Dateien)
|
||||||
|
|
||||||
|
## Test-Strategie
|
||||||
|
|
||||||
|
### Unit-Tests (vitest)
|
||||||
|
|
||||||
|
- `sync-status.svelte.ts`: State-Übergänge bei Messages
|
||||||
|
- `toast.svelte.ts`: Store-API, Auto-Dismiss
|
||||||
|
- `sw-cache-strategy.test.ts`:
|
||||||
|
- `resolveStrategy(url)` → gibt Strategy-Namen zurück (cache-first, swr, network-only)
|
||||||
|
- `diffManifest(currentIds, cachedIds)` → `{ toAdd, toRemove }`
|
||||||
|
- Concurrency-Queue: vier parallel, Gesamt-Reihenfolge idempotent
|
||||||
|
|
||||||
|
### E2E-Tests (Playwright, lokales Docker)
|
||||||
|
|
||||||
|
- **Install + Sync**: Seite öffnen, warten bis `sync-done`, Cache-Einträge überprüfen.
|
||||||
|
- **Offline-Lesen**: Netz aus (Playwright-API), Navigation `/` → `/recipes/[id]` → zurück, Rezept ist sichtbar.
|
||||||
|
- **Offline-Schreiben**: Netz aus, Favorit-Toggle klicken, Toast erscheint, Herz nicht gefüllt.
|
||||||
|
- **Update-Sync**: Im Browser ein neues Rezept via Register importieren, Tab neu laden, `sync-check` feuert, Rezept-ID-Liste gewachsen.
|
||||||
|
- **Sync-Indikator-Zustände**: Manuell getriggert, alle drei States visuell überprüfen.
|
||||||
|
|
||||||
|
### Manuelle Tests
|
||||||
|
|
||||||
|
- Android Chrome: beforeinstallprompt → Install-Button → Home-Screen-App startet
|
||||||
|
- Safari iOS: Teilen → Zum Home-Bildschirm, Start der App, Offline-Navigation
|
||||||
|
- Chrome DevTools → Application → Storage → Clear Site Data → Re-Load → Initial-Sync läuft durch
|
||||||
|
|
||||||
|
## Out of Scope (v1.1)
|
||||||
|
|
||||||
|
Bewusst raus, mögliche v1.2-Themen:
|
||||||
|
|
||||||
|
- **Background Sync für Schreib-Aktionen** — Rating/Kommentare offline speichern und später syncen. Braucht Konflikt-Resolution, schedule.sync-API, Duplikat-Erkennung.
|
||||||
|
- **Push-Benachrichtigungen** — "Jemand hat ein neues Rezept hinzugefügt". Viel Infrastruktur für wenig Nutzen.
|
||||||
|
- **Offline-Web-Suche** — nicht sinnvoll, braucht SearXNG.
|
||||||
|
- **Partial-Sync nach Profil** — alle Rezepte bleiben synchronisiert, keine Profil-spezifische Teilmenge.
|
||||||
|
|
||||||
|
## Risiken + Mitigation
|
||||||
|
|
||||||
|
| Risiko | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Storage-Quota erschöpft | `navigator.storage.estimate()` vor Sync, Toast bei < 100 MB frei |
|
||||||
|
| SW-Deploy: alte Clients sehen alten Cache | Cache-Name inkl. Build-Hash, `activate` räumt alte Versionen |
|
||||||
|
| Alter SW blockiert Update | `skipWaiting()` + `clients.claim()` — neuer SW übernimmt sofort |
|
||||||
|
| Fetch-Loop (SW ruft sich selbst) | Exakte URL-Muster-Matching, keine Wildcards auf `/api/**` |
|
||||||
|
| iOS Safari vergisst Cache | Bekanntes iOS-Verhalten bei langer Inaktivität; Akzeptieren, nächster Start synct nach |
|
||||||
|
| SW nur auf HTTPS oder localhost | Produktion läuft unter `https://kochwas.siegeln.net` ✓. Dev-Server läuft auf HTTP — für SW-Tests braucht's entweder `npm run build && npm run preview` (baut auf localhost, SW registrierbar) oder die lokale Docker-Compose-Prod-Variante |
|
||||||
336
docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md
Normal file
336
docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# Foto-Rezept-Magie — Design Spec
|
||||||
|
|
||||||
|
**Status:** approved (brainstorming)
|
||||||
|
**Datum:** 2026-04-21
|
||||||
|
**Ziel-Release:** v1.3.0
|
||||||
|
|
||||||
|
## 1. Motivation & Scope
|
||||||
|
|
||||||
|
Nutzer sollen ein **gedrucktes oder handgeschriebenes Rezept** fotografieren können. Das Foto wird an ein Vision-LLM (Gemini 2.5 Flash) gesendet, dort zu einer strukturierten Recipe-Shape extrahiert, und direkt in einen vorausgefüllten `RecipeEditor` gepackt — der Nutzer korrigiert bei Bedarf und speichert. Das Foto selbst wird nie persistiert.
|
||||||
|
|
||||||
|
**In Scope (v1):**
|
||||||
|
|
||||||
|
- Einzelnes Foto von gedrucktem Rezept ODER Handschrift.
|
||||||
|
- Extraktion von Titel, Portionen, Zeiten, Zutaten (mit Menge/Einheit/Name), Zubereitungsschritten.
|
||||||
|
- Auslöse-Button als `Camera`-Icon (lucide) im Header, disabled wenn offline oder ohne API-Key.
|
||||||
|
- Direkter Flow in den `RecipeEditor` (kein separater Preview-Schritt).
|
||||||
|
- Server-seitiger Gemini-Call mit structured-output-Schema, Zod-Validierung, 1× Retry bei Schema-Fehler.
|
||||||
|
- Hartes Nicht-Speichern des Fotos nach dem Call.
|
||||||
|
|
||||||
|
**Explizit Out-of-Scope (v1):**
|
||||||
|
|
||||||
|
- Multi-Foto (Kochbuch-Doppelseite): Endpoint nimmt ein Bild entgegen; Erweiterung auf Array bei Bedarf.
|
||||||
|
- Extraktion von `image_path` aus dem Bild (Dish-Crop aus Kochbuchseite).
|
||||||
|
- Foto-Backup / Persistenz des Input-Fotos.
|
||||||
|
- Claude als Fallback.
|
||||||
|
- Interpretierende Felder: `cuisine`, `category`, `tags`, freie `description`.
|
||||||
|
- Foto-vom-Gericht → AI-erfindet-Rezept (anderer Use-Case).
|
||||||
|
|
||||||
|
## 2. User Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Header (Camera-Icon, lucide)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
/new/from-photo (File-Picker: <input type="file" accept="image/*" capture="environment">)
|
||||||
|
│
|
||||||
|
▼ Nutzer wählt/knipst Foto — File bleibt nur im Browser-State
|
||||||
|
│
|
||||||
|
▼ POST /api/recipes/extract-from-photo (multipart/form-data)
|
||||||
|
│ Server: MIME + Größe validieren, sharp-Preprocess, Gemini-Call, Response
|
||||||
|
│ Foto wird NICHT persistiert.
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Seite swappt Spinner → <RecipeEditor initialData={recipe}>
|
||||||
|
│
|
||||||
|
▼ Nutzer korrigiert, klickt „Speichern" (Editor-Save-Pfad — ob bestehend oder neu: siehe §11)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
/recipes/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Invarianten:**
|
||||||
|
|
||||||
|
- Extraktion erzeugt **kein** DB-Record. Erst der Save-Klick im Editor schreibt.
|
||||||
|
- Bei Tab-Close während Extraktion: kein Müll in der DB, AbortController-fähiger Fetch.
|
||||||
|
- Offline: Kamera-Icon im Header nicht geklickbar; falls trotzdem Route geöffnet, klare Offline-Meldung.
|
||||||
|
|
||||||
|
## 3. Komponenten & Dateien
|
||||||
|
|
||||||
|
**Neue Dateien**
|
||||||
|
|
||||||
|
| Pfad | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `src/routes/new/from-photo/+page.svelte` | Shell. States: `idle` / `loading` / `success` / `error:<code>`. |
|
||||||
|
| `src/lib/server/ai/gemini-client.ts` | Thin wrapper: `extractRecipeFromImage(buffer, mime): Promise<Partial<Recipe>>`. Liest `GEMINI_API_KEY`, `GEMINI_MODEL`, `GEMINI_TIMEOUT_MS` aus `$env/dynamic/private`. |
|
||||||
|
| `src/lib/server/ai/recipe-extraction-prompt.ts` | System-Prompt (DE) + JSON-Schema für Gemini `responseSchema`. Isoliert, weil iterabel. |
|
||||||
|
| `src/lib/server/ai/description-phrases.ts` | 50er-Pool von Magie-Phrasen für das `description`-Feld. Export `pickRandomPhrase(): string`. Siehe §5a. |
|
||||||
|
| `src/lib/server/ai/image-preprocess.ts` | `sharp`-basierter Resize (≤1600px lange Kante) + JPEG re-encode (quality 85) + Metadata-Strip. HEIC → JPEG. |
|
||||||
|
| `src/routes/api/recipes/extract-from-photo/+server.ts` | POST. Multipart-Parse, Validierung, preprocess, Gemini-Call, Zod-Validierung, Response. |
|
||||||
|
| `src/lib/client/photo-upload.svelte.ts` | Frontend-Store für Upload-Zustand. |
|
||||||
|
| `tests/unit/ai/recipe-extraction-prompt.test.ts` | Schema-Ping, Retry-Pfad, Zod-Ablehnung. |
|
||||||
|
| `tests/unit/ai/image-preprocess.test.ts` | Resize, HEIC, Metadata-Strip. |
|
||||||
|
| `tests/unit/ai/gemini-client.test.ts` | Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler. |
|
||||||
|
| `tests/unit/ai/description-phrases.test.ts` | Pool hat 50 Einträge, alle unique non-empty, `pickRandomPhrase` liefert nur Pool-Einträge. |
|
||||||
|
| `tests/api/extract-from-photo.test.ts` | Happy-Path, 413, 415, 422 (NO_RECIPE_IN_IMAGE). |
|
||||||
|
| `tests/e2e/remote/photo-import.spec.ts` | Kamera-Icon, Upload-Fixture (Endpoint gestubt), Editor-Prefill, Save, Offline-State. |
|
||||||
|
| `tests/fixtures/photo-recipe/` | 3 Fixture-Fotos: gedrucktes Rezept, Handschrift, No-Recipe-Bild. |
|
||||||
|
|
||||||
|
**Geänderte Dateien**
|
||||||
|
|
||||||
|
| Pfad | Änderung |
|
||||||
|
|---|---|
|
||||||
|
| `src/routes/+layout.svelte` | Header: `Camera`-Icon, `aria-label="Rezept aus Foto erstellen"`. Nur gerendert wenn `GEMINI_API_KEY` gesetzt (Graceful Degradation). Disabled wenn offline (`networkStore`). Führt zu `/new/from-photo`. |
|
||||||
|
| `src/lib/components/RecipeEditor.svelte` | Akzeptiert optionale `initialData?: Partial<Recipe>`-Prop. Wenn gesetzt, Felder vorbefüllen, kein DB-Round-trip. Heute liest der Editor über eine Rezept-ID — dieser Pfad wird abstrahiert. |
|
||||||
|
| `Dockerfile` | `sharp` im Native-Build-Stage ergänzen (wie `better-sqlite3`). |
|
||||||
|
| `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example` | Env-Vars `GEMINI_API_KEY`, `GEMINI_MODEL`, `GEMINI_TIMEOUT_MS` ergänzen. |
|
||||||
|
| `docs/OPERATIONS.md` | Abschnitt zu Gemini-Config + Recreate-Hinweis bei Env-Änderung. |
|
||||||
|
| `docs/ARCHITECTURE.md` | AI-Extraktionspfad ergänzen. |
|
||||||
|
| `CLAUDE.md` | Zeile in Gotcha-Tabelle: Graceful Degradation ohne Key + `sharp` im Build-Stage. |
|
||||||
|
|
||||||
|
**Keine DB-Migration.** Recipe-Shape bleibt; der Endpoint produziert ein `Partial<Recipe>` im Response-Body.
|
||||||
|
|
||||||
|
## 4. API-Contract
|
||||||
|
|
||||||
|
**`POST /api/recipes/extract-from-photo`**
|
||||||
|
|
||||||
|
Request: `multipart/form-data`
|
||||||
|
|
||||||
|
- `photo`: File. Erlaubt: `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`.
|
||||||
|
- Max 8 MB (vor Preprocess).
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recipe": {
|
||||||
|
"title": "Zürcher Geschnetzeltes",
|
||||||
|
"description": "Aus dem Bild herbeigezaubert.",
|
||||||
|
"servings_default": 4,
|
||||||
|
"servings_unit": "Portionen",
|
||||||
|
"prep_time_min": 20,
|
||||||
|
"cook_time_min": 15,
|
||||||
|
"total_time_min": null,
|
||||||
|
"cuisine": null,
|
||||||
|
"category": null,
|
||||||
|
"image_path": null,
|
||||||
|
"source_url": null,
|
||||||
|
"source_domain": null,
|
||||||
|
"ingredients": [
|
||||||
|
{ "position": 1, "quantity": 500, "unit": "g", "name": "Kalbsgeschnetzeltes", "note": null, "section": null },
|
||||||
|
{ "position": 2, "quantity": 200, "unit": "ml", "name": "Rahm", "note": null, "section": null }
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{ "position": 1, "text": "Fleisch in heißer Pfanne kurz anbraten, herausnehmen." }
|
||||||
|
],
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response Fehler-Codes:
|
||||||
|
|
||||||
|
| Status | `code` | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| 413 | `PAYLOAD_TOO_LARGE` | Photo > 8 MB. |
|
||||||
|
| 415 | `UNSUPPORTED_MEDIA_TYPE` | MIME nicht in der Whitelist. |
|
||||||
|
| 422 | `NO_RECIPE_IN_IMAGE` | AI-Output valide, aber `title` leer oder (`ingredients.length === 0` UND `steps.length === 0`). |
|
||||||
|
| 429 | `AI_RATE_LIMITED` | Gemini 429 durchgereicht. |
|
||||||
|
| 503 | `AI_TIMEOUT` | Gemini-Timeout (Default 20 s). |
|
||||||
|
| 503 | `AI_FAILED` | Gemini-5xx nach 1 Retry ODER Schema-Validierung nach 1 Retry fehlgeschlagen. |
|
||||||
|
| 503 | `AI_NOT_CONFIGURED` | `GEMINI_API_KEY` leer — Endpoint sollte dann ohnehin nicht erreichbar sein via UI, belt-and-suspenders. |
|
||||||
|
|
||||||
|
## 5. Prompt-Strategie
|
||||||
|
|
||||||
|
Datei: `src/lib/server/ai/recipe-extraction-prompt.ts`
|
||||||
|
|
||||||
|
- **Sprache:** Deutsch.
|
||||||
|
- **Rolle:** „Du bist ein Rezept-Extraktions-Assistent."
|
||||||
|
- **Regeln:**
|
||||||
|
- Nur was lesbar auf dem Bild steht, ins Ergebnis. Sonst `null` oder leeres Array.
|
||||||
|
- Zutatenmengen: Zahl in `quantity`, Einheit separat (`g`, `ml`, `EL`, `TL`, `Stück`, `Prise`…).
|
||||||
|
- Bruchteile (`½`, `¼`, `1 ½`) zu Dezimalzahlen.
|
||||||
|
- Zubereitungsschritte: pro erkennbarer Nummerierung/Absatz ein Schritt.
|
||||||
|
- `description` wird server-seitig **nach** dem AI-Call aus einem 50er-Pool zufällig gewählt (`description-phrases.ts`, siehe §5a). Die AI bekommt `description` gar nicht erst im Schema — keine Halluzinationsfläche.
|
||||||
|
- **Output:** Gemini `responseMimeType: "application/json"` + `responseSchema`. Strict-typed, keine zusätzlichen Keys.
|
||||||
|
- **Temperature:** `0.1`.
|
||||||
|
- **Retry bei Schema-Fehler:** Genau 1 zusätzlicher Call mit Appendix „Dein letztes JSON war invalid. Schema: … Bitte nur JSON zurück." Dann `AI_FAILED`.
|
||||||
|
|
||||||
|
Zod-Schema spiegelt das Response-Schema serverseitig und wird auf die Gemini-Antwort angewendet.
|
||||||
|
|
||||||
|
## 5a. Description-Phrasen-Pool
|
||||||
|
|
||||||
|
Datei: `src/lib/server/ai/description-phrases.ts`
|
||||||
|
|
||||||
|
50 deutsche Magie-Phrasen, zufällig gezogen pro Extraktions-Call. Die Auswahl geschieht server-seitig im Endpoint, nachdem die AI-Antwort validiert wurde. Der Nutzer kann die Phrase im Editor weiter editieren, sie ist also ein Starter, kein Lock-in.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const DESCRIPTION_PHRASES: readonly string[] = [
|
||||||
|
'Mit dem Zauberstab aus dem Kochbuch geholt.',
|
||||||
|
'Foto-Magie frisch aus dem Ofen.',
|
||||||
|
'Aus dem Bild herbeigezaubert.',
|
||||||
|
'Ein Klick, ein Foto, fertig.',
|
||||||
|
'Knipsen statt Abtippen.',
|
||||||
|
'Von der Buchseite direkt in die Pfanne.',
|
||||||
|
'Die Kamera hat mitgelesen.',
|
||||||
|
'Abrakadabra — Rezept da.',
|
||||||
|
'Per Linse in die Küche teleportiert.',
|
||||||
|
'Von Oma abfotografiert, von der KI entziffert.',
|
||||||
|
'Frisch aus dem Bilderrahmen.',
|
||||||
|
'Klick, zisch, Rezept.',
|
||||||
|
'Das Foto wurde überredet, sich zu verraten.',
|
||||||
|
'Schnappschuss zur Schüssel.',
|
||||||
|
'Einmal lesen lassen, schon da.',
|
||||||
|
'Keine Hand hat dieses Rezept abgetippt.',
|
||||||
|
'Vom Bild in die Bratpfanne.',
|
||||||
|
'Papier ist geduldig, das Foto war es auch.',
|
||||||
|
'Eine Seite, ein Foto, ein Rezept.',
|
||||||
|
'Die KI hat drübergeschielt.',
|
||||||
|
'Handschriftlich entziffert — oder zumindest versucht.',
|
||||||
|
'Aus der Linse in die Liste.',
|
||||||
|
'Vom Küchentisch zur Kachel.',
|
||||||
|
'Knips und weg — zumindest der Zettel.',
|
||||||
|
'Das Bild hat geredet.',
|
||||||
|
'Keine Tippfehler, nur Sehfehler.',
|
||||||
|
'Per Foto eingebürgert.',
|
||||||
|
'Rezept-Übersetzung aus dem Bild.',
|
||||||
|
'Die Seite hat sich verraten.',
|
||||||
|
'Blitzlicht und dann Gulasch.',
|
||||||
|
'Ein Augenzwinkern der Kamera genügte.',
|
||||||
|
'Geknipst, gelesen, gespeichert.',
|
||||||
|
'Fotografische Gedächtnishilfe.',
|
||||||
|
'Aus der Schublade ans Licht.',
|
||||||
|
'Das Rezept stand schon da — wir haben nur hingeguckt.',
|
||||||
|
'Zaubertrick mit Kamera.',
|
||||||
|
'Vom Papier befreit.',
|
||||||
|
'Ein Foto sagt mehr als tausend Zutatenlisten.',
|
||||||
|
'Eingescannt, rausgelesen, reingeschrieben.',
|
||||||
|
'Die Kamera als Küchenhilfe.',
|
||||||
|
'Handy hoch, Rezept runter.',
|
||||||
|
'Aus dem Kochbuch gebeamt.',
|
||||||
|
'Ein scharfes Foto, ein klares Rezept.',
|
||||||
|
'Vom Regal zur App in einem Schritt.',
|
||||||
|
'Aus dem Bild geschöpft wie Suppe aus dem Topf.',
|
||||||
|
'Optisch erfasst, digital serviert.',
|
||||||
|
'Das Kleingedruckte hat die KI gelesen.',
|
||||||
|
'Vom Kladdenzettel in die Datenbank.',
|
||||||
|
'Kurz gezückt, schon gekocht.',
|
||||||
|
'Kein Schreibkrampf, nur ein Klick.'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function pickRandomPhrase(): string {
|
||||||
|
return DESCRIPTION_PHRASES[Math.floor(Math.random() * DESCRIPTION_PHRASES.length)];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Invariant:** Genau 50 Einträge, alle non-empty, alle unique. Unit-Test prüft das.
|
||||||
|
|
||||||
|
## 6. Fehlerbehandlung
|
||||||
|
|
||||||
|
**Client-Zustände auf `/new/from-photo`:**
|
||||||
|
|
||||||
|
| State | UI |
|
||||||
|
|---|---|
|
||||||
|
| `idle` | `Camera`-Button groß mittig, Text „Foto wählen oder aufnehmen". Hilfetext: „Gedrucktes Rezept oder Handschrift. Eine Seite, scharf, gut ausgeleuchtet." |
|
||||||
|
| `loading` | `Loader2` (spin) + Text „Lese das Rezept…". `X`-Button für Abbrechen (AbortController). |
|
||||||
|
| `success` | `<RecipeEditor initialData={recipe}>`. Top-Banner mit `Wand2`: „Aus Foto erstellt — bitte prüfen und ggf. korrigieren." Verschwindet nach erstem Feld-Edit. |
|
||||||
|
| `error: NO_RECIPE_IN_IMAGE` | Yellow-Box. Buttons: `Camera` „Anderes Foto" (→ idle), `FilePlus` „Leer anlegen" (→ leerer Editor). |
|
||||||
|
| `error: AI_TIMEOUT` / `AI_RATE_LIMITED` / `AI_FAILED` | Red-Toast, Grund. `RotateCw` „Nochmal versuchen" — reused das gleiche File-Objekt, kein Re-Upload durch den Nutzer. |
|
||||||
|
| `error: PAYLOAD_TOO_LARGE` | Toast „Foto zu groß (max 8 MB). In besserer Beleuchtung neu aufnehmen." |
|
||||||
|
| Offline (auf Route) | Hinweis „Diese Funktion braucht Internet." |
|
||||||
|
|
||||||
|
**A11y:** Lade-State `aria-live="polite"`, Fehler-Boxen `role="alert"`, Kamera-Icon mit `aria-label`.
|
||||||
|
|
||||||
|
**Server-Seite:**
|
||||||
|
|
||||||
|
- Gemini-Call mit `AbortSignal`, Default-Timeout `GEMINI_TIMEOUT_MS` (20000).
|
||||||
|
- Retry-Matrix:
|
||||||
|
|
||||||
|
| Gemini-Signal | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| 429 | `AI_RATE_LIMITED`, kein Retry. |
|
||||||
|
| Network/5xx | 1× Retry mit 500 ms backoff, dann `AI_FAILED`. |
|
||||||
|
| Invalid JSON | 1× Retry mit Append-Prompt, dann `AI_FAILED`. |
|
||||||
|
| Valid JSON aber Schema-invalid | gleicher Pfad wie Invalid JSON. |
|
||||||
|
| Timeout | `AI_TIMEOUT`, kein Retry. |
|
||||||
|
|
||||||
|
- **Logging:** `console.warn` mit `{ code, durationMs, imageKB }` — **ohne** Prompt/Response-Inhalt (Privacy).
|
||||||
|
|
||||||
|
**Icons (alle aus `lucide-svelte`):**
|
||||||
|
|
||||||
|
| Zweck | Icon |
|
||||||
|
|---|---|
|
||||||
|
| Header-Button | `Camera` |
|
||||||
|
| Lade-State | `Loader2` (spin) |
|
||||||
|
| Erfolgs-Banner | `Wand2` |
|
||||||
|
| Fehler | `AlertTriangle` |
|
||||||
|
| „Nochmal versuchen" | `RotateCw` |
|
||||||
|
| „Anderes Foto" | `Camera` |
|
||||||
|
| „Leer anlegen" | `FilePlus` |
|
||||||
|
| Abbrechen (Loading) | `X` |
|
||||||
|
|
||||||
|
## 7. Sicherheit / Missbrauch
|
||||||
|
|
||||||
|
- **Rate-Limit:** 10 Requests/Min pro IP, simple In-Memory-Throttle im Endpoint. Schützt vor versehentlichem Dauer-Tappen und Kosten-Runaways. Übertrieben für's Heimnetz, aber billig einzubauen.
|
||||||
|
- **MIME-Validierung nicht blind client-seitig** — Buffer-Header prüfen (`sharp` metadata) nach Empfang.
|
||||||
|
- **`.heic`/`.heif`** funktioniert, wenn `sharp` mit `libheif` gebaut ist (beim offiziellen sharp-arm64-Build dabei). Fixture-Test dafür.
|
||||||
|
- **Kein Auth** (Kochwas-Policy). Key stays server-side.
|
||||||
|
- **Privacy-Statement** im OPERATIONS.md: „Fotos gehen einmal an Google Gemini und werden danach nicht gespeichert. Gemini nutzt API-Daten im Paid-Tier nicht für Training."
|
||||||
|
|
||||||
|
## 8. Konfiguration
|
||||||
|
|
||||||
|
Env-Vars (alle in `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example` ergänzen):
|
||||||
|
|
||||||
|
| Var | Default | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `GEMINI_API_KEY` | — (required) | Ohne Key: Feature graceful deaktiviert. |
|
||||||
|
| `GEMINI_MODEL` | `gemini-2.5-flash` | Modell-Wechsel (z.B. auf `gemini-2.5-pro`) ohne Rebuild. |
|
||||||
|
| `GEMINI_TIMEOUT_MS` | `20000` | Timeout für Vision-Call. |
|
||||||
|
|
||||||
|
**Wichtig:** Env-Änderungen greifen erst nach `docker compose up -d --force-recreate`, nicht nach `restart` (siehe Auto-Memory `project_deploy_env_recreate.md`).
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
**Unit (Vitest, mocked Gemini):**
|
||||||
|
|
||||||
|
- `image-preprocess.test.ts`: Resize, HEIC→JPEG, Metadata-Strip, JPEG-Qualität.
|
||||||
|
- `recipe-extraction-prompt.test.ts`: Prompt enthält Schema; Zod akzeptiert gültige Response; Zod lehnt invalide Response ab; Retry-Logik greift genau 1×.
|
||||||
|
- `gemini-client.test.ts`: Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler.
|
||||||
|
|
||||||
|
**API (SvelteKit-Endpoint, gemockter Gemini-Client):**
|
||||||
|
|
||||||
|
- `tests/api/extract-from-photo.test.ts`: Happy-Path mit Fixture-JPEG; 413 bei >8MB; 415 bei nicht-Bild; 422 bei Titel-OK-aber-0-Ingredients-UND-0-Steps; 503 mit `AI_NOT_CONFIGURED` wenn Key fehlt.
|
||||||
|
|
||||||
|
**E2E (Playwright gegen `kochwas-dev.siegeln.net`):**
|
||||||
|
|
||||||
|
- `tests/e2e/remote/photo-import.spec.ts`: Kamera-Icon-Klick; File-Upload (Endpoint gestubt, kein echter Gemini-Call); Editor-Prefill; Save; Redirect auf `/recipes/:id`; Kamera-Icon-disabled bei `context.setOffline(true)`.
|
||||||
|
|
||||||
|
**Fixtures:** `tests/fixtures/photo-recipe/`: gedruckte Seite, Handschrift-Karte, No-Recipe-Bild.
|
||||||
|
|
||||||
|
**Explizit nicht getestet:** Die Gemini-Vision-Qualität selbst. Das ist Model-Verhalten, nicht unser Code. Manuelle Verifikation nach Deploy.
|
||||||
|
|
||||||
|
## 10. PWA / Service Worker
|
||||||
|
|
||||||
|
- `/new/from-photo` in den Shell-Pre-Cache aufnehmen.
|
||||||
|
- Feature funktioniert nur online — Offline-State wird bewusst gehandhabt (siehe §6).
|
||||||
|
- Service-Worker ändert nichts am Extract-Endpoint (keine SW-Cachung für `/api/recipes/extract-from-photo`).
|
||||||
|
|
||||||
|
## 11. Offene Kleinigkeiten (in Planung zu entscheiden)
|
||||||
|
|
||||||
|
- **Save-Endpoint:** Ob der bestehende Editor-Save-Endpoint das Anlegen eines Rezepts aus Scratch unterstützt, oder ob `insertRecipe` über einen neuen POST `/api/recipes` exponiert werden muss — vor dem Planning prüfen.
|
||||||
|
- **Sharp Build-Stage:** Verifizieren, dass das offizielle `sharp`-npm-Package auf arm64 mit libheif-Support ausgeliefert wird; andernfalls Build-Stage-Rezept ähnlich zu `better-sqlite3`.
|
||||||
|
- **Rate-Limit-Impl:** In-Memory-LRU oder Redis-like überflüssig — `Map<ip, {count, resetAt}>` reicht.
|
||||||
|
|
||||||
|
## 12. Akzeptanz-Kriterien
|
||||||
|
|
||||||
|
- [ ] Kamera-Icon in der Kopfzeile sichtbar, führt zu `/new/from-photo`.
|
||||||
|
- [ ] Kamera-Icon unsichtbar wenn `GEMINI_API_KEY` leer.
|
||||||
|
- [ ] Kamera-Icon disabled wenn offline (`networkStore.online === false`).
|
||||||
|
- [ ] File-Picker öffnet mobile Rückkamera direkt (`capture="environment"`).
|
||||||
|
- [ ] Gedrucktes Fixture-Rezept wird vom Prompt + Mock-Gemini-Response in gültige Recipe-Shape überführt.
|
||||||
|
- [ ] Handschrift-Fixture ebenso (Mock).
|
||||||
|
- [ ] No-Recipe-Fixture → 422 `NO_RECIPE_IN_IMAGE` → UI zeigt Yellow-Box mit beiden Buttons.
|
||||||
|
- [ ] Editor öffnet mit vorbefüllten Feldern, Nutzer kann editieren, Speichern navigiert zu `/recipes/:id`.
|
||||||
|
- [ ] Foto-Datei wird nach Request nicht auf Disk gefunden (Test-Assertion im API-Test).
|
||||||
|
- [ ] Build im Dockerfile-arm64-Stage erfolgreich mit `sharp`.
|
||||||
|
- [ ] `npm test` + `npm run check` grün.
|
||||||
295
docs/superpowers/specs/2026-04-21-shopping-list-design.md
Normal file
295
docs/superpowers/specs/2026-04-21-shopping-list-design.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Einkaufsliste — Design-Spec
|
||||||
|
|
||||||
|
**Datum**: 2026-04-21
|
||||||
|
**Status**: Spec, vor Implementierung
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Aus Rezepten auf der Wunschliste eine flache, aggregierte Einkaufsliste erzeugen. Die Liste ist haushaltsweit geteilt, mobil-first, im Supermarkt abhakbar. Portionen sind pro Rezept anpassbar. Identische Zutaten (gleicher Name + gleiche Einheit) werden über mehrere Rezepte hinweg summiert.
|
||||||
|
|
||||||
|
## Entscheidungen (aus Brainstorming)
|
||||||
|
|
||||||
|
| Thema | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| Sichtbarkeit | Global, eine Liste für alle Profile |
|
||||||
|
| Portionen | Default `servings_default` beim Hinzufügen; zentral auf der Einkaufslisten-Seite anpassbar |
|
||||||
|
| Aggregation | Flache Liste, exaktes Matching auf `(LOWER(TRIM(name)), LOWER(TRIM(unit)))`. Keine Fuzzy-Matches — lieber zwei Zeilen als falsche Summen. Rezept-Herkunft pro Zeile sichtbar. |
|
||||||
|
| Abhaken | Checkbox, durchgestrichen, sortiert ans Ende. Manuelles Cleanup via „Erledigte entfernen" / „Liste leeren" |
|
||||||
|
| Kopplung | Komplett entkoppelt von Wunschliste und `cooking_log`. Abhaken beeinflusst nur die Einkaufsliste. |
|
||||||
|
| Header-Badge | Zählt **nicht-abgehakte** aggregierte Zutaten-Zeilen. Versteckt sich bei Count = 0. |
|
||||||
|
| Manuelle Einträge | Out of scope. Nur rezeptbasiert. |
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
Migration `013_shopping_list.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE shopping_cart_recipe (
|
||||||
|
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
|
||||||
|
servings INTEGER NOT NULL,
|
||||||
|
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
|
||||||
|
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shopping_cart_check (
|
||||||
|
name_key TEXT NOT NULL,
|
||||||
|
unit_key TEXT NOT NULL,
|
||||||
|
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (name_key, unit_key)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Derivation-Prinzip**: Die aggregierte Liste wird **nicht materialisiert**. Sie wird bei jedem Lesen aus `shopping_cart_recipe JOIN recipe JOIN ingredient` plus Skalierungs-Faktor berechnet. Vorteil: Rezept-Edits wirken live auf die Liste.
|
||||||
|
|
||||||
|
**Abhaken pro aggregierter Zeile**: `(name_key, unit_key)` — nicht pro Rezept-Zutat. Wenn zwei Rezepte beide „Mehl, g" haben, gibt es eine Zeile „400 g Mehl", und ein Haken reicht. Wird eines der Rezepte entfernt, bleibt „200 g Mehl" mit Haken sichtbar.
|
||||||
|
|
||||||
|
**Orphan-Checks** (aggregierter Schlüssel ist nicht mehr durch ein Rezept im Cart abgedeckt): Werden nicht aktiv gelöscht, tauchen aber in der Ausgabe von `listShoppingList` nicht auf (der Join erzeugt keine Zeile). Späteres Cleanup optional via `clearCart` / `clearCheckedItems`.
|
||||||
|
|
||||||
|
### Aggregations-SQL (Kern)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
LOWER(TRIM(i.name)) AS name_key,
|
||||||
|
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
|
||||||
|
MIN(i.name) AS display_name,
|
||||||
|
MIN(i.unit) AS display_unit,
|
||||||
|
SUM(i.quantity * cr.servings * 1.0 / r.servings_default) AS total_quantity,
|
||||||
|
GROUP_CONCAT(DISTINCT r.title) AS from_recipes,
|
||||||
|
EXISTS(SELECT 1 FROM shopping_cart_check c
|
||||||
|
WHERE c.name_key = LOWER(TRIM(i.name))
|
||||||
|
AND c.unit_key = LOWER(TRIM(COALESCE(i.unit, '')))) AS checked
|
||||||
|
FROM shopping_cart_recipe cr
|
||||||
|
JOIN recipe r ON r.id = cr.recipe_id
|
||||||
|
JOIN ingredient i ON i.recipe_id = r.id
|
||||||
|
GROUP BY name_key, unit_key
|
||||||
|
ORDER BY checked ASC, display_name COLLATE NOCASE;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge Cases**:
|
||||||
|
- `i.quantity IS NULL` → `total_quantity` bleibt NULL, UI rendert ohne Mengenangabe.
|
||||||
|
- `r.servings_default IS NULL` → Division through-by-NULL → `total_quantity` NULL; defensiver: `COALESCE(r.servings_default, cr.servings)` (Faktor = 1, wenn kein Default bekannt).
|
||||||
|
- `i.unit IS NULL` → `unit_key = ''`, Anzeige ohne Einheit.
|
||||||
|
- Rezept hat keine Zutaten (sehr selten) → kein Beitrag zur Liste, Rezept-Chip erscheint trotzdem (Signal: „ups, keine Zutaten").
|
||||||
|
|
||||||
|
## Server-Module
|
||||||
|
|
||||||
|
### `src/lib/server/shopping/repository.ts`
|
||||||
|
|
||||||
|
Neue Typen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ShoppingCartRecipe = {
|
||||||
|
recipe_id: number;
|
||||||
|
title: string;
|
||||||
|
image_path: string | null;
|
||||||
|
servings: number;
|
||||||
|
servings_default: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListRow = {
|
||||||
|
name_key: string;
|
||||||
|
unit_key: string;
|
||||||
|
display_name: string;
|
||||||
|
display_unit: string | null;
|
||||||
|
total_quantity: number | null;
|
||||||
|
from_recipes: string; // comma-separated recipe titles
|
||||||
|
checked: 0 | 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingListSnapshot = {
|
||||||
|
recipes: ShoppingCartRecipe[];
|
||||||
|
rows: ShoppingListRow[];
|
||||||
|
uncheckedCount: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- `addRecipeToCart(db, recipeId, profileId, servings?)` — `INSERT … ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings`. Wenn `servings` fehlt, nimmt `COALESCE(recipe.servings_default, 4)`.
|
||||||
|
- `removeRecipeFromCart(db, recipeId)`
|
||||||
|
- `setCartServings(db, recipeId, servings)` — App-seitig validiert: `1 ≤ servings ≤ 50`. SQL-Level `CHECK (servings > 0)` zusätzlich als Sicherheitsnetz.
|
||||||
|
- `listShoppingList(db) → ShoppingListSnapshot` — liefert Cart-Rezepte, aggregierte Zeilen und `uncheckedCount` in einer Transaktion.
|
||||||
|
- `toggleCheck(db, nameKey, unitKey, checked: boolean)` — Insert bzw. Delete in `shopping_cart_check`.
|
||||||
|
- `clearCheckedItems(db)` — transaktional:
|
||||||
|
1. Aggregation laufen lassen und `recipe_id`s finden, deren sämtliche aggregierten Zeilen abgehakt sind (ein Rezept zählt als „erledigt", wenn all seine `(name_key, unit_key)`-Beiträge in `shopping_cart_check` stehen)
|
||||||
|
2. Diese Rezepte via `DELETE FROM shopping_cart_recipe WHERE recipe_id IN (…)` entfernen
|
||||||
|
3. Check-Einträge, die jetzt keinen Bezug mehr haben, mit `DELETE FROM shopping_cart_check WHERE (name_key, unit_key) NOT IN (<aktive Keys nach Step 2>)` aufräumen
|
||||||
|
- `clearCart(db)` — `DELETE FROM shopping_cart_recipe; DELETE FROM shopping_cart_check;`
|
||||||
|
|
||||||
|
### Routen
|
||||||
|
|
||||||
|
| Methode + Pfad | Body/Params | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/shopping-list` | — | Snapshot holen |
|
||||||
|
| `POST /api/shopping-list/recipe` | `{ recipe_id, servings?, profile_id? }` | Rezept in Cart; idempotent |
|
||||||
|
| `PATCH /api/shopping-list/recipe/:recipe_id` | `{ servings }` | Portionen ändern |
|
||||||
|
| `DELETE /api/shopping-list/recipe/:recipe_id` | — | Rezept raus |
|
||||||
|
| `POST /api/shopping-list/check` | `{ name_key, unit_key }` | Abhaken |
|
||||||
|
| `DELETE /api/shopping-list/check` | `{ name_key, unit_key }` | Haken weg |
|
||||||
|
| `DELETE /api/shopping-list/checked` | — | Erledigte entfernen |
|
||||||
|
| `DELETE /api/shopping-list` | — | Liste leeren |
|
||||||
|
|
||||||
|
Error-Handling: 404 wenn `recipe_id` nicht im Cart (nur bei DELETE/PATCH auf spezifischem Rezept), 400 bei Validation-Fehlern (servings ≤ 0, fehlende Felder), 500 mit JSON-Body `{ message }` bei DB-Fehlern.
|
||||||
|
|
||||||
|
## Client-Store
|
||||||
|
|
||||||
|
`src/lib/client/shopping-cart.svelte.ts` — analog zu `wishlist.svelte.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class ShoppingCartStore {
|
||||||
|
uncheckedCount = $state(0);
|
||||||
|
recipeIds = $state<Set<number>>(new Set()); // für „ist dieses Rezept im Cart?"
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
async refresh(): Promise<void>;
|
||||||
|
async addRecipe(recipeId: number): Promise<void>;
|
||||||
|
async removeRecipe(recipeId: number): Promise<void>;
|
||||||
|
isInCart(recipeId: number): boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `refresh()` ruft `GET /api/shopping-list` auf und extrahiert `recipeIds` + `uncheckedCount` aus dem Snapshot. Ein separater Leichtgewichts-Count-Endpoint ist nicht nötig; der Snapshot ist klein.
|
||||||
|
- Store wird in `+layout.svelte` beim `onMount` initialisiert (wie `wishlistStore.refresh()`).
|
||||||
|
- Nach jedem Mutating-Call (add/remove/toggle/clear) wird `refresh()` vom aufrufenden Code getriggert.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### (a) Wunschlisten-Karte — Relayout
|
||||||
|
|
||||||
|
Aktuell drücken zwei rechts-gestapelte Buttons den Titel-Text auf Handys zusammen. Neues Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬─────────────────────────────┐
|
||||||
|
│ │ [Utensils|3] [Cart] [Trash] │ Action-Leiste oben, horizontal
|
||||||
|
│ Bild │ Titel (fett, 2 Zeilen max) │
|
||||||
|
│ 96px │ Hendrik, Verena, Leana │ wanted_by + ★
|
||||||
|
│ │ ★ 4.5 │
|
||||||
|
└──────────┴─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Konkret in `src/routes/wishlist/+page.svelte`:
|
||||||
|
- `.actions` wird horizontal, als erste Zeile über dem Titel rechts-bündig.
|
||||||
|
- `source_domain`-Span aus der `.meta`-Zeile entfernt (Platz).
|
||||||
|
- Neuer Cart-Button zwischen Utensils und Trash:
|
||||||
|
- Nicht im Cart: neutral (Icon grau), aria-label „In den Einkaufswagen"
|
||||||
|
- Im Cart: grün gefüllt, Häkchen-Badge unten rechts, aria-label „Aus Einkaufswagen entfernen"
|
||||||
|
- Alle drei Buttons ≥ 44 × 44 px (mobile Tap-Target).
|
||||||
|
|
||||||
|
Vergleichbare Reorg in `src/routes/recipes/[id]/+page.svelte` nötig? — **Nein**. Der Cart-Button erscheint nur auf der Wunschliste. (Begründung: Rezept-Detail hat schon ein volles Action-Menü; das Hinzufügen zum Cart passiert bewusst aus der Wunschlisten-Perspektive.)
|
||||||
|
|
||||||
|
### (b) Header-Badge
|
||||||
|
|
||||||
|
`src/routes/+layout.svelte` — rechts neben dem bestehenden Kochtopf-Icon:
|
||||||
|
|
||||||
|
- Icon `ShoppingCart` aus `lucide-svelte`
|
||||||
|
- Badge-Kreis oben rechts mit `shoppingCartStore.uncheckedCount`
|
||||||
|
- Nur sichtbar wenn `uncheckedCount > 0`
|
||||||
|
- Klick → `goto('/shopping-list')`
|
||||||
|
- Gleicher Visual-Style wie der CookingPot (Farb-Konsistenz grün)
|
||||||
|
|
||||||
|
### (c) Seite `/shopping-list`
|
||||||
|
|
||||||
|
Datei: `src/routes/shopping-list/+page.svelte`
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ Einkaufsliste │ Header
|
||||||
|
│ 12 noch zu besorgen · 3 Rezepte │
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ [Carbonara 4p- +] [Lasagne 6p- +] … │ Rezept-Chips, horizontal scrollbar
|
||||||
|
│ │ (Titel + Portions-Stepper + X)
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ ☐ 400 g Mehl │
|
||||||
|
│ aus Carbonara, Lasagne │
|
||||||
|
│ ☐ 6 Stk Eier │
|
||||||
|
│ aus Carbonara │
|
||||||
|
│ … │
|
||||||
|
│ ☑ 200 g Butter (durchgestrichen) │ Abgehakt, ans Ende
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ [Erledigte entfernen] [Liste leeren] │ Sticky Footer
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Komponenten** (neue Svelte-Dateien):
|
||||||
|
- `src/lib/components/ShoppingCartChip.svelte` — Rezept-Chip mit Stepper + Remove
|
||||||
|
- `src/lib/components/ShoppingListRow.svelte` — eine Zutatenzeile mit Checkbox
|
||||||
|
|
||||||
|
**Portions-Stepper**: - und + Buttons, mittig die Zahl. Min 1, Max 50 (sanity). Klick sendet PATCH, triggert Store-Refresh → Liste rerendert.
|
||||||
|
|
||||||
|
**Zutaten-Reihenfolge**: Erst nicht-abgehakt, dann abgehakt; innerhalb jeder Gruppe alphabetisch (`display_name COLLATE NOCASE`). Abgehakt = durchgestrichen + grauer Text.
|
||||||
|
|
||||||
|
**Mengen-Formatierung** (`src/lib/quantity-format.ts`, neu):
|
||||||
|
- `formatQuantity(q: number | null): string`
|
||||||
|
- `null` → `''`
|
||||||
|
- Ganz-nahe-Ganzzahl (Epsilon 0.01) → Integer
|
||||||
|
- Sonst auf max. 2 Nachkommastellen, trailing Nullen weg
|
||||||
|
- Beispiele: `400 → "400"`, `0.5 → "0.5"`, `0.333 → "0.33"`, `null → ""`
|
||||||
|
|
||||||
|
**Aktionen im Footer**:
|
||||||
|
- „Erledigte entfernen" — sichtbar wenn ≥ 1 Check, kein Confirm (reversibel genug)
|
||||||
|
- „Liste leeren" — Confirm via `confirmAction`: „Komplette Einkaufsliste löschen? Das macht nicht rückgängig."
|
||||||
|
|
||||||
|
**Empty State**: Icon `ShoppingCart` (große Version), „Einkaufswagen ist leer", Hint „Lege Rezepte auf der Wunschliste in den Wagen, um sie hier zu sehen."
|
||||||
|
|
||||||
|
**Offline-Verhalten**: Wie die Wunschliste — alle Mutating-Calls via `requireOnline()`. Service-Worker cached nichts von `/api/shopping-list/*` (network-only analog zu Wishlist). Die PWA-Seite selbst wird vom SW-Shell-Cache serviert, aber ohne Daten. Offline-Robustheit (local queue + sync) ist **out of scope** für v1.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit/Integration-Tests (Vitest, in-memory DB)
|
||||||
|
|
||||||
|
- `tests/integration/shopping-repository.test.ts`:
|
||||||
|
- `addRecipeToCart` idempotent, `ON CONFLICT` überschreibt `servings`
|
||||||
|
- Aggregation: gleiche `(name_key, unit_key)` summiert; unterschiedliche unit_keys bleiben getrennt
|
||||||
|
- Portions-Skalierung: `servings_default=4`, `servings=2` → alle Mengen halbiert
|
||||||
|
- Nulls: `quantity IS NULL` → `total_quantity IS NULL`; `unit IS NULL` → `unit_key=''`
|
||||||
|
- `toggleCheck` persistiert über `listShoppingList`-Aufrufe
|
||||||
|
- Abgehakt-Status überlebt Entfernen eines Rezepts, solange Schlüssel von einem anderen kommt
|
||||||
|
- `clearCheckedItems`: entfernt nur vollständig abgehakte Rezepte + räumt Orphan-Checks
|
||||||
|
- `countUncheckedItems` nach diversen Ops korrekt
|
||||||
|
- `clearCart` cleant beide Tabellen
|
||||||
|
- `tests/unit/shopping-cart-store.test.ts`:
|
||||||
|
- Mock-Fetch, testet refresh-Trigger nach add/remove
|
||||||
|
- `isInCart(id)` reflektiert aktuellen Zustand
|
||||||
|
- `uncheckedCount` reactive nach refresh
|
||||||
|
- `tests/unit/quantity-format.test.ts`:
|
||||||
|
- `formatQuantity(400) === "400"`
|
||||||
|
- `formatQuantity(0.5) === "0.5"`
|
||||||
|
- `formatQuantity(0.333333) === "0.33"`
|
||||||
|
- `formatQuantity(400.001) === "400"` (Epsilon)
|
||||||
|
- `formatQuantity(null) === ""`
|
||||||
|
|
||||||
|
### E2E-Tests (Playwright, `tests/e2e/remote/shopping.spec.ts`)
|
||||||
|
|
||||||
|
**Wichtig**: E2E-Tests laufen gegen `kochwas-dev.siegeln.net` und erfordern einen erfolgreichen Deploy des Features. Werden nach dem Feature-Merge manuell ausgelöst, nicht im Rahmen der Implementierungs-Phase.
|
||||||
|
|
||||||
|
Abgedeckt:
|
||||||
|
- Rezept auf Wunschliste → Cart-Button klicken → Header-Badge erscheint
|
||||||
|
- Navigation zu `/shopping-list`, Portions-Stepper hoch/runter → Zutatenmengen reagieren
|
||||||
|
- Zutat abhaken → Badge-Count sinkt, Zeile durchgestrichen, Reload persistiert
|
||||||
|
- „Erledigte entfernen" → vollständig abgehakte Rezepte weg, teilweise abgehakte bleiben
|
||||||
|
- „Liste leeren" → Empty-State, Badge verschwindet
|
||||||
|
- Zwei Rezepte mit gleicher Zutat (Fixture-Setup) → aggregierte Zeile mit Summe
|
||||||
|
- Cleanup-Fixture entfernt Cart + Checks nach jedem Test
|
||||||
|
|
||||||
|
**Nicht getestet**: exakte CSS-Styles, Animationen — visuelle Kontrolle beim Deploy.
|
||||||
|
|
||||||
|
## Implementierungs-Reihenfolge (Hinweis für Plan)
|
||||||
|
|
||||||
|
1. Migration 013 + Repository + Unit-Tests
|
||||||
|
2. API-Routen + Integrationstests
|
||||||
|
3. Client-Store
|
||||||
|
4. Header-Badge-Icon
|
||||||
|
5. Wunschlisten-Karte Relayout + Cart-Button
|
||||||
|
6. Seite `/shopping-list` (Chips → Rows → Footer → Empty State)
|
||||||
|
7. Quantity-Formatter + Tests
|
||||||
|
8. Service-Worker network-only für `/api/shopping-list/*`
|
||||||
|
9. Deploy, dann E2E-Tests nachschieben
|
||||||
|
|
||||||
|
## Out of Scope (für v1)
|
||||||
|
|
||||||
|
- Manuelle Einträge („Klopapier")
|
||||||
|
- Supermarkt-Abteilungs-Sortierung
|
||||||
|
- Offline-Queue (add/check während offline, sync später)
|
||||||
|
- Synonym/Fuzzy-Matching von Zutaten-Namen (der User harmonisiert langfristig händisch)
|
||||||
|
- Auto-Kopplung zu `cooking_log` / Wunschliste-Remove
|
||||||
|
- Teilen per Link / Export
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Hauptseite: "Zuletzt angesehen" Sort + Collapsible Sections
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Die Hauptseite (`src/routes/+page.svelte`) hat heute drei Sektionen — "Deine
|
||||||
|
Favoriten", "Zuletzt hinzugefügt", "Alle Rezepte" — und vier Sort-Optionen
|
||||||
|
für "Alle Rezepte" (Name, Bewertung, Zuletzt gekocht, Hinzugefügt). Der
|
||||||
|
User möchte:
|
||||||
|
|
||||||
|
1. Eine fünfte Sort-Option "Zuletzt angesehen" für "Alle Rezepte"
|
||||||
|
2. "Deine Favoriten" und "Zuletzt hinzugefügt" auf-/zuklappbar machen
|
||||||
|
|
||||||
|
Beides reduziert visuelle Last und gibt Zugriff auf "kürzlich
|
||||||
|
beschäftigte mich" Rezepte ohne Suche.
|
||||||
|
|
||||||
|
## Design-Entscheidungen (durch Brainstorming bestätigt)
|
||||||
|
|
||||||
|
- **View-Tracking**: zählt sofort beim Laden der Detailseite — kein Threshold
|
||||||
|
- **Storage**: SQLite, pro Profil (konsistent mit Ratings, Cooked, Wishlist)
|
||||||
|
- **Collapsibles**: standardmäßig offen, User-Wahl persistiert pro Device
|
||||||
|
|
||||||
|
## Sektion 1 — Schema & View-Tracking
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Neue Datei `src/lib/server/db/migrations/014_recipe_view.sql`
|
||||||
|
(Numbering: aktuell ist die letzte Migration `013_shopping_list.sql`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE recipe_view (
|
||||||
|
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (profile_id, recipe_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_recipe_view_recent
|
||||||
|
ON recipe_view(profile_id, last_viewed_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent über `INSERT OR REPLACE` — mehrfache Visits ein- und desselben
|
||||||
|
Profils auf dasselbe Rezept führen nur zur Aktualisierung des Timestamps,
|
||||||
|
kein Multi-Insert.
|
||||||
|
|
||||||
|
Cascade auf beide FKs: löscht ein User ein Rezept oder ein Profil, gehen
|
||||||
|
zugehörige Views automatisch mit.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
Neuer Endpoint `POST /api/recipes/[id]/view`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Request body: { "profile_id": number }
|
||||||
|
Response: 204 No Content
|
||||||
|
Errors:
|
||||||
|
- 400 wenn profile_id fehlt oder kein Number
|
||||||
|
- 404 wenn Recipe nicht existiert (FK-Violation)
|
||||||
|
- 404 wenn Profil nicht existiert (FK-Violation)
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation: einfache `INSERT OR REPLACE` mit den IDs. `last_viewed_at`
|
||||||
|
nutzt den Default (`datetime('now')`).
|
||||||
|
|
||||||
|
### Client-Hook
|
||||||
|
|
||||||
|
In `src/routes/recipes/[id]/+page.svelte`, in `onMount`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (profileStore.active) {
|
||||||
|
void fetch(`/api/recipes/${recipe.id}/view`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ profile_id: profileStore.active.id })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fire-and-forget, kein UI-Block, kein Error-Handling — wenn der Beacon
|
||||||
|
fehlschlägt, ist es kein User-Visible-Bug, das nächste View korrigiert
|
||||||
|
es.
|
||||||
|
|
||||||
|
## Sektion 2 — Sort "Zuletzt angesehen"
|
||||||
|
|
||||||
|
### Page
|
||||||
|
|
||||||
|
In `src/routes/+page.svelte`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
|
||||||
|
const ALL_SORTS = [
|
||||||
|
...,
|
||||||
|
{ value: 'viewed', label: 'Zuletzt angesehen' }
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
`GET /api/recipes/all` bekommt einen optionalen `profile_id`-Query-Param.
|
||||||
|
Der Endpoint reicht ihn an `listAllRecipesPaginated` durch.
|
||||||
|
|
||||||
|
### DB-Layer
|
||||||
|
|
||||||
|
`listAllRecipesPaginated` in `src/lib/server/recipes/search-local.ts`
|
||||||
|
bekommt einen optionalen `profileId: number | null`-Parameter. Wenn
|
||||||
|
`sort === 'viewed'` UND `profileId !== null`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT r.*, ...
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN recipe_view v
|
||||||
|
ON v.recipe_id = r.id AND v.profile_id = :profileId
|
||||||
|
ORDER BY v.last_viewed_at DESC NULLS LAST,
|
||||||
|
r.title COLLATE NOCASE ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei `sort === 'viewed'` ohne `profileId`: fällt auf alphabetische
|
||||||
|
Sortierung zurück (kein Crash, sinnvolles Default-Verhalten).
|
||||||
|
|
||||||
|
### Reactive Refetch bei Profile-Switch
|
||||||
|
|
||||||
|
Auf Home-Page-Ebene: ein `$effect` der auf `profileStore.activeId` lauscht
|
||||||
|
und — wenn `allSort === 'viewed'` — `setAllSort('viewed')` retriggert
|
||||||
|
(forciert Refetch mit neuem profile_id). Sonst (anderer Sort) keine
|
||||||
|
Aktion, weil andere Sorts nicht profilabhängig sind.
|
||||||
|
|
||||||
|
### Snapshot-Kompatibilität
|
||||||
|
|
||||||
|
Der existierende `rehydrateAll(sort, count, exhausted)` in `+page.svelte`
|
||||||
|
muss `profile_id` mitschicken, sonst zeigt der Back-Nav für sort='viewed'
|
||||||
|
einen anderen Inhalt als vor dem Forward-Klick. Das gleiche gilt für
|
||||||
|
`loadAllMore` und `setAllSort`.
|
||||||
|
|
||||||
|
## Sektion 3 — Auf-/Zuklappbare Sektionen
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
In `src/routes/+page.svelte`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CollapseKey = 'favorites' | 'recent';
|
||||||
|
let collapsed = $state<Record<CollapseKey, boolean>>({
|
||||||
|
favorites: false,
|
||||||
|
recent: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kochwas.collapsed.sections';
|
||||||
|
|
||||||
|
function toggle(key: CollapseKey) {
|
||||||
|
collapsed[key] = !collapsed[key];
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `onMount`: aus localStorage parsen, fehlerhafte JSON ignorieren
|
||||||
|
(default-state behalten).
|
||||||
|
|
||||||
|
### Markup
|
||||||
|
|
||||||
|
Pro Sektion:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<section class="listing">
|
||||||
|
<button
|
||||||
|
class="section-head"
|
||||||
|
onclick={() => toggle('favorites')}
|
||||||
|
aria-expanded={!collapsed.favorites}
|
||||||
|
>
|
||||||
|
<ChevronDown size={18} class:rotated={collapsed.favorites} />
|
||||||
|
<h2>Deine Favoriten</h2>
|
||||||
|
<span class="count">{favorites.length}</span>
|
||||||
|
</button>
|
||||||
|
{#if !collapsed.favorites}
|
||||||
|
<div transition:slide={{ duration: 180 }}>
|
||||||
|
<ul class="cards">…</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual / CSS
|
||||||
|
|
||||||
|
- Header `<button>`: transparenter Border, full-width, `display: flex`,
|
||||||
|
`align-items: center`, `gap: 0.5rem`, `min-height: 44px` (Tap-Target)
|
||||||
|
- Chevron-Icon (lucide-svelte `ChevronDown`): rotiert auf
|
||||||
|
`transform: rotate(-90deg)` wenn `.rotated`
|
||||||
|
- Count-Pill rechts: kleiner grauer Text, hilft zu sehen wie viel hinter
|
||||||
|
einer zugeklappten Sektion steckt
|
||||||
|
- Hover: leichter Hintergrund (`#f4f8f5`, wie andere interaktive Elemente)
|
||||||
|
- Animation: `svelte/transition`'s `slide`, ~180 ms
|
||||||
|
|
||||||
|
### Persistenz-Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "favorites": false, "recent": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Truthy = collapsed. Default-Zustand wenn key fehlt: beide false.
|
||||||
|
|
||||||
|
### "Alle Rezepte" bleibt nicht-collapsible
|
||||||
|
|
||||||
|
Hauptliste, immer sichtbar — User würde das Scrollen verlieren.
|
||||||
|
|
||||||
|
## Test-Strategie
|
||||||
|
|
||||||
|
### Schema/Migration
|
||||||
|
|
||||||
|
- Migrations-Test (existierendes Pattern in `tests/integration`): nach
|
||||||
|
`applyMigrations` muss `recipe_view` existieren mit erwarteten
|
||||||
|
Spalten
|
||||||
|
|
||||||
|
### View-Endpoint
|
||||||
|
|
||||||
|
- `POST /api/recipes/[id]/view` Integration-Test:
|
||||||
|
- Erstes POST → Row mit `last_viewed_at` ungefähr `now`
|
||||||
|
- Zweites POST → gleiche Row, `last_viewed_at` aktualisiert
|
||||||
|
- POST mit ungültiger profile_id → 404
|
||||||
|
- POST mit ungültiger recipe_id → 404
|
||||||
|
- POST ohne profile_id im Body → 400
|
||||||
|
|
||||||
|
### Sort-Logik
|
||||||
|
|
||||||
|
- Unit-Test für `listAllRecipesPaginated(db, 'viewed', limit, offset, profileId)`:
|
||||||
|
- Mit Views-Daten: angesehene Rezepte zuerst (DESC nach `last_viewed_at`),
|
||||||
|
Rest alphabetisch
|
||||||
|
- Ohne profileId: fallback auf alphabetisch
|
||||||
|
- Mit profileId aber ohne Views: alle als NULL → alphabetisch
|
||||||
|
|
||||||
|
### Collapsibles (manuell oder unit)
|
||||||
|
|
||||||
|
- localStorage-Persistenz: Toggle, Reload, gleicher State
|
||||||
|
- Default-State wenn localStorage leer/corrupt: beide offen
|
||||||
|
- Ein Unit-Test für eine reine Helper-Funktion (parse/serialize), Markup
|
||||||
|
ist Snapshot-mässig nicht so wertvoll testbar
|
||||||
|
|
||||||
|
## Reihenfolge der Umsetzung
|
||||||
|
|
||||||
|
1. Migration + DB-Layer + Sort-Query (`search-local.ts`-Erweiterung)
|
||||||
|
2. View-Endpoint (`POST /api/recipes/[id]/view`) + Client-Beacon in
|
||||||
|
`recipes/[id]/+page.svelte`
|
||||||
|
3. Sort-Option in `+page.svelte` UI + API-Param weiterreichen +
|
||||||
|
profile_id in `loadAllMore`/`rehydrateAll`/`setAllSort` durchreichen
|
||||||
|
4. Collapsible-Pattern in `+page.svelte` für Favoriten und Recent
|
||||||
|
|
||||||
|
Jede Phase atomar committen + pushen.
|
||||||
1946
package-lock.json
generated
1946
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kochwas",
|
"name": "kochwas",
|
||||||
"version": "0.1.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,16 +11,23 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"render:icons": "node scripts/render-icons.mjs",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:remote": "playwright test --config=playwright.remote.config.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.7",
|
"prettier-plugin-svelte": "^3.2.7",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"svelte": "^5.1.0",
|
"svelte": "^5.1.0",
|
||||||
"svelte-check": "^4.0.5",
|
"svelte-check": "^4.0.5",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
@@ -28,11 +35,15 @@
|
|||||||
"vitest": "^2.1.4"
|
"vitest": "^2.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/yauzl": "^2.10.3",
|
"@types/yauzl": "^2.10.3",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"linkedom": "^0.18.5",
|
"linkedom": "^0.18.5",
|
||||||
|
"lucide-svelte": "^1.0.1",
|
||||||
|
"node-addon-api": "^8.7.0",
|
||||||
|
"node-gyp": "^12.3.0",
|
||||||
"yauzl": "^3.3.0",
|
"yauzl": "^3.3.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
22
playwright.config.ts
Normal file
22
playwright.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
// E2E-Tests nutzen den SvelteKit-Preview-Build. `npm run build` muss
|
||||||
|
// vor den Tests gelaufen sein — Playwright startet dann nur den
|
||||||
|
// Preview-Server (kein Dev-Server, damit der SW registrierbar ist).
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests/e2e',
|
||||||
|
testIgnore: ['tests/e2e/remote/**'],
|
||||||
|
fullyParallel: false,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:4173',
|
||||||
|
headless: true,
|
||||||
|
serviceWorkers: 'allow'
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run preview',
|
||||||
|
url: 'http://localhost:4173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000
|
||||||
|
}
|
||||||
|
});
|
||||||
32
playwright.remote.config.ts
Normal file
32
playwright.remote.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
// Zweite Playwright-Config fuer E2E-Smoketests gegen ein deployed
|
||||||
|
// Environment (standardmaessig kochwas-dev.siegeln.net).
|
||||||
|
//
|
||||||
|
// Getrennt von playwright.config.ts, weil diese Tests:
|
||||||
|
// - keinen lokalen Preview-Server starten
|
||||||
|
// - gegen eine echte Datenbank laufen (daher workers: 1, afterEach-Cleanup)
|
||||||
|
// - Service-Worker-Lifecycle nicht manipulieren (das macht offline.spec.ts lokal)
|
||||||
|
//
|
||||||
|
// Ausfuehrung: npm run test:e2e:remote
|
||||||
|
// Ziel-URL ueberschreiben: E2E_REMOTE_URL=https://... npm run test:e2e:remote
|
||||||
|
const BASE_URL = process.env.E2E_REMOTE_URL ?? 'https://kochwas-dev.siegeln.net';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests/e2e/remote',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-remote' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
headless: true,
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
// Service-Worker blocken: Diese Suite testet Live-API-Verhalten gegen
|
||||||
|
// den Server, keine PWA-Features (dafuer offline.spec.ts lokal). Die
|
||||||
|
// frische SW-Registrierung pro Context akkumulierte im Single-Worker-
|
||||||
|
// Run Browser-State und crashte Chromium zufaellig nach 20-30 Specs.
|
||||||
|
serviceWorkers: 'block'
|
||||||
|
}
|
||||||
|
});
|
||||||
19
scripts/render-icons.mjs
Normal file
19
scripts/render-icons.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Rendert PWA-Icons aus static/icon.svg in die Größen, die Android/iOS
|
||||||
|
// für Home-Screen-Icons bevorzugen. Einmal lokal ausführen und die
|
||||||
|
// PNGs committen — keine CI-Abhängigkeit.
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = join(here, '..');
|
||||||
|
const src = await readFile(join(root, 'static/icon.svg'));
|
||||||
|
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
await sharp(src, { density: 400 })
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 248, g: 250, b: 248, alpha: 1 } })
|
||||||
|
.png()
|
||||||
|
.toFile(join(root, `static/icon-${size}.png`));
|
||||||
|
console.log(`wrote static/icon-${size}.png`);
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
use_default_settings: true
|
# Defaults laden, aber Engine-Liste rigoros auf brave eindampfen.
|
||||||
|
# keep_only ist robuster als einzelne `disabled: true`-Overrides: SearXNGs
|
||||||
|
# Merge-Semantik für partial overrides (nur name + disabled ohne engine:)
|
||||||
|
# greift nicht zuverlässig — DDG & Co. wurden trotzdem abgefragt. keep_only
|
||||||
|
# wirft alles andere vor dem Laden raus, kein Captcha-/403-Log-Lärm mehr.
|
||||||
|
# Mojeek blockt die Pi-IP mit 403 und ist deshalb draußen.
|
||||||
|
use_default_settings:
|
||||||
|
engines:
|
||||||
|
keep_only:
|
||||||
|
- brave
|
||||||
|
|
||||||
server:
|
server:
|
||||||
# In production override via env (see docker-compose.prod.yml).
|
# Platzhalter wird beim Container-Start per os.path.expandvars aus der
|
||||||
secret_key: ${SEARXNG_SECRET:-dev-secret-change-in-prod}
|
# SEARXNG_SECRET-Env-Variable gesetzt (Default im docker-compose.prod.yml).
|
||||||
|
secret_key: "${SEARXNG_SECRET}"
|
||||||
# Disables rate limiter + bot detection. This is a private internal service
|
# Disables rate limiter + bot detection. This is a private internal service
|
||||||
# called only by kochwas — no public exposure, no abuse risk.
|
# called only by kochwas — no public exposure, no abuse risk.
|
||||||
limiter: false
|
limiter: false
|
||||||
@@ -21,11 +31,30 @@ search:
|
|||||||
autocomplete: ''
|
autocomplete: ''
|
||||||
default_lang: 'de'
|
default_lang: 'de'
|
||||||
|
|
||||||
|
# Höhere Timeouts als Default (3s), weil der Pi und einige Upstream-Engines
|
||||||
|
# öfter knapp drüber liegen — lieber 8s warten als gar kein Ergebnis.
|
||||||
|
outgoing:
|
||||||
|
request_timeout: 8.0
|
||||||
|
max_request_timeout: 12.0
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
default_locale: de
|
default_locale: de
|
||||||
|
|
||||||
# Quieten engines that fail on cold start and aren't useful here
|
|
||||||
enabled_plugins:
|
enabled_plugins:
|
||||||
- 'Hash plugin'
|
- 'Hash plugin'
|
||||||
- 'Tracker URL remover'
|
- 'Tracker URL remover'
|
||||||
- 'Open Access DOI rewrite'
|
- 'Open Access DOI rewrite'
|
||||||
|
|
||||||
|
engines:
|
||||||
|
# Brave Search API (engine: braveapi). Die Engine "brave" ist der
|
||||||
|
# HTML-Scraper von search.brave.com und ignoriert api_key — deshalb
|
||||||
|
# hier explizit braveapi, sonst landen wir in Brave-Rate-Limits.
|
||||||
|
# Key kommt aus dem BRAVE_API_KEY-Env (.env auf dem Pi, nicht im Repo),
|
||||||
|
# expandiert via Python os.path.expandvars im searxng-init-Container.
|
||||||
|
- name: brave
|
||||||
|
engine: braveapi
|
||||||
|
shortcut: br
|
||||||
|
categories: [general, web]
|
||||||
|
timeout: 6.0
|
||||||
|
api_key: "${BRAVE_API_KEY}"
|
||||||
|
disabled: false
|
||||||
|
|||||||
25
src/lib/client/api-fetch-wrapper.ts
Normal file
25
src/lib/client/api-fetch-wrapper.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch wrapper for actions where a non-OK response should pop a modal
|
||||||
|
* via alertAction(). Returns the Response on 2xx, or null after showing
|
||||||
|
* the alert. Caller should `if (!res) return;` after the call.
|
||||||
|
*
|
||||||
|
* Use this for *interactive* actions (rename, delete, save). For form
|
||||||
|
* submissions where the error should appear inline next to the field
|
||||||
|
* (e.g. admin/domains add()), keep manual handling.
|
||||||
|
*/
|
||||||
|
export async function asyncFetch(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit | undefined,
|
||||||
|
errorTitle: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (res.ok) return res;
|
||||||
|
const body = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
await alertAction({
|
||||||
|
title: errorTitle,
|
||||||
|
message: body?.message ?? `HTTP ${res.status}`
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
54
src/lib/client/confirm.svelte.ts
Normal file
54
src/lib/client/confirm.svelte.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export type ConfirmOptions = {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
/** If true, hide the cancel button — used for simple info/alert dialogs. */
|
||||||
|
infoOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = ConfirmOptions & {
|
||||||
|
resolve: (result: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ConfirmStore {
|
||||||
|
pending = $state<PendingRequest | null>(null);
|
||||||
|
|
||||||
|
ask(options: ConfirmOptions): Promise<boolean> {
|
||||||
|
// If another dialog is already open, close it as cancelled so we don't stack.
|
||||||
|
if (this.pending) this.pending.resolve(false);
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
this.pending = { ...options, resolve };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
answer(result: boolean): void {
|
||||||
|
if (!this.pending) return;
|
||||||
|
const p = this.pending;
|
||||||
|
this.pending = null;
|
||||||
|
p.resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confirmStore = new ConfirmStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a modal confirmation dialog. Resolves to true on confirm, false on cancel/Escape.
|
||||||
|
* Safe on the server: falls back to the native confirm() only in the browser.
|
||||||
|
*/
|
||||||
|
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve(false);
|
||||||
|
return confirmStore.ask(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a modal info dialog with a single OK button. Resolves when dismissed.
|
||||||
|
* Use instead of window.alert().
|
||||||
|
*/
|
||||||
|
export function alertAction(options: Omit<ConfirmOptions, 'destructive' | 'cancelLabel' | 'infoOnly'>): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve();
|
||||||
|
return confirmStore
|
||||||
|
.ask({ ...options, infoOnly: true, confirmLabel: options.confirmLabel ?? 'OK' })
|
||||||
|
.then(() => undefined);
|
||||||
|
}
|
||||||
44
src/lib/client/install-prompt.svelte.ts
Normal file
44
src/lib/client/install-prompt.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Captures the beforeinstallprompt event (Android Chrome) and holds it for
|
||||||
|
// manual triggering by the user. On iOS Safari this event does not exist —
|
||||||
|
// we detect the browser via UserAgent and show an info hint instead.
|
||||||
|
class InstallPromptStore {
|
||||||
|
available = $state(false);
|
||||||
|
platform = $state<'android' | 'ios' | 'other'>('other');
|
||||||
|
private deferred: BeforeInstallPromptEvent | null = null;
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
this.platform = detectPlatform();
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.deferred = e as BeforeInstallPromptEvent;
|
||||||
|
this.available = true;
|
||||||
|
});
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
this.deferred = null;
|
||||||
|
this.available = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(): Promise<void> {
|
||||||
|
if (!this.deferred) return;
|
||||||
|
await this.deferred.prompt();
|
||||||
|
this.deferred = null;
|
||||||
|
this.available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform(): 'android' | 'ios' | 'other' {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
|
||||||
|
if (/Android/i.test(ua)) return 'android';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal type for the Chrome-specific event
|
||||||
|
type BeforeInstallPromptEvent = Event & {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installPrompt = new InstallPromptStore();
|
||||||
14
src/lib/client/network.svelte.ts
Normal file
14
src/lib/client/network.svelte.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Reaktiver Online-Status, basierend auf navigator.onLine + events.
|
||||||
|
// Bewusst kein aktives Heuristik-Probing (Test-Fetches) — für unsere
|
||||||
|
// Zwecke reicht der Browser-Status.
|
||||||
|
class NetworkStore {
|
||||||
|
online = $state(typeof navigator === 'undefined' ? true : navigator.onLine);
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.addEventListener('online', () => (this.online = true));
|
||||||
|
window.addEventListener('offline', () => (this.online = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const network = new NetworkStore();
|
||||||
76
src/lib/client/photo-upload.svelte.ts
Normal file
76
src/lib/client/photo-upload.svelte.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Recipe } from '$lib/types';
|
||||||
|
|
||||||
|
export type UploadStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export class PhotoUploadStore {
|
||||||
|
status = $state<UploadStatus>('idle');
|
||||||
|
recipe = $state<Recipe | null>(null);
|
||||||
|
errorCode = $state<string | null>(null);
|
||||||
|
errorMessage = $state<string | null>(null);
|
||||||
|
lastFile = $state<File | null>(null);
|
||||||
|
|
||||||
|
private controller: AbortController | null = null;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
|
||||||
|
constructor(opts: { fetchImpl?: typeof fetch } = {}) {
|
||||||
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(file: File): Promise<void> {
|
||||||
|
this.lastFile = file;
|
||||||
|
await this.doUpload(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async retry(): Promise<void> {
|
||||||
|
if (this.lastFile) await this.doUpload(this.lastFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.status = 'idle';
|
||||||
|
this.recipe = null;
|
||||||
|
this.errorCode = null;
|
||||||
|
this.errorMessage = null;
|
||||||
|
this.lastFile = null;
|
||||||
|
this.controller?.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(): void {
|
||||||
|
this.controller?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doUpload(file: File): Promise<void> {
|
||||||
|
this.status = 'loading';
|
||||||
|
this.recipe = null;
|
||||||
|
this.errorCode = null;
|
||||||
|
this.errorMessage = null;
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', file);
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl('/api/recipes/extract-from-photo', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
signal: this.controller.signal
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.errorCode = typeof body.code === 'string' ? body.code : 'UNKNOWN';
|
||||||
|
this.errorMessage =
|
||||||
|
typeof body.message === 'string' ? body.message : `HTTP ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.recipe = body.recipe as Recipe;
|
||||||
|
this.status = 'success';
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === 'AbortError') {
|
||||||
|
this.status = 'idle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status = 'error';
|
||||||
|
this.errorCode = 'NETWORK';
|
||||||
|
this.errorMessage = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Profile } from '$lib/types';
|
import type { Profile } from '$lib/types';
|
||||||
|
import { alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
const STORAGE_KEY = 'kochwas.activeProfileId';
|
const STORAGE_KEY = 'kochwas.activeProfileId';
|
||||||
|
|
||||||
@@ -60,3 +61,19 @@ class ProfileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const profileStore = new ProfileStore();
|
export const profileStore = new ProfileStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active profile, or null after showing the standard
|
||||||
|
* "kein Profil gewählt" dialog. Use as the first line of any per-profile
|
||||||
|
* action so we don't repeat the guard at every call-site.
|
||||||
|
*
|
||||||
|
* `message` ueberschreibt den Default, wenn eine Aktion einen spezifischen
|
||||||
|
* Hinweis braucht (z. B. „um mitzuwünschen" auf der Wunschliste).
|
||||||
|
*/
|
||||||
|
export async function requireProfile(
|
||||||
|
message = 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||||
|
): Promise<Profile | null> {
|
||||||
|
if (profileStore.active) return profileStore.active;
|
||||||
|
await alertAction({ title: 'Kein Profil gewählt', message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
117
src/lib/client/pwa.svelte.ts
Normal file
117
src/lib/client/pwa.svelte.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { SW_UPDATE_POLL_INTERVAL_MS, SW_VERSION_QUERY_TIMEOUT_MS } from '$lib/constants';
|
||||||
|
|
||||||
|
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
|
||||||
|
// skipWaiting im install-Handler, User bestätigt via Toast) mit
|
||||||
|
// zusätzlichem Zombie-Schutz.
|
||||||
|
//
|
||||||
|
// Warum der Zombie-Schutz nötig ist: Chromium hält auf diesem Deploy
|
||||||
|
// reproduzierbar nach einem SKIP_WAITING+Reload einen bit-identischen
|
||||||
|
// waiting-SW im Registration-Slot — wohl durch einen Race zwischen
|
||||||
|
// SW-Update-Check und activate. Der reine Workbox-Standard würde den
|
||||||
|
// als „neues Update" interpretieren und den Toast bei jedem Reload
|
||||||
|
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
|
||||||
|
// beiden SWs, vergleichen und räumen identische Bytes still auf.
|
||||||
|
//
|
||||||
|
// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick
|
||||||
|
// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der
|
||||||
|
// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue
|
||||||
|
// Seite wieder mit frischem Zombie ausstattet.
|
||||||
|
class PwaStore {
|
||||||
|
updateAvailable = $state(false);
|
||||||
|
private registration: ServiceWorkerRegistration | null = null;
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.registration = await navigator.serviceWorker.ready;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.registration) return;
|
||||||
|
|
||||||
|
if (this.registration.waiting && this.registration.active) {
|
||||||
|
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
||||||
|
|
||||||
|
// Alle 30 Minuten aktiv nach Updates fragen, damit der User sie auch
|
||||||
|
// mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren.
|
||||||
|
this.pollTimer = setInterval(() => {
|
||||||
|
void this.registration?.update().catch(() => {});
|
||||||
|
}, SW_UPDATE_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onUpdateFound(): void {
|
||||||
|
const installing = this.registration?.installing;
|
||||||
|
if (!installing) return;
|
||||||
|
installing.addEventListener('statechange', () => {
|
||||||
|
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
|
||||||
|
const active = this.registration?.active;
|
||||||
|
if (active && active !== installing) {
|
||||||
|
void this.evaluateWaiting(installing, active);
|
||||||
|
} else {
|
||||||
|
this.updateAvailable = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
|
||||||
|
const [waitingVersion, activeVersion] = await Promise.all([
|
||||||
|
queryVersion(waiting),
|
||||||
|
queryVersion(active)
|
||||||
|
]);
|
||||||
|
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
|
||||||
|
// Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite
|
||||||
|
// läuft nahtlos unter dem neuen SW weiter (funktional identisch).
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Versions-Unterschied oder unbekannt: User entscheidet via Toast.
|
||||||
|
this.updateAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
this.updateAvailable = false;
|
||||||
|
const waiting = this.registration?.waiting;
|
||||||
|
if (!waiting) {
|
||||||
|
// Kein wartender SW — reicht ein normaler Reload.
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange
|
||||||
|
// feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig.
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
() => location.reload(),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(): void {
|
||||||
|
this.updateAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryVersion(sw: ServiceWorker): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
const timer = setTimeout(() => resolve(null), SW_VERSION_QUERY_TIMEOUT_MS);
|
||||||
|
channel.port1.onmessage = (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const v = (e.data as { version?: unknown } | null)?.version;
|
||||||
|
resolve(typeof v === 'string' ? v : null);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
sw.postMessage({ type: 'GET_VERSION' }, [channel.port2]);
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pwaStore = new PwaStore();
|
||||||
10
src/lib/client/require-online.ts
Normal file
10
src/lib/client/require-online.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { network } from './network.svelte';
|
||||||
|
import { toastStore } from './toast.svelte';
|
||||||
|
|
||||||
|
// Soll vor jedem Schreib-Fetch aufgerufen werden. Liefert true wenn
|
||||||
|
// online (User darf weitermachen) oder false + Toast wenn offline.
|
||||||
|
export function requireOnline(action = 'Die Aktion'): boolean {
|
||||||
|
if (network.online) return true;
|
||||||
|
toastStore.error(`${action} braucht eine Internet-Verbindung.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
78
src/lib/client/scroll-restore.ts
Normal file
78
src/lib/client/scroll-restore.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Persistent scroll restoration across client navigations.
|
||||||
|
//
|
||||||
|
// SvelteKit only restores scroll synchronously after the new page mounts.
|
||||||
|
// Pages whose content is fetched in onMount/afterNavigate (e.g. home,
|
||||||
|
// wishlist, shopping-list) are still empty at that point, so the saved
|
||||||
|
// scrollY can't be reached and the browser clamps to 0.
|
||||||
|
//
|
||||||
|
// We patch this by saving scrollY on beforeNavigate (keyed by the URL
|
||||||
|
// we're leaving — NOT location.pathname, which on popstate is already
|
||||||
|
// the new URL by the time the callback fires) and re-applying it after
|
||||||
|
// popstate as soon as the document is tall enough — rAF-polled with a
|
||||||
|
// hard time budget so we never spin.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kochwas:scroll';
|
||||||
|
const POLL_BUDGET_MS = 1500;
|
||||||
|
const MIN_RESTORE_Y = 40; // ignore noise: don't override a default top scroll
|
||||||
|
|
||||||
|
type ScrollMap = Record<string, number>;
|
||||||
|
|
||||||
|
function readMap(): ScrollMap {
|
||||||
|
if (typeof sessionStorage === 'undefined') return {};
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as ScrollMap) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMap(map: ScrollMap): void {
|
||||||
|
if (typeof sessionStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
|
} catch {
|
||||||
|
// quota exceeded — silently drop, scroll memory is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFor(url: URL): string {
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordScroll(fromUrl: URL | null | undefined): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!fromUrl) return;
|
||||||
|
const map = readMap();
|
||||||
|
map[keyFor(fromUrl)] = window.scrollY;
|
||||||
|
writeMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreScroll(
|
||||||
|
navType: string | null | undefined,
|
||||||
|
toUrl: URL | null | undefined
|
||||||
|
): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (navType !== 'popstate') return;
|
||||||
|
if (!toUrl) return;
|
||||||
|
const target = readMap()[keyFor(toUrl)];
|
||||||
|
if (!target || target < MIN_RESTORE_Y) return;
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const step = () => {
|
||||||
|
const docHeight = document.documentElement.scrollHeight;
|
||||||
|
const reachable = Math.max(0, docHeight - window.innerHeight);
|
||||||
|
if (reachable >= target - 4) {
|
||||||
|
window.scrollTo({ top: target, left: 0, behavior: 'instant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (performance.now() - start >= POLL_BUDGET_MS) {
|
||||||
|
// Best effort — content never grew tall enough; clamp will land us
|
||||||
|
// at the bottom of what's available.
|
||||||
|
window.scrollTo({ top: target, left: 0, behavior: 'instant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
86
src/lib/client/search-filter.svelte.ts
Normal file
86
src/lib/client/search-filter.svelte.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { AllowedDomain } from '$lib/types';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kochwas.filter.domains';
|
||||||
|
|
||||||
|
// Leere Menge = kein Filter aktiv (alle Domains werden gesucht). Damit fügt sich
|
||||||
|
// eine neu vom Admin freigeschaltete Domain automatisch ein, ohne dass der User
|
||||||
|
// sie extra aktivieren muss. Wenn der User aktiv auswählt, speichern wir die
|
||||||
|
// Auswahl als explizite Menge — und genau die wird dann gesucht.
|
||||||
|
class SearchFilterStore {
|
||||||
|
domains = $state<AllowedDomain[]>([]);
|
||||||
|
active = $state<Set<string>>(new Set());
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const arr = JSON.parse(raw) as string[];
|
||||||
|
if (Array.isArray(arr)) this.active = new Set(arr);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupted state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/domains');
|
||||||
|
if (res.ok) {
|
||||||
|
this.domains = (await res.json()) as AllowedDomain[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// offline / server error — leave domains empty, UI falls back to "no filter"
|
||||||
|
}
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
persist(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...this.active]));
|
||||||
|
} catch {
|
||||||
|
// ignore quota / disabled storage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(domain: string): void {
|
||||||
|
const next = new Set(this.active);
|
||||||
|
if (next.has(domain)) next.delete(domain);
|
||||||
|
else next.add(domain);
|
||||||
|
this.active = next;
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll(): void {
|
||||||
|
// "Alle" == leere Menge, damit neue Domains automatisch dabei sind.
|
||||||
|
this.active = new Set();
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOnly(domain: string): void {
|
||||||
|
this.active = new Set([domain]);
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Übernimmt eine vorbereitete Draft-Auswahl auf einmal — wird vom
|
||||||
|
// Filter-Dropdown genutzt, der Toggles erst lokal sammelt und erst beim
|
||||||
|
// „OK"-Klick committet. Triggert den active-$effect nur ein einziges Mal.
|
||||||
|
commit(next: Set<string>): void {
|
||||||
|
this.active = next;
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// True wenn der User die Suche eingeschränkt hat (mindestens eine aber nicht alle).
|
||||||
|
get isFiltered(): boolean {
|
||||||
|
return this.active.size > 0 && this.active.size < this.domains.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Als Query-Param-String. Leer = kein Filter.
|
||||||
|
get queryParam(): string {
|
||||||
|
if (this.active.size === 0) return '';
|
||||||
|
return [...this.active].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchFilterStore = new SearchFilterStore();
|
||||||
225
src/lib/client/search.svelte.ts
Normal file
225
src/lib/client/search.svelte.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
|
import type { WebHit } from '$lib/server/search/searxng';
|
||||||
|
|
||||||
|
export type SearchSnapshot = {
|
||||||
|
query: string;
|
||||||
|
hits: SearchHit[];
|
||||||
|
webHits: WebHit[];
|
||||||
|
searchedFor: string | null;
|
||||||
|
webError: string | null;
|
||||||
|
localExhausted: boolean;
|
||||||
|
webPageno: number;
|
||||||
|
webExhausted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchStoreOptions = {
|
||||||
|
pageSize?: number;
|
||||||
|
debounceMs?: number;
|
||||||
|
filterDebounceMs?: number;
|
||||||
|
minQueryLength?: number;
|
||||||
|
webFilterParam?: () => string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SearchStore {
|
||||||
|
query = $state('');
|
||||||
|
hits = $state<SearchHit[]>([]);
|
||||||
|
webHits = $state<WebHit[]>([]);
|
||||||
|
searching = $state(false);
|
||||||
|
webSearching = $state(false);
|
||||||
|
webError = $state<string | null>(null);
|
||||||
|
searchedFor = $state<string | null>(null);
|
||||||
|
localExhausted = $state(false);
|
||||||
|
webPageno = $state(0);
|
||||||
|
webExhausted = $state(false);
|
||||||
|
loadingMore = $state(false);
|
||||||
|
|
||||||
|
private readonly pageSize: number;
|
||||||
|
private readonly debounceMs: number;
|
||||||
|
private readonly filterDebounceMs: number;
|
||||||
|
private readonly minQueryLength: number;
|
||||||
|
private readonly webFilterParam: () => string;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private skipNextDebounce = false;
|
||||||
|
|
||||||
|
constructor(opts: SearchStoreOptions = {}) {
|
||||||
|
this.pageSize = opts.pageSize ?? 30;
|
||||||
|
this.debounceMs = opts.debounceMs ?? 300;
|
||||||
|
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
|
||||||
|
this.minQueryLength = opts.minQueryLength ?? 4;
|
||||||
|
this.webFilterParam = opts.webFilterParam ?? (() => '');
|
||||||
|
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
|
||||||
|
}
|
||||||
|
|
||||||
|
runDebounced(): void {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
if (this.skipNextDebounce) {
|
||||||
|
this.skipNextDebounce = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const q = this.query.trim();
|
||||||
|
if (q.length < this.minQueryLength) {
|
||||||
|
this.resetResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.searching = true;
|
||||||
|
this.webHits = [];
|
||||||
|
this.webSearching = false;
|
||||||
|
this.webError = null;
|
||||||
|
this.debounceTimer = setTimeout(() => {
|
||||||
|
void this.runSearch(q);
|
||||||
|
}, this.debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runSearch(q: string): Promise<void> {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = null;
|
||||||
|
this.localExhausted = false;
|
||||||
|
this.webPageno = 0;
|
||||||
|
this.webExhausted = false;
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}`
|
||||||
|
);
|
||||||
|
const body = (await res.json()) as { hits: SearchHit[] };
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
this.hits = body.hits;
|
||||||
|
this.searchedFor = q;
|
||||||
|
if (this.hits.length < this.pageSize) this.localExhausted = true;
|
||||||
|
if (this.hits.length === 0) {
|
||||||
|
await this.runWebSearch(q, 1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.query.trim() === q) this.searching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWebSearch(q: string, pageno: number): Promise<void> {
|
||||||
|
this.webSearching = true;
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.webFilterParam()}`
|
||||||
|
);
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { message?: string };
|
||||||
|
this.webError = err.message ?? `HTTP ${res.status}`;
|
||||||
|
this.webExhausted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { hits: WebHit[] };
|
||||||
|
this.webHits = pageno === 1 ? body.hits : [...this.webHits, ...body.hits];
|
||||||
|
this.webPageno = pageno;
|
||||||
|
if (body.hits.length === 0) this.webExhausted = true;
|
||||||
|
} finally {
|
||||||
|
if (this.query.trim() === q) this.webSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMore(): Promise<void> {
|
||||||
|
if (this.loadingMore) return;
|
||||||
|
const q = this.query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
this.loadingMore = true;
|
||||||
|
try {
|
||||||
|
if (!this.localExhausted) {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}`
|
||||||
|
);
|
||||||
|
const body = (await res.json()) as { hits: SearchHit[] };
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
const more = body.hits;
|
||||||
|
const seen = new Set(this.hits.map((h) => h.id));
|
||||||
|
const deduped = more.filter((h) => !seen.has(h.id));
|
||||||
|
this.hits = [...this.hits, ...deduped];
|
||||||
|
if (more.length < this.pageSize) this.localExhausted = true;
|
||||||
|
} else if (!this.webExhausted) {
|
||||||
|
const nextPage = this.webPageno + 1;
|
||||||
|
const wasEmpty = this.webHits.length === 0;
|
||||||
|
if (wasEmpty) this.webSearching = true;
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl(
|
||||||
|
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.webFilterParam()}`
|
||||||
|
);
|
||||||
|
if (this.query.trim() !== q) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { message?: string };
|
||||||
|
this.webError = err.message ?? `HTTP ${res.status}`;
|
||||||
|
this.webExhausted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { hits: WebHit[] };
|
||||||
|
const more = body.hits;
|
||||||
|
const seen = new Set(this.webHits.map((h) => h.url));
|
||||||
|
const deduped = more.filter((h) => !seen.has(h.url));
|
||||||
|
if (deduped.length === 0) {
|
||||||
|
this.webExhausted = true;
|
||||||
|
} else {
|
||||||
|
this.webHits = [...this.webHits, ...deduped];
|
||||||
|
this.webPageno = nextPage;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.query.trim() === q) this.webSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reSearch(): void {
|
||||||
|
const q = this.query.trim();
|
||||||
|
if (q.length < this.minQueryLength) return;
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.searching = true;
|
||||||
|
this.webHits = [];
|
||||||
|
this.webSearching = false;
|
||||||
|
this.webError = null;
|
||||||
|
this.debounceTimer = setTimeout(() => void this.runSearch(q), this.filterDebounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = null;
|
||||||
|
this.query = '';
|
||||||
|
this.resetResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetResults(): void {
|
||||||
|
this.hits = [];
|
||||||
|
this.webHits = [];
|
||||||
|
this.searchedFor = null;
|
||||||
|
this.searching = false;
|
||||||
|
this.webSearching = false;
|
||||||
|
this.webError = null;
|
||||||
|
this.localExhausted = false;
|
||||||
|
this.webPageno = 0;
|
||||||
|
this.webExhausted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureSnapshot(): SearchSnapshot {
|
||||||
|
return {
|
||||||
|
query: this.query,
|
||||||
|
hits: this.hits,
|
||||||
|
webHits: this.webHits,
|
||||||
|
searchedFor: this.searchedFor,
|
||||||
|
webError: this.webError,
|
||||||
|
localExhausted: this.localExhausted,
|
||||||
|
webPageno: this.webPageno,
|
||||||
|
webExhausted: this.webExhausted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSnapshot(s: SearchSnapshot): void {
|
||||||
|
this.skipNextDebounce = true;
|
||||||
|
this.query = s.query;
|
||||||
|
this.hits = s.hits;
|
||||||
|
this.webHits = s.webHits;
|
||||||
|
this.searchedFor = s.searchedFor;
|
||||||
|
this.webError = s.webError;
|
||||||
|
this.localExhausted = s.localExhausted;
|
||||||
|
this.webPageno = s.webPageno;
|
||||||
|
this.webExhausted = s.webExhausted;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/lib/client/shopping-cart.svelte.ts
Normal file
52
src/lib/client/shopping-cart.svelte.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
type Snapshot = {
|
||||||
|
recipes: { recipe_id: number }[];
|
||||||
|
uncheckedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ShoppingCartStore {
|
||||||
|
uncheckedCount = $state(0);
|
||||||
|
recipeIds = $state<Set<number>>(new Set());
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
|
||||||
|
constructor(fetchImpl?: typeof fetch) {
|
||||||
|
this.fetchImpl = fetchImpl ?? ((...a) => fetch(...a));
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchImpl('/api/shopping-list');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = (await res.json()) as Snapshot;
|
||||||
|
this.recipeIds = new Set(body.recipes.map((r) => r.recipe_id));
|
||||||
|
this.uncheckedCount = body.uncheckedCount;
|
||||||
|
this.loaded = true;
|
||||||
|
} catch {
|
||||||
|
// keep last known state on network error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRecipe(recipeId: number): Promise<void> {
|
||||||
|
const res = await this.fetchImpl('/api/shopping-list/recipe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ recipe_id: recipeId })
|
||||||
|
});
|
||||||
|
// Consume body to avoid leaking response, even if we ignore the payload.
|
||||||
|
await res.json().catch(() => null);
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRecipe(recipeId: number): Promise<void> {
|
||||||
|
const res = await this.fetchImpl(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' });
|
||||||
|
await res.json().catch(() => null);
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
isInCart(recipeId: number): boolean {
|
||||||
|
return this.recipeIds.has(recipeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shoppingCartStore = new ShoppingCartStore();
|
||||||
33
src/lib/client/sw-register.ts
Normal file
33
src/lib/client/sw-register.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Registriert den Service-Worker und verdrahtet ihn mit dem
|
||||||
|
// Sync-Status-Store. Im Dev-Modus läuft Kochwas über HTTP; die
|
||||||
|
// SW-API ist da nur auf localhost verfügbar. SvelteKit liefert den
|
||||||
|
// SW unter /service-worker.js im Production-Build.
|
||||||
|
import { syncStatus, type SWMessage } from '$lib/client/sync-status.svelte';
|
||||||
|
|
||||||
|
export async function registerServiceWorker(): Promise<void> {
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register('/service-worker.js', { type: 'module' });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('SW-Registrierung fehlgeschlagen', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
const data = event.data as SWMessage | undefined;
|
||||||
|
if (data && typeof data === 'object' && 'type' in data) {
|
||||||
|
syncStatus.handle(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beim App-Start: wenn wir einen aktiven SW haben, frage ihn, ob er
|
||||||
|
// neu synct (initial oder Delta).
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: 'sync-check' });
|
||||||
|
} else {
|
||||||
|
// Erste Session: SW kommt erst mit dem nächsten Reload zum Einsatz.
|
||||||
|
// Beim nächsten Start triggert sync-check dann den Initial-Sync.
|
||||||
|
navigator.serviceWorker.ready.then((reg) => {
|
||||||
|
reg.active?.postMessage({ type: 'sync-start' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/lib/client/sync-status.svelte.ts
Normal file
53
src/lib/client/sync-status.svelte.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// State, den der Service-Worker per postMessage befüllt. Die App
|
||||||
|
// spiegelt den Sync-Fortschritt im SyncIndicator.
|
||||||
|
export type SyncState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'syncing'; current: number; total: number }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
export type SWMessage =
|
||||||
|
| { type: 'sync-start'; total: number }
|
||||||
|
| { type: 'sync-progress'; current: number; total: number }
|
||||||
|
| { type: 'sync-done'; lastSynced: number }
|
||||||
|
| { type: 'sync-error'; message: string };
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kochwas.sw.lastSynced';
|
||||||
|
|
||||||
|
function loadLastSynced(): number | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLastSynced(ts: number): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncStatusStore {
|
||||||
|
state = $state<SyncState>({ kind: 'idle' });
|
||||||
|
lastSynced = $state<number | null>(loadLastSynced());
|
||||||
|
|
||||||
|
handle(msg: SWMessage): void {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'sync-start':
|
||||||
|
this.state = { kind: 'syncing', current: 0, total: msg.total };
|
||||||
|
break;
|
||||||
|
case 'sync-progress':
|
||||||
|
this.state = { kind: 'syncing', current: msg.current, total: msg.total };
|
||||||
|
break;
|
||||||
|
case 'sync-done':
|
||||||
|
this.state = { kind: 'idle' };
|
||||||
|
this.lastSynced = msg.lastSynced;
|
||||||
|
saveLastSynced(msg.lastSynced);
|
||||||
|
break;
|
||||||
|
case 'sync-error':
|
||||||
|
this.state = { kind: 'error', message: msg.message };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncStatus = new SyncStatusStore();
|
||||||
25
src/lib/client/toast.svelte.ts
Normal file
25
src/lib/client/toast.svelte.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type ToastKind = 'info' | 'error' | 'success';
|
||||||
|
export type Toast = { id: number; kind: ToastKind; message: string };
|
||||||
|
|
||||||
|
class ToastStore {
|
||||||
|
toasts = $state<Toast[]>([]);
|
||||||
|
private nextId = 1;
|
||||||
|
private readonly dismissMs = 3000;
|
||||||
|
|
||||||
|
private push(kind: ToastKind, message: string): number {
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.toasts = [...this.toasts, { id, kind, message }];
|
||||||
|
setTimeout(() => this.dismiss(id), this.dismissMs);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string): number { return this.push('info', message); }
|
||||||
|
error(message: string): number { return this.push('error', message); }
|
||||||
|
success(message: string): number { return this.push('success', message); }
|
||||||
|
|
||||||
|
dismiss(id: number): void {
|
||||||
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toastStore = new ToastStore();
|
||||||
16
src/lib/client/wishlist.svelte.ts
Normal file
16
src/lib/client/wishlist.svelte.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class WishlistStore {
|
||||||
|
count = $state(0);
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/wishlist/count');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
this.count = typeof body.count === 'number' ? body.count : 0;
|
||||||
|
} catch {
|
||||||
|
// keep last known count on network error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wishlistStore = new WishlistStore();
|
||||||
149
src/lib/components/ConfirmDialog.svelte
Normal file
149
src/lib/components/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { confirmStore } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
|
let confirmButton = $state<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (confirmStore.pending) {
|
||||||
|
void tick().then(() => confirmButton?.focus());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (!confirmStore.pending) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmStore.answer(false);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmStore.answer(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if confirmStore.pending}
|
||||||
|
{@const p = confirmStore.pending}
|
||||||
|
<div class="backdrop" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||||
|
<button
|
||||||
|
class="backdrop-close"
|
||||||
|
aria-label="Abbrechen"
|
||||||
|
onclick={() => confirmStore.answer(false)}
|
||||||
|
></button>
|
||||||
|
<div class="dialog" role="document">
|
||||||
|
<h2 id="confirm-title">{p.title}</h2>
|
||||||
|
{#if p.message}
|
||||||
|
<p class="message">{p.message}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
{#if !p.infoOnly}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn cancel"
|
||||||
|
onclick={() => confirmStore.answer(false)}
|
||||||
|
>
|
||||||
|
{p.cancelLabel ?? 'Abbrechen'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn confirm"
|
||||||
|
class:destructive={p.destructive}
|
||||||
|
bind:this={confirmButton}
|
||||||
|
onclick={() => confirmStore.answer(true)}
|
||||||
|
>
|
||||||
|
{p.confirmLabel ?? 'Bestätigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.backdrop-close {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.4rem 1.25rem 1.1rem;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);
|
||||||
|
animation: pop 0.14s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin: 0 0 1.1rem;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.7rem 1.1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.cancel {
|
||||||
|
background: white;
|
||||||
|
color: #444;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
}
|
||||||
|
.cancel:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.confirm {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.confirm.destructive {
|
||||||
|
background: #c53030;
|
||||||
|
}
|
||||||
|
.confirm:focus-visible,
|
||||||
|
.cancel:focus-visible {
|
||||||
|
outline: 2px solid #1a1a1a;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
190
src/lib/components/ImageUploadBox.svelte
Normal file
190
src/lib/components/ImageUploadBox.svelte
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { ImagePlus, ImageOff } from 'lucide-svelte';
|
||||||
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
recipeId: number;
|
||||||
|
imagePath: string | null;
|
||||||
|
onchange: (path: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { recipeId, imagePath: initial, onchange }: Props = $props();
|
||||||
|
|
||||||
|
let imagePath = $state<string | null>(untrack(() => initial));
|
||||||
|
let uploading = $state(false);
|
||||||
|
let fileInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
const imageSrc = $derived(
|
||||||
|
imagePath === null
|
||||||
|
? null
|
||||||
|
: /^https?:\/\//i.test(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: `/images/${imagePath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onFileChosen(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!requireOnline('Der Bild-Upload')) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${recipeId}/image`,
|
||||||
|
{ method: 'POST', body: fd },
|
||||||
|
'Upload fehlgeschlagen'
|
||||||
|
);
|
||||||
|
if (!res) return;
|
||||||
|
const body = await res.json();
|
||||||
|
imagePath = body.image_path;
|
||||||
|
onchange(imagePath);
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage() {
|
||||||
|
if (imagePath === null) return;
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Bild entfernen?',
|
||||||
|
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
|
||||||
|
confirmLabel: 'Entfernen',
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Entfernen')) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${recipeId}/image`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
'Entfernen fehlgeschlagen'
|
||||||
|
);
|
||||||
|
if (!res) return;
|
||||||
|
imagePath = null;
|
||||||
|
onchange(null);
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-row">
|
||||||
|
<div class="image-preview" class:empty={!imageSrc}>
|
||||||
|
{#if imageSrc}
|
||||||
|
<img src={imageSrc} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder">Kein Bild</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="image-actions">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<ImagePlus size={16} strokeWidth={2} />
|
||||||
|
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
|
||||||
|
</button>
|
||||||
|
{#if imagePath}
|
||||||
|
<button class="btn ghost" type="button" onclick={removeImage} disabled={uploading}>
|
||||||
|
<ImageOff size={16} strokeWidth={2} />
|
||||||
|
<span>Entfernen</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if uploading}
|
||||||
|
<span class="upload-status">Lade …</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
class="file-input"
|
||||||
|
onchange={onFileChosen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.image-preview {
|
||||||
|
width: 160px;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #eef3ef;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.image-preview.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.image-preview .placeholder {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.image-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.upload-status {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.image-hint {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 40px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
221
src/lib/components/IngredientRow.svelte
Normal file
221
src/lib/components/IngredientRow.svelte
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Trash2, ChevronUp, ChevronDown, Plus } from 'lucide-svelte';
|
||||||
|
import type { DraftIng } from './recipe-editor-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ing: DraftIng;
|
||||||
|
idx: number;
|
||||||
|
total: number;
|
||||||
|
onmove: (dir: -1 | 1) => void;
|
||||||
|
onremove: () => void;
|
||||||
|
onaddSection: () => void;
|
||||||
|
onremoveSection: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { ing, idx, total, onmove, onremove, onaddSection, onremoveSection }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if ing.section_heading === null}
|
||||||
|
<li class="section-insert">
|
||||||
|
<button type="button" class="add-section" onclick={onaddSection}>
|
||||||
|
<Plus size={12} strokeWidth={2.5} />
|
||||||
|
<span>Abschnitt hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="section-heading-row">
|
||||||
|
<input
|
||||||
|
class="section-heading"
|
||||||
|
type="text"
|
||||||
|
bind:value={ing.section_heading}
|
||||||
|
placeholder='Sektion, z. B. „Für den Teig"'
|
||||||
|
aria-label="Sektionsüberschrift"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="section-remove"
|
||||||
|
aria-label="Sektion entfernen"
|
||||||
|
onclick={onremoveSection}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
<li class="ing-row">
|
||||||
|
<div class="move">
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Zutat nach oben"
|
||||||
|
disabled={idx === 0}
|
||||||
|
onclick={() => onmove(-1)}
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Zutat nach unten"
|
||||||
|
disabled={idx === total - 1}
|
||||||
|
onclick={() => onmove(1)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
|
||||||
|
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
|
||||||
|
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
|
||||||
|
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
|
||||||
|
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
|
||||||
|
<Trash2 size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ing-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.move {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.move-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #555;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.move-btn:hover:not(:disabled) {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.move-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ing-row input {
|
||||||
|
padding: 0.5rem 0.55rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 38px;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.del {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #f1b4b4;
|
||||||
|
background: white;
|
||||||
|
color: #c53030;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.del:hover {
|
||||||
|
background: #fdf3f3;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ing-row {
|
||||||
|
grid-template-columns: 28px 70px 1fr 40px;
|
||||||
|
grid-template-areas:
|
||||||
|
'move qty name del'
|
||||||
|
'move unit unit del'
|
||||||
|
'note note note note';
|
||||||
|
}
|
||||||
|
.ing-row .move {
|
||||||
|
grid-area: move;
|
||||||
|
}
|
||||||
|
.ing-row .qty {
|
||||||
|
grid-area: qty;
|
||||||
|
}
|
||||||
|
.ing-row .unit {
|
||||||
|
grid-area: unit;
|
||||||
|
}
|
||||||
|
.ing-row .name {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
.ing-row .note {
|
||||||
|
grid-area: note;
|
||||||
|
}
|
||||||
|
.ing-row .del {
|
||||||
|
grid-area: del;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.section-insert {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
list-style: none;
|
||||||
|
margin: -0.2rem 0 0.1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
/* Parent-UL liegt im RecipeEditor, daher :global(.ing-list). Ohne das
|
||||||
|
scopt Svelte die Klasse und der Selector matcht zur Laufzeit nicht. */
|
||||||
|
:global(.ing-list):hover .section-insert,
|
||||||
|
.section-insert:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.add-section {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border: 1px dashed #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.add-section:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.section-heading-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 32px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.section-heading {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.section-remove {
|
||||||
|
width: 32px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.section-remove:hover {
|
||||||
|
background: #fdf3f3;
|
||||||
|
border-color: #f1b4b4;
|
||||||
|
color: #c53030;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { CircleUser } from 'lucide-svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
|
import { alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
let showModal = $state(false);
|
let showModal = $state(false);
|
||||||
let newName = $state('');
|
let newName = $state('');
|
||||||
let newEmoji = $state('🍳');
|
let newEmoji = $state('');
|
||||||
|
|
||||||
async function createAndSelect() {
|
async function createAndSelect() {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
@@ -13,17 +15,19 @@
|
|||||||
newName = '';
|
newName = '';
|
||||||
showModal = false;
|
showModal = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert((e as Error).message);
|
await alertAction({
|
||||||
|
title: 'Profil konnte nicht angelegt werden',
|
||||||
|
message: (e as Error).message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="chip" onclick={() => (showModal = true)} aria-label="Profil wechseln">
|
<button class="chip" onclick={() => (showModal = true)} aria-label="Profil wechseln">
|
||||||
|
<span class="icon"><CircleUser size={20} strokeWidth={1.75} /></span>
|
||||||
{#if profileStore.active}
|
{#if profileStore.active}
|
||||||
<span class="emoji">{profileStore.active.avatar_emoji ?? '🙂'}</span>
|
|
||||||
<span class="name">{profileStore.active.name}</span>
|
<span class="name">{profileStore.active.name}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="emoji">👤</span>
|
|
||||||
<span class="name">Profil wählen</span>
|
<span class="name">Profil wählen</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -53,7 +57,11 @@
|
|||||||
showModal = false;
|
showModal = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="emoji-lg">{p.avatar_emoji ?? '🙂'}</span>
|
{#if p.avatar_emoji}
|
||||||
|
<span class="emoji-lg">{p.avatar_emoji}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="icon-lg"><CircleUser size={28} strokeWidth={1.5} /></span>
|
||||||
|
{/if}
|
||||||
<span>{p.name}</span>
|
<span>{p.name}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -65,7 +73,8 @@
|
|||||||
<div class="new-row">
|
<div class="new-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Emoji"
|
placeholder="🙂"
|
||||||
|
aria-label="Emoji (optional)"
|
||||||
bind:value={newEmoji}
|
bind:value={newEmoji}
|
||||||
maxlength="8"
|
maxlength="8"
|
||||||
class="emoji-input"
|
class="emoji-input"
|
||||||
@@ -90,7 +99,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.5rem 0.9rem;
|
padding: 0.5rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
background: white;
|
background: white;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@@ -100,8 +109,10 @@
|
|||||||
.chip:hover {
|
.chip:hover {
|
||||||
background: #f4f8f5;
|
background: #f4f8f5;
|
||||||
}
|
}
|
||||||
.emoji {
|
.icon {
|
||||||
font-size: 1.1rem;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #2b6a3d;
|
||||||
}
|
}
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -165,6 +176,11 @@
|
|||||||
.emoji-lg {
|
.emoji-lg {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
.icon-lg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #e4eae7;
|
border-top: 1px solid #e4eae7;
|
||||||
|
|||||||
342
src/lib/components/RecipeEditor.svelte
Normal file
342
src/lib/components/RecipeEditor.svelte
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { Plus } from 'lucide-svelte';
|
||||||
|
import type { Recipe, Ingredient, Step } from '$lib/types';
|
||||||
|
import ImageUploadBox from '$lib/components/ImageUploadBox.svelte';
|
||||||
|
import IngredientRow from '$lib/components/IngredientRow.svelte';
|
||||||
|
import StepList from '$lib/components/StepList.svelte';
|
||||||
|
import type { DraftIng, DraftStep } from '$lib/components/recipe-editor-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
recipe: Recipe;
|
||||||
|
saving?: boolean;
|
||||||
|
onsave: (patch: {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
servings_default: number | null;
|
||||||
|
prep_time_min: number | null;
|
||||||
|
cook_time_min: number | null;
|
||||||
|
total_time_min: number | null;
|
||||||
|
ingredients: Ingredient[];
|
||||||
|
steps: Step[];
|
||||||
|
}) => void | Promise<void>;
|
||||||
|
oncancel: () => void;
|
||||||
|
/** Fires whenever the image was uploaded or removed — separate from save,
|
||||||
|
* because the image is its own endpoint and persists immediately. */
|
||||||
|
onimagechange?: (image_path: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { recipe, saving = false, onsave, oncancel, onimagechange }: Props = $props();
|
||||||
|
|
||||||
|
// Form-lokaler Zustand: Initialwerte aus dem Prop snapshotten (untrack),
|
||||||
|
// damit User-Edits nicht von prop-Updates ueberschrieben werden.
|
||||||
|
let title = $state(untrack(() => recipe.title));
|
||||||
|
let description = $state(untrack(() => recipe.description ?? ''));
|
||||||
|
let servings = $state<number | ''>(untrack(() => recipe.servings_default ?? ''));
|
||||||
|
let prepMin = $state<number | ''>(untrack(() => recipe.prep_time_min ?? ''));
|
||||||
|
let cookMin = $state<number | ''>(untrack(() => recipe.cook_time_min ?? ''));
|
||||||
|
let totalMin = $state<number | ''>(untrack(() => recipe.total_time_min ?? ''));
|
||||||
|
|
||||||
|
let ingredients = $state<DraftIng[]>(
|
||||||
|
untrack(() =>
|
||||||
|
recipe.ingredients.map((i) => ({
|
||||||
|
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
||||||
|
unit: i.unit ?? '',
|
||||||
|
name: i.name,
|
||||||
|
note: i.note ?? '',
|
||||||
|
section_heading: i.section_heading
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let steps = $state<DraftStep[]>(
|
||||||
|
untrack(() => recipe.steps.map((s) => ({ text: s.text })))
|
||||||
|
);
|
||||||
|
|
||||||
|
function addIngredient() {
|
||||||
|
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '', section_heading: null }];
|
||||||
|
}
|
||||||
|
function removeIngredient(idx: number) {
|
||||||
|
ingredients = ingredients.filter((_, i) => i !== idx);
|
||||||
|
}
|
||||||
|
function moveIngredient(idx: number, dir: -1 | 1) {
|
||||||
|
const target = idx + dir;
|
||||||
|
if (target < 0 || target >= ingredients.length) return;
|
||||||
|
const next = [...ingredients];
|
||||||
|
[next[idx], next[target]] = [next[target], next[idx]];
|
||||||
|
ingredients = next;
|
||||||
|
}
|
||||||
|
function addSection(idx: number) {
|
||||||
|
const next = [...ingredients];
|
||||||
|
next[idx] = { ...next[idx], section_heading: '' };
|
||||||
|
ingredients = next;
|
||||||
|
}
|
||||||
|
function removeSection(idx: number) {
|
||||||
|
const next = [...ingredients];
|
||||||
|
next[idx] = { ...next[idx], section_heading: null };
|
||||||
|
ingredients = next;
|
||||||
|
}
|
||||||
|
function addStep() {
|
||||||
|
steps = [...steps, { text: '' }];
|
||||||
|
}
|
||||||
|
function removeStep(idx: number) {
|
||||||
|
steps = steps.filter((_, i) => i !== idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQty(raw: string): number | null {
|
||||||
|
const cleaned = raw.trim().replace(',', '.');
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const n = Number(cleaned);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumOrNull(v: number | ''): number | null {
|
||||||
|
return v === '' ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const cleanedIngredients: Ingredient[] = ingredients
|
||||||
|
.filter((i) => i.name.trim())
|
||||||
|
.map((i, idx) => {
|
||||||
|
const qty = parseQty(i.qty);
|
||||||
|
const unit = i.unit.trim() || null;
|
||||||
|
const name = i.name.trim();
|
||||||
|
const note = i.note.trim() || null;
|
||||||
|
const rawParts: string[] = [];
|
||||||
|
if (qty !== null) rawParts.push(String(qty).replace('.', ','));
|
||||||
|
if (unit) rawParts.push(unit);
|
||||||
|
rawParts.push(name);
|
||||||
|
const heading = i.section_heading === null ? null : (i.section_heading.trim() || null);
|
||||||
|
return {
|
||||||
|
position: idx + 1,
|
||||||
|
quantity: qty,
|
||||||
|
unit,
|
||||||
|
name,
|
||||||
|
note,
|
||||||
|
raw_text: rawParts.join(' '),
|
||||||
|
section_heading: heading
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const cleanedSteps: Step[] = steps
|
||||||
|
.filter((s) => s.text.trim())
|
||||||
|
.map((s, idx) => ({ position: idx + 1, text: s.text.trim() }));
|
||||||
|
|
||||||
|
await onsave({
|
||||||
|
title: title.trim() || recipe.title,
|
||||||
|
description: description.trim() || null,
|
||||||
|
servings_default: toNumOrNull(servings),
|
||||||
|
prep_time_min: toNumOrNull(prepMin),
|
||||||
|
cook_time_min: toNumOrNull(cookMin),
|
||||||
|
total_time_min: toNumOrNull(totalMin),
|
||||||
|
ingredients: cleanedIngredients,
|
||||||
|
steps: cleanedSteps
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor">
|
||||||
|
{#if recipe.id !== null}
|
||||||
|
<section class="block">
|
||||||
|
<h2>Bild</h2>
|
||||||
|
<ImageUploadBox
|
||||||
|
recipeId={recipe.id}
|
||||||
|
imagePath={recipe.image_path}
|
||||||
|
onchange={(p) => onimagechange?.(p)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<section class="block info">
|
||||||
|
<p class="hint">Bild kannst du nach dem Speichern hinzufügen.</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<label class="field">
|
||||||
|
<span class="lbl">Titel</span>
|
||||||
|
<input type="text" bind:value={title} placeholder="Rezeptname" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="lbl">Beschreibung</span>
|
||||||
|
<textarea bind:value={description} rows="2" placeholder="Kurze Beschreibung (optional)"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Portionen</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={servings}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Vorb. (min)</span>
|
||||||
|
<input type="number" min="0" bind:value={prepMin} placeholder="—" />
|
||||||
|
</label>
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Kochen (min)</span>
|
||||||
|
<input type="number" min="0" bind:value={cookMin} placeholder="—" />
|
||||||
|
</label>
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Gesamt (min)</span>
|
||||||
|
<input type="number" min="0" bind:value={totalMin} placeholder="—" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>Zutaten</h2>
|
||||||
|
<ul class="ing-list">
|
||||||
|
{#each ingredients as ing, idx (idx)}
|
||||||
|
<IngredientRow
|
||||||
|
{ing}
|
||||||
|
{idx}
|
||||||
|
total={ingredients.length}
|
||||||
|
onmove={(dir) => moveIngredient(idx, dir)}
|
||||||
|
onremove={() => removeIngredient(idx)}
|
||||||
|
onaddSection={() => addSection(idx)}
|
||||||
|
onremoveSection={() => removeSection(idx)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<button class="add" type="button" onclick={addIngredient}>
|
||||||
|
<Plus size={16} strokeWidth={2} />
|
||||||
|
<span>Zutat hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>Zubereitung</h2>
|
||||||
|
<StepList {steps} onadd={addStep} onremove={removeStep} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
<button class="btn ghost" type="button" onclick={oncancel} disabled={saving}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" type="button" onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Speichere …' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: white;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.block h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.block.info {
|
||||||
|
background: #f6faf7;
|
||||||
|
border: 1px dashed #cfd9d1;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.ing-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border: 1px dashed #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.add:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { scaleIngredients } from '$lib/recipes/scaler';
|
import { scaleIngredients } from '$lib/recipes/scaler';
|
||||||
import type { Recipe } from '$lib/types';
|
import type { Recipe } from '$lib/types';
|
||||||
|
import TimeDisplay from '$lib/components/TimeDisplay.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
recipe: Recipe;
|
recipe: Recipe;
|
||||||
showActions?: import('svelte').Snippet;
|
showActions?: import('svelte').Snippet;
|
||||||
banner?: import('svelte').Snippet;
|
banner?: import('svelte').Snippet;
|
||||||
|
titleSlot?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { recipe, showActions, banner }: Props = $props();
|
let { recipe, showActions, banner, titleSlot }: Props = $props();
|
||||||
|
|
||||||
const defaultServings = $derived(recipe.servings_default ?? 4);
|
const defaultServings = $derived(recipe.servings_default ?? 4);
|
||||||
let servingsOverride = $state<number | null>(null);
|
let servingsOverride = $state<number | null>(null);
|
||||||
@@ -40,15 +42,6 @@
|
|||||||
if (Number.isInteger(q)) return String(q);
|
if (Number.isInteger(q)) return String(q);
|
||||||
return q.toLocaleString('de-DE', { maximumFractionDigits: 2 });
|
return q.toLocaleString('de-DE', { maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeSummary(): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (recipe.prep_time_min) parts.push(`Vorb. ${recipe.prep_time_min} min`);
|
|
||||||
if (recipe.cook_time_min) parts.push(`Kochen ${recipe.cook_time_min} min`);
|
|
||||||
if (!recipe.prep_time_min && !recipe.cook_time_min && recipe.total_time_min)
|
|
||||||
parts.push(`Gesamt ${recipe.total_time_min} min`);
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if banner}
|
{#if banner}
|
||||||
@@ -61,7 +54,11 @@
|
|||||||
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
|
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="hdr-body">
|
<div class="hdr-body">
|
||||||
<h1>{recipe.title}</h1>
|
{#if titleSlot}
|
||||||
|
{@render titleSlot()}
|
||||||
|
{:else}
|
||||||
|
<h1>{recipe.title}</h1>
|
||||||
|
{/if}
|
||||||
{#if recipe.description}
|
{#if recipe.description}
|
||||||
<p class="desc">{recipe.description}</p>
|
<p class="desc">{recipe.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -74,9 +71,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if timeSummary()}
|
<TimeDisplay
|
||||||
<p class="times">{timeSummary()}</p>
|
prepTimeMin={recipe.prep_time_min}
|
||||||
{/if}
|
cookTimeMin={recipe.cook_time_min}
|
||||||
|
totalTimeMin={recipe.total_time_min}
|
||||||
|
/>
|
||||||
{#if recipe.source_url}
|
{#if recipe.source_url}
|
||||||
<p class="src">
|
<p class="src">
|
||||||
Quelle: <a href={recipe.source_url} target="_blank" rel="noopener">{recipe.source_domain}</a>
|
Quelle: <a href={recipe.source_url} target="_blank" rel="noopener">{recipe.source_domain}</a>
|
||||||
@@ -112,8 +111,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if tab === 'ing'}
|
<div class="panes">
|
||||||
<section class="ingredients" role="tabpanel">
|
<section
|
||||||
|
class="ingredients"
|
||||||
|
role="tabpanel"
|
||||||
|
class:hidden-mobile={tab !== 'ing'}
|
||||||
|
>
|
||||||
<div class="servings">
|
<div class="servings">
|
||||||
<button class="srv-btn" aria-label="Weniger" onclick={decr}>−</button>
|
<button class="srv-btn" aria-label="Weniger" onclick={decr}>−</button>
|
||||||
<div class="srv-value">
|
<div class="srv-value">
|
||||||
@@ -124,6 +127,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="ing-list">
|
<ul class="ing-list">
|
||||||
{#each scaled as ing, i (i)}
|
{#each scaled as ing, i (i)}
|
||||||
|
{#if ing.section_heading && ing.section_heading.trim()}
|
||||||
|
<li class="section-heading">{ing.section_heading}</li>
|
||||||
|
{/if}
|
||||||
<li>
|
<li>
|
||||||
{#if ing.quantity !== null || ing.unit}
|
{#if ing.quantity !== null || ing.unit}
|
||||||
<span class="qty">
|
<span class="qty">
|
||||||
@@ -141,15 +147,18 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
<section
|
||||||
<section class="steps" role="tabpanel">
|
class="steps"
|
||||||
|
role="tabpanel"
|
||||||
|
class:hidden-mobile={tab !== 'prep'}
|
||||||
|
>
|
||||||
<ol>
|
<ol>
|
||||||
{#each recipe.steps as step (step.position)}
|
{#each recipe.steps as step (step.position)}
|
||||||
<li>{step.text}</li>
|
<li>{step.text}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -163,6 +172,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
|
/* Nie mehr als 30% der Bildschirmhöhe — auf schmalen Screens würde das
|
||||||
|
Bild sonst alles Wichtige wegdrücken, auf breiten Desktops wäre es
|
||||||
|
unverhältnismäßig groß. */
|
||||||
|
max-height: 30vh;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: #eef3ef;
|
background: #eef3ef;
|
||||||
}
|
}
|
||||||
@@ -188,7 +201,7 @@
|
|||||||
.pill {
|
.pill {
|
||||||
padding: 0.15rem 0.55rem;
|
padding: 0.15rem 0.55rem;
|
||||||
background: #eaf4ed;
|
background: #eaf4ed;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
}
|
}
|
||||||
@@ -196,11 +209,6 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
.times {
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.src {
|
.src {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -276,6 +284,19 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.ing-list .section-heading {
|
||||||
|
list-style: none;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
border-bottom: 1px solid #e4eae7;
|
||||||
|
}
|
||||||
|
.ing-list .section-heading:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.ing-list li {
|
.ing-list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -321,4 +342,62 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.hidden-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Querformat-Tablets und Desktop: Zutaten + Zubereitung nebeneinander,
|
||||||
|
Tabs ausgeblendet. Zutaten sticky, damit sie beim Scrollen der
|
||||||
|
Zubereitung oben bleiben.
|
||||||
|
Schriftgrößen hier bewusst größer — das Rezept wird auf einem 10"-
|
||||||
|
Tablet beim Kochen aus ~50 cm Abstand gelesen. */
|
||||||
|
@media (min-width: 820px) {
|
||||||
|
.tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.panes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) 1.6fr;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.hidden-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.ingredients {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ing-list li {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0.85rem 0.25rem;
|
||||||
|
}
|
||||||
|
.qty {
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
.srv-value strong {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.srv-value span {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.steps li {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
padding: 1rem 0 1rem 3.4rem;
|
||||||
|
}
|
||||||
|
.steps li::before {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
360
src/lib/components/SearchFilter.svelte
Normal file
360
src/lib/components/SearchFilter.svelte
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SlidersHorizontal, Check, X, ChevronDown } from 'lucide-svelte';
|
||||||
|
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||||
|
|
||||||
|
// inline: Button wird transparent und ohne eigenen Border gestylt,
|
||||||
|
// damit er sich in einen umgebenden Such-Container einpassen lässt.
|
||||||
|
let { inline = false }: { inline?: boolean } = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let container: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// Draft-Auswahl: wird beim Öffnen vom Store initialisiert und nur bei „OK"
|
||||||
|
// in den Store committet. Dadurch bleibt die laufende Suche unangetastet,
|
||||||
|
// solange der User im Menu herumklickt, und ein versehentlicher Klick
|
||||||
|
// daneben verwirft die Auswahl (statt sie halbfertig anzuwenden).
|
||||||
|
let draft = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
function snapshotActive(): Set<string> {
|
||||||
|
// Leere Menge heißt im Store „alle aktiv". Für die Draft machen wir
|
||||||
|
// das explizit, damit toggle() ein vorhersehbares Verhalten hat.
|
||||||
|
if (searchFilterStore.active.size === 0) {
|
||||||
|
return new Set(searchFilterStore.domains.map((d) => d.domain));
|
||||||
|
}
|
||||||
|
return new Set(searchFilterStore.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
draft = snapshotActive();
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
// Wenn alle gewählt sind, speichern wir die leere Menge — damit sind
|
||||||
|
// neu zur Whitelist hinzugefügte Domains automatisch dabei.
|
||||||
|
const allSelected =
|
||||||
|
draft.size === searchFilterStore.domains.length &&
|
||||||
|
searchFilterStore.domains.every((d) => draft.has(d.domain));
|
||||||
|
searchFilterStore.commit(allSelected ? new Set() : draft);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTrigger() {
|
||||||
|
if (open) cancel();
|
||||||
|
else openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && open) cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein Klick-außerhalb-Handler: die Liste schließt sich nur noch explizit
|
||||||
|
// über OK/Abbrechen. Früher wurde bei Re-Render einer Checkbox-Zeile
|
||||||
|
// gelegentlich ein click-Target gesehen, das nicht mehr im container hing,
|
||||||
|
// was das Menu fälschlich schloss.
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onToggleDomain(domain: string) {
|
||||||
|
const next = new Set(draft);
|
||||||
|
if (next.has(domain)) next.delete(domain);
|
||||||
|
else next.add(domain);
|
||||||
|
draft = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllDraft() {
|
||||||
|
draft = new Set(searchFilterStore.domains.map((d) => d.domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNoneDraft() {
|
||||||
|
draft = new Set();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrap" bind:this={container}>
|
||||||
|
<button
|
||||||
|
class="trigger"
|
||||||
|
class:filtered={searchFilterStore.isFiltered}
|
||||||
|
class:inline
|
||||||
|
type="button"
|
||||||
|
aria-label="Suchfilter"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggleTrigger}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={16} strokeWidth={2} />
|
||||||
|
<ChevronDown size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
<div class="menu-head">
|
||||||
|
<span class="head-title">Gefunden auf</span>
|
||||||
|
<div class="quicks">
|
||||||
|
<button class="quick" type="button" onclick={selectAllDraft}>Alle</button>
|
||||||
|
<button class="quick" type="button" onclick={selectNoneDraft}>Keine</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if searchFilterStore.domains.length === 0}
|
||||||
|
<p class="empty">Keine Domains in der Whitelist.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each searchFilterStore.domains as d (d.id)}
|
||||||
|
{@const isOn = draft.has(d.domain)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="row"
|
||||||
|
type="button"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={isOn}
|
||||||
|
onclick={() => onToggleDomain(d.domain)}
|
||||||
|
>
|
||||||
|
<span class="box" class:on={isOn}>
|
||||||
|
{#if isOn}<Check size={14} strokeWidth={3} />{/if}
|
||||||
|
</span>
|
||||||
|
{#if d.favicon_path}
|
||||||
|
<img class="favicon" src={`/images/${d.favicon_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="favicon fallback" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="dom">{d.display_name ?? d.domain}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="menu-foot">
|
||||||
|
<button class="btn ghost" type="button" onclick={cancel}>
|
||||||
|
<X size={16} strokeWidth={2} />
|
||||||
|
<span>Abbrechen</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" type="button" onclick={apply}>
|
||||||
|
<Check size={16} strokeWidth={2.5} />
|
||||||
|
<span>OK</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #2b6a3d;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
min-height: 44px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.trigger:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.trigger.filtered {
|
||||||
|
background: #eaf4ed;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
}
|
||||||
|
/* In der Suchmaske: kein eigener Rahmen/Hintergrund, der Container drumherum
|
||||||
|
trägt die visuelle Form. Hover füllt die volle Container-Höhe. */
|
||||||
|
.wrap:has(.trigger.inline) {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.trigger.inline {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid #e4eae7;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0 0.85rem 0 0.65rem;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.trigger.inline:first-child {
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
border-bottom-left-radius: 12px;
|
||||||
|
}
|
||||||
|
.trigger.inline.filtered {
|
||||||
|
background: transparent;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.trigger.inline:hover {
|
||||||
|
background: rgba(43, 106, 61, 0.06);
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
left: 0;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 80;
|
||||||
|
padding: 0.35rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.menu-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #f0f3f1;
|
||||||
|
}
|
||||||
|
.head-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.quick {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.quick:hover {
|
||||||
|
background: #eaf4ed;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 44px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1.5px solid #cfd9d1;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
background: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.box.on {
|
||||||
|
background: #2b6a3d;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.favicon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.favicon.fallback {
|
||||||
|
background: #eef3ef;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.dom {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 0.8rem 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.menu-foot {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 0.5rem 0.35rem;
|
||||||
|
border-top: 1px solid #f0f3f1;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.quicks {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
min-height: 40px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn.ghost:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: #235532;
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.trigger {
|
||||||
|
padding: 0.5rem 0.55rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
left: -0.25rem;
|
||||||
|
min-width: calc(100vw - 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
180
src/lib/components/SearchLoader.svelte
Normal file
180
src/lib/components/SearchLoader.svelte
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
type Scope = 'local' | 'web';
|
||||||
|
type Size = 'sm' | 'md';
|
||||||
|
let { scope = 'local', size = 'md' }: { scope?: Scope; size?: Size } = $props();
|
||||||
|
|
||||||
|
const LOCAL_MESSAGES = [
|
||||||
|
'Stöbere im Rezeptbuch …',
|
||||||
|
'Schaue unter den Topfdeckeln …',
|
||||||
|
'Krame in den Gewürzregalen …',
|
||||||
|
'Durchsuche Omas Geheimrezepte …'
|
||||||
|
];
|
||||||
|
const WEB_MESSAGES = [
|
||||||
|
'Schnuppere in fremden Küchen …',
|
||||||
|
'Befrage Chefkoch, Emmi und Co. …',
|
||||||
|
'Durchforste die Kochblog-Gassen …',
|
||||||
|
'Klopfe an Internet-Kochtöpfe …'
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMOJIS = ['🍳', '🥘', '🍲', '🍜', '🥣'];
|
||||||
|
|
||||||
|
const messages = $derived(scope === 'web' ? WEB_MESSAGES : LOCAL_MESSAGES);
|
||||||
|
let msgIdx = $state(0);
|
||||||
|
let emojiIdx = $state(0);
|
||||||
|
|
||||||
|
let msgTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let emojiTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
msgTimer = setInterval(() => {
|
||||||
|
msgIdx = (msgIdx + 1) % messages.length;
|
||||||
|
}, 1800);
|
||||||
|
emojiTimer = setInterval(() => {
|
||||||
|
emojiIdx = (emojiIdx + 1) % EMOJIS.length;
|
||||||
|
}, 900);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (msgTimer) clearInterval(msgTimer);
|
||||||
|
if (emojiTimer) clearInterval(emojiTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="loader" class:sm={size === 'sm'}>
|
||||||
|
<div class="pot-wrap" aria-hidden="true">
|
||||||
|
<span class="steam s1">·</span>
|
||||||
|
<span class="steam s2">·</span>
|
||||||
|
<span class="steam s3">·</span>
|
||||||
|
<span class="pot">{EMOJIS[emojiIdx]}</span>
|
||||||
|
</div>
|
||||||
|
<p class="caption" aria-live="polite">{messages[msgIdx]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.75rem 0;
|
||||||
|
}
|
||||||
|
.loader.sm {
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.pot-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
.loader.sm .pot-wrap {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
.pot {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transform-origin: 50% 85%;
|
||||||
|
animation: wobble 1.4s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.loader.sm .pot {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
.steam {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #8fb097;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 55%;
|
||||||
|
opacity: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.s1 {
|
||||||
|
left: 22%;
|
||||||
|
animation: rise 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
.s2 {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
animation: rise 2.4s ease-out infinite 0.6s;
|
||||||
|
}
|
||||||
|
.s3 {
|
||||||
|
left: 72%;
|
||||||
|
animation: rise 2.4s ease-out infinite 1.2s;
|
||||||
|
}
|
||||||
|
@keyframes wobble {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%) rotate(-7deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(-50%) rotate(7deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 0) scale(0.6);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -34px) scale(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.s1,
|
||||||
|
.s3 {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.s1 {
|
||||||
|
animation-name: rise-left;
|
||||||
|
}
|
||||||
|
.s3 {
|
||||||
|
animation-name: rise-right;
|
||||||
|
}
|
||||||
|
@keyframes rise-left {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, 0) scale(0.6);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-8px, -34px) scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes rise-right {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, 0) scale(0.6);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(8px, -34px) scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.caption {
|
||||||
|
color: #6a7670;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 1.3em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.loader.sm .caption {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
src/lib/components/ShoppingCartChip.svelte
Normal file
75
src/lib/components/ShoppingCartChip.svelte
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, Minus, Plus } from 'lucide-svelte';
|
||||||
|
import type { ShoppingCartRecipe } from '$lib/server/shopping/repository';
|
||||||
|
|
||||||
|
let { recipe, onServingsChange, onRemove }: {
|
||||||
|
recipe: ShoppingCartRecipe;
|
||||||
|
onServingsChange: (id: number, servings: number) => void;
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function dec() {
|
||||||
|
if (recipe.servings > 1) onServingsChange(recipe.recipe_id, recipe.servings - 1);
|
||||||
|
}
|
||||||
|
function inc() {
|
||||||
|
if (recipe.servings < 50) onServingsChange(recipe.recipe_id, recipe.servings + 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chip">
|
||||||
|
<a class="title" href={`/recipes/${recipe.recipe_id}`}>{recipe.title}</a>
|
||||||
|
<div class="controls">
|
||||||
|
<button aria-label="Portion weniger" onclick={dec} disabled={recipe.servings <= 1}>
|
||||||
|
<Minus size={16} />
|
||||||
|
</button>
|
||||||
|
<span class="val" aria-label="Portionen">{recipe.servings}p</span>
|
||||||
|
<button aria-label="Portion mehr" onclick={inc} disabled={recipe.servings >= 50}>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
<button aria-label="Rezept aus Einkaufsliste entfernen" class="rm" onclick={() => onRemove(recipe.recipe_id)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.controls { display: flex; gap: 0.25rem; align-items: center; }
|
||||||
|
.controls button {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.controls button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.controls button.rm { margin-left: auto; }
|
||||||
|
.controls button.rm:hover { color: #c53030; border-color: #f1b4b4; background: #fdf3f3; }
|
||||||
|
.val { min-width: 32px; text-align: center; font-weight: 600; color: #444; }
|
||||||
|
</style>
|
||||||
57
src/lib/components/ShoppingListRow.svelte
Normal file
57
src/lib/components/ShoppingListRow.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ShoppingListRow } from '$lib/server/shopping/repository';
|
||||||
|
import { formatQuantity } from '$lib/quantity-format';
|
||||||
|
|
||||||
|
let { row, onToggle }: {
|
||||||
|
row: ShoppingListRow;
|
||||||
|
onToggle: (row: ShoppingListRow, next: boolean) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const qtyStr = $derived(formatQuantity(row.total_quantity));
|
||||||
|
const hasUnit = $derived(!!row.display_unit && row.display_unit.trim().length > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="row" class:checked={row.checked}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.checked === 1}
|
||||||
|
onchange={(e) => onToggle(row, (e.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span class="text">
|
||||||
|
<span class="name">
|
||||||
|
{#if qtyStr}
|
||||||
|
<span class="qty">{qtyStr}{hasUnit ? ` ${row.display_unit}` : ''}</span>
|
||||||
|
{/if}
|
||||||
|
{row.display_name}
|
||||||
|
</span>
|
||||||
|
<span class="src">aus {row.from_recipes}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
.row input {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
|
.name { font-size: 1rem; }
|
||||||
|
.qty { font-weight: 600; margin-right: 0.3rem; }
|
||||||
|
.src { color: #888; font-size: 0.82rem; }
|
||||||
|
.row.checked { background: #f6f8f7; }
|
||||||
|
.row.checked .name,
|
||||||
|
.row.checked .qty { text-decoration: line-through; color: #888; }
|
||||||
|
</style>
|
||||||
101
src/lib/components/StepList.svelte
Normal file
101
src/lib/components/StepList.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Plus, Trash2 } from 'lucide-svelte';
|
||||||
|
import type { DraftStep } from './recipe-editor-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
steps: DraftStep[];
|
||||||
|
onadd: () => void;
|
||||||
|
onremove: (idx: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { steps, onadd, onremove }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ol class="step-list">
|
||||||
|
{#each steps as step, idx (idx)}
|
||||||
|
<li class="step-row">
|
||||||
|
<span class="num">{idx + 1}</span>
|
||||||
|
<textarea
|
||||||
|
bind:value={step.text}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Schritt beschreiben …"
|
||||||
|
></textarea>
|
||||||
|
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => onremove(idx)}>
|
||||||
|
<Trash2 size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
<button class="add" type="button" onclick={onadd}>
|
||||||
|
<Plus size={16} strokeWidth={2} />
|
||||||
|
<span>Schritt hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.step-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.step-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr 40px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.step-row textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.del {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #f1b4b4;
|
||||||
|
background: white;
|
||||||
|
color: #c53030;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.del:hover {
|
||||||
|
background: #fdf3f3;
|
||||||
|
}
|
||||||
|
.add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border: 1px dashed #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.add:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
src/lib/components/SyncIndicator.svelte
Normal file
129
src/lib/components/SyncIndicator.svelte
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { RefreshCw, WifiOff } from 'lucide-svelte';
|
||||||
|
import { network } from '$lib/client/network.svelte';
|
||||||
|
import { syncStatus } from '$lib/client/sync-status.svelte';
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
const label = $derived.by(() => {
|
||||||
|
if (syncStatus.state.kind === 'syncing') {
|
||||||
|
return `Sync ${syncStatus.state.current}/${syncStatus.state.total}`;
|
||||||
|
}
|
||||||
|
if (!network.online) return 'Offline';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatRelative(ts: number | null): string {
|
||||||
|
if (ts === null) return 'noch nicht synchronisiert';
|
||||||
|
const diffMs = Date.now() - ts;
|
||||||
|
const min = Math.round(diffMs / 60_000);
|
||||||
|
if (min < 1) return 'gerade eben';
|
||||||
|
if (min < 60) return `vor ${min} Min`;
|
||||||
|
const h = Math.round(min / 60);
|
||||||
|
if (h < 24) return `vor ${h} Std`;
|
||||||
|
const d = Math.round(h / 24);
|
||||||
|
return `vor ${d} Tag${d === 1 ? '' : 'en'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRefresh() {
|
||||||
|
navigator.serviceWorker?.controller?.postMessage({ type: 'sync-check' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if label}
|
||||||
|
<div class="wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
class:offline={!network.online}
|
||||||
|
class:syncing={syncStatus.state.kind === 'syncing'}
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
>
|
||||||
|
{#if !network.online}
|
||||||
|
<WifiOff size={14} strokeWidth={2} />
|
||||||
|
{:else}
|
||||||
|
<RefreshCw size={14} strokeWidth={2} class="spin" />
|
||||||
|
{/if}
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
{#if expanded}
|
||||||
|
<div class="card" role="dialog">
|
||||||
|
<p class="when">Zuletzt synchronisiert: {formatRelative(syncStatus.lastSynced)}</p>
|
||||||
|
<button class="refresh" type="button" onclick={requestRefresh} disabled={!network.online}>
|
||||||
|
Jetzt aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: var(--pill-radius);
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.pill.offline {
|
||||||
|
color: #666;
|
||||||
|
background: #f1f3f1;
|
||||||
|
}
|
||||||
|
.pill.syncing {
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-color: #b7d6c2;
|
||||||
|
background: #eaf4ed;
|
||||||
|
}
|
||||||
|
.pill :global(.spin) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.when {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.refresh {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.refresh:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
30
src/lib/components/TimeDisplay.svelte
Normal file
30
src/lib/components/TimeDisplay.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
prepTimeMin: number | null;
|
||||||
|
cookTimeMin: number | null;
|
||||||
|
totalTimeMin: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { prepTimeMin, cookTimeMin, totalTimeMin }: Props = $props();
|
||||||
|
|
||||||
|
const summary = $derived.by(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (prepTimeMin) parts.push(`Vorb. ${prepTimeMin} min`);
|
||||||
|
if (cookTimeMin) parts.push(`Kochen ${cookTimeMin} min`);
|
||||||
|
if (!prepTimeMin && !cookTimeMin && totalTimeMin)
|
||||||
|
parts.push(`Gesamt ${totalTimeMin} min`);
|
||||||
|
return parts.join(' · ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if summary}
|
||||||
|
<p class="times">{summary}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.times {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
src/lib/components/Toast.svelte
Normal file
55
src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
import { toastStore } from '$lib/client/toast.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toasts" aria-live="polite" aria-atomic="true">
|
||||||
|
{#each toastStore.toasts as t (t.id)}
|
||||||
|
<div class="toast" class:error={t.kind === 'error'} class:success={t.kind === 'success'}>
|
||||||
|
<span class="msg">{t.message}</span>
|
||||||
|
<button class="close" aria-label="Schließen" onclick={() => toastStore.dismiss(t.id)}>
|
||||||
|
<X size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
|
||||||
|
max-width: min(92vw, 480px);
|
||||||
|
}
|
||||||
|
.toast.error { background: #c53030; }
|
||||||
|
.toast.success { background: #2b6a3d; }
|
||||||
|
.close {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.close:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
110
src/lib/components/UpdateToast.svelte
Normal file
110
src/lib/components/UpdateToast.svelte
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { RefreshCw, X } from 'lucide-svelte';
|
||||||
|
import { pwaStore } from '$lib/client/pwa.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pwaStore.updateAvailable}
|
||||||
|
<div class="toast" role="status" aria-live="polite">
|
||||||
|
<span class="msg">Neue Kochwas-Version verfügbar</span>
|
||||||
|
<button class="reload" onclick={() => pwaStore.reload()}>
|
||||||
|
<RefreshCw size={16} strokeWidth={2.2} />
|
||||||
|
<span>Neu laden</span>
|
||||||
|
</button>
|
||||||
|
<button class="dismiss" aria-label="Später" onclick={() => pwaStore.dismiss()}>
|
||||||
|
<X size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.85rem 0.6rem 1.1rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--pill-radius);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 500;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
animation: slide-up 0.3s ease-out;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, 130%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.reload {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--pill-radius);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.reload:hover {
|
||||||
|
background: #235532;
|
||||||
|
}
|
||||||
|
.dismiss {
|
||||||
|
background: transparent;
|
||||||
|
color: #aaa;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--pill-radius);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dismiss:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.toast {
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
transform: none;
|
||||||
|
max-width: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
flex: 1;
|
||||||
|
white-space: normal;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(130%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/lib/components/recipe-editor-types.ts
Normal file
9
src/lib/components/recipe-editor-types.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type DraftIng = {
|
||||||
|
qty: string;
|
||||||
|
unit: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
section_heading: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DraftStep = { text: string };
|
||||||
11
src/lib/constants.ts
Normal file
11
src/lib/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Shared timing constants. Keep magic numbers here so callers stay readable
|
||||||
|
// and the rationale lives next to the value.
|
||||||
|
|
||||||
|
// How long to wait for a Service Worker to answer GET_VERSION before
|
||||||
|
// treating the response as missing. Short on purpose — SWs that take this
|
||||||
|
// long are likely the Chromium zombie case (see pwa.svelte.ts).
|
||||||
|
export const SW_VERSION_QUERY_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
// Active update check while the page sits open in a tab. 30 minutes is a
|
||||||
|
// trade-off between being timely and not hammering the server.
|
||||||
|
export const SW_UPDATE_POLL_INTERVAL_MS = 30 * 60_000;
|
||||||
7
src/lib/quantity-format.ts
Normal file
7
src/lib/quantity-format.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function formatQuantity(q: number | null): string {
|
||||||
|
if (q === null || q === undefined) return '';
|
||||||
|
const rounded = Math.round(q);
|
||||||
|
if (Math.abs(q - rounded) < 0.01) return String(rounded);
|
||||||
|
// auf max. 2 Nachkommastellen, trailing Nullen raus
|
||||||
|
return q.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
157
src/lib/quotes.ts
Normal file
157
src/lib/quotes.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
export const QUOTES: readonly string[] = [
|
||||||
|
'Weil Pizza bestellen auch keine Lösung ist.',
|
||||||
|
'Kochen für Menschen, die eigentlich lieber essen würden.',
|
||||||
|
'Rezepte, bei denen sogar der Rauchmelder mitsingt.',
|
||||||
|
'Endlich Schluss mit „Was koch ich heute?"-Depressionen.',
|
||||||
|
'Für alle, die ihre Pfanne schon beim Namen nennen.',
|
||||||
|
'Weil Mama nicht immer ans Telefon geht.',
|
||||||
|
'Kochen ohne Tränen (Zwiebeln ausgenommen).',
|
||||||
|
'Rezepte, die sogar dein Ex hinkriegen würde.',
|
||||||
|
'Hier wird gekocht, nicht diskutiert.',
|
||||||
|
'Gut genug für Instagram, ehrlich genug für dich.',
|
||||||
|
'Weil Tiefkühlpizza auch nur Teig mit Problemen ist.',
|
||||||
|
'Rezepte für Erwachsene, die sich nicht so fühlen.',
|
||||||
|
'Kochen ist wie Liebe – man sollte es nicht halbherzig tun.',
|
||||||
|
'Für Menschen mit Hunger und wenig Geduld.',
|
||||||
|
'Das Kochbuch deiner Oma, nur ohne Augenrollen.',
|
||||||
|
'Weil „Toast mit Käse" kein Abendessen ist. Oder doch.',
|
||||||
|
'Rezepte, die halten, was dein Magen verspricht.',
|
||||||
|
"Hier gibt's Butter. Viel Butter.",
|
||||||
|
'Küchenchaos mit Anleitung.',
|
||||||
|
'Weil Lieferando deine Adresse schon auswendig kann.',
|
||||||
|
'Kochen für Profis, Anfänger und Katastrophen.',
|
||||||
|
'Das einzige Rezept-Buch, das nicht beleidigt ist, wenn du blätterst.',
|
||||||
|
'Für alle, die „al dente" endlich mal richtig aussprechen wollen.',
|
||||||
|
'Rezepte ohne 4.000 Wörter Einleitung über Omas Garten.',
|
||||||
|
'Heute kochen, morgen angeben.',
|
||||||
|
'Weil Hunger ein schlechter Lebenslauf ist.',
|
||||||
|
'Essen wie bei Muttern, nur ohne Nachfragen.',
|
||||||
|
'Rezepte, die deinen Kühlschrank endlich ernst nehmen.',
|
||||||
|
'Für Hobbyköche und Hoffnungsvolle.',
|
||||||
|
'Nicht perfekt. Aber lecker.',
|
||||||
|
'Die Küche ruft. Nimm ab.',
|
||||||
|
'Kochen ist günstiger als Therapie. Meistens.',
|
||||||
|
'Rezepte für das Chaos, das sich Alltag nennt.',
|
||||||
|
'Weil Wasser kochen allein nicht reicht.',
|
||||||
|
'Damit dein Dinner-Date nicht zum Escape-Room wird.',
|
||||||
|
'Essen, das besser schmeckt als es aussieht. Und besser aussieht als gedacht.',
|
||||||
|
'Kochbuch war gestern. Heute ist Browser.',
|
||||||
|
'Für Menschen, die Salz für eine Persönlichkeit halten.',
|
||||||
|
'Weil deine Mikrowelle auch mal Urlaub braucht.',
|
||||||
|
'Hier werden Träume wahr. Und Teller leer.',
|
||||||
|
'Weil guter Geschmack kein Zufall sein sollte.',
|
||||||
|
'Kochen für Leute, deren Rauchmelder zu sensibel ist.',
|
||||||
|
'Das Beste, was deiner Küche seit der Spülmaschine passiert ist.',
|
||||||
|
'Rezepte ohne „Eine Prise Liebe"-Quatsch.',
|
||||||
|
'Für Abende, an denen Netflix nicht reicht.',
|
||||||
|
'Weniger Bestellapps, mehr Bestellerrezepte.',
|
||||||
|
'Weil Essen eine Sprache ist, die jeder versteht.',
|
||||||
|
'Für die, die googeln, ob man Wasser anbrennen lassen kann.',
|
||||||
|
'Rezepte, die sogar dein WG-Mitbewohner nicht klaut. Okay, vielleicht doch.',
|
||||||
|
'Kochen. Essen. Wiederholen.',
|
||||||
|
'Weil Nudeln-mit-Pesto kein Lebensmodell ist.',
|
||||||
|
'Rezepte, an die sich selbst die Pfanne erinnert.',
|
||||||
|
'Mehr Kochen, weniger Ratlosigkeit um 18 Uhr.',
|
||||||
|
'Endlich Abendessen ohne Hintergedanken.',
|
||||||
|
'Rezepte, die dein Gemüsefach endlich rechtfertigen.',
|
||||||
|
'Weil „Irgendwas mit Reis" keine Antwort ist.',
|
||||||
|
'Für alle, die das Salz bisher nur falsch dosiert haben.',
|
||||||
|
'Das kulinarische Äquivalent zu einer Umarmung.',
|
||||||
|
'Weil Kochen die einzige Show ist, bei der du Hauptrolle spielst.',
|
||||||
|
'Abendessen, das sich nicht entschuldigen muss.',
|
||||||
|
'Für Tage, an denen sogar Tiefkühlpizza aufgibt.',
|
||||||
|
'Rezepte, die deinen Rauchmelder schonen.',
|
||||||
|
'Weil „Da war doch noch was im Kühlschrank" kein Plan ist.',
|
||||||
|
'Kochen für Menschen, deren Fantasie im Supermarkt endet.',
|
||||||
|
'Für alle, die „Prise" schon mal gegoogelt haben.',
|
||||||
|
'Rezepte, die dein Sonntagabend-Ich dir danken wird.',
|
||||||
|
'Weil jede gute Küche mit einem „Ups" anfängt.',
|
||||||
|
'Für die, die ihren Kochlöffel lieber als die Kollegen mögen.',
|
||||||
|
'Kochen ist das neue Meditieren. Aber mit Geräuschen.',
|
||||||
|
'Rezepte, die halten, auch wenn du mal nicht.',
|
||||||
|
'Für alle, die „zart-schmelzend" als Lebensziel ansehen.',
|
||||||
|
'Abendessen mit Charakter. Manchmal auch Charakterkrise.',
|
||||||
|
'Weil Essen zubereiten billiger ist als Therapie-Stunden.',
|
||||||
|
'Rezepte für Menschen mit hohen Erwartungen und kleiner Pfanne.',
|
||||||
|
'Für Momente, in denen der Hunger größer ist als die Geduld.',
|
||||||
|
'Koch-Erinnerungen, ohne Oma anzurufen.',
|
||||||
|
'Weil nichts so verbindet wie ein geteilter Löffel.',
|
||||||
|
'Rezepte, bei denen der Käse nicht fragt, ob er darf.',
|
||||||
|
'Für alle, die „kurz ins Kochbuch schauen" für drei Stunden halten.',
|
||||||
|
'Essen, das dich nicht bei Instagram bloßstellt.',
|
||||||
|
'Rezepte ohne „Zuerst das Chaos sortieren"-Schritt.',
|
||||||
|
'Weil jedes gute Essen eine kleine Rebellion ist.',
|
||||||
|
'Für die, die Kochen als Sport zählen.',
|
||||||
|
'Abends kochen ist günstiger als Achtsamkeitskurse.',
|
||||||
|
'Rezepte, die dein Kaufhaus-Kochbuch alt aussehen lassen.',
|
||||||
|
'Für alle, die „Ich kann nicht kochen" als Feature, nicht Bug nutzen.',
|
||||||
|
'Weil Butter manchmal die Antwort ist. Und manchmal die Frage.',
|
||||||
|
'Hunger. Hinweise. Happy End.',
|
||||||
|
'Rezepte für Leute, die ihren Kaffee auch ernst nehmen.',
|
||||||
|
'Weil Kochen ein gutes Gespräch ersetzt. Manchmal.',
|
||||||
|
'Abendessen ohne Ausrede.',
|
||||||
|
'Rezepte, die der Küchenuhr einen Grund geben.',
|
||||||
|
'Für alle, die „Salz und Pfeffer nach Geschmack" als Lebensweisheit sehen.',
|
||||||
|
'Kochen gegen die Uhr, gewinnen gegen den Kühlschrank.',
|
||||||
|
'Rezepte, die sogar das Spülbecken beeindrucken.',
|
||||||
|
'Weil „Was gibt\'s?" eine Freundschaftsfrage ist.',
|
||||||
|
'Für Tage, an denen alles gelingt – außer Google Maps.',
|
||||||
|
'Essen, das dich wieder zum Esser macht.',
|
||||||
|
'Rezepte, die in weniger Zeit klappen als ein Staffelfinale.',
|
||||||
|
'Weil dein Magen kein Demokrat ist.',
|
||||||
|
'Kochen ist, was passiert, während du andere Pläne machst.',
|
||||||
|
'Rezepte für die Küche, nicht für die Galerie.',
|
||||||
|
'Für alle, die beim Würzen Gefühle haben.',
|
||||||
|
'Weil jeder Topf mal sein Abenteuer braucht.',
|
||||||
|
'Rezepte, die auch bei Regen funktionieren.',
|
||||||
|
'Abendessen ohne Nachspielzeit.',
|
||||||
|
'Für die, die „Zutaten nach Augenmaß" als Lifestyle führen.',
|
||||||
|
'Kochen: die einzige App, die wirklich offline läuft.',
|
||||||
|
'Rezepte, die dein Besteck wieder in Bewegung bringen.',
|
||||||
|
'Für Menschen mit Küche, aber ohne Plan.',
|
||||||
|
'Weil Lorbeer kein Zufall ist.',
|
||||||
|
'Rezepte, die auch deine Nachbarn hören lassen.',
|
||||||
|
'Für alle, die beim Schnippeln Podcasts brauchen.',
|
||||||
|
'Abendessen, bei dem sich der Kühlschrank freut.',
|
||||||
|
'Rezepte, die deine Pfanne streicheln.',
|
||||||
|
'Weil Essen ohne Geschichte nur Kalorien ist.',
|
||||||
|
'Für Menschen, die ihre Kochschürze mit Stolz tragen.',
|
||||||
|
'Rezepte für den inneren Gourmet und den äußeren Alltag.',
|
||||||
|
'Weil jeder Abend einen guten Duft verdient hat.',
|
||||||
|
'Für alle, die Lieferheld auswendig können, aber nicht mehr wollen.',
|
||||||
|
'Kochen ist Sport für Menschen, die gerne sitzen.',
|
||||||
|
'Rezepte, bei denen dein Teller dich anlacht.',
|
||||||
|
'Für Tage, an denen nur Butter versteht.',
|
||||||
|
'Weil Pasta keine Jahreszeit kennt.',
|
||||||
|
'Rezepte, die dein „Kann nicht kochen"-Etikett abkratzen.',
|
||||||
|
'Abendessen für Optimisten und Realisten.',
|
||||||
|
'Für alle, die „kurz umrühren" als Kardio zählen.',
|
||||||
|
'Weil jede gute Mahlzeit mit „Kann ich helfen?" anfängt.',
|
||||||
|
'Rezepte, die dein Bauchgefühl bestätigen.',
|
||||||
|
'Für Küchen mit Charakter und Besitzer mit Hunger.',
|
||||||
|
'Kochen ist wie Atmen, nur mit Soße.',
|
||||||
|
'Rezepte, die keine Ausreden akzeptieren.',
|
||||||
|
'Für alle, die „al forno" schon fast richtig sprechen.',
|
||||||
|
'Weil Soße die Antwort auf fast jede Frage ist.',
|
||||||
|
'Abendessen ohne Drama. Außer beim Zwiebelschneiden.',
|
||||||
|
'Rezepte für den Herd und fürs Herz.',
|
||||||
|
'Für die, die „nur eine Kleinigkeit" mit drei Gängen übersetzen.',
|
||||||
|
'Weil Kochen der kürzeste Weg zu „Kannst du nochmal?" ist.',
|
||||||
|
'Rezepte, die auch dein Nachbar riechen darf.',
|
||||||
|
'Für alle, die Käse als Bindfaden der Freundschaft sehen.',
|
||||||
|
'Kochen schlägt Scrollen. Meistens.',
|
||||||
|
'Rezepte, die dein Küchentuch endlich rehabilitieren.',
|
||||||
|
'Für Menschen mit wenig Zeit und viel Hunger.',
|
||||||
|
'Weil Olivenöl zwar kein Grundnahrungsmittel ist, aber fast.',
|
||||||
|
'Rezepte, bei denen deine Waage nicht mitredet.',
|
||||||
|
'Abendessen ohne Kompromiss.',
|
||||||
|
'Für alle, die beim Kochen tanzen und beim Tanzen kochen.',
|
||||||
|
'Rezepte für den Alltag, die nicht nach Alltag schmecken.',
|
||||||
|
'Weil jede gute Mahlzeit einen Moment der Stille verdient.',
|
||||||
|
'Kochen: alte Tradition, neue Ergebnisse.',
|
||||||
|
'Weil „Ich hole nur Wasser" nie bei nur Wasser bleibt.'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function randomQuote(): string {
|
||||||
|
return QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
||||||
|
}
|
||||||
56
src/lib/server/ai/description-phrases.ts
Normal file
56
src/lib/server/ai/description-phrases.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export const DESCRIPTION_PHRASES: readonly string[] = [
|
||||||
|
'Mit dem Zauberstab aus dem Kochbuch geholt.',
|
||||||
|
'Foto-Magie frisch aus dem Ofen.',
|
||||||
|
'Aus dem Bild herbeigezaubert.',
|
||||||
|
'Ein Klick, ein Foto, fertig.',
|
||||||
|
'Knipsen statt Abtippen.',
|
||||||
|
'Von der Buchseite direkt in die Pfanne.',
|
||||||
|
'Die Kamera hat mitgelesen.',
|
||||||
|
'Abrakadabra — Rezept da.',
|
||||||
|
'Per Linse in die Küche teleportiert.',
|
||||||
|
'Von Oma abfotografiert, von der KI entziffert.',
|
||||||
|
'Frisch aus dem Bilderrahmen.',
|
||||||
|
'Klick, zisch, Rezept.',
|
||||||
|
'Das Foto wurde überredet, sich zu verraten.',
|
||||||
|
'Schnappschuss zur Schüssel.',
|
||||||
|
'Einmal lesen lassen, schon da.',
|
||||||
|
'Keine Hand hat dieses Rezept abgetippt.',
|
||||||
|
'Vom Bild in die Bratpfanne.',
|
||||||
|
'Papier ist geduldig, das Foto war es auch.',
|
||||||
|
'Eine Seite, ein Foto, ein Rezept.',
|
||||||
|
'Die KI hat drübergeschielt.',
|
||||||
|
'Handschriftlich entziffert — oder zumindest versucht.',
|
||||||
|
'Aus der Linse in die Liste.',
|
||||||
|
'Vom Küchentisch zur Kachel.',
|
||||||
|
'Knips und weg — zumindest der Zettel.',
|
||||||
|
'Das Bild hat geredet.',
|
||||||
|
'Keine Tippfehler, nur Sehfehler.',
|
||||||
|
'Per Foto eingebürgert.',
|
||||||
|
'Rezept-Übersetzung aus dem Bild.',
|
||||||
|
'Die Seite hat sich verraten.',
|
||||||
|
'Blitzlicht und dann Gulasch.',
|
||||||
|
'Ein Augenzwinkern der Kamera genügte.',
|
||||||
|
'Geknipst, gelesen, gespeichert.',
|
||||||
|
'Fotografische Gedächtnishilfe.',
|
||||||
|
'Aus der Schublade ans Licht.',
|
||||||
|
'Das Rezept stand schon da — wir haben nur hingeguckt.',
|
||||||
|
'Zaubertrick mit Kamera.',
|
||||||
|
'Vom Papier befreit.',
|
||||||
|
'Ein Foto sagt mehr als tausend Zutatenlisten.',
|
||||||
|
'Eingescannt, rausgelesen, reingeschrieben.',
|
||||||
|
'Die Kamera als Küchenhilfe.',
|
||||||
|
'Handy hoch, Rezept runter.',
|
||||||
|
'Aus dem Kochbuch gebeamt.',
|
||||||
|
'Ein scharfes Foto, ein klares Rezept.',
|
||||||
|
'Vom Regal zur App in einem Schritt.',
|
||||||
|
'Aus dem Bild geschöpft wie Suppe aus dem Topf.',
|
||||||
|
'Optisch erfasst, digital serviert.',
|
||||||
|
'Das Kleingedruckte hat die KI gelesen.',
|
||||||
|
'Vom Kladdenzettel in die Datenbank.',
|
||||||
|
'Kurz gezückt, schon gekocht.',
|
||||||
|
'Kein Schreibkrampf, nur ein Klick.'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function pickRandomPhrase(): string {
|
||||||
|
return DESCRIPTION_PHRASES[Math.floor(Math.random() * DESCRIPTION_PHRASES.length)];
|
||||||
|
}
|
||||||
170
src/lib/server/ai/gemini-client.ts
Normal file
170
src/lib/server/ai/gemini-client.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import {
|
||||||
|
RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
||||||
|
RECIPE_EXTRACTION_USER_PROMPT,
|
||||||
|
GEMINI_RESPONSE_SCHEMA,
|
||||||
|
extractionResponseSchema,
|
||||||
|
type ExtractionResponse
|
||||||
|
} from './recipe-extraction-prompt';
|
||||||
|
|
||||||
|
export type GeminiErrorCode =
|
||||||
|
| 'AI_NOT_CONFIGURED'
|
||||||
|
| 'AI_RATE_LIMITED'
|
||||||
|
| 'AI_TIMEOUT'
|
||||||
|
| 'AI_FAILED';
|
||||||
|
|
||||||
|
export class GeminiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: GeminiErrorCode,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'GeminiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus(err: unknown): number | undefined {
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) {
|
||||||
|
const s = (err as { status?: unknown }).status;
|
||||||
|
if (typeof s === 'number') return s;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCfg(): { apiKey: string; model: string; timeoutMs: number } {
|
||||||
|
const apiKey = env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY ?? '';
|
||||||
|
const model =
|
||||||
|
env.GEMINI_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';
|
||||||
|
const rawTimeout =
|
||||||
|
env.GEMINI_TIMEOUT_MS ?? process.env.GEMINI_TIMEOUT_MS ?? '20000';
|
||||||
|
const timeoutMs = Number(rawTimeout) || 20000;
|
||||||
|
return { apiKey, model, timeoutMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new GeminiError('AI_TIMEOUT', `Gemini timeout after ${ms} ms`)),
|
||||||
|
ms
|
||||||
|
);
|
||||||
|
promise.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callGemini(
|
||||||
|
imageBuffer: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
appendUserNote?: string
|
||||||
|
): Promise<ExtractionResponse> {
|
||||||
|
const { apiKey, model: modelId, timeoutMs } = getCfg();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new GeminiError('AI_NOT_CONFIGURED', 'GEMINI_API_KEY is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new GoogleGenerativeAI(apiKey);
|
||||||
|
const model = client.getGenerativeModel({
|
||||||
|
model: modelId,
|
||||||
|
systemInstruction: RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
||||||
|
generationConfig: {
|
||||||
|
temperature: 0.1,
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
responseSchema: GEMINI_RESPONSE_SCHEMA as any
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts: Array<
|
||||||
|
{ inlineData: { data: string; mimeType: string } } | { text: string }
|
||||||
|
> = [
|
||||||
|
{ inlineData: { data: imageBuffer.toString('base64'), mimeType } },
|
||||||
|
{ text: RECIPE_EXTRACTION_USER_PROMPT }
|
||||||
|
];
|
||||||
|
if (appendUserNote) parts.push({ text: appendUserNote });
|
||||||
|
|
||||||
|
const result = await withTimeout(
|
||||||
|
model.generateContent({ contents: [{ role: 'user', parts }] }),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
const text = result.response.text();
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new GeminiError('AI_FAILED', 'Gemini returned non-JSON output');
|
||||||
|
}
|
||||||
|
const validated = extractionResponseSchema.safeParse(parsed);
|
||||||
|
if (!validated.success) {
|
||||||
|
throw new GeminiError(
|
||||||
|
'AI_FAILED',
|
||||||
|
`Schema validation failed: ${validated.error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return validated.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public entry: one retry on recoverable failures (5xx or schema-invalid),
|
||||||
|
// no retry on 429, AI_TIMEOUT, or config errors.
|
||||||
|
export async function extractRecipeFromImage(
|
||||||
|
imageBuffer: Buffer,
|
||||||
|
mimeType: string
|
||||||
|
): Promise<ExtractionResponse> {
|
||||||
|
let firstMsg: string | null = null;
|
||||||
|
try {
|
||||||
|
return await callGemini(imageBuffer, mimeType);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof GeminiError && e.code === 'AI_NOT_CONFIGURED') throw e;
|
||||||
|
if (e instanceof GeminiError && e.code === 'AI_TIMEOUT') throw e;
|
||||||
|
|
||||||
|
const status = getStatus(e);
|
||||||
|
if (status === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit');
|
||||||
|
|
||||||
|
const recoverable =
|
||||||
|
(e instanceof GeminiError && e.code === 'AI_FAILED') ||
|
||||||
|
(status !== undefined && status >= 500);
|
||||||
|
if (!recoverable) {
|
||||||
|
throw e instanceof GeminiError
|
||||||
|
? e
|
||||||
|
: new GeminiError('AI_FAILED', String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
firstMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
try {
|
||||||
|
return await callGemini(
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
|
||||||
|
);
|
||||||
|
} catch (retryErr) {
|
||||||
|
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||||
|
if (retryErr instanceof GeminiError) {
|
||||||
|
if (retryErr.code === 'AI_FAILED') {
|
||||||
|
throw new GeminiError(
|
||||||
|
'AI_FAILED',
|
||||||
|
`retry failed: ${retryMsg} (first: ${firstMsg})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw retryErr;
|
||||||
|
}
|
||||||
|
const retryStatus = getStatus(retryErr);
|
||||||
|
if (retryStatus === 429)
|
||||||
|
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry');
|
||||||
|
throw new GeminiError(
|
||||||
|
'AI_FAILED',
|
||||||
|
`retry failed: ${retryMsg} (first: ${firstMsg})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/lib/server/ai/image-preprocess.ts
Normal file
54
src/lib/server/ai/image-preprocess.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type SharpType from 'sharp';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const MAX_EDGE = 1600;
|
||||||
|
const JPEG_QUALITY = 85;
|
||||||
|
|
||||||
|
export type PreprocessedImage = {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimeType: 'image/jpeg';
|
||||||
|
};
|
||||||
|
|
||||||
|
// sharp per Node-Runtime-require laden, nicht via ES-Import: adapter-node
|
||||||
|
// bundelt ES-Imports (auch dynamische, auch mit @vite-ignore) ins Server-
|
||||||
|
// Bundle, was sharp's internes dynamic-require fuer die Plattform-.node-Binary
|
||||||
|
// zerstoert. createRequire + require() ist pure Node-Runtime-Logik, die
|
||||||
|
// Rollup nicht anfasst -- sharp wird regulaer aus node_modules geladen.
|
||||||
|
const nodeRequire = createRequire(import.meta.url);
|
||||||
|
let sharpModule: typeof SharpType | null = null;
|
||||||
|
function loadSharp(): typeof SharpType {
|
||||||
|
if (!sharpModule) {
|
||||||
|
sharpModule = nodeRequire('sharp') as typeof SharpType;
|
||||||
|
}
|
||||||
|
return sharpModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen.
|
||||||
|
// sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist
|
||||||
|
// (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds).
|
||||||
|
export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> {
|
||||||
|
const sharp = loadSharp();
|
||||||
|
const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation
|
||||||
|
const meta = await pipeline.metadata();
|
||||||
|
if (!meta.width || !meta.height) {
|
||||||
|
throw new Error('Unable to read image dimensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const longEdge = Math.max(meta.width, meta.height);
|
||||||
|
const resized =
|
||||||
|
longEdge > MAX_EDGE
|
||||||
|
? pipeline.resize({
|
||||||
|
width: meta.width >= meta.height ? MAX_EDGE : undefined,
|
||||||
|
height: meta.height > meta.width ? MAX_EDGE : undefined,
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
: pipeline;
|
||||||
|
|
||||||
|
// Default-Verhalten seit sharp 0.33: alle Metadata (EXIF/IPTC/XMP) werden
|
||||||
|
// gestripped. Nur `.keepMetadata()`/`.keepExif()` würde sie erhalten.
|
||||||
|
const buffer = await resized
|
||||||
|
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return { buffer, mimeType: 'image/jpeg' };
|
||||||
|
}
|
||||||
21
src/lib/server/ai/rate-limit.ts
Normal file
21
src/lib/server/ai/rate-limit.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export type RateLimiter = { check: (key: string) => boolean };
|
||||||
|
|
||||||
|
export function createRateLimiter(opts: {
|
||||||
|
windowMs: number;
|
||||||
|
max: number;
|
||||||
|
}): RateLimiter {
|
||||||
|
const store = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
return {
|
||||||
|
check(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = store.get(key);
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + opts.windowMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.count >= opts.max) return false;
|
||||||
|
entry.count += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
94
src/lib/server/ai/recipe-extraction-prompt.ts
Normal file
94
src/lib/server/ai/recipe-extraction-prompt.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { SchemaType } from '@google/generative-ai';
|
||||||
|
|
||||||
|
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein hochpräziser OCR-Experte für kulinarische Dokumente (Rezepte). Deine Aufgabe ist die Extraktion von Rezeptdaten (Titel, Zutaten, Zubereitungsschritte, Zeiten, Portionen) in valides JSON gemäß dem vorgegebenen Schema.
|
||||||
|
|
||||||
|
SPRACHE:
|
||||||
|
- Die Texte sind ausschließlich auf Deutsch. Nutze deutsches Sprachverständnis (Umlaute ä/ö/ü/ß, deutsche Zutatennamen, deutsche Maßeinheiten) als starken Prior bei der Rekonstruktion unklarer Zeichen. Gib die Ausgabe vollständig auf Deutsch zurück.
|
||||||
|
|
||||||
|
LOGIK-REGELN FÜR SCHWER LESBARE TEXTE:
|
||||||
|
- Handle als "Kontext-Detektiv": Wenn Zeichen unklar sind, nutze kulinarisches Wissen zur Rekonstruktion (z.B. "Pr-se" -> "Prise").
|
||||||
|
- Bei absoluter Unleserlichkeit eines Wortes: Nutze "[?]".
|
||||||
|
- Halluziniere keine fehlenden Werte: Wenn eine Mengenangabe komplett fehlt, setze 'quantity' auf null. Was nicht auf dem Bild steht, ist null (oder leeres Array).
|
||||||
|
|
||||||
|
FORMATIERUNGS-REGELN:
|
||||||
|
- Zutaten: quantity (Zahl) separat von unit (String). Brüche (½, ¼, 1 ½) strikt in Dezimalzahlen (0.5, 0.25, 1.5).
|
||||||
|
- Einheiten: Normalisiere auf (g, ml, l, kg, EL, TL, Stück, Prise, Msp).
|
||||||
|
- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
|
||||||
|
- Zeit: Alle Angaben strikt in Minuten (Integer). "1 Stunde" = 60.
|
||||||
|
- Rauschen ignorieren: Keine Werbung, Einleitungstexte oder Bildunterschriften extrahieren.
|
||||||
|
|
||||||
|
STRIKTE ANWEISUNG: Gib ausschließlich das rohe JSON-Objekt gemäß Schema zurück. Kein Markdown-Code-Block, kein Einleitungstext, keine Prosa.`;
|
||||||
|
|
||||||
|
export const RECIPE_EXTRACTION_USER_PROMPT =
|
||||||
|
'Analysiere dieses Bild hochauflösend. Extrahiere alle rezeptrelevanten Informationen gemäß deiner System-Instruktion. Achte besonders auf schwache Handschriften oder verblassten Text und stelle sicher, dass die Zuordnung von Menge zu Zutat logisch korrekt ist.';
|
||||||
|
|
||||||
|
// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
|
||||||
|
// übergeben; Gemini respektiert die Struktur und liefert valides JSON.
|
||||||
|
export const GEMINI_RESPONSE_SCHEMA = {
|
||||||
|
type: SchemaType.OBJECT,
|
||||||
|
properties: {
|
||||||
|
title: { type: SchemaType.STRING, nullable: false },
|
||||||
|
servings_default: { type: SchemaType.INTEGER, nullable: true },
|
||||||
|
servings_unit: { type: SchemaType.STRING, nullable: true },
|
||||||
|
prep_time_min: { type: SchemaType.INTEGER, nullable: true },
|
||||||
|
cook_time_min: { type: SchemaType.INTEGER, nullable: true },
|
||||||
|
total_time_min: { type: SchemaType.INTEGER, nullable: true },
|
||||||
|
ingredients: {
|
||||||
|
type: SchemaType.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: SchemaType.OBJECT,
|
||||||
|
properties: {
|
||||||
|
quantity: { type: SchemaType.NUMBER, nullable: true },
|
||||||
|
unit: { type: SchemaType.STRING, nullable: true },
|
||||||
|
name: { type: SchemaType.STRING, nullable: false },
|
||||||
|
note: { type: SchemaType.STRING, nullable: true }
|
||||||
|
},
|
||||||
|
required: ['name']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
type: SchemaType.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: SchemaType.OBJECT,
|
||||||
|
properties: {
|
||||||
|
text: { type: SchemaType.STRING, nullable: false }
|
||||||
|
},
|
||||||
|
required: ['text']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['title', 'ingredients', 'steps']
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Zod-Spiegel des Schemas. .strict() verhindert, dass Gemini zusätzliche Keys
|
||||||
|
// unbemerkt durchschmuggelt.
|
||||||
|
const ingredientSchema = z
|
||||||
|
.object({
|
||||||
|
quantity: z.number().nullable(),
|
||||||
|
unit: z.string().max(30).nullable(),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
note: z.string().max(300).nullable()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const stepSchema = z
|
||||||
|
.object({
|
||||||
|
text: z.string().min(1).max(4000)
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const extractionResponseSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
servings_default: z.number().int().nonnegative().nullable(),
|
||||||
|
servings_unit: z.string().max(30).nullable(),
|
||||||
|
prep_time_min: z.number().int().nonnegative().nullable(),
|
||||||
|
cook_time_min: z.number().int().nonnegative().nullable(),
|
||||||
|
total_time_min: z.number().int().nonnegative().nullable(),
|
||||||
|
ingredients: z.array(ingredientSchema),
|
||||||
|
steps: z.array(stepSchema)
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ExtractionResponse = z.infer<typeof extractionResponseSchema>;
|
||||||
39
src/lib/server/api-helpers.ts
Normal file
39
src/lib/server/api-helpers.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { ZodSchema } from 'zod';
|
||||||
|
|
||||||
|
// Shared error body shape for SvelteKit `error()` calls. `issues` is set
|
||||||
|
// when validateBody fails so the client can show a precise validation
|
||||||
|
// hint; everywhere else only `message` is used.
|
||||||
|
export type ErrorResponse = {
|
||||||
|
message: string;
|
||||||
|
issues?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a route param (or query param) as a positive integer (>=1).
|
||||||
|
* Throws SvelteKit `error(400)` with `Missing <field>` when null/undefined,
|
||||||
|
* or `Invalid <field>` when the value is not an integer >= 1.
|
||||||
|
*/
|
||||||
|
export function parsePositiveIntParam(
|
||||||
|
raw: string | undefined | null,
|
||||||
|
field: string
|
||||||
|
): number {
|
||||||
|
if (raw == null) error(400, { message: `Missing ${field}` });
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an unknown body against a Zod schema. Throws SvelteKit
|
||||||
|
* `error(400, { message: 'Invalid body', issues })` on mismatch and returns
|
||||||
|
* the typed parse result on success. Accepts `null` (the typical result of
|
||||||
|
* `await request.json().catch(() => null)`).
|
||||||
|
*/
|
||||||
|
export function validateBody<T>(body: unknown, schema: ZodSchema<T>): T {
|
||||||
|
const parsed = schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
error(400, { message: 'Invalid body', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
|
import { DATABASE_PATH, IMAGE_DIR } from '$lib/server/paths';
|
||||||
import { runMigrations } from './migrate';
|
import { runMigrations } from './migrate';
|
||||||
|
|
||||||
let instance: Database.Database | null = null;
|
let instance: Database.Database | null = null;
|
||||||
|
|
||||||
export function getDb(path = process.env.DATABASE_PATH ?? './data/kochwas.db'): Database.Database {
|
export function getDb(path = DATABASE_PATH): Database.Database {
|
||||||
if (instance) return instance;
|
if (instance) return instance;
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
const imageDir = process.env.IMAGE_DIR ?? './data/images';
|
mkdirSync(IMAGE_DIR, { recursive: true });
|
||||||
mkdirSync(imageDir, { recursive: true });
|
|
||||||
instance = new Database(path);
|
instance = new Database(path);
|
||||||
instance.pragma('journal_mode = WAL');
|
instance.pragma('journal_mode = WAL');
|
||||||
instance.pragma('foreign_keys = ON');
|
instance.pragma('foreign_keys = ON');
|
||||||
|
|||||||
10
src/lib/server/db/migrations/003_thumbnail_cache.sql
Normal file
10
src/lib/server/db/migrations/003_thumbnail_cache.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Long-term cache for page → image URL mappings extracted via og:image,
|
||||||
|
-- JSON-LD, or first content <img>. Fetching every recipe page on every
|
||||||
|
-- search is expensive; store the mapping with a 30-day default TTL.
|
||||||
|
CREATE TABLE thumbnail_cache (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
image TEXT, -- NULL = page has no image (cache the negative too)
|
||||||
|
expires_at TEXT NOT NULL -- ISO-8601 UTC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_thumbnail_cache_expires ON thumbnail_cache(expires_at);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Let the user dismiss individual recipes from the "Zuletzt hinzugefügt"
|
||||||
|
-- list on the homepage. The recipe itself stays searchable and fully
|
||||||
|
-- functional — only its appearance in the "recent" list is suppressed.
|
||||||
|
ALTER TABLE recipe ADD COLUMN hidden_from_recent INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX idx_recipe_hidden_from_recent ON recipe(hidden_from_recent, created_at);
|
||||||
29
src/lib/server/db/migrations/005_wishlist_per_user.sql
Normal file
29
src/lib/server/db/migrations/005_wishlist_per_user.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Wishlist: from "one entry per recipe" to "per-user membership".
|
||||||
|
-- Multiple profiles can now wish for the same recipe. The old wishlist_like
|
||||||
|
-- table merges into this — liking WAS already "me too", so existing likes
|
||||||
|
-- become wishlist memberships.
|
||||||
|
|
||||||
|
CREATE TABLE wishlist_new (
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
|
||||||
|
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (recipe_id, profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Preserve existing explicit additions (only if a profile was attached)
|
||||||
|
INSERT OR IGNORE INTO wishlist_new (recipe_id, profile_id, added_at)
|
||||||
|
SELECT recipe_id, added_by_profile_id, added_at
|
||||||
|
FROM wishlist
|
||||||
|
WHERE added_by_profile_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Likes become memberships
|
||||||
|
INSERT OR IGNORE INTO wishlist_new (recipe_id, profile_id, added_at)
|
||||||
|
SELECT recipe_id, profile_id, created_at
|
||||||
|
FROM wishlist_like;
|
||||||
|
|
||||||
|
DROP TABLE wishlist_like;
|
||||||
|
DROP TABLE wishlist;
|
||||||
|
ALTER TABLE wishlist_new RENAME TO wishlist;
|
||||||
|
|
||||||
|
CREATE INDEX idx_wishlist_profile ON wishlist(profile_id);
|
||||||
|
CREATE INDEX idx_wishlist_recipe ON wishlist(recipe_id);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Frühere Versionen haben '🍳' als Default im "Neues Profil"-Emoji-Feld
|
||||||
|
-- vorausgefüllt — die meisten User haben das einfach so stehen lassen,
|
||||||
|
-- ohne bewusst ein Emoji zu wählen. Ergebnis: alle Profile sehen gleich aus.
|
||||||
|
-- Wir räumen das auf: alle avatar_emoji='🍳'-Einträge werden zu NULL,
|
||||||
|
-- was die UI als "kein Emoji, Lucide-Icon nehmen" interpretiert.
|
||||||
|
UPDATE profile SET avatar_emoji = NULL WHERE avatar_emoji = '🍳';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Erweitert thumbnail_cache um ein has_recipe-Flag. Beim Thumbnail-
|
||||||
|
-- Enrichment checken wir, ob die Seite überhaupt ein schema.org/Recipe
|
||||||
|
-- JSON-LD enthält — sonst kann der Importer das Rezept später sowieso
|
||||||
|
-- nicht extrahieren, und der User sieht nur die „Diese Seite enthält
|
||||||
|
-- kein Rezept"-Fehlermeldung.
|
||||||
|
--
|
||||||
|
-- NULL = unbekannt (vor dieser Migration gecached oder Fetch schlug fehl,
|
||||||
|
-- dann behalten wir den Treffer konservativ);
|
||||||
|
-- 0 = gesicherter Nicht-Treffer (ausblenden);
|
||||||
|
-- 1 = Rezept vorhanden.
|
||||||
|
ALTER TABLE thumbnail_cache ADD COLUMN has_recipe INTEGER;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Bei Migration 007 war `allowTruncate` in fetchText noch nicht implementiert,
|
||||||
|
-- weshalb Seiten >512 KB einen Fehler warfen und hasRecipe als NULL (unbekannt)
|
||||||
|
-- gespeichert wurde. Diese Einträge würden weitere 30 Tage nicht revalidiert
|
||||||
|
-- und Treffer ohne schema.org/Recipe-Markup fälschlich durchlassen. Wir
|
||||||
|
-- räumen sie jetzt einmalig ab, damit sie beim nächsten Fetch korrekt
|
||||||
|
-- klassifiziert werden. Ein reines Cache-Flush, keine User-Daten betroffen.
|
||||||
|
DELETE FROM thumbnail_cache WHERE has_recipe IS NULL;
|
||||||
5
src/lib/server/db/migrations/009_domain_favicon.sql
Normal file
5
src/lib/server/db/migrations/009_domain_favicon.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Speichert das Favicon-Dateiname für jede Whitelist-Domain, damit die
|
||||||
|
-- UI (Filter-Dropdown, Karten) das Site-Icon neben dem Domain-Namen
|
||||||
|
-- anzeigen kann. NULL = noch nicht geladen; wird beim nächsten GET
|
||||||
|
-- /api/domains automatisch nachgezogen.
|
||||||
|
ALTER TABLE allowed_domain ADD COLUMN favicon_path TEXT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Der Recipe-Detektor prüft ab jetzt zusätzlich zu JSON-LD auch Microdata
|
||||||
|
-- (itemtype=schema.org/Recipe). Der Cache kann has_recipe=0-Einträge
|
||||||
|
-- enthalten, die mit dem alten Check falsch-negativ waren (z.B. rezeptwelt.de,
|
||||||
|
-- das Microdata statt JSON-LD nutzt). Einmalig wegräumen, damit die Seiten
|
||||||
|
-- beim nächsten Search neu klassifiziert werden. Reiner Cache-Flush.
|
||||||
|
DELETE FROM thumbnail_cache WHERE has_recipe = 0;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Der Favicon-Fetcher versucht ab jetzt zuerst die <link rel="icon">-Tags
|
||||||
|
-- aus der Homepage, weil WordPress-Seiten (z.B. Emmi kocht einfach) unter
|
||||||
|
-- /favicon.ico ein generisches Zahnrad-Default des Hosters ausliefern und
|
||||||
|
-- das eigentliche Site-Icon erst im <head> auftaucht. Einmalig alle
|
||||||
|
-- gespeicherten Favicon-Pfade zurücksetzen, damit sie mit der neuen
|
||||||
|
-- Heuristik neu geladen werden. Alte Dateien bleiben als Orphans im
|
||||||
|
-- IMAGE_DIR, sind aber harmlos.
|
||||||
|
UPDATE allowed_domain SET favicon_path = NULL;
|
||||||
7
src/lib/server/db/migrations/012_ingredient_section.sql
Normal file
7
src/lib/server/db/migrations/012_ingredient_section.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Nullable-Spalte fuer optionale Sektionsueberschriften bei Zutaten. User
|
||||||
|
-- soll im Editor gruppieren koennen ("Fuer den Teig", "Fuer die Fuellung").
|
||||||
|
-- Rendering-Regel: Ist section_heading gesetzt (nicht NULL, nicht leer),
|
||||||
|
-- startet an dieser Zeile eine neue Sektion mit diesem Titel; alle folgenden
|
||||||
|
-- Zutaten gehoeren dazu, bis die naechste Zeile wieder eine Ueberschrift hat.
|
||||||
|
-- Ordnung bleibt die bestehende position-Spalte.
|
||||||
|
ALTER TABLE ingredient ADD COLUMN section_heading TEXT;
|
||||||
18
src/lib/server/db/migrations/013_shopping_list.sql
Normal file
18
src/lib/server/db/migrations/013_shopping_list.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Einkaufsliste: haushaltsweit geteilt. shopping_cart_recipe haelt die
|
||||||
|
-- Rezepte im Wagen (inkl. gewuenschter Portionsgroesse), shopping_cart_check
|
||||||
|
-- die abgehakten aggregierten Zutaten-Zeilen. Aggregation wird bei jedem
|
||||||
|
-- Read aus shopping_cart_recipe JOIN ingredient derived — nichts
|
||||||
|
-- materialisiert, damit Rezept-Edits live durchschlagen.
|
||||||
|
CREATE TABLE shopping_cart_recipe (
|
||||||
|
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
|
||||||
|
servings INTEGER NOT NULL CHECK (servings > 0),
|
||||||
|
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
|
||||||
|
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shopping_cart_check (
|
||||||
|
name_key TEXT NOT NULL,
|
||||||
|
unit_key TEXT NOT NULL,
|
||||||
|
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (name_key, unit_key)
|
||||||
|
);
|
||||||
10
src/lib/server/db/migrations/014_recipe_view.sql
Normal file
10
src/lib/server/db/migrations/014_recipe_view.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Merkt je Profil, wann ein Rezept zuletzt angesehen wurde.
|
||||||
|
-- Dient als Basis fuer "Zuletzt gesehen"-Sortierung auf der Startseite.
|
||||||
|
CREATE TABLE recipe_view (
|
||||||
|
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
|
||||||
|
last_viewed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (profile_id, recipe_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_recipe_view_recent
|
||||||
|
ON recipe_view (profile_id, last_viewed_at DESC);
|
||||||
166
src/lib/server/domains/favicons.ts
Normal file
166
src/lib/server/domains/favicons.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fetchBuffer, fetchText } from '../http';
|
||||||
|
import { listDomains, setDomainFavicon } from './repository';
|
||||||
|
|
||||||
|
const EXT_BY_CONTENT_TYPE: Record<string, string> = {
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico'
|
||||||
|
};
|
||||||
|
|
||||||
|
function extensionFor(contentType: string | null): string {
|
||||||
|
if (!contentType) return '.ico';
|
||||||
|
const base = contentType.split(';')[0].trim().toLowerCase();
|
||||||
|
return EXT_BY_CONTENT_TYPE[base] ?? '.ico';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryFetch(url: string): Promise<{ data: Uint8Array; contentType: string | null } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetchBuffer(url, { timeoutMs: 3_000, maxBytes: 256 * 1024 });
|
||||||
|
if (res.data.byteLength === 0) return null;
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parst <link rel="…icon">-Tags aus dem <head>. WordPress-Seiten liefern
|
||||||
|
// oft ein generisches /favicon.ico (Zahnrad-Default vom Hoster oder Plugin),
|
||||||
|
// während das eigentliche Site-Icon per <link rel="icon"> eingebunden ist.
|
||||||
|
// Darum zuerst den Head durchsehen, nicht blind /favicon.ico nehmen.
|
||||||
|
type IconLink = { href: string; size: number; isApple: boolean };
|
||||||
|
|
||||||
|
function extractIconLinks(html: string, baseUrl: string): IconLink[] {
|
||||||
|
const head = html.slice(0, 300_000);
|
||||||
|
const icons: IconLink[] = [];
|
||||||
|
const linkRe = /<link\b[^>]*>/gi;
|
||||||
|
for (const m of head.matchAll(linkRe)) {
|
||||||
|
const tag = m[0];
|
||||||
|
const relMatch = tag.match(/\brel\s*=\s*["']([^"']+)["']/i);
|
||||||
|
if (!relMatch) continue;
|
||||||
|
const rel = relMatch[1].toLowerCase();
|
||||||
|
const isApple = rel.includes('apple-touch-icon');
|
||||||
|
if (!isApple && !/\b(shortcut\s+icon|icon)\b/.test(rel)) continue;
|
||||||
|
const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i);
|
||||||
|
if (!hrefMatch) continue;
|
||||||
|
const raw = hrefMatch[1].trim();
|
||||||
|
if (!raw || raw.startsWith('data:')) continue;
|
||||||
|
let href: string;
|
||||||
|
try {
|
||||||
|
href = new URL(raw, baseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = 0;
|
||||||
|
const sizesMatch = tag.match(/\bsizes\s*=\s*["']([^"']+)["']/i);
|
||||||
|
if (sizesMatch) {
|
||||||
|
const sm = sizesMatch[1].match(/(\d+)\s*x\s*\d+/i);
|
||||||
|
if (sm) size = Number(sm[1]);
|
||||||
|
}
|
||||||
|
if (!size && isApple) size = 180;
|
||||||
|
icons.push({ href, size, isApple });
|
||||||
|
}
|
||||||
|
return icons;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holt Icon-Kandidaten per HTML-Parse. 32–192 px bevorzugt (für 24×24-Darstellung
|
||||||
|
// ist das sharp genug, ohne SVG-Wahnsinn); alles außerhalb landet am Ende.
|
||||||
|
async function resolveIconsFromHtml(domain: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const baseUrl = `https://${domain}/`;
|
||||||
|
const html = await fetchText(baseUrl, {
|
||||||
|
timeoutMs: 3_500,
|
||||||
|
maxBytes: 256 * 1024,
|
||||||
|
allowTruncate: true
|
||||||
|
});
|
||||||
|
const icons = extractIconLinks(html, baseUrl);
|
||||||
|
if (icons.length === 0) return [];
|
||||||
|
const sweet = (s: number) => s >= 32 && s <= 192;
|
||||||
|
icons.sort((a, b) => {
|
||||||
|
if (sweet(a.size) && !sweet(b.size)) return -1;
|
||||||
|
if (!sweet(a.size) && sweet(b.size)) return 1;
|
||||||
|
return b.size - a.size;
|
||||||
|
});
|
||||||
|
return icons.map((i) => i.href);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFaviconBytes(
|
||||||
|
domain: string
|
||||||
|
): Promise<{ data: Uint8Array; contentType: string | null } | null> {
|
||||||
|
// 1. Aus der Homepage die <link rel="icon">-Kandidaten ziehen — das
|
||||||
|
// ist normalerweise das "echte" Site-Icon, nicht der Hoster-Default.
|
||||||
|
const htmlIcons = await resolveIconsFromHtml(domain);
|
||||||
|
for (const url of htmlIcons) {
|
||||||
|
const got = await tryFetch(url);
|
||||||
|
if (got) return got;
|
||||||
|
}
|
||||||
|
// 2. Klassiker: /favicon.ico. Viele ältere Seiten haben nur den.
|
||||||
|
const direct = await tryFetch(`https://${domain}/favicon.ico`);
|
||||||
|
if (direct) return direct;
|
||||||
|
// 3. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas.
|
||||||
|
return tryFetch(`https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(
|
||||||
|
data: Uint8Array,
|
||||||
|
contentType: string | null,
|
||||||
|
imageDir: string
|
||||||
|
): Promise<string> {
|
||||||
|
const hash = createHash('sha256').update(data).digest('hex');
|
||||||
|
const ext = extensionFor(contentType);
|
||||||
|
const filename = `favicon-${hash}${ext}`;
|
||||||
|
const target = join(imageDir, filename);
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
await mkdir(imageDir, { recursive: true });
|
||||||
|
await writeFile(target, data);
|
||||||
|
}
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAndStoreFavicon(
|
||||||
|
domain: string,
|
||||||
|
imageDir: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const result = await fetchFaviconBytes(domain);
|
||||||
|
if (!result) return null;
|
||||||
|
try {
|
||||||
|
return await persist(result.data, result.contentType, imageDir);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lädt Favicons für alle Whitelist-Domains, bei denen noch keines gespeichert
|
||||||
|
// ist. Parallel mit Limit 8. Bleibt bewusst sync vom Aufrufer aus gesehen,
|
||||||
|
// damit der erste GET /api/domains eine vollständige Liste zurückgibt.
|
||||||
|
// Beim zweiten Request ist nichts mehr zu tun.
|
||||||
|
export async function ensureFavicons(
|
||||||
|
db: Database.Database,
|
||||||
|
imageDir: string
|
||||||
|
): Promise<void> {
|
||||||
|
const domains = listDomains(db).filter((d) => !d.favicon_path);
|
||||||
|
if (domains.length === 0) return;
|
||||||
|
const queue = [...domains];
|
||||||
|
const LIMIT = 8;
|
||||||
|
const workers = Array.from({ length: Math.min(LIMIT, queue.length) }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const d = queue.shift();
|
||||||
|
if (!d) break;
|
||||||
|
const path = await fetchAndStoreFavicon(d.domain, imageDir);
|
||||||
|
if (path) setDomainFavicon(db, d.id, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ export function normalizeDomain(raw: string): string {
|
|||||||
|
|
||||||
export function listDomains(db: Database.Database): AllowedDomain[] {
|
export function listDomains(db: Database.Database): AllowedDomain[] {
|
||||||
return db
|
return db
|
||||||
.prepare('SELECT id, domain, display_name FROM allowed_domain ORDER BY domain')
|
.prepare(
|
||||||
|
'SELECT id, domain, display_name, favicon_path FROM allowed_domain ORDER BY domain'
|
||||||
|
)
|
||||||
.all() as AllowedDomain[];
|
.all() as AllowedDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ export function addDomain(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO allowed_domain(domain, display_name, added_by_profile_id)
|
`INSERT INTO allowed_domain(domain, display_name, added_by_profile_id)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
RETURNING id, domain, display_name`
|
RETURNING id, domain, display_name, favicon_path`
|
||||||
)
|
)
|
||||||
.get(normalized, displayName, addedByProfileId) as AllowedDomain;
|
.get(normalized, displayName, addedByProfileId) as AllowedDomain;
|
||||||
return row;
|
return row;
|
||||||
@@ -31,3 +33,46 @@ export function addDomain(
|
|||||||
export function removeDomain(db: Database.Database, id: number): void {
|
export function removeDomain(db: Database.Database, id: number): void {
|
||||||
db.prepare('DELETE FROM allowed_domain WHERE id = ?').run(id);
|
db.prepare('DELETE FROM allowed_domain WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setDomainFavicon(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number,
|
||||||
|
faviconPath: string | null
|
||||||
|
): void {
|
||||||
|
db.prepare('UPDATE allowed_domain SET favicon_path = ? WHERE id = ?').run(
|
||||||
|
faviconPath,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDomainById(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number
|
||||||
|
): AllowedDomain | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, domain, display_name, favicon_path FROM allowed_domain WHERE id = ?'
|
||||||
|
)
|
||||||
|
.get(id) as AllowedDomain | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDomain(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number,
|
||||||
|
patch: { domain?: string; display_name?: string | null }
|
||||||
|
): AllowedDomain | null {
|
||||||
|
const current = getDomainById(db, id);
|
||||||
|
if (!current) return null;
|
||||||
|
const nextDomain =
|
||||||
|
patch.domain !== undefined ? normalizeDomain(patch.domain) : current.domain;
|
||||||
|
const nextLabel =
|
||||||
|
patch.display_name !== undefined ? patch.display_name : current.display_name;
|
||||||
|
// Wenn sich die Domain ändert: favicon_path zurücksetzen, damit der Caller
|
||||||
|
// es neu laden kann. Sonst zeigen wir fälschlich das alte Icon.
|
||||||
|
const nextFavicon = nextDomain !== current.domain ? null : current.favicon_path;
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE allowed_domain SET domain = ?, display_name = ?, favicon_path = ? WHERE id = ?'
|
||||||
|
).run(nextDomain, nextLabel, nextFavicon, id);
|
||||||
|
return getDomainById(db, id);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type Database from 'better-sqlite3';
|
|
||||||
import { normalizeDomain } from './repository';
|
|
||||||
|
|
||||||
export function isDomainAllowed(db: Database.Database, urlString: string): boolean {
|
|
||||||
let host: string;
|
|
||||||
try {
|
|
||||||
host = new URL(urlString).hostname;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const normalized = normalizeDomain(host);
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT 1 AS ok FROM allowed_domain WHERE domain = ? LIMIT 1')
|
|
||||||
.get(normalized);
|
|
||||||
return row !== undefined;
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,8 @@ import { ImporterError } from './recipes/importer';
|
|||||||
export function mapImporterError(e: unknown): never {
|
export function mapImporterError(e: unknown): never {
|
||||||
if (e instanceof ImporterError) {
|
if (e instanceof ImporterError) {
|
||||||
const status =
|
const status =
|
||||||
e.code === 'INVALID_URL' || e.code === 'DOMAIN_BLOCKED'
|
e.code === 'INVALID_URL'
|
||||||
? e.code === 'DOMAIN_BLOCKED'
|
? 400
|
||||||
? 403
|
|
||||||
: 400
|
|
||||||
: e.code === 'NO_RECIPE_FOUND'
|
: e.code === 'NO_RECIPE_FOUND'
|
||||||
? 422
|
? 422
|
||||||
: 502; // FETCH_FAILED
|
: 502; // FETCH_FAILED
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ export type FetchOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
extraHeaders?: Record<string, string>;
|
extraHeaders?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, return the data read up to `maxBytes` instead of throwing.
|
||||||
|
* Useful when we only care about the page head (og:image, JSON-LD) — most
|
||||||
|
* recipe sites are >1 MB today because of inlined bundles, but the head is
|
||||||
|
* usually well under 512 KB.
|
||||||
|
*/
|
||||||
|
allowTruncate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULTS: Required<Omit<FetchOptions, 'extraHeaders'>> = {
|
const DEFAULTS: Required<Omit<FetchOptions, 'extraHeaders' | 'allowTruncate'>> = {
|
||||||
maxBytes: 10 * 1024 * 1024,
|
maxBytes: 10 * 1024 * 1024,
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
userAgent: 'Kochwas/0.1'
|
userAgent: 'Kochwas/0.1'
|
||||||
@@ -25,16 +32,23 @@ function assertSafeUrl(url: string): void {
|
|||||||
|
|
||||||
async function readBody(
|
async function readBody(
|
||||||
response: Response,
|
response: Response,
|
||||||
maxBytes: number
|
maxBytes: number,
|
||||||
): Promise<{ data: Uint8Array; total: number }> {
|
allowTruncate: boolean
|
||||||
|
): Promise<{ data: Uint8Array; total: number; truncated: boolean }> {
|
||||||
const reader = response.body?.getReader();
|
const reader = response.body?.getReader();
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
const buf = new Uint8Array(await response.arrayBuffer());
|
const buf = new Uint8Array(await response.arrayBuffer());
|
||||||
if (buf.byteLength > maxBytes) throw new Error(`Response exceeds ${maxBytes} bytes`);
|
if (buf.byteLength > maxBytes) {
|
||||||
return { data: buf, total: buf.byteLength };
|
if (allowTruncate) {
|
||||||
|
return { data: buf.slice(0, maxBytes), total: maxBytes, truncated: true };
|
||||||
|
}
|
||||||
|
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||||
|
}
|
||||||
|
return { data: buf, total: buf.byteLength, truncated: false };
|
||||||
}
|
}
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
let truncated = false;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
@@ -42,6 +56,14 @@ async function readBody(
|
|||||||
total += value.byteLength;
|
total += value.byteLength;
|
||||||
if (total > maxBytes) {
|
if (total > maxBytes) {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
|
if (allowTruncate) {
|
||||||
|
// keep what we have up to the chunk boundary; good enough for HTML head
|
||||||
|
const keep = value.byteLength - (total - maxBytes);
|
||||||
|
if (keep > 0) chunks.push(value.slice(0, keep));
|
||||||
|
total = maxBytes;
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||||
}
|
}
|
||||||
chunks.push(value);
|
chunks.push(value);
|
||||||
@@ -53,7 +75,7 @@ async function readBody(
|
|||||||
merged.set(c, offset);
|
merged.set(c, offset);
|
||||||
offset += c.byteLength;
|
offset += c.byteLength;
|
||||||
}
|
}
|
||||||
return { data: merged, total };
|
return { data: merged, total, truncated };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
||||||
@@ -82,7 +104,7 @@ async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
|||||||
export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string> {
|
export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string> {
|
||||||
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||||
const res = await doFetch(url, opts);
|
const res = await doFetch(url, opts);
|
||||||
const { data } = await readBody(res, maxBytes);
|
const { data } = await readBody(res, maxBytes, opts.allowTruncate ?? false);
|
||||||
return new TextDecoder('utf-8').decode(data);
|
return new TextDecoder('utf-8').decode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +114,6 @@ export async function fetchBuffer(
|
|||||||
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
||||||
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||||
const res = await doFetch(url, opts);
|
const res = await doFetch(url, opts);
|
||||||
const { data } = await readBody(res, maxBytes);
|
const { data } = await readBody(res, maxBytes, opts.allowTruncate ?? false);
|
||||||
return { data, contentType: res.headers.get('content-type') };
|
return { data, contentType: res.headers.get('content-type') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,42 @@ const FRACTION_MAP: Record<string, number> = {
|
|||||||
'3/4': 0.75
|
'3/4': 0.75
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Vulgar-Fraction-Codepoints — kommen in deutschsprachigen Rezept-Quellen
|
||||||
|
// regelmäßig vor (Chefkoch et al. liefern sie vereinzelt, mehr aber bei
|
||||||
|
// Apple's Food App, Fork etc.).
|
||||||
|
const UNICODE_FRACTION_MAP: Record<string, number> = {
|
||||||
|
'\u00BD': 0.5, // ½
|
||||||
|
'\u00BC': 0.25, // ¼
|
||||||
|
'\u00BE': 0.75, // ¾
|
||||||
|
'\u2150': 1 / 7,
|
||||||
|
'\u2151': 1 / 9,
|
||||||
|
'\u2152': 1 / 10,
|
||||||
|
'\u2153': 1 / 3, // ⅓
|
||||||
|
'\u2154': 2 / 3, // ⅔
|
||||||
|
'\u2155': 0.2, // ⅕
|
||||||
|
'\u2156': 0.4, // ⅖
|
||||||
|
'\u2157': 0.6, // ⅗
|
||||||
|
'\u2158': 0.8, // ⅘
|
||||||
|
'\u2159': 1 / 6, // ⅙
|
||||||
|
'\u215A': 5 / 6, // ⅚
|
||||||
|
'\u215B': 0.125, // ⅛
|
||||||
|
'\u215C': 0.375, // ⅜
|
||||||
|
'\u215D': 0.625, // ⅝
|
||||||
|
'\u215E': 0.875 // ⅞
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mengen außerhalb dieses Bereichs sind fast sicher ein Parse-Müll
|
||||||
|
// (z. B. Microformat-Date oder Telefon-Nummer in einem JSON-LD-Quantity-
|
||||||
|
// Feld). Wir geben null zurück, raw_text bleibt für die UI erhalten.
|
||||||
|
const MAX_REASONABLE_QTY = 10000;
|
||||||
|
|
||||||
|
function clampQuantity(n: number | null): number | null {
|
||||||
|
if (n === null || !Number.isFinite(n)) return null;
|
||||||
|
if (n <= 0) return null;
|
||||||
|
if (n > MAX_REASONABLE_QTY) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
function parseQuantity(raw: string): number | null {
|
function parseQuantity(raw: string): number | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed];
|
if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed];
|
||||||
@@ -39,6 +75,16 @@ function parseQuantity(raw: string): number | null {
|
|||||||
return Number.isFinite(num) ? num : null;
|
return Number.isFinite(num) ? num : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits "TL Salz" → unit "TL", name "Salz"; "Zitrone" → unit null, name "Zitrone".
|
||||||
|
function splitUnitAndName(rest: string): { unit: string | null; name: string } {
|
||||||
|
const trimmed = rest.trim();
|
||||||
|
const firstTokenMatch = /^(\S+)\s+(.+)$/.exec(trimmed);
|
||||||
|
if (firstTokenMatch && UNITS.has(firstTokenMatch[1])) {
|
||||||
|
return { unit: firstTokenMatch[1], name: firstTokenMatch[2].trim() };
|
||||||
|
}
|
||||||
|
return { unit: null, name: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseIngredient(raw: string, position = 0): Ingredient {
|
export function parseIngredient(raw: string, position = 0): Ingredient {
|
||||||
const rawText = raw.trim();
|
const rawText = raw.trim();
|
||||||
let working = rawText;
|
let working = rawText;
|
||||||
@@ -51,18 +97,24 @@ export function parseIngredient(raw: string, position = 0): Ingredient {
|
|||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unicode-Bruch am Anfang? Dann das eine Zeichen als Menge nehmen
|
||||||
|
// und den Rest wie üblich in Unit + Name aufteilen.
|
||||||
|
const firstChar = working.charAt(0);
|
||||||
|
if (UNICODE_FRACTION_MAP[firstChar] !== undefined) {
|
||||||
|
const tail = working.slice(1).trimStart();
|
||||||
|
if (tail.length > 0) {
|
||||||
|
const quantity = clampQuantity(UNICODE_FRACTION_MAP[firstChar]);
|
||||||
|
const { unit, name } = splitUnitAndName(tail);
|
||||||
|
return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
|
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
|
||||||
const qtyMatch = qtyPattern.exec(working);
|
const qtyMatch = qtyPattern.exec(working);
|
||||||
if (!qtyMatch) {
|
if (!qtyMatch) {
|
||||||
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
|
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText, section_heading: null };
|
||||||
}
|
}
|
||||||
const quantity = parseQuantity(qtyMatch[1]);
|
const quantity = clampQuantity(parseQuantity(qtyMatch[1]));
|
||||||
let rest = qtyMatch[2].trim();
|
const { unit, name } = splitUnitAndName(qtyMatch[2]);
|
||||||
let unit: string | null = null;
|
return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
|
||||||
const firstTokenMatch = /^(\S+)\s+(.+)$/.exec(rest);
|
|
||||||
if (firstTokenMatch && UNITS.has(firstTokenMatch[1])) {
|
|
||||||
unit = firstTokenMatch[1];
|
|
||||||
rest = firstTokenMatch[2].trim();
|
|
||||||
}
|
|
||||||
return { position, quantity, unit, name: rest, note, raw_text: rawText };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,9 +106,252 @@ function findRecipeNode(html: string): JsonLdNode | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Microdata-Alternative zum JSON-LD: viele SSR-Sites (inkl. rezeptwelt.de)
|
||||||
|
// nutzen <div itemtype="https://schema.org/Recipe"> statt application/ld+json.
|
||||||
|
// Ein einfacher Regex reicht — wir brauchen nur das Flag, nicht die Daten.
|
||||||
|
const MICRODATA_RECIPE = /itemtype\s*=\s*["']https?:\/\/schema\.org\/Recipe["']/i;
|
||||||
|
|
||||||
|
export function hasRecipeMarkup(html: string): boolean {
|
||||||
|
if (MICRODATA_RECIPE.test(html)) return true;
|
||||||
|
try {
|
||||||
|
return findRecipeNode(html) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @deprecated use hasRecipeMarkup
|
||||||
|
export function hasRecipeJsonLd(html: string): boolean {
|
||||||
|
return hasRecipeMarkup(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function microdataValueOf(el: Element): string {
|
||||||
|
if (el.hasAttribute('content')) return (el.getAttribute('content') ?? '').trim();
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (tag === 'meta') return (el.getAttribute('content') ?? '').trim();
|
||||||
|
if (tag === 'a' || tag === 'link' || tag === 'area')
|
||||||
|
return (el.getAttribute('href') ?? '').trim();
|
||||||
|
if (
|
||||||
|
tag === 'img' ||
|
||||||
|
tag === 'source' ||
|
||||||
|
tag === 'video' ||
|
||||||
|
tag === 'audio' ||
|
||||||
|
tag === 'embed' ||
|
||||||
|
tag === 'iframe' ||
|
||||||
|
tag === 'track'
|
||||||
|
)
|
||||||
|
return (el.getAttribute('src') ?? '').trim();
|
||||||
|
if (tag === 'object') return (el.getAttribute('data') ?? '').trim();
|
||||||
|
if (tag === 'data' || tag === 'meter')
|
||||||
|
return (el.getAttribute('value') ?? '').trim();
|
||||||
|
if (tag === 'time')
|
||||||
|
return (el.getAttribute('datetime') ?? el.textContent ?? '').trim();
|
||||||
|
return (el.textContent ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type MicroProps = Map<string, Element[]>;
|
||||||
|
|
||||||
|
function gatherMicrodataProps(scope: Element): MicroProps {
|
||||||
|
// Alle itemprop-Descendants sammeln, dabei aber nicht in verschachtelte
|
||||||
|
// itemscopes einsteigen (sonst landen z.B. HowToStep.text im Haupt-Scope).
|
||||||
|
const map: MicroProps = new Map();
|
||||||
|
function walk(el: Element) {
|
||||||
|
for (const child of Array.from(el.children) as Element[]) {
|
||||||
|
const hasProp = child.hasAttribute('itemprop');
|
||||||
|
const hasScope = child.hasAttribute('itemscope');
|
||||||
|
if (hasProp) {
|
||||||
|
const names = (child.getAttribute('itemprop') ?? '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const name of names) {
|
||||||
|
const arr = map.get(name) ?? [];
|
||||||
|
arr.push(child);
|
||||||
|
map.set(name, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasScope) walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(scope);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function microText(map: MicroProps, name: string): string | null {
|
||||||
|
const els = map.get(name);
|
||||||
|
if (!els || els.length === 0) return null;
|
||||||
|
const v = microdataValueOf(els[0]);
|
||||||
|
return v || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function microAllTexts(map: MicroProps, name: string): string[] {
|
||||||
|
const els = map.get(name) ?? [];
|
||||||
|
return els.map(microdataValueOf).filter((v) => v !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rausholen von Text mit erhaltenen Zeilenumbrüchen — <br> → \n, Block-
|
||||||
|
// Elemente (<p>, <li> …) bekommen ebenfalls Newline-Grenzen. <img>, <script>,
|
||||||
|
// <style> werden komplett übersprungen, damit alt-Attribute und andere
|
||||||
|
// Nicht-Text-Content nicht in den Rezepttext bluten.
|
||||||
|
function textWithLineBreaks(el: Element): string {
|
||||||
|
const BLOCK = new Set(['p', 'div', 'li', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'tr']);
|
||||||
|
const SKIP = new Set(['script', 'style', 'img', 'noscript']);
|
||||||
|
let out = '';
|
||||||
|
const walk = (node: Node): void => {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
out += node.nodeValue ?? '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
const e = node as Element;
|
||||||
|
const tag = e.tagName.toLowerCase();
|
||||||
|
if (SKIP.has(tag)) return;
|
||||||
|
const block = BLOCK.has(tag);
|
||||||
|
if (tag === 'br') {
|
||||||
|
out += '\n';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (block && out && !out.endsWith('\n')) out += '\n';
|
||||||
|
for (const child of Array.from(node.childNodes)) walk(child);
|
||||||
|
if (block && out && !out.endsWith('\n')) out += '\n';
|
||||||
|
};
|
||||||
|
walk(el);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teilt extrahierten Rezepttext in einzelne Schritte auf. Rezeptwelt und
|
||||||
|
// andere SSR-Sites liefern oft einen einzigen HowToStep-Block, der intern
|
||||||
|
// mit "1. …<br>2. …<br>3. …" mehrere Schritte vereint.
|
||||||
|
function splitStepText(raw: string): string[] {
|
||||||
|
const numbered = /^(\d+)[.)]\s+(.+)$/;
|
||||||
|
const lines = raw
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((l) => l.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
const numberedCount = lines.filter((l) => numbered.test(l)).length;
|
||||||
|
if (numberedCount >= 2) {
|
||||||
|
// Mehrere nummerierte Zeilen → jede ist ein eigener Schritt. Nicht-
|
||||||
|
// nummerierte Folgezeilen gehören zum vorherigen Schritt.
|
||||||
|
const out: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
for (const l of lines) {
|
||||||
|
const m = l.match(numbered);
|
||||||
|
if (m) {
|
||||||
|
if (current) out.push(current);
|
||||||
|
current = m[2];
|
||||||
|
} else {
|
||||||
|
current += current ? ' ' + l : l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) out.push(current);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return [lines.join(' ')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepsFromElement(el: Element): string[] {
|
||||||
|
const textEl = el.querySelector('[itemprop="text"]') ?? el;
|
||||||
|
const raw = textWithLineBreaks(textEl);
|
||||||
|
return splitStepText(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function microSteps(scope: Element): Step[] {
|
||||||
|
const out: Step[] = [];
|
||||||
|
let pos = 1;
|
||||||
|
const containers = Array.from(scope.querySelectorAll('[itemprop="recipeInstructions"]'));
|
||||||
|
for (const el of containers) {
|
||||||
|
const itemtype = (el.getAttribute('itemtype') ?? '').toLowerCase();
|
||||||
|
if (itemtype.includes('howtosection')) {
|
||||||
|
// HowToSection enthält HowToStep-Kinder als itemListElement.
|
||||||
|
const steps = Array.from(
|
||||||
|
el.querySelectorAll(
|
||||||
|
'[itemprop="itemListElement"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const step of steps) {
|
||||||
|
for (const t of stepsFromElement(step)) out.push({ position: pos++, text: t });
|
||||||
|
}
|
||||||
|
} else if (itemtype.includes('howtostep')) {
|
||||||
|
for (const t of stepsFromElement(el)) out.push({ position: pos++, text: t });
|
||||||
|
} else if (el.hasAttribute('itemscope')) {
|
||||||
|
// Anderer unbekannter Scope — trotzdem Text versuchen.
|
||||||
|
for (const t of stepsFromElement(el)) out.push({ position: pos++, text: t });
|
||||||
|
} else {
|
||||||
|
const lis = Array.from(el.querySelectorAll('li'));
|
||||||
|
if (lis.length > 0) {
|
||||||
|
for (const li of lis) {
|
||||||
|
for (const t of splitStepText(textWithLineBreaks(li))) {
|
||||||
|
out.push({ position: pos++, text: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const t of splitStepText(textWithLineBreaks(el))) {
|
||||||
|
out.push({ position: pos++, text: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRecipeFromMicrodata(html: string): Recipe | null {
|
||||||
|
let document: Document;
|
||||||
|
try {
|
||||||
|
({ document } = parseHTML(html));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const scope = document.querySelector(
|
||||||
|
'[itemtype*="schema.org/Recipe" i]'
|
||||||
|
);
|
||||||
|
if (!scope) return null;
|
||||||
|
const props = gatherMicrodataProps(scope);
|
||||||
|
|
||||||
|
const title = microText(props, 'name');
|
||||||
|
if (!title) return null;
|
||||||
|
|
||||||
|
const ingredients = microAllTexts(props, 'recipeIngredient')
|
||||||
|
.map((raw, i) => parseIngredient(raw, i + 1))
|
||||||
|
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||||
|
|
||||||
|
const steps = microSteps(scope);
|
||||||
|
const prep = parseIso8601Duration(microText(props, 'prepTime') ?? undefined);
|
||||||
|
const cook = parseIso8601Duration(microText(props, 'cookTime') ?? undefined);
|
||||||
|
const total = parseIso8601Duration(microText(props, 'totalTime') ?? undefined);
|
||||||
|
|
||||||
|
const tags = new Set<string>([
|
||||||
|
...microAllTexts(props, 'recipeCategory'),
|
||||||
|
...microAllTexts(props, 'recipeCuisine'),
|
||||||
|
...microAllTexts(props, 'keywords')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
title,
|
||||||
|
description: microText(props, 'description'),
|
||||||
|
source_url: microText(props, 'url'),
|
||||||
|
source_domain: null,
|
||||||
|
image_path: microText(props, 'image'),
|
||||||
|
servings_default: toServings(microText(props, 'recipeYield')),
|
||||||
|
servings_unit: null,
|
||||||
|
prep_time_min: prep,
|
||||||
|
cook_time_min: cook,
|
||||||
|
total_time_min: total,
|
||||||
|
cuisine: microText(props, 'recipeCuisine'),
|
||||||
|
category: microText(props, 'recipeCategory'),
|
||||||
|
ingredients,
|
||||||
|
steps,
|
||||||
|
tags: [...tags]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function extractRecipeFromHtml(html: string): Recipe | null {
|
export function extractRecipeFromHtml(html: string): Recipe | null {
|
||||||
const node = findRecipeNode(html);
|
const node = findRecipeNode(html);
|
||||||
if (!node) return null;
|
if (!node) {
|
||||||
|
// Fallback auf Microdata — rezeptwelt.de & andere SSR-Sites nutzen das
|
||||||
|
// anstatt application/ld+json.
|
||||||
|
return extractRecipeFromMicrodata(html);
|
||||||
|
}
|
||||||
|
|
||||||
const title = toText(node.name) ?? '';
|
const title = toText(node.name) ?? '';
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
|
|||||||
6
src/lib/server/paths.ts
Normal file
6
src/lib/server/paths.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Filesystem paths read from env at module load. Centralized so a misset
|
||||||
|
// env var only causes one place to be wrong, not six. Both defaults match
|
||||||
|
// the docker-compose volume mounts under `/app/data`.
|
||||||
|
|
||||||
|
export const DATABASE_PATH = process.env.DATABASE_PATH ?? './data/kochwas.db';
|
||||||
|
export const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||||
@@ -73,6 +73,17 @@ export function isFavorite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listFavoriteProfiles(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number
|
||||||
|
): number[] {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.prepare('SELECT profile_id FROM favorite WHERE recipe_id = ?')
|
||||||
|
.all(recipeId) as { profile_id: number }[]
|
||||||
|
).map((r) => r.profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
export function logCooked(
|
export function logCooked(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
@@ -139,3 +150,14 @@ export function renameRecipe(
|
|||||||
recipeId
|
recipeId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setRecipeHiddenFromRecent(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number,
|
||||||
|
hidden: boolean
|
||||||
|
): void {
|
||||||
|
db.prepare('UPDATE recipe SET hidden_from_recent = ? WHERE id = ?').run(
|
||||||
|
hidden ? 1 : 0,
|
||||||
|
recipeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type Database from 'better-sqlite3';
|
|||||||
import type { Recipe } from '$lib/types';
|
import type { Recipe } from '$lib/types';
|
||||||
import { fetchText } from '../http';
|
import { fetchText } from '../http';
|
||||||
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
|
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
|
||||||
import { isDomainAllowed } from '../domains/whitelist';
|
|
||||||
import { downloadImage } from '../images/image-downloader';
|
import { downloadImage } from '../images/image-downloader';
|
||||||
import {
|
import {
|
||||||
getRecipeById,
|
getRecipeById,
|
||||||
@@ -14,7 +13,6 @@ export class ImporterError extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly code:
|
public readonly code:
|
||||||
| 'INVALID_URL'
|
| 'INVALID_URL'
|
||||||
| 'DOMAIN_BLOCKED'
|
|
||||||
| 'FETCH_FAILED'
|
| 'FETCH_FAILED'
|
||||||
| 'NO_RECIPE_FOUND',
|
| 'NO_RECIPE_FOUND',
|
||||||
message: string
|
message: string
|
||||||
@@ -32,11 +30,12 @@ function hostnameOrThrow(url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewRecipe(db: Database.Database, url: string): Promise<Recipe> {
|
// Manuelle URL-Importe sind absichtlich NICHT mehr auf die allowed_domain-
|
||||||
|
// Whitelist beschränkt — der User pastet bewusst eine URL und erwartet,
|
||||||
|
// dass der Import klappt. Die Whitelist bleibt für die Web-Suche (searxng)
|
||||||
|
// relevant, weil dort ein breites Crawl-Feld eingeschränkt werden soll.
|
||||||
|
export async function previewRecipe(_db: Database.Database, url: string): Promise<Recipe> {
|
||||||
const host = hostnameOrThrow(url);
|
const host = hostnameOrThrow(url);
|
||||||
if (!isDomainAllowed(db, url)) {
|
|
||||||
throw new ImporterError('DOMAIN_BLOCKED', `Domain not allowed: ${host}`);
|
|
||||||
}
|
|
||||||
let html: string;
|
let html: string;
|
||||||
try {
|
try {
|
||||||
html = await fetchText(url);
|
html = await fetchText(url);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user