Compare commits
69 Commits
f92ce677f6
...
search-sta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -2,7 +2,7 @@ name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ data/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright-report-remote/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -17,6 +17,8 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
|
||||
| **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. |
|
||||
|
||||
## Dateien, die man typischerweise anfasst
|
||||
|
||||
@@ -27,6 +29,9 @@ Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://k
|
||||
- `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 (Network, Sync-Status, Toast, Install-Prompt)
|
||||
|
||||
## Arbeitsweise (wie wir es machen)
|
||||
|
||||
|
||||
@@ -31,14 +31,13 @@ src/
|
||||
│ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache)
|
||||
│ │ ├── wishlist/ # Repo
|
||||
│ │ └── backup/ # ZIP-Export via archiver, Import via yauzl
|
||||
│ ├── quotes.ts # 49 Flachwitze für die Homepage
|
||||
│ ├── 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
|
||||
├── search/ # /search (lokal), /search/web (Internet)
|
||||
├── wishlist/
|
||||
├── admin/ # Whitelist, Profile, Backup/Restore
|
||||
├── images/[filename] # Statische Auslieferung lokaler Bilder
|
||||
@@ -52,7 +51,7 @@ src/
|
||||
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` + `recipe_ingredient` + `recipe_step` + `recipe_tag`
|
||||
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]`
|
||||
|
||||
### Web-Suche
|
||||
@@ -106,6 +105,35 @@ Bei Schema-Änderung:
|
||||
- **Keine Svelte-Component-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird manuell getestet)
|
||||
- **Vor Commit**: `npm test && npm run check` muss grün sein.
|
||||
|
||||
### 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 = SWR, 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' | 'swr' | '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
|
||||
|
||||
@@ -133,7 +133,7 @@ Die App hat ein eingebautes Backup unter `/admin` (ZIP-Export mit DB + Bildern).
|
||||
| `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 |
|
||||
| `IMAGES_PATH` | `data/images` | Pfad für lokale Bild-Dateien |
|
||||
| `IMAGE_DIR` | `data/images` | Pfad für lokale Bild-Dateien |
|
||||
| `PORT` | `3000` | Node-HTTP-Port (adapter-node) |
|
||||
|
||||
Siehe `.env.example` im Repo.
|
||||
@@ -146,3 +146,28 @@ Siehe `.env.example` im Repo.
|
||||
- **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 (SWR)
|
||||
- `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).
|
||||
|
||||
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
|
||||
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.
|
||||
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
|
||||
- **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`.
|
||||
- **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)
|
||||
- **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 |
|
||||
1152
package-lock.json
generated
1152
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,23 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"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": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^22.9.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
"sharp": "^0.34.5",
|
||||
"svelte": "^5.1.0",
|
||||
"svelte-check": "^4.0.5",
|
||||
"typescript": "^5.6.3",
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
30
playwright.remote.config.ts
Normal file
30
playwright.remote.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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 zulassen, aber keine Offline-Manipulation — die
|
||||
// Tests hier pruefen Live-Verhalten gegen den Server.
|
||||
serviceWorkers: 'allow'
|
||||
}
|
||||
});
|
||||
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,4 +1,13 @@
|
||||
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:
|
||||
# Platzhalter wird beim Container-Start per os.path.expandvars aus der
|
||||
@@ -31,71 +40,21 @@ outgoing:
|
||||
ui:
|
||||
default_locale: de
|
||||
|
||||
# Quieten engines that fail on cold start and aren't useful here
|
||||
enabled_plugins:
|
||||
- 'Hash plugin'
|
||||
- 'Tracker URL remover'
|
||||
- 'Open Access DOI rewrite'
|
||||
|
||||
engines:
|
||||
# Brave mit API-Key: stabiler als der HTML-Scraper, kein Rate-Limit-Spam
|
||||
# mehr. Key kommt aus dem BRAVE_API_KEY-Env (.env auf dem Pi, nicht im Repo).
|
||||
# Fehlt der Key oder ist er leer, fällt Brave bei der ersten Anfrage zurück
|
||||
# auf einen 401 — andere Engines laufen normal weiter.
|
||||
# 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: brave
|
||||
engine: braveapi
|
||||
shortcut: br
|
||||
categories: [general, web]
|
||||
timeout: 6.0
|
||||
# Wert wird beim Container-Start durch Python-os.path.expandvars aus der
|
||||
# BRAVE_API_KEY-Env-Variable eingesetzt (siehe docker-compose.prod.yml
|
||||
# entrypoint-Override). SearXNG selbst hat kein !env-Tag.
|
||||
api_key: "${BRAVE_API_KEY}"
|
||||
disabled: false
|
||||
|
||||
# DuckDuckGo: deaktiviert, weil DDG die Pi-IP als Bot erkannt hat und
|
||||
# bei jeder Anfrage mit CAPTCHA antwortet. Brave (API) + Mojeek decken
|
||||
# die Websuche zuverlässig ab — DDG-Scraping wäre nur zusätzlicher Lärm.
|
||||
- name: duckduckgo
|
||||
disabled: true
|
||||
|
||||
# Mojeek: eigener Index, seltener Rate-Limits, ergänzt Brave.
|
||||
- name: mojeek
|
||||
engine: mojeek
|
||||
shortcut: mjk
|
||||
timeout: 6.0
|
||||
disabled: false
|
||||
|
||||
# Video-/News-Engines abdrehen — wir wollen nur Text-Treffer für Rezeptseiten.
|
||||
- name: google videos
|
||||
disabled: true
|
||||
- name: google news
|
||||
disabled: true
|
||||
- name: google images
|
||||
disabled: true
|
||||
- name: bing videos
|
||||
disabled: true
|
||||
- name: bing news
|
||||
disabled: true
|
||||
- name: bing images
|
||||
disabled: true
|
||||
- name: karmasearch videos
|
||||
disabled: true
|
||||
|
||||
# Startpage: hat unsere Pi-IP als Bot erkannt und blockt mit Captcha
|
||||
# (1h suspended_time pro Fehler). Bringt für Rezeptsuche nichts, was
|
||||
# nicht schon Brave/DDG liefern.
|
||||
- name: startpage
|
||||
disabled: true
|
||||
|
||||
# Tor-basierte Engines brauchen einen Tor-Proxy im Container — haben
|
||||
# wir nicht, also harmlos deaktivieren, um Init-Fehler loszuwerden.
|
||||
- name: ahmia
|
||||
disabled: true
|
||||
- name: torch
|
||||
disabled: true
|
||||
|
||||
# Wikidata produziert beim Cold-Start einen KeyError (Init-Bug in der
|
||||
# aktuellen SearXNG-Version 2026.4). Für Rezeptsuche ohne Mehrwert.
|
||||
- name: wikidata
|
||||
disabled: true
|
||||
|
||||
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;
|
||||
}
|
||||
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();
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Profile } from '$lib/types';
|
||||
import { alertAction } from '$lib/client/confirm.svelte';
|
||||
|
||||
const STORAGE_KEY = 'kochwas.activeProfileId';
|
||||
|
||||
@@ -60,3 +61,19 @@ class 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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
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;
|
||||
@@ -5,6 +23,7 @@ class PwaStore {
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
|
||||
try {
|
||||
this.registration = await navigator.serviceWorker.ready;
|
||||
} catch {
|
||||
@@ -12,10 +31,8 @@ class PwaStore {
|
||||
}
|
||||
if (!this.registration) return;
|
||||
|
||||
// Wenn beim Mount schon ein neuer SW installiert und aktiv wartet,
|
||||
// zeigen wir den Toast direkt an.
|
||||
if (this.registration.waiting) {
|
||||
this.updateAvailable = true;
|
||||
if (this.registration.waiting && this.registration.active) {
|
||||
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
|
||||
}
|
||||
|
||||
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
||||
@@ -24,24 +41,54 @@ class PwaStore {
|
||||
// mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren.
|
||||
this.pollTimer = setInterval(() => {
|
||||
void this.registration?.update().catch(() => {});
|
||||
}, 30 * 60_000);
|
||||
}, SW_UPDATE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private onUpdateFound(): void {
|
||||
const installing = this.registration?.installing;
|
||||
if (!installing) return;
|
||||
installing.addEventListener('statechange', () => {
|
||||
// 'installed' UND ein laufender controller = Update für bestehenden Tab.
|
||||
// (Ohne controller wäre das die erste Installation, kein Update.)
|
||||
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
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;
|
||||
location.reload();
|
||||
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 {
|
||||
@@ -49,4 +96,22 @@ class PwaStore {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
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));
|
||||
}
|
||||
|
||||
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}${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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -99,7 +99,7 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
border: 1px solid #cfd9d1;
|
||||
background: white;
|
||||
font-size: 0.95rem;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Plus, Trash2, GripVertical } from 'lucide-svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
|
||||
import type { Recipe, Ingredient, Step } from '$lib/types';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
type Props = {
|
||||
recipe: Recipe;
|
||||
@@ -16,16 +20,82 @@
|
||||
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 }: Props = $props();
|
||||
let { recipe, saving = false, onsave, oncancel, onimagechange }: Props = $props();
|
||||
|
||||
let title = $state(recipe.title);
|
||||
let description = $state(recipe.description ?? '');
|
||||
let servings = $state<number | ''>(recipe.servings_default ?? '');
|
||||
let prepMin = $state<number | ''>(recipe.prep_time_min ?? '');
|
||||
let cookMin = $state<number | ''>(recipe.cook_time_min ?? '');
|
||||
let totalMin = $state<number | ''>(recipe.total_time_min ?? '');
|
||||
let imagePath = $state<string | null>(untrack(() => recipe.image_path));
|
||||
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/${recipe.id}/image`,
|
||||
{ method: 'POST', body: fd },
|
||||
'Upload fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
const body = await res.json();
|
||||
imagePath = body.image_path;
|
||||
onimagechange?.(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/${recipe.id}/image`,
|
||||
{ method: 'DELETE' },
|
||||
'Entfernen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
imagePath = null;
|
||||
onimagechange?.(null);
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ?? ''));
|
||||
|
||||
type DraftIng = {
|
||||
qty: string;
|
||||
@@ -36,15 +106,17 @@
|
||||
type DraftStep = { text: string };
|
||||
|
||||
let ingredients = $state<DraftIng[]>(
|
||||
recipe.ingredients.map((i) => ({
|
||||
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
||||
unit: i.unit ?? '',
|
||||
name: i.name,
|
||||
note: i.note ?? ''
|
||||
}))
|
||||
untrack(() =>
|
||||
recipe.ingredients.map((i) => ({
|
||||
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
||||
unit: i.unit ?? '',
|
||||
name: i.name,
|
||||
note: i.note ?? ''
|
||||
}))
|
||||
)
|
||||
);
|
||||
let steps = $state<DraftStep[]>(
|
||||
recipe.steps.map((s) => ({ text: s.text }))
|
||||
untrack(() => recipe.steps.map((s) => ({ text: s.text })))
|
||||
);
|
||||
|
||||
function addIngredient() {
|
||||
@@ -53,6 +125,13 @@
|
||||
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 addStep() {
|
||||
steps = [...steps, { text: '' }];
|
||||
}
|
||||
@@ -110,6 +189,52 @@
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<section class="block image-block">
|
||||
<h2>Bild</h2>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div class="meta">
|
||||
<label class="field">
|
||||
<span class="lbl">Titel</span>
|
||||
@@ -149,7 +274,26 @@
|
||||
<ul class="ing-list">
|
||||
{#each ingredients as ing, idx (idx)}
|
||||
<li class="ing-row">
|
||||
<span class="grip" aria-hidden="true"><GripVertical size={16} /></span>
|
||||
<div class="move">
|
||||
<button
|
||||
class="move-btn"
|
||||
type="button"
|
||||
aria-label="Zutat nach oben"
|
||||
disabled={idx === 0}
|
||||
onclick={() => moveIngredient(idx, -1)}
|
||||
>
|
||||
<ChevronUp size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
<button
|
||||
class="move-btn"
|
||||
type="button"
|
||||
aria-label="Zutat nach unten"
|
||||
disabled={idx === ingredients.length - 1}
|
||||
onclick={() => moveIngredient(idx, 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" />
|
||||
@@ -252,6 +396,67 @@
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.image-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
min-height: 40px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.block h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.75rem;
|
||||
@@ -268,14 +473,34 @@
|
||||
}
|
||||
.ing-row {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 70px 70px 1fr 90px 40px;
|
||||
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.grip {
|
||||
color: #bbb;
|
||||
.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;
|
||||
@@ -375,14 +600,14 @@
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.ing-row {
|
||||
grid-template-columns: 70px 1fr 40px;
|
||||
grid-template-columns: 28px 70px 1fr 40px;
|
||||
grid-template-areas:
|
||||
'qty name del'
|
||||
'unit unit del'
|
||||
'note note note';
|
||||
'move qty name del'
|
||||
'move unit unit del'
|
||||
'note note note note';
|
||||
}
|
||||
.grip {
|
||||
display: none;
|
||||
.ing-row .move {
|
||||
grid-area: move;
|
||||
}
|
||||
.ing-row .qty {
|
||||
grid-area: qty;
|
||||
|
||||
@@ -175,16 +175,13 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
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;
|
||||
background: #eef3ef;
|
||||
}
|
||||
/* Mobile: verhindere, dass das Header-Bild einen unverhältnismäßig
|
||||
großen Teil des Viewports füllt und alles Wichtige wegdrückt. */
|
||||
@media (max-width: 820px) {
|
||||
.cover {
|
||||
max-height: 30vh;
|
||||
}
|
||||
}
|
||||
.hdr-body {
|
||||
padding: 1rem 1rem 0.25rem;
|
||||
}
|
||||
@@ -207,7 +204,7 @@
|
||||
.pill {
|
||||
padding: 0.15rem 0.55rem;
|
||||
background: #eaf4ed;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 0.8rem;
|
||||
color: #2b6a3d;
|
||||
}
|
||||
@@ -350,7 +347,9 @@
|
||||
|
||||
/* Querformat-Tablets und Desktop: Zutaten + Zubereitung nebeneinander,
|
||||
Tabs ausgeblendet. Zutaten sticky, damit sie beim Scrollen der
|
||||
Zubereitung oben bleiben. */
|
||||
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;
|
||||
@@ -370,5 +369,30 @@
|
||||
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>
|
||||
|
||||
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>
|
||||
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>
|
||||
@@ -28,7 +28,7 @@
|
||||
padding: 0.6rem 0.85rem 0.6rem 1.1rem;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
z-index: 500;
|
||||
max-width: calc(100% - 2rem);
|
||||
@@ -58,7 +58,7 @@
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
@@ -75,7 +75,7 @@
|
||||
padding: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dismiss:hover {
|
||||
|
||||
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;
|
||||
@@ -48,7 +48,108 @@ export const QUOTES: readonly string[] = [
|
||||
'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.'
|
||||
'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 {
|
||||
|
||||
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 { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { DATABASE_PATH, IMAGE_DIR } from '$lib/server/paths';
|
||||
import { runMigrations } from './migrate';
|
||||
|
||||
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;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const imageDir = process.env.IMAGE_DIR ?? './data/images';
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
mkdirSync(IMAGE_DIR, { recursive: true });
|
||||
instance = new Database(path);
|
||||
instance.pragma('journal_mode = WAL');
|
||||
instance.pragma('foreign_keys = ON');
|
||||
|
||||
@@ -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;
|
||||
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fetchBuffer } from '../http';
|
||||
import { fetchBuffer, fetchText } from '../http';
|
||||
import { listDomains, setDomainFavicon } from './repository';
|
||||
|
||||
const EXT_BY_CONTENT_TYPE: Record<string, string> = {
|
||||
@@ -33,14 +33,83 @@ async function tryFetch(url: string): Promise<{ data: Uint8Array; contentType: s
|
||||
}
|
||||
}
|
||||
|
||||
// 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. Versuche /favicon.ico direkt (klassisch, funktioniert auf fast allen Seiten).
|
||||
// 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;
|
||||
// 2. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas und
|
||||
// geben SVG/PNG in der gewünschten Größe.
|
||||
// 3. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas.
|
||||
return tryFetch(`https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(domain)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
if (e instanceof ImporterError) {
|
||||
const status =
|
||||
e.code === 'INVALID_URL' || e.code === 'DOMAIN_BLOCKED'
|
||||
? e.code === 'DOMAIN_BLOCKED'
|
||||
? 403
|
||||
: 400
|
||||
e.code === 'INVALID_URL'
|
||||
? 400
|
||||
: e.code === 'NO_RECIPE_FOUND'
|
||||
? 422
|
||||
: 502; // FETCH_FAILED
|
||||
|
||||
@@ -28,6 +28,42 @@ const FRACTION_MAP: Record<string, number> = {
|
||||
'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 {
|
||||
const trimmed = raw.trim();
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const rawText = raw.trim();
|
||||
let working = rawText;
|
||||
@@ -51,18 +97,24 @@ export function parseIngredient(raw: string, position = 0): Ingredient {
|
||||
).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 };
|
||||
}
|
||||
}
|
||||
|
||||
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
|
||||
const qtyMatch = qtyPattern.exec(working);
|
||||
if (!qtyMatch) {
|
||||
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
|
||||
}
|
||||
const quantity = parseQuantity(qtyMatch[1]);
|
||||
let rest = qtyMatch[2].trim();
|
||||
let unit: string | null = 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 };
|
||||
const quantity = clampQuantity(parseQuantity(qtyMatch[1]));
|
||||
const { unit, name } = splitUnitAndName(qtyMatch[2]);
|
||||
return { position, quantity, unit, name, note, raw_text: rawText };
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -2,7 +2,6 @@ import type Database from 'better-sqlite3';
|
||||
import type { Recipe } from '$lib/types';
|
||||
import { fetchText } from '../http';
|
||||
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
|
||||
import { isDomainAllowed } from '../domains/whitelist';
|
||||
import { downloadImage } from '../images/image-downloader';
|
||||
import {
|
||||
getRecipeById,
|
||||
@@ -14,7 +13,6 @@ export class ImporterError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| 'INVALID_URL'
|
||||
| 'DOMAIN_BLOCKED'
|
||||
| 'FETCH_FAILED'
|
||||
| 'NO_RECIPE_FOUND',
|
||||
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);
|
||||
if (!isDomainAllowed(db, url)) {
|
||||
throw new ImporterError('DOMAIN_BLOCKED', `Domain not allowed: ${host}`);
|
||||
}
|
||||
let html: string;
|
||||
try {
|
||||
html = await fetchText(url);
|
||||
|
||||
@@ -196,6 +196,17 @@ export function updateRecipeMeta(
|
||||
db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id);
|
||||
}
|
||||
|
||||
export function updateImagePath(
|
||||
db: Database.Database,
|
||||
id: number,
|
||||
filename: string | null
|
||||
): void {
|
||||
db.prepare('UPDATE recipe SET image_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
filename,
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceIngredients(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
|
||||
@@ -312,6 +312,10 @@ export async function searchWeb(
|
||||
// Nur Text-Engines abfragen — SearXNG-Video/Image-Engines (karmasearch etc.)
|
||||
// bringen uns für Rezeptseiten nichts und produzieren nur 403-Log-Noise.
|
||||
endpoint.searchParams.set('categories', 'general');
|
||||
// Nur Brave (via API) — Mojeek blockt die Pi-IP mit 403, andere Engines
|
||||
// sind von SearXNG-Seite durch keep_only ohnehin ausgeknipst. So bleibt
|
||||
// das Log sauber und kochwas ist unabhängig von der globalen Engine-Liste.
|
||||
endpoint.searchParams.set('engines', 'brave');
|
||||
if (pageno > 1) endpoint.searchParams.set('pageno', String(pageno));
|
||||
|
||||
const body = await fetchText(endpoint.toString(), {
|
||||
@@ -361,6 +365,9 @@ export async function searchWeb(
|
||||
});
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
// Absichtliches Prod-Logging: diese drei [searxng]-Zeilen erlauben "warum
|
||||
// wurde Domain X gefiltert?" ohne Code-Änderung. Strukturiert genug für
|
||||
// grep/awk, klein genug für jeden Log-Sammler.
|
||||
console.log(
|
||||
`[searxng] q=${JSON.stringify(trimmed)} pageno=${pageno} domains=${domains.length} raw=${results.length} non_whitelist=${dropNonWhitelist} non_recipe_url=${dropNonRecipeUrl} dup=${dropDup} kept_pre_enrich=${hits.length}`
|
||||
);
|
||||
|
||||
42
src/lib/sw/cache-strategy.ts
Normal file
42
src/lib/sw/cache-strategy.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
|
||||
|
||||
type RequestShape = { url: string; method: string };
|
||||
|
||||
// Pure function — sole decision-maker for "which strategy for this request?".
|
||||
// Called by the service worker for every fetch event.
|
||||
export function resolveStrategy(req: RequestShape): CacheStrategy {
|
||||
// All write methods: never cache.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return 'network-only';
|
||||
|
||||
// Reduce URL to pathname — query string not needed for matching
|
||||
// except that online-only endpoints need no special handling here.
|
||||
const path = req.url.startsWith('http') ? new URL(req.url).pathname : req.url.split('?')[0];
|
||||
|
||||
// Explicitly online-only GETs
|
||||
if (
|
||||
path === '/api/recipes/import' ||
|
||||
path === '/api/recipes/preview' ||
|
||||
path.startsWith('/api/recipes/search/web')
|
||||
) {
|
||||
return 'network-only';
|
||||
}
|
||||
|
||||
// Images
|
||||
if (path.startsWith('/images/')) return 'images';
|
||||
|
||||
// App-shell: build assets and known static files
|
||||
if (
|
||||
path.startsWith('/_app/') ||
|
||||
path === '/manifest.webmanifest' ||
|
||||
path === '/icon.svg' ||
|
||||
path === '/icon-192.png' ||
|
||||
path === '/icon-512.png' ||
|
||||
path === '/favicon.ico' ||
|
||||
path === '/robots.txt'
|
||||
) {
|
||||
return 'shell';
|
||||
}
|
||||
|
||||
// Everything else: recipe pages, API reads, lists — all SWR.
|
||||
return 'swr';
|
||||
}
|
||||
14
src/lib/sw/diff-manifest.ts
Normal file
14
src/lib/sw/diff-manifest.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Vergleicht die aktuelle Rezept-ID-Liste (vom Server) mit dem, was
|
||||
// der Cache schon hat. Der SW nutzt das Delta, um nur Neue zu laden
|
||||
// und Gelöschte abzuräumen.
|
||||
type ManifestDiff = { toAdd: number[]; toRemove: number[] };
|
||||
|
||||
export function diffManifest(currentIds: number[], cachedIds: number[]): ManifestDiff {
|
||||
const current = new Set(currentIds);
|
||||
const cached = new Set(cachedIds);
|
||||
const toAdd: number[] = [];
|
||||
const toRemove: number[] = [];
|
||||
for (const id of current) if (!cached.has(id)) toAdd.push(id);
|
||||
for (const id of cached) if (!current.has(id)) toRemove.push(id);
|
||||
return { toAdd, toRemove };
|
||||
}
|
||||
@@ -12,26 +12,25 @@
|
||||
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
||||
import UpdateToast from '$lib/components/UpdateToast.svelte';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
|
||||
import { network } from '$lib/client/network.svelte';
|
||||
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||
import { registerServiceWorker } from '$lib/client/sw-register';
|
||||
import { SearchStore } from '$lib/client/search.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const NAV_PAGE_SIZE = 30;
|
||||
const navStore = new SearchStore({
|
||||
pageSize: 30,
|
||||
filterParam: () => {
|
||||
const p = searchFilterStore.queryParam;
|
||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||
}
|
||||
});
|
||||
|
||||
let navQuery = $state('');
|
||||
let navHits = $state<SearchHit[]>([]);
|
||||
let navWebHits = $state<WebHit[]>([]);
|
||||
let navSearching = $state(false);
|
||||
let navWebSearching = $state(false);
|
||||
let navWebError = $state<string | null>(null);
|
||||
let navOpen = $state(false);
|
||||
let navLocalExhausted = $state(false);
|
||||
let navWebPageno = $state(0);
|
||||
let navWebExhausted = $state(false);
|
||||
let navLoadingMore = $state(false);
|
||||
let navContainer: HTMLElement | undefined = $state();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let menuOpen = $state(false);
|
||||
let menuContainer: HTMLElement | undefined = $state();
|
||||
|
||||
@@ -39,123 +38,21 @@
|
||||
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
|
||||
);
|
||||
|
||||
function filterParam(): string {
|
||||
const p = searchFilterStore.queryParam;
|
||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const q = navQuery.trim();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (q.length <= 3) {
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
navSearching = false;
|
||||
navWebSearching = false;
|
||||
navWebError = null;
|
||||
navOpen = false;
|
||||
navLocalExhausted = false;
|
||||
navWebPageno = 0;
|
||||
navWebExhausted = false;
|
||||
return;
|
||||
}
|
||||
navSearching = true;
|
||||
navWebHits = [];
|
||||
navWebSearching = false;
|
||||
navWebError = null;
|
||||
navOpen = true;
|
||||
navLocalExhausted = false;
|
||||
navWebPageno = 0;
|
||||
navWebExhausted = false;
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (navQuery.trim() !== q) return;
|
||||
navHits = body.hits;
|
||||
if (navHits.length < NAV_PAGE_SIZE) navLocalExhausted = true;
|
||||
if (navHits.length === 0) {
|
||||
navWebSearching = true;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}`
|
||||
);
|
||||
if (navQuery.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
navWebError = err.message ?? `HTTP ${wres.status}`;
|
||||
navWebExhausted = true;
|
||||
} else {
|
||||
const wbody = await wres.json();
|
||||
navWebHits = wbody.hits;
|
||||
navWebPageno = 1;
|
||||
if (navWebHits.length === 0) navWebExhausted = true;
|
||||
}
|
||||
} finally {
|
||||
if (navQuery.trim() === q) navWebSearching = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (navQuery.trim() === q) navSearching = false;
|
||||
}
|
||||
}, 300);
|
||||
// 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;
|
||||
});
|
||||
|
||||
async function loadMoreNav() {
|
||||
if (navLoadingMore) return;
|
||||
const q = navQuery.trim();
|
||||
if (!q) return;
|
||||
navLoadingMore = true;
|
||||
try {
|
||||
if (!navLocalExhausted) {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (navQuery.trim() !== q) return;
|
||||
const more = body.hits as SearchHit[];
|
||||
const seen = new Set(navHits.map((h) => h.id));
|
||||
const deduped = more.filter((h) => !seen.has(h.id));
|
||||
navHits = [...navHits, ...deduped];
|
||||
if (more.length < NAV_PAGE_SIZE) navLocalExhausted = true;
|
||||
} else if (!navWebExhausted) {
|
||||
const nextPage = navWebPageno + 1;
|
||||
navWebSearching = navWebHits.length === 0;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}`
|
||||
);
|
||||
if (navQuery.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
navWebError = err.message ?? `HTTP ${wres.status}`;
|
||||
navWebExhausted = true;
|
||||
return;
|
||||
}
|
||||
const wbody = await wres.json();
|
||||
const more = wbody.hits as WebHit[];
|
||||
const seen = new Set(navWebHits.map((h) => h.url));
|
||||
const deduped = more.filter((h) => !seen.has(h.url));
|
||||
if (deduped.length === 0) {
|
||||
navWebExhausted = true;
|
||||
} else {
|
||||
navWebHits = [...navWebHits, ...deduped];
|
||||
navWebPageno = nextPage;
|
||||
}
|
||||
} finally {
|
||||
if (navQuery.trim() === q) navWebSearching = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
navLoadingMore = false;
|
||||
}
|
||||
function loadMoreNav() {
|
||||
return navStore.loadMore();
|
||||
}
|
||||
|
||||
function submitNav(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const q = navQuery.trim();
|
||||
const q = navStore.query.trim();
|
||||
if (!q) return;
|
||||
navOpen = false;
|
||||
void goto(`/?q=${encodeURIComponent(q)}`);
|
||||
@@ -179,15 +76,11 @@
|
||||
|
||||
function pickHit() {
|
||||
navOpen = false;
|
||||
navQuery = '';
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
navStore.reset();
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
navQuery = '';
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
navStore.reset();
|
||||
navOpen = false;
|
||||
menuOpen = false;
|
||||
// Badge nach jeder Client-Navigation frisch halten — sonst kann er
|
||||
@@ -202,6 +95,9 @@
|
||||
void wishlistStore.refresh();
|
||||
void searchFilterStore.load();
|
||||
void pwaStore.init();
|
||||
network.init();
|
||||
installPrompt.init();
|
||||
void registerServiceWorker();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
@@ -211,6 +107,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toast />
|
||||
<SyncIndicator />
|
||||
<ConfirmDialog />
|
||||
<UpdateToast />
|
||||
|
||||
@@ -229,9 +127,9 @@
|
||||
<SearchFilter inline />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={navQuery}
|
||||
bind:value={navStore.query}
|
||||
onfocus={() => {
|
||||
if (navHits.length > 0 || navQuery.trim().length > 3) navOpen = true;
|
||||
if (navStore.hits.length > 0 || navStore.query.trim().length > 3) navOpen = true;
|
||||
}}
|
||||
placeholder="Rezept suchen…"
|
||||
autocomplete="off"
|
||||
@@ -241,12 +139,12 @@
|
||||
</form>
|
||||
{#if navOpen}
|
||||
<div class="dropdown" role="listbox">
|
||||
{#if navSearching && navHits.length === 0 && navWebHits.length === 0}
|
||||
{#if navStore.searching && navStore.hits.length === 0 && navStore.webHits.length === 0}
|
||||
<SearchLoader scope="local" size="sm" />
|
||||
{:else}
|
||||
{#if navHits.length > 0}
|
||||
{#if navStore.hits.length > 0}
|
||||
<ul class="dd-list">
|
||||
{#each navHits as r (r.id)}
|
||||
{#each navStore.hits as r (r.id)}
|
||||
<li>
|
||||
<a
|
||||
href={`/recipes/${r.id}`}
|
||||
@@ -272,14 +170,14 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if navWebHits.length > 0}
|
||||
{#if navHits.length > 0}
|
||||
{#if navStore.webHits.length > 0}
|
||||
{#if navStore.hits.length > 0}
|
||||
<p class="dd-section">Aus dem Internet</p>
|
||||
{:else}
|
||||
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
||||
{/if}
|
||||
<ul class="dd-list">
|
||||
{#each navWebHits as w (w.url)}
|
||||
{#each navStore.webHits as w (w.url)}
|
||||
<li>
|
||||
<a
|
||||
href={`/preview?url=${encodeURIComponent(w.url)}`}
|
||||
@@ -303,23 +201,23 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if navWebSearching}
|
||||
{#if navStore.webSearching}
|
||||
<SearchLoader scope="web" size="sm" />
|
||||
{:else if navWebError && navWebHits.length === 0}
|
||||
{:else if navStore.webError && navStore.webHits.length === 0}
|
||||
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
|
||||
{:else if navHits.length === 0 && navWebHits.length === 0 && !navSearching}
|
||||
{:else if navStore.hits.length === 0 && navStore.webHits.length === 0 && !navStore.searching}
|
||||
<p class="dd-status">Auch im Internet nichts gefunden.</p>
|
||||
{/if}
|
||||
|
||||
{#if !(navLocalExhausted && navWebExhausted) && (navHits.length > 0 || navWebHits.length > 0)}
|
||||
{#if !(navStore.localExhausted && navStore.webExhausted) && (navStore.hits.length > 0 || navStore.webHits.length > 0)}
|
||||
<button
|
||||
class="dd-web"
|
||||
type="button"
|
||||
onclick={loadMoreNav}
|
||||
disabled={navLoadingMore || navWebSearching}
|
||||
disabled={navStore.loadingMore || navStore.webSearching}
|
||||
>
|
||||
<span
|
||||
>{navLoadingMore || navWebSearching
|
||||
>{navStore.loadingMore || navStore.webSearching
|
||||
? 'Lade …'
|
||||
: '+ weitere Ergebnisse'}</span
|
||||
>
|
||||
@@ -376,6 +274,9 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(:root) {
|
||||
--pill-radius: 999px;
|
||||
}
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -419,7 +320,7 @@
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
color: #2b6a3d;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
@@ -611,7 +512,7 @@
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
text-decoration: none;
|
||||
font-size: 1.15rem;
|
||||
position: relative;
|
||||
@@ -626,7 +527,7 @@
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
background: #c53030;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { CookingPot, X } from 'lucide-svelte';
|
||||
import type { Snapshot } from './$types';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
import { randomQuote } from '$lib/quotes';
|
||||
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import { SearchStore, type SearchSnapshot } from '$lib/client/search.svelte';
|
||||
|
||||
const LOCAL_PAGE = 30;
|
||||
|
||||
let query = $state('');
|
||||
const store = new SearchStore({
|
||||
pageSize: LOCAL_PAGE,
|
||||
filterParam: () => {
|
||||
const p = searchFilterStore.queryParam;
|
||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||
}
|
||||
});
|
||||
|
||||
let quote = $state('');
|
||||
let recent = $state<SearchHit[]>([]);
|
||||
let favorites = $state<SearchHit[]>([]);
|
||||
let hits = $state<SearchHit[]>([]);
|
||||
let webHits = $state<WebHit[]>([]);
|
||||
let searching = $state(false);
|
||||
let webSearching = $state(false);
|
||||
let webError = $state<string | null>(null);
|
||||
let searchedFor = $state<string | null>(null);
|
||||
let localExhausted = $state(false);
|
||||
let webPageno = $state(0);
|
||||
let webExhausted = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let skipNextSearch = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const ALL_PAGE = 10;
|
||||
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
|
||||
@@ -43,41 +39,12 @@
|
||||
let allExhausted = $state(false);
|
||||
let allLoading = $state(false);
|
||||
let allSentinel: HTMLElement | undefined = $state();
|
||||
let allChips: HTMLElement | undefined = $state();
|
||||
let allObserver: IntersectionObserver | null = null;
|
||||
|
||||
type SearchSnapshot = {
|
||||
query: string;
|
||||
hits: SearchHit[];
|
||||
webHits: WebHit[];
|
||||
searchedFor: string | null;
|
||||
webError: string | null;
|
||||
localExhausted: boolean;
|
||||
webPageno: number;
|
||||
webExhausted: boolean;
|
||||
};
|
||||
|
||||
export const snapshot: Snapshot<SearchSnapshot> = {
|
||||
capture: () => ({
|
||||
query,
|
||||
hits,
|
||||
webHits,
|
||||
searchedFor,
|
||||
webError,
|
||||
localExhausted,
|
||||
webPageno,
|
||||
webExhausted
|
||||
}),
|
||||
restore: (v) => {
|
||||
query = v.query;
|
||||
hits = v.hits;
|
||||
webHits = v.webHits;
|
||||
searchedFor = v.searchedFor;
|
||||
webError = v.webError;
|
||||
localExhausted = v.localExhausted;
|
||||
webPageno = v.webPageno;
|
||||
webExhausted = v.webExhausted;
|
||||
skipNextSearch = true;
|
||||
}
|
||||
capture: () => store.captureSnapshot(),
|
||||
restore: (s) => store.restoreSnapshot(s)
|
||||
};
|
||||
|
||||
async function loadRecent() {
|
||||
@@ -105,10 +72,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetAllRecipes() {
|
||||
allRecipes = [];
|
||||
allExhausted = false;
|
||||
allLoading = false;
|
||||
async function setAllSort(next: AllSort) {
|
||||
if (next === allSort) return;
|
||||
allSort = next;
|
||||
if (typeof window !== 'undefined') localStorage.setItem('kochwas.allSort', next);
|
||||
if (allLoading) return;
|
||||
// Position der Sort-Chips vor dem Swap merken — wenn der Rezept-Block
|
||||
// beim Tausch kürzer wird, hält der Browser sonst nicht Schritt und
|
||||
// snapt nach oben. Wir korrigieren nach dem Render per scrollBy.
|
||||
const chipsBefore = allChips?.getBoundingClientRect().top ?? 0;
|
||||
allLoading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
const hits = body.hits as SearchHit[];
|
||||
allRecipes = hits;
|
||||
allExhausted = hits.length < ALL_PAGE;
|
||||
await tick();
|
||||
const chipsAfter = allChips?.getBoundingClientRect().top ?? 0;
|
||||
const delta = chipsAfter - chipsBefore;
|
||||
if (typeof window !== 'undefined' && Math.abs(delta) > 1) {
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} finally {
|
||||
allLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFavorites(profileId: number) {
|
||||
@@ -126,7 +117,7 @@
|
||||
// Restore query from URL so history.back() from preview/recipe
|
||||
// brings the user back to the same search results.
|
||||
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
|
||||
if (urlQ) query = urlQ;
|
||||
if (urlQ) store.query = urlQ;
|
||||
void loadRecent();
|
||||
void searchFilterStore.load();
|
||||
const saved = localStorage.getItem('kochwas.allSort');
|
||||
@@ -136,14 +127,6 @@
|
||||
void loadAllMore();
|
||||
});
|
||||
|
||||
function setAllSort(next: AllSort) {
|
||||
if (next === allSort) return;
|
||||
allSort = next;
|
||||
if (typeof window !== 'undefined') localStorage.setItem('kochwas.allSort', next);
|
||||
resetAllRecipes();
|
||||
void loadAllMore();
|
||||
}
|
||||
|
||||
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -170,14 +153,7 @@
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
searchFilterStore.active;
|
||||
const q = query.trim();
|
||||
if (!q || q.length <= 3) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
searching = true;
|
||||
webHits = [];
|
||||
webSearching = false;
|
||||
webError = null;
|
||||
debounceTimer = setTimeout(() => void runSearch(q), 150);
|
||||
store.reSearch();
|
||||
});
|
||||
|
||||
// Sync current query back into the URL as ?q=... via replaceState,
|
||||
@@ -185,7 +161,7 @@
|
||||
// when the user clicks a result or otherwise navigates away.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const q = query.trim();
|
||||
const q = store.query.trim();
|
||||
const url = new URL(window.location.href);
|
||||
const current = url.searchParams.get('q') ?? '';
|
||||
if (q === current) return;
|
||||
@@ -203,143 +179,23 @@
|
||||
void loadFavorites(active.id);
|
||||
});
|
||||
|
||||
function filterParam(): string {
|
||||
const p = searchFilterStore.queryParam;
|
||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||
}
|
||||
|
||||
async function runSearch(q: string) {
|
||||
localExhausted = false;
|
||||
webPageno = 0;
|
||||
webExhausted = false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (query.trim() !== q) return;
|
||||
hits = body.hits;
|
||||
searchedFor = q;
|
||||
if (hits.length < LOCAL_PAGE) localExhausted = true;
|
||||
if (hits.length === 0) {
|
||||
// Gar keine lokalen Treffer → erste Web-Seite gleich laden,
|
||||
// damit der User nicht extra auf „+ weitere" klicken muss.
|
||||
webSearching = true;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}`
|
||||
);
|
||||
if (query.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
webError = err.message ?? `HTTP ${wres.status}`;
|
||||
webExhausted = true;
|
||||
} else {
|
||||
const wbody = await wres.json();
|
||||
webHits = wbody.hits;
|
||||
webPageno = 1;
|
||||
if (wbody.hits.length === 0) webExhausted = true;
|
||||
}
|
||||
} finally {
|
||||
if (query.trim() === q) webSearching = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (query.trim() === q) searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
if (!localExhausted) {
|
||||
// Noch mehr lokale Treffer holen.
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}&offset=${hits.length}${filterParam()}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (query.trim() !== q) return;
|
||||
const more = body.hits as SearchHit[];
|
||||
const seen = new Set(hits.map((h) => h.id));
|
||||
const deduped = more.filter((h) => !seen.has(h.id));
|
||||
hits = [...hits, ...deduped];
|
||||
if (more.length < LOCAL_PAGE) localExhausted = true;
|
||||
} else if (!webExhausted) {
|
||||
// Lokale erschöpft → auf Web umschalten / weiterblättern.
|
||||
const nextPage = webPageno + 1;
|
||||
webSearching = webHits.length === 0;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}`
|
||||
);
|
||||
if (query.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
webError = err.message ?? `HTTP ${wres.status}`;
|
||||
webExhausted = true;
|
||||
return;
|
||||
}
|
||||
const wbody = await wres.json();
|
||||
const more = wbody.hits as WebHit[];
|
||||
const seen = new Set(webHits.map((h) => h.url));
|
||||
const deduped = more.filter((h) => !seen.has(h.url));
|
||||
if (deduped.length === 0) {
|
||||
webExhausted = true;
|
||||
} else {
|
||||
webHits = [...webHits, ...deduped];
|
||||
webPageno = nextPage;
|
||||
}
|
||||
} finally {
|
||||
if (query.trim() === q) webSearching = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const q = query.trim();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (skipNextSearch) {
|
||||
// Snapshot-Restore hat hits/webHits/searchedFor wiederhergestellt —
|
||||
// nicht erneut fetchen.
|
||||
skipNextSearch = false;
|
||||
return;
|
||||
}
|
||||
if (q.length <= 3) {
|
||||
hits = [];
|
||||
webHits = [];
|
||||
searchedFor = null;
|
||||
searching = false;
|
||||
webSearching = false;
|
||||
webError = null;
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
webHits = [];
|
||||
webSearching = false;
|
||||
webError = null;
|
||||
debounceTimer = setTimeout(() => {
|
||||
void runSearch(q);
|
||||
}, 300);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
store.query; // register reactive dep
|
||||
store.runDebounced();
|
||||
});
|
||||
|
||||
function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
const q = store.query.trim();
|
||||
if (q.length <= 3) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
searching = true;
|
||||
void runSearch(q);
|
||||
void store.runSearch(q);
|
||||
}
|
||||
|
||||
async function dismissFromRecent(recipeId: number, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!requireOnline('Das Entfernen')) return;
|
||||
recent = recent.filter((r) => r.id !== recipeId);
|
||||
await fetch(`/api/recipes/${recipeId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -348,7 +204,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
const activeSearch = $derived(query.trim().length > 3);
|
||||
const activeSearch = $derived(store.query.trim().length > 3);
|
||||
</script>
|
||||
|
||||
<section class="hero">
|
||||
@@ -359,7 +215,7 @@
|
||||
<SearchFilter inline />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
bind:value={store.query}
|
||||
placeholder="Rezept suchen…"
|
||||
autocomplete="off"
|
||||
inputmode="search"
|
||||
@@ -371,12 +227,12 @@
|
||||
|
||||
{#if activeSearch}
|
||||
<section class="results">
|
||||
{#if searching && hits.length === 0 && webHits.length === 0}
|
||||
{#if store.searching && store.hits.length === 0 && store.webHits.length === 0}
|
||||
<SearchLoader scope="local" />
|
||||
{:else}
|
||||
{#if hits.length > 0}
|
||||
{#if store.hits.length > 0}
|
||||
<ul class="cards">
|
||||
{#each hits as r (r.id)}
|
||||
{#each store.hits as r (r.id)}
|
||||
<li>
|
||||
<a href={`/recipes/${r.id}`} class="card">
|
||||
{#if r.image_path}
|
||||
@@ -394,20 +250,20 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if searchedFor === query.trim() && !webSearching && webHits.length === 0 && !webError}
|
||||
<p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}".</p>
|
||||
{:else if store.searchedFor === store.query.trim() && !store.webSearching && store.webHits.length === 0 && !store.webError}
|
||||
<p class="muted no-local-msg">Keine lokalen Rezepte für „{store.searchedFor}".</p>
|
||||
{/if}
|
||||
|
||||
{#if webHits.length > 0}
|
||||
{#if hits.length > 0}
|
||||
{#if store.webHits.length > 0}
|
||||
{#if store.hits.length > 0}
|
||||
<h3 class="sep">Aus dem Internet</h3>
|
||||
{:else if searchedFor === query.trim()}
|
||||
{:else if store.searchedFor === store.query.trim()}
|
||||
<p class="muted no-local-msg">
|
||||
Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:
|
||||
Keine lokalen Rezepte für „{store.searchedFor}" — Ergebnisse aus dem Internet:
|
||||
</p>
|
||||
{/if}
|
||||
<ul class="cards">
|
||||
{#each webHits as w (w.url)}
|
||||
{#each store.webHits as w (w.url)}
|
||||
<li>
|
||||
<a class="card" href={`/preview?url=${encodeURIComponent(w.url)}`}>
|
||||
{#if w.thumbnail}
|
||||
@@ -425,16 +281,16 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if webSearching}
|
||||
{#if store.webSearching}
|
||||
<SearchLoader scope="web" />
|
||||
{:else if webError && webHits.length === 0}
|
||||
<p class="error">Internet-Suche zurzeit nicht möglich: {webError}</p>
|
||||
{:else if store.webError && store.webHits.length === 0}
|
||||
<p class="error">Internet-Suche zurzeit nicht möglich: {store.webError}</p>
|
||||
{/if}
|
||||
|
||||
{#if searchedFor === query.trim() && !(localExhausted && webExhausted) && !(searching && hits.length === 0)}
|
||||
{#if store.searchedFor === store.query.trim() && !(store.localExhausted && store.webExhausted) && !(store.searching && store.hits.length === 0)}
|
||||
<div class="more-cta">
|
||||
<button class="more-btn" onclick={loadMore} disabled={loadingMore || webSearching}>
|
||||
{loadingMore || webSearching ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||
<button class="more-btn" onclick={() => store.loadMore()} disabled={store.loadingMore || store.webSearching}>
|
||||
{store.loadingMore || store.webSearching ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -500,7 +356,12 @@
|
||||
<div class="listing-head">
|
||||
<h2>Alle Rezepte</h2>
|
||||
</div>
|
||||
<div class="sort-chips" role="tablist" aria-label="Sortierung">
|
||||
<div
|
||||
class="sort-chips"
|
||||
role="tablist"
|
||||
aria-label="Sortierung"
|
||||
bind:this={allChips}
|
||||
>
|
||||
{#each ALL_SORTS as s (s.value)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -629,7 +490,7 @@
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
color: #2b6a3d;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
@@ -736,7 +597,7 @@
|
||||
right: 0.4rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #444;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Globe, Users, DatabaseBackup, type Icon } from 'lucide-svelte';
|
||||
import { Globe, Users, DatabaseBackup, Smartphone, type Icon } from 'lucide-svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const items: { href: string; label: string; icon: typeof Icon }[] = [
|
||||
{ href: '/admin/domains', label: 'Domains', icon: Globe },
|
||||
{ href: '/admin/profiles', label: 'Profile', icon: Users },
|
||||
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup }
|
||||
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup },
|
||||
{ href: '/admin/app', label: 'App', icon: Smartphone }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -41,7 +42,7 @@
|
||||
padding: 0.5rem 0.95rem 0.5rem 0.8rem;
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
text-decoration: none;
|
||||
color: #444;
|
||||
font-size: 0.95rem;
|
||||
|
||||
142
src/routes/admin/app/+page.svelte
Normal file
142
src/routes/admin/app/+page.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { Download, RefreshCw, Trash2 } from 'lucide-svelte';
|
||||
import { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||
import { syncStatus } from '$lib/client/sync-status.svelte';
|
||||
import { network } from '$lib/client/network.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { toastStore } from '$lib/client/toast.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
function triggerInstall() {
|
||||
void installPrompt.prompt();
|
||||
}
|
||||
|
||||
function triggerSync() {
|
||||
if (!requireOnline('Das Synchronisieren')) return;
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'sync-check' });
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
const ok = await confirmAction({
|
||||
title: 'Offline-Cache leeren?',
|
||||
message:
|
||||
'Alle lokal gespeicherten Rezepte und Bilder werden entfernt. Beim nächsten Online-Start werden sie neu geladen.',
|
||||
confirmLabel: 'Leeren',
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter((k) => k.startsWith('kochwas-')).map((k) => caches.delete(k)));
|
||||
toastStore.success('Cache geleert. Lade jetzt neu.');
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string {
|
||||
if (ts === null) return 'noch nicht';
|
||||
return new Date(ts).toLocaleString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>App</h1>
|
||||
<p class="intro">Einstellungen für die Installation und den Offline-Cache.</p>
|
||||
|
||||
<section class="card">
|
||||
<h2>Installieren</h2>
|
||||
{#if installPrompt.platform === 'ios'}
|
||||
<p>
|
||||
Öffne das Teilen-Menü in Safari und wähle <strong
|
||||
>„Zum Home-Bildschirm hinzufügen"</strong
|
||||
>.
|
||||
</p>
|
||||
{:else if installPrompt.available}
|
||||
<button type="button" class="btn primary" onclick={triggerInstall}>
|
||||
<Download size={16} strokeWidth={2} /> Als App installieren
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
Installation aktuell nicht möglich (entweder schon installiert oder Browser unterstützt es
|
||||
nicht).
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Offline-Synchronisation</h2>
|
||||
{#if syncStatus.state.kind === 'syncing'}
|
||||
<p>Lädt gerade: {syncStatus.state.current}/{syncStatus.state.total} Rezepte.</p>
|
||||
{:else if syncStatus.state.kind === 'error'}
|
||||
<p class="error">Fehler: {syncStatus.state.message}</p>
|
||||
{:else}
|
||||
<p>Zuletzt synchronisiert: {formatTime(syncStatus.lastSynced)}</p>
|
||||
{/if}
|
||||
<button type="button" class="btn" onclick={triggerSync} disabled={!network.online}>
|
||||
<RefreshCw size={16} strokeWidth={2} /> Jetzt synchronisieren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cache</h2>
|
||||
<p class="muted">Nur bei Problemen: entfernt alle Offline-Daten.</p>
|
||||
<button type="button" class="btn danger" onclick={clearCache}>
|
||||
<Trash2 size={16} strokeWidth={2} /> Offline-Cache leeren
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.intro {
|
||||
color: #666;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: #2b6a3d;
|
||||
}
|
||||
.card p {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
}
|
||||
.error {
|
||||
color: #c53030;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.92rem;
|
||||
min-height: 40px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
}
|
||||
.btn.danger {
|
||||
color: #c53030;
|
||||
border-color: #f1b4b4;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
||||
import type { AllowedDomain } from '$lib/types';
|
||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
let domains = $state<AllowedDomain[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -25,6 +27,7 @@
|
||||
async function add() {
|
||||
errored = null;
|
||||
if (!newDomain.trim()) return;
|
||||
if (!requireOnline('Das Hinzufügen')) return;
|
||||
adding = true;
|
||||
const res = await fetch('/api/domains', {
|
||||
method: 'POST',
|
||||
@@ -59,24 +62,22 @@
|
||||
|
||||
async function saveEdit(d: AllowedDomain) {
|
||||
if (!editDomain.trim()) return;
|
||||
if (!requireOnline('Das Speichern')) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/domains/${d.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: editDomain.trim(),
|
||||
display_name: editLabel.trim() || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/domains/${d.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: editDomain.trim(),
|
||||
display_name: editLabel.trim() || null
|
||||
})
|
||||
},
|
||||
'Speichern fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
cancelEdit();
|
||||
await load();
|
||||
} finally {
|
||||
@@ -92,6 +93,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Entfernen')) return;
|
||||
await fetch(`/api/domains/${d.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
|
||||
let newName = $state('');
|
||||
let newEmoji = $state('🍳');
|
||||
@@ -10,6 +12,7 @@
|
||||
async function add() {
|
||||
errored = null;
|
||||
if (!newName.trim()) return;
|
||||
if (!requireOnline('Das Anlegen')) return;
|
||||
adding = true;
|
||||
try {
|
||||
await profileStore.create(newName.trim(), newEmoji || null);
|
||||
@@ -24,19 +27,17 @@
|
||||
async function rename(id: number, currentName: string) {
|
||||
const next = prompt('Neuer Name:', currentName);
|
||||
if (!next || next === currentName) return;
|
||||
const res = await fetch(`/api/profiles/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: next.trim() })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Umbenennen fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Umbenennen')) return;
|
||||
const res = await asyncFetch(
|
||||
`/api/profiles/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: next.trim() })
|
||||
},
|
||||
'Umbenennen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
await profileStore.load();
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Löschen')) return;
|
||||
await fetch(`/api/profiles/${id}`, { method: 'DELETE' });
|
||||
if (profileStore.activeId === id) profileStore.clear();
|
||||
await profileStore.load();
|
||||
@@ -183,7 +185,7 @@
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: #eaf4ed;
|
||||
color: #2b6a3d;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.actions {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createBackupStream, backupFilename } from '$lib/server/backup/export';
|
||||
import { DATABASE_PATH, IMAGE_DIR } from '$lib/server/paths';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
const DB_PATH = process.env.DATABASE_PATH ?? './data/kochwas.db';
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const archive = createBackupStream({ dbPath: DB_PATH, imagesDir: IMAGE_DIR });
|
||||
const archive = createBackupStream({ dbPath: DATABASE_PATH, imagesDir: IMAGE_DIR });
|
||||
const filename = backupFilename();
|
||||
return new Response(Readable.toWeb(archive) as ReadableStream, {
|
||||
status: 200,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import { addDomain, listDomains, setDomainFavicon } from '$lib/server/domains/repository';
|
||||
import { ensureFavicons, fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
domain: z.string().min(3).max(253),
|
||||
@@ -11,8 +13,6 @@ const CreateSchema = z.object({
|
||||
added_by_profile_id: z.number().int().positive().nullable().optional()
|
||||
});
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const db = getDb();
|
||||
// Favicons lazy nachziehen — beim zweiten Aufruf gibt es nichts mehr zu tun.
|
||||
@@ -21,16 +21,14 @@ export const GET: RequestHandler = async () => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = CreateSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const data = validateBody(await request.json().catch(() => null), CreateSchema);
|
||||
try {
|
||||
const db = getDb();
|
||||
const d = addDomain(
|
||||
db,
|
||||
parsed.data.domain,
|
||||
parsed.data.display_name ?? null,
|
||||
parsed.data.added_by_profile_id ?? null
|
||||
data.domain,
|
||||
data.display_name ?? null,
|
||||
data.added_by_profile_id ?? null
|
||||
);
|
||||
// Favicon direkt nach dem Insert mitziehen, damit die Antwort schon das
|
||||
// Icon enthält — der POST ist eh ein interaktiver Admin-Vorgang.
|
||||
@@ -41,6 +39,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
}
|
||||
return json(d, { status: 201 });
|
||||
} catch (e) {
|
||||
if (isHttpError(e)) throw e;
|
||||
error(409, { message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import {
|
||||
removeDomain,
|
||||
updateDomain,
|
||||
setDomainFavicon
|
||||
} from '$lib/server/domains/repository';
|
||||
import { fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
domain: z.string().min(3).max(253).optional(),
|
||||
display_name: z.string().max(100).nullable().optional()
|
||||
});
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = UpdateSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), UpdateSchema);
|
||||
try {
|
||||
const db = getDb();
|
||||
const updated = updateDomain(db, id, parsed.data);
|
||||
const updated = updateDomain(db, id, data);
|
||||
if (!updated) error(404, { message: 'Not found' });
|
||||
// Wenn updateDomain favicon_path genullt hat (Domain geändert), frisch laden.
|
||||
if (updated.favicon_path === null) {
|
||||
@@ -41,12 +33,14 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
}
|
||||
return json(updated);
|
||||
} catch (e) {
|
||||
// HTTP-Errors aus error() durchreichen, sonst landet ein 404 als 409.
|
||||
if (isHttpError(e)) throw e;
|
||||
error(409, { message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
removeDomain(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import { createProfile, listProfiles } from '$lib/server/profiles/repository';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
@@ -14,15 +15,12 @@ export const GET: RequestHandler = async () => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = CreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
error(400, { message: 'Invalid body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = validateBody(await request.json().catch(() => null), CreateSchema);
|
||||
try {
|
||||
const p = createProfile(getDb(), parsed.data.name, parsed.data.avatar_emoji ?? null);
|
||||
const p = createProfile(getDb(), data.name, data.avatar_emoji ?? null);
|
||||
return json(p, { status: 201 });
|
||||
} catch (e) {
|
||||
if (isHttpError(e)) throw e;
|
||||
error(409, { message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { deleteProfile, renameProfile } from '$lib/server/profiles/repository';
|
||||
|
||||
const RenameSchema = z.object({ name: z.string().min(1).max(50) });
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = RenameSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
renameProfile(getDb(), id, parsed.data.name);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), RenameSchema);
|
||||
renameProfile(getDb(), id, data.name);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
deleteProfile(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import {
|
||||
deleteRecipe,
|
||||
getRecipeById,
|
||||
@@ -35,7 +36,7 @@ const PatchSchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
servings_default: z.number().int().positive().nullable().optional(),
|
||||
servings_default: z.number().int().nonnegative().nullable().optional(),
|
||||
servings_unit: z.string().max(30).nullable().optional(),
|
||||
prep_time_min: z.number().int().nonnegative().nullable().optional(),
|
||||
cook_time_min: z.number().int().nonnegative().nullable().optional(),
|
||||
@@ -48,14 +49,8 @@ const PatchSchema = z
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'Empty patch' });
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const db = getDb();
|
||||
const recipe = getRecipeById(db, id);
|
||||
if (!recipe) error(404, { message: 'Recipe not found' });
|
||||
@@ -68,12 +63,10 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = PatchSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const p = validateBody(body, PatchSchema);
|
||||
const db = getDb();
|
||||
const p = parsed.data;
|
||||
// Spezielle Kurz-Updates (bleiben als Sonderfall, weil sie FTS triggern
|
||||
// bzw. andere Tabellen mitpflegen).
|
||||
if (p.title !== undefined && Object.keys(p).length === 1) {
|
||||
@@ -121,7 +114,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
deleteRecipe(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { addComment, deleteComment, listComments } from '$lib/server/recipes/actions';
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -11,30 +12,20 @@ const Schema = z.object({
|
||||
|
||||
const DeleteSchema = z.object({ comment_id: z.number().int().positive() });
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
return json(listComments(getDb(), id));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const cid = addComment(getDb(), id, parsed.data.profile_id, parsed.data.text);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
const cid = addComment(getDb(), id, data.profile_id, data.text);
|
||||
return json({ id: cid }, { status: 201 });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = DeleteSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
deleteComment(getDb(), parsed.data.comment_id);
|
||||
const data = validateBody(await request.json().catch(() => null), DeleteSchema);
|
||||
deleteComment(getDb(), data.comment_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { logCooked } from '$lib/server/recipes/actions';
|
||||
import { removeFromWishlistForAll } from '$lib/server/wishlist/repository';
|
||||
|
||||
const Schema = z.object({ profile_id: z.number().int().positive() });
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
const db = getDb();
|
||||
const entry = logCooked(db, id, parsed.data.profile_id);
|
||||
const entry = logCooked(db, id, data.profile_id);
|
||||
// Wenn das Rezept heute gekocht wurde, ist der Wunsch erfüllt — für alle
|
||||
// Profile raus aus der Wunschliste. Client nutzt den removed_from_wishlist-
|
||||
// Flag, um den lokalen State (Badge, Button) ohne Reload zu aktualisieren.
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { addFavorite, removeFavorite } from '$lib/server/recipes/actions';
|
||||
|
||||
const Schema = z.object({ profile_id: z.number().int().positive() });
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
addFavorite(getDb(), id, parsed.data.profile_id);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
addFavorite(getDb(), id, data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
removeFavorite(getDb(), id, parsed.data.profile_id);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
removeFavorite(getDb(), id, data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
56
src/routes/api/recipes/[id]/image/+server.ts
Normal file
56
src/routes/api/recipes/[id]/image/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam } from '$lib/server/api-helpers';
|
||||
import { getRecipeById, updateImagePath } from '$lib/server/recipes/repository';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
'image/avif': '.avif'
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const db = getDb();
|
||||
if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' });
|
||||
|
||||
const form = await request.formData().catch(() => null);
|
||||
const file = form?.get('file');
|
||||
if (!(file instanceof File)) error(400, { message: 'Field "file" missing' });
|
||||
if (file.size === 0) error(400, { message: 'Empty file' });
|
||||
if (file.size > MAX_BYTES) error(413, { message: 'Image too large (max 10 MB)' });
|
||||
|
||||
const mime = file.type.toLowerCase();
|
||||
const ext = EXT_BY_MIME[mime];
|
||||
if (!ext) error(415, { message: `Image format ${file.type || 'unknown'} not supported` });
|
||||
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const hash = createHash('sha256').update(buf).digest('hex');
|
||||
const filename = `${hash}${ext}`;
|
||||
const target = join(IMAGE_DIR, filename);
|
||||
if (!existsSync(target)) {
|
||||
await mkdir(IMAGE_DIR, { recursive: true });
|
||||
await writeFile(target, buf);
|
||||
}
|
||||
updateImagePath(db, id, filename);
|
||||
return json({ ok: true, image_path: filename });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = ({ params }) => {
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const db = getDb();
|
||||
if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' });
|
||||
updateImagePath(db, id, null);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { clearRating, setRating } from '$lib/server/recipes/actions';
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -11,26 +12,16 @@ const Schema = z.object({
|
||||
|
||||
const DeleteSchema = z.object({ profile_id: z.number().int().positive() });
|
||||
|
||||
function parseId(raw: string): number {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
setRating(getDb(), id, parsed.data.profile_id, parsed.data.stars);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
setRating(getDb(), id, data.profile_id, data.stars);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = DeleteSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
clearRating(getDb(), id, parsed.data.profile_id);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), DeleteSchema);
|
||||
clearRating(getDb(), id, data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
30
src/routes/api/recipes/blank/+server.ts
Normal file
30
src/routes/api/recipes/blank/+server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { insertRecipe } from '$lib/server/recipes/repository';
|
||||
|
||||
// Legt ein leeres Rezept an und gibt die ID zurück. Der Client leitet
|
||||
// danach nach /recipes/{id}?edit=1 um, damit der Editor sofort offen ist.
|
||||
// Titel "Neues Rezept" ist ein Platzhalter — der User überschreibt ihn
|
||||
// beim ersten Speichern.
|
||||
export const POST: RequestHandler = async () => {
|
||||
const id = insertRecipe(getDb(), {
|
||||
id: null,
|
||||
title: 'Neues Rezept',
|
||||
description: null,
|
||||
source_url: null,
|
||||
source_domain: null,
|
||||
image_path: null,
|
||||
servings_default: 4,
|
||||
servings_unit: null,
|
||||
prep_time_min: null,
|
||||
cook_time_min: null,
|
||||
total_time_min: null,
|
||||
cuisine: null,
|
||||
category: null,
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
tags: []
|
||||
});
|
||||
return json({ id });
|
||||
};
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import { importRecipe } from '$lib/server/recipes/importer';
|
||||
import { mapImporterError } from '$lib/server/errors';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const ImportSchema = z.object({ url: z.string().url() });
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = ImportSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const data = validateBody(await request.json().catch(() => null), ImportSchema);
|
||||
try {
|
||||
const result = await importRecipe(getDb(), IMAGE_DIR, parsed.data.url);
|
||||
const result = await importRecipe(getDb(), IMAGE_DIR, data.url);
|
||||
return json({ id: result.id, duplicate: result.duplicate });
|
||||
} catch (e) {
|
||||
mapImporterError(e);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import {
|
||||
addToWishlist,
|
||||
listWishlist,
|
||||
@@ -32,9 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = AddSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' });
|
||||
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id);
|
||||
const data = validateBody(await request.json().catch(() => null), AddSchema);
|
||||
addToWishlist(getDb(), data.recipe_id, data.profile_id);
|
||||
return json({ ok: true }, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam } from '$lib/server/api-helpers';
|
||||
import {
|
||||
removeFromWishlist,
|
||||
removeFromWishlistForAll
|
||||
} from '$lib/server/wishlist/repository';
|
||||
|
||||
function parsePositiveInt(raw: string | null, field: string): number {
|
||||
const n = raw === null ? NaN : Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
|
||||
return n;
|
||||
}
|
||||
|
||||
// DELETE /api/wishlist/:id?profile_id=X → entfernt nur den eigenen Wunsch
|
||||
// DELETE /api/wishlist/:id?all=true → entfernt für ALLE Profile
|
||||
export const DELETE: RequestHandler = async ({ params, url }) => {
|
||||
const id = parsePositiveInt(params.recipe_id!, 'recipe_id');
|
||||
const id = parsePositiveIntParam(params.recipe_id, 'recipe_id');
|
||||
const db = getDb();
|
||||
if (url.searchParams.get('all') === 'true') {
|
||||
removeFromWishlistForAll(db, id);
|
||||
} else {
|
||||
const profileId = parsePositiveInt(url.searchParams.get('profile_id'), 'profile_id');
|
||||
const profileId = parsePositiveIntParam(url.searchParams.get('profile_id'), 'profile_id');
|
||||
removeFromWishlist(db, id, profileId);
|
||||
}
|
||||
return json({ ok: true });
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { join, basename, extname } from 'node:path';
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
$effect(() => {
|
||||
const u = ($page.url.searchParams.get('url') ?? '').trim();
|
||||
targetUrl = u;
|
||||
if (u) void load(u);
|
||||
if (u) {
|
||||
void load(u);
|
||||
} else {
|
||||
loading = false;
|
||||
errored = 'Kein ?url=-Parameter. Suche zuerst ein Rezept und klicke auf einen Treffer.';
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
|
||||
@@ -1,20 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { CookingPot, Link } from 'lucide-svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { alertAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let filter = $state('');
|
||||
let importUrl = $state('');
|
||||
let menuOpen = $state(false);
|
||||
let importOpen = $state(false);
|
||||
let creatingBlank = $state(false);
|
||||
let menuWrap: HTMLElement | undefined = $state();
|
||||
let importInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen;
|
||||
}
|
||||
|
||||
async function openImport() {
|
||||
menuOpen = false;
|
||||
importOpen = true;
|
||||
await tick();
|
||||
importInput?.focus();
|
||||
}
|
||||
|
||||
function closeImport() {
|
||||
importOpen = false;
|
||||
importUrl = '';
|
||||
}
|
||||
|
||||
function submitImport(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const url = importUrl.trim();
|
||||
if (!url) return;
|
||||
if (!requireOnline('Der URL-Import')) return;
|
||||
importOpen = false;
|
||||
goto(`/preview?url=${encodeURIComponent(url)}`);
|
||||
}
|
||||
|
||||
async function createBlank() {
|
||||
if (creatingBlank) return;
|
||||
if (!requireOnline('Das Anlegen')) return;
|
||||
menuOpen = false;
|
||||
creatingBlank = true;
|
||||
try {
|
||||
const res = await fetch('/api/recipes/blank', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
await alertAction({
|
||||
title: 'Anlegen fehlgeschlagen',
|
||||
message: `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body = await res.json();
|
||||
goto(`/recipes/${body.id}?edit=1`);
|
||||
} finally {
|
||||
creatingBlank = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!menuOpen) return;
|
||||
if (menuWrap && !menuWrap.contains(e.target as Node)) menuOpen = false;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (importOpen) closeImport();
|
||||
else if (menuOpen) menuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('click', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('click', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
});
|
||||
|
||||
// Umlaute und Diakritika auf Basis-Buchstaben normalisieren, damit
|
||||
// "apfel" auch "Äpfel" findet und "A/Ä/O/Ö/U/Ü" im gleichen Section-Header landen.
|
||||
function normalize(s: string): string {
|
||||
@@ -66,22 +134,86 @@
|
||||
</script>
|
||||
|
||||
<header class="head">
|
||||
<h1>Register</h1>
|
||||
<p class="sub">{data.recipes.length} Rezepte insgesamt</p>
|
||||
<div class="head-top">
|
||||
<div class="head-titles">
|
||||
<h1>Register</h1>
|
||||
<p class="sub">{data.recipes.length} Rezepte insgesamt</p>
|
||||
</div>
|
||||
<div class="add-menu" bind:this={menuWrap}>
|
||||
<button
|
||||
type="button"
|
||||
class="add-btn"
|
||||
onclick={toggleMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.2} />
|
||||
<span>Rezept hinzufügen</span>
|
||||
<ChevronDown size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openImport}>
|
||||
<Link size={16} strokeWidth={2} />
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">Von URL importieren</div>
|
||||
<div class="menu-desc">Rezept aus einer Website ziehen</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={createBlank}
|
||||
disabled={creatingBlank}
|
||||
>
|
||||
<Pencil size={16} strokeWidth={2} />
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">Leeres Rezept</div>
|
||||
<div class="menu-desc">Manuell ausfüllen</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="import-url" onsubmit={submitImport}>
|
||||
<span class="import-icon" aria-hidden="true"><Link size={16} strokeWidth={2} /></span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={importUrl}
|
||||
placeholder="Neues Rezept von URL importieren …"
|
||||
aria-label="Rezept-URL importieren"
|
||||
/>
|
||||
<button type="submit" class="import-go" disabled={!importUrl.trim()}>
|
||||
Importieren
|
||||
</button>
|
||||
</form>
|
||||
{#if importOpen}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) closeImport();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="import-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2 id="import-title">Rezept-URL importieren</h2>
|
||||
<form onsubmit={submitImport}>
|
||||
<input
|
||||
bind:this={importInput}
|
||||
type="url"
|
||||
bind:value={importUrl}
|
||||
placeholder="https://…"
|
||||
aria-label="Rezept-URL"
|
||||
required
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" onclick={closeImport}>Abbrechen</button>
|
||||
<button type="submit" class="btn primary" disabled={!importUrl.trim()}>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="filter-wrap">
|
||||
<input
|
||||
@@ -136,6 +268,16 @@
|
||||
.head {
|
||||
padding: 1.25rem 0 0.5rem;
|
||||
}
|
||||
.head-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head-titles {
|
||||
min-width: 0;
|
||||
}
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
@@ -146,55 +288,145 @@
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.import-url {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 12px;
|
||||
padding: 0.25rem 0.25rem 0.25rem 0.75rem;
|
||||
.add-menu {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.import-url:focus-within {
|
||||
outline: 2px solid #2b6a3d;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.import-icon {
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #6a7670;
|
||||
}
|
||||
.import-url input {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
min-height: 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
.import-url input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.import-go {
|
||||
padding: 0 0.9rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
min-height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.import-go:hover:not(:disabled) {
|
||||
.add-btn:hover {
|
||||
background: #235532;
|
||||
}
|
||||
.import-go:disabled {
|
||||
opacity: 0.45;
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
right: 0;
|
||||
min-width: 260px;
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 0.3rem;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: #1a1a1a;
|
||||
width: 100%;
|
||||
}
|
||||
.menu-item:hover:not(:disabled) {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.menu-item:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
.menu-item :global(svg) {
|
||||
color: #2b6a3d;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.menu-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.menu-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.menu-desc {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(20, 30, 25, 0.45);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 1.1rem 1.1rem 1rem;
|
||||
width: min(440px, 100%);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.modal h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
color: #2b6a3d;
|
||||
}
|
||||
.modal input {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
min-height: 44px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal input:focus {
|
||||
outline: 2px solid #2b6a3d;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.modal .btn {
|
||||
padding: 0.6rem 1rem;
|
||||
min-height: 42px;
|
||||
border: 1px solid #cfd9d1;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal .btn:hover:not(:disabled) {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.modal .btn.primary {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border: 0;
|
||||
}
|
||||
.modal .btn.primary:hover:not(:disabled) {
|
||||
background: #235532;
|
||||
}
|
||||
.modal .btn.primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.filter-wrap {
|
||||
@@ -209,7 +441,7 @@
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--pill-radius);
|
||||
background: white;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { onMount, onDestroy, tick, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
Heart,
|
||||
@@ -16,9 +17,11 @@
|
||||
import RecipeView from '$lib/components/RecipeView.svelte';
|
||||
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
|
||||
import StarRating from '$lib/components/StarRating.svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { profileStore, requireProfile } from '$lib/client/profile.svelte';
|
||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import type { CommentRow } from '$lib/server/recipes/actions';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -38,7 +41,25 @@
|
||||
|
||||
let editMode = $state(false);
|
||||
let saving = $state(false);
|
||||
let recipeState = $state(data.recipe);
|
||||
let recipeState = $state(untrack(() => data.recipe));
|
||||
|
||||
// Einmalige Pulse-Animation beim Aktivieren (nicht beim Wieder-Abwählen).
|
||||
// Per tick()-Zwischenschritt "aus → an" erzwingen, damit die Animation
|
||||
// auch bei mehrmaligem Klick innerhalb weniger hundert ms neu startet.
|
||||
let pulseFav = $state(false);
|
||||
let pulseWish = $state(false);
|
||||
|
||||
async function firePulse(which: 'fav' | 'wish') {
|
||||
if (which === 'fav') {
|
||||
pulseFav = false;
|
||||
await tick();
|
||||
pulseFav = true;
|
||||
} else {
|
||||
pulseWish = false;
|
||||
await tick();
|
||||
pulseWish = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRecipe(patch: {
|
||||
title: string;
|
||||
@@ -50,21 +71,19 @@
|
||||
ingredients: typeof data.recipe.ingredients;
|
||||
steps: typeof data.recipe.steps;
|
||||
}) {
|
||||
if (!requireOnline('Das Speichern')) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
},
|
||||
'Speichern fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
const body = await res.json();
|
||||
if (body.recipe) {
|
||||
recipeState = body.recipe;
|
||||
@@ -101,55 +120,44 @@
|
||||
);
|
||||
|
||||
async function setRating(stars: number) {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Rating')) return;
|
||||
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileStore.active.id, stars })
|
||||
body: JSON.stringify({ profile_id: profile.id, stars })
|
||||
});
|
||||
const existing = ratings.find((r) => r.profile_id === profileStore.active!.id);
|
||||
const existing = ratings.find((r) => r.profile_id === profile.id);
|
||||
if (existing) existing.stars = stars;
|
||||
else ratings = [...ratings, { profile_id: profileStore.active.id, stars }];
|
||||
else ratings = [...ratings, { profile_id: profile.id, stars }];
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profileId = profileStore.active.id;
|
||||
const method = isFav ? 'DELETE' : 'PUT';
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Favorit-Setzen')) return;
|
||||
const wasFav = isFav;
|
||||
const method = wasFav ? 'DELETE' : 'PUT';
|
||||
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileId })
|
||||
body: JSON.stringify({ profile_id: profile.id })
|
||||
});
|
||||
favoriteProfileIds = isFav
|
||||
? favoriteProfileIds.filter((id) => id !== profileId)
|
||||
: [...favoriteProfileIds, profileId];
|
||||
favoriteProfileIds = wasFav
|
||||
? favoriteProfileIds.filter((id) => id !== profile.id)
|
||||
: [...favoriteProfileIds, profile.id];
|
||||
if (!wasFav) void firePulse('fav');
|
||||
}
|
||||
|
||||
async function logCooked() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Der Kochjournal-Eintrag')) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileStore.active.id })
|
||||
body: JSON.stringify({ profile_id: profile.id })
|
||||
});
|
||||
const entry = await res.json();
|
||||
cookingLog = [entry, ...cookingLog];
|
||||
@@ -160,19 +168,15 @@
|
||||
}
|
||||
|
||||
async function addComment() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Speichern des Kommentars')) return;
|
||||
const text = newComment.trim();
|
||||
if (!text) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileStore.active.id, text })
|
||||
body: JSON.stringify({ profile_id: profile.id, text })
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
@@ -180,16 +184,38 @@
|
||||
...comments,
|
||||
{
|
||||
id: body.id,
|
||||
profile_id: profileStore.active.id,
|
||||
profile_id: profile.id,
|
||||
text,
|
||||
created_at: new Date().toISOString(),
|
||||
author: profileStore.active.name
|
||||
author: profile.name
|
||||
}
|
||||
];
|
||||
newComment = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(id: number) {
|
||||
const ok = await confirmAction({
|
||||
title: 'Kommentar löschen?',
|
||||
message: 'Der Eintrag verschwindet ohne Umweg.',
|
||||
confirmLabel: 'Löschen',
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Löschen')) return;
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}/comments`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ comment_id: id })
|
||||
},
|
||||
'Löschen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
comments = comments.filter((c) => c.id !== id);
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const ok = await confirmAction({
|
||||
title: 'Rezept löschen?',
|
||||
@@ -198,6 +224,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Löschen')) return;
|
||||
await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' });
|
||||
goto('/');
|
||||
}
|
||||
@@ -221,19 +248,17 @@
|
||||
editingTitle = false;
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: next })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Umbenennen fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Umbenennen')) return;
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: next })
|
||||
},
|
||||
'Umbenennen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
title = next;
|
||||
editingTitle = false;
|
||||
}
|
||||
@@ -249,28 +274,25 @@
|
||||
}
|
||||
|
||||
async function toggleWishlist() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profileId = profileStore.active.id;
|
||||
if (onMyWishlist) {
|
||||
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, {
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Wunschlisten-Setzen')) return;
|
||||
const wasOn = onMyWishlist;
|
||||
if (wasOn) {
|
||||
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profile.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profileId);
|
||||
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profile.id);
|
||||
} else {
|
||||
await fetch('/api/wishlist', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profileId })
|
||||
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profile.id })
|
||||
});
|
||||
wishlistProfileIds = [...wishlistProfileIds, profileId];
|
||||
wishlistProfileIds = [...wishlistProfileIds, profile.id];
|
||||
}
|
||||
void wishlistStore.refresh();
|
||||
if (!wasOn) void firePulse('wish');
|
||||
}
|
||||
|
||||
// Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen.
|
||||
@@ -315,6 +337,15 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Wenn wir über "Manuell anlegen" hier landen, ist ?edit=1 gesetzt
|
||||
// und wir starten direkt im Editor. Den Param danach aus der URL
|
||||
// entfernen, damit Refresh nicht automatisch wieder edit-Mode ist.
|
||||
if ($page.url.searchParams.get('edit') === '1') {
|
||||
editMode = true;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('edit');
|
||||
history.replaceState(history.state, '', url.toString());
|
||||
}
|
||||
const stored = localStorage.getItem('kochwas.wakeLock');
|
||||
if (stored !== null) wakeLockEnabled = stored === '1';
|
||||
if (wakeLockEnabled) void acquireWakeLock();
|
||||
@@ -338,6 +369,7 @@
|
||||
{saving}
|
||||
onsave={saveRecipe}
|
||||
oncancel={() => (editMode = false)}
|
||||
onimagechange={(path) => (recipeState = { ...recipeState, image_path: path })}
|
||||
/>
|
||||
{:else}
|
||||
<RecipeView recipe={recipeState}>
|
||||
@@ -377,11 +409,23 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
||||
<button
|
||||
class="btn"
|
||||
class:heart={isFav}
|
||||
class:pulse={pulseFav}
|
||||
onclick={toggleFavorite}
|
||||
onanimationend={() => (pulseFav = false)}
|
||||
>
|
||||
<Heart size={18} strokeWidth={2} fill={isFav ? 'currentColor' : 'none'} />
|
||||
<span>Favorit</span>
|
||||
</button>
|
||||
<button class="btn" class:wish={onMyWishlist} onclick={toggleWishlist}>
|
||||
<button
|
||||
class="btn"
|
||||
class:wish={onMyWishlist}
|
||||
class:pulse={pulseWish}
|
||||
onclick={toggleWishlist}
|
||||
onanimationend={() => (pulseWish = false)}
|
||||
>
|
||||
{#if onMyWishlist}
|
||||
<Check size={18} strokeWidth={2.5} />
|
||||
<span>Auf Wunschliste</span>
|
||||
@@ -444,6 +488,16 @@
|
||||
<div class="author">{c.author}</div>
|
||||
<div class="text">{c.text}</div>
|
||||
<div class="date">{new Date(c.created_at).toLocaleString('de-DE')}</div>
|
||||
{#if profileStore.active?.id === c.profile_id}
|
||||
<button
|
||||
type="button"
|
||||
class="comment-del"
|
||||
aria-label="Kommentar löschen"
|
||||
onclick={() => void deleteComment(c.id)}
|
||||
>
|
||||
<Trash2 size="14" />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -580,11 +634,38 @@
|
||||
color: #c53030;
|
||||
border-color: #f1b4b4;
|
||||
background: #fdf3f3;
|
||||
--pulse-color: rgba(197, 48, 48, 0.45);
|
||||
}
|
||||
.btn.wish {
|
||||
color: #2b6a3d;
|
||||
border-color: #b7d6c2;
|
||||
background: #eaf4ed;
|
||||
--pulse-color: rgba(43, 106, 61, 0.45);
|
||||
}
|
||||
/* Einmalige Bestätigung beim Aktivieren der Aktion — kurzer Scale-Bounce
|
||||
plus ausklingender Ring in der Aktionsfarbe (siehe --pulse-color).
|
||||
prefers-reduced-motion: Ring aus, kein Scale. */
|
||||
.btn.pulse {
|
||||
animation: btnPulse 0.5s ease-out;
|
||||
}
|
||||
@keyframes btnPulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 var(--pulse-color, rgba(43, 106, 61, 0.45));
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.07);
|
||||
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn.pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.btn.screen-on {
|
||||
color: #b07e00;
|
||||
@@ -624,6 +705,26 @@
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
position: relative;
|
||||
}
|
||||
.comment-del {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.comment-del:hover {
|
||||
background: #f3f5f3;
|
||||
color: #b42626;
|
||||
}
|
||||
.comments .author {
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Heart, Trash2, CookingPot } from 'lucide-svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
|
||||
import { profileStore, requireProfile } from '$lib/client/profile.svelte';
|
||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||
import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||
import { requireOnline } from '$lib/client/require-online';
|
||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
||||
|
||||
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
|
||||
{ value: 'popular', label: 'Meist gewünscht' },
|
||||
{ value: 'newest', label: 'Neueste' },
|
||||
{ value: 'oldest', label: 'Älteste' }
|
||||
];
|
||||
|
||||
let entries = $state<WishlistEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let sort = $state<SortKey>('popular');
|
||||
@@ -28,14 +35,12 @@
|
||||
});
|
||||
|
||||
async function toggleMine(entry: WishlistEntry) {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profileId = profileStore.active.id;
|
||||
const profile = await requireProfile(
|
||||
'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
||||
);
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Die Wunschlisten-Aktion')) return;
|
||||
const profileId = profile.id;
|
||||
if (entry.on_my_wishlist) {
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
|
||||
method: 'DELETE'
|
||||
@@ -59,6 +64,7 @@
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
if (!requireOnline('Das Entfernen')) return;
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}?all=true`, { method: 'DELETE' });
|
||||
await load();
|
||||
void wishlistStore.refresh();
|
||||
@@ -80,15 +86,19 @@
|
||||
<p class="sub">Das wollen wir bald mal essen.</p>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<label>
|
||||
Sortieren:
|
||||
<select bind:value={sort}>
|
||||
<option value="popular">Am meisten gewünscht</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="sort-chips" role="tablist" aria-label="Sortierung">
|
||||
{#each SORT_OPTIONS as s (s.value)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={sort === s.value}
|
||||
class="chip"
|
||||
class:active={sort === s.value}
|
||||
onclick={() => (sort = s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
@@ -131,7 +141,7 @@
|
||||
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
|
||||
onclick={() => toggleMine(e)}
|
||||
>
|
||||
<Heart size={18} strokeWidth={2} fill={e.on_my_wishlist ? 'currentColor' : 'none'} />
|
||||
<Utensils size={18} strokeWidth={2} />
|
||||
{#if e.wanted_by_count > 0}
|
||||
<span class="count">{e.wanted_by_count}</span>
|
||||
{/if}
|
||||
@@ -162,24 +172,32 @@
|
||||
margin: 0.2rem 0 0;
|
||||
color: #666;
|
||||
}
|
||||
.controls {
|
||||
.sort-chips {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 0 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
.controls select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
min-height: 40px;
|
||||
.chip {
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: white;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: var(--pill-radius);
|
||||
color: #2b6a3d;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:hover {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.chip.active {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border-color: #2b6a3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
@@ -284,9 +302,9 @@
|
||||
color: #444;
|
||||
}
|
||||
.like.active {
|
||||
color: #c53030;
|
||||
background: #fdf3f3;
|
||||
border-color: #f1b4b4;
|
||||
color: #2b6a3d;
|
||||
background: #eaf4ed;
|
||||
border-color: #b7d6c2;
|
||||
}
|
||||
.del:hover {
|
||||
color: #c53030;
|
||||
|
||||
@@ -2,88 +2,258 @@
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { build, files, version } from '$service-worker';
|
||||
import { resolveStrategy } from '$lib/sw/cache-strategy';
|
||||
import { diffManifest } from '$lib/sw/diff-manifest';
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const APP_CACHE = `kochwas-app-${version}`;
|
||||
const IMAGE_CACHE = `kochwas-images-v1`;
|
||||
const APP_ASSETS = [...build, ...files];
|
||||
const SHELL_CACHE = `kochwas-shell-${version}`;
|
||||
const DATA_CACHE = 'kochwas-data-v1';
|
||||
const IMAGES_CACHE = 'kochwas-images-v1';
|
||||
|
||||
sw.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(APP_CACHE).then((cache) => cache.addAll(APP_ASSETS))
|
||||
);
|
||||
// Activate new worker without waiting for old clients to close.
|
||||
void sw.skipWaiting();
|
||||
});
|
||||
// App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt)
|
||||
const SHELL_ASSETS = [...build, ...files];
|
||||
|
||||
sw.addEventListener('activate', (event) => {
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => k.startsWith('kochwas-app-') && k !== APP_CACHE)
|
||||
.map((k) => caches.delete(k))
|
||||
);
|
||||
await sw.clients.claim();
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
await cache.addAll(SHELL_ASSETS);
|
||||
// Kein self.skipWaiting() hier — der Client (pwaStore) fragt den
|
||||
// User via UpdateToast, ob der neue SW sofort übernehmen soll, und
|
||||
// schickt dann eine SKIP_WAITING-Message. Ohne diese Trennung
|
||||
// würde pwaStore beim Install-Event fälschlich "Neue Version"
|
||||
// zeigen (weil statechange='installed' + controller=alter SW), und
|
||||
// der neue SW würde einen Tick später ungefragt übernehmen.
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('fetch', (event) => {
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Alte Shell-Caches (vorherige Versionen) räumen
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => k.startsWith('kochwas-shell-') && k !== SHELL_CACHE)
|
||||
.map((k) => caches.delete(k))
|
||||
);
|
||||
await self.clients.claim();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
if (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet
|
||||
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
const strategy = resolveStrategy({ url: req.url, method: req.method });
|
||||
if (strategy === 'network-only') return;
|
||||
|
||||
// Images served from /images/* — cache-first with background update
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(IMAGE_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
const network = fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok) void cache.put(req, res.clone());
|
||||
return res;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return cached ?? (await network) ?? new Response('Offline', { status: 503 });
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// App shell assets (build/* and static files) — cache-first
|
||||
if (APP_ASSETS.includes(url.pathname)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(APP_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
return cached ?? fetch(req);
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// API and HTML pages — network-first, fall back to cache for HTML
|
||||
if (req.destination === 'document') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
const cache = await caches.open(APP_CACHE);
|
||||
if (res.ok) void cache.put(req, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
const cached = await caches.match(req);
|
||||
return cached ?? new Response('Offline', { status: 503 });
|
||||
}
|
||||
})()
|
||||
);
|
||||
if (strategy === 'shell') {
|
||||
event.respondWith(cacheFirst(req, SHELL_CACHE));
|
||||
} else if (strategy === 'images') {
|
||||
event.respondWith(cacheFirst(req, IMAGES_CACHE));
|
||||
} else if (strategy === 'swr') {
|
||||
event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
const fresh = await fetch(req);
|
||||
if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {});
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(req);
|
||||
const fetchPromise = fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok) cache.put(req, res.clone()).catch(() => {});
|
||||
return res;
|
||||
})
|
||||
.catch(() => hit ?? Response.error());
|
||||
return hit ?? fetchPromise;
|
||||
}
|
||||
|
||||
const META_CACHE = 'kochwas-meta';
|
||||
const MANIFEST_KEY = '/__cache-manifest__';
|
||||
const PAGE_SIZE = 50; // /api/recipes/all limitiert auf 50
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
type RecipeSummary = { id: number; image_path: string | null };
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event.data as { type?: string } | undefined;
|
||||
if (!data) return;
|
||||
if (data.type === 'sync-start') {
|
||||
event.waitUntil(runSync(false));
|
||||
} else if (data.type === 'sync-check') {
|
||||
event.waitUntil(runSync(true));
|
||||
} else if (data.type === 'SKIP_WAITING') {
|
||||
// Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt.
|
||||
void self.skipWaiting();
|
||||
} else if (data.type === 'GET_VERSION') {
|
||||
// Zombie-Schutz: Chromium hält nach einem SKIP_WAITING-Zyklus
|
||||
// mitunter einen bit-identischen waiting-SW im Registration-Slot
|
||||
// (Race zwischen SW-Update-Check während activate). Ohne diesen
|
||||
// Version-Handshake zeigt init() den „Neue Version"-Toast bei jedem
|
||||
// Reload erneut, obwohl es nichts zu aktualisieren gibt.
|
||||
const port = event.ports[0] as MessagePort | undefined;
|
||||
port?.postMessage({ version });
|
||||
}
|
||||
});
|
||||
|
||||
async function runSync(isUpdate: boolean): Promise<void> {
|
||||
try {
|
||||
// Storage-Quota-Check vor dem Pre-Cache
|
||||
if (navigator.storage?.estimate) {
|
||||
const est = await navigator.storage.estimate();
|
||||
const freeBytes = (est.quota ?? 0) - (est.usage ?? 0);
|
||||
if (freeBytes < 100 * 1024 * 1024) {
|
||||
await broadcast({
|
||||
type: 'sync-error',
|
||||
message: `Nicht genug Speicher für Offline-Modus (${Math.round(freeBytes / 1024 / 1024)} MB frei)`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = await fetchAllSummaries();
|
||||
const currentIds = summaries.map((s) => s.id);
|
||||
const cachedIds = await loadCachedIds();
|
||||
const { toAdd, toRemove } = diffManifest(currentIds, cachedIds);
|
||||
const worklist = isUpdate ? toAdd : currentIds; // initial: alles laden
|
||||
|
||||
await broadcast({ type: 'sync-start', total: worklist.length });
|
||||
|
||||
const successful = new Set<number>();
|
||||
let done = 0;
|
||||
const tasks = worklist.map((id) => async () => {
|
||||
const summary = summaries.find((s) => s.id === id);
|
||||
const ok = await cacheRecipe(id, summary?.image_path ?? null);
|
||||
if (ok) successful.add(id);
|
||||
done += 1;
|
||||
await broadcast({ type: 'sync-progress', current: done, total: worklist.length });
|
||||
});
|
||||
await runPool(tasks, CONCURRENCY);
|
||||
|
||||
if (isUpdate && toRemove.length > 0) {
|
||||
await removeRecipes(toRemove);
|
||||
}
|
||||
|
||||
// Manifest: für Update = (cached - toRemove) + neue successes
|
||||
// Für Initial = nur die diesmal erfolgreich gecachten
|
||||
const finalManifest = isUpdate
|
||||
? Array.from(
|
||||
new Set([...cachedIds.filter((id) => !toRemove.includes(id)), ...successful])
|
||||
)
|
||||
: Array.from(successful);
|
||||
|
||||
await saveCachedIds(finalManifest);
|
||||
await broadcast({ type: 'sync-done', lastSynced: Date.now() });
|
||||
} catch (e) {
|
||||
await broadcast({
|
||||
type: 'sync-error',
|
||||
message: (e as Error).message ?? 'Unbekannter Sync-Fehler'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllSummaries(): Promise<RecipeSummary[]> {
|
||||
const result: RecipeSummary[] = [];
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const res = await fetch(`/api/recipes/all?sort=name&limit=${PAGE_SIZE}&offset=${offset}`);
|
||||
if (!res.ok) throw new Error(`/api/recipes/all HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { hits: { id: number; image_path: string | null }[] };
|
||||
result.push(...body.hits.map((h) => ({ id: h.id, image_path: h.image_path })));
|
||||
if (body.hits.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cacheRecipe(id: number, imagePath: string | null): Promise<boolean> {
|
||||
const data = await caches.open(DATA_CACHE);
|
||||
const images = await caches.open(IMAGES_CACHE);
|
||||
const [htmlOk, apiOk] = await Promise.all([
|
||||
addToCache(data, `/recipes/${id}`),
|
||||
addToCache(data, `/api/recipes/${id}`)
|
||||
]);
|
||||
if (imagePath && !/^https?:\/\//i.test(imagePath)) {
|
||||
// Image-Fehler soll den Recipe-Eintrag nicht invalidieren (bei
|
||||
// manchen Rezepten gibt es schlicht kein Bild)
|
||||
await addToCache(images, `/images/${imagePath}`);
|
||||
}
|
||||
return htmlOk && apiOk;
|
||||
}
|
||||
|
||||
async function addToCache(cache: Cache, url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
console.warn(`[sw] cache miss ${url}: HTTP ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
await cache.put(url, res);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn(`[sw] cache error ${url}:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRecipes(ids: number[]): Promise<void> {
|
||||
const data = await caches.open(DATA_CACHE);
|
||||
for (const id of ids) {
|
||||
await data.delete(`/recipes/${id}`);
|
||||
await data.delete(`/api/recipes/${id}`);
|
||||
}
|
||||
// Orphan-Bilder: wir räumen nicht aktiv — neuer Hash = neuer Entry,
|
||||
// alte Einträge stören nicht.
|
||||
}
|
||||
|
||||
async function loadCachedIds(): Promise<number[]> {
|
||||
const meta = await caches.open(META_CACHE);
|
||||
const res = await meta.match(MANIFEST_KEY);
|
||||
if (!res) return [];
|
||||
try {
|
||||
return (await res.json()) as number[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCachedIds(ids: number[]): Promise<void> {
|
||||
const meta = await caches.open(META_CACHE);
|
||||
await meta.put(
|
||||
MANIFEST_KEY,
|
||||
new Response(JSON.stringify(ids), { headers: { 'content-type': 'application/json' } })
|
||||
);
|
||||
}
|
||||
|
||||
async function runPool<T>(tasks: (() => Promise<T>)[], limit: number): Promise<void> {
|
||||
const executing: Promise<void>[] = [];
|
||||
for (const task of tasks) {
|
||||
const p: Promise<void> = task().then(() => {
|
||||
executing.splice(executing.indexOf(p), 1);
|
||||
});
|
||||
executing.push(p);
|
||||
if (executing.length >= limit) await Promise.race(executing);
|
||||
}
|
||||
await Promise.all(executing);
|
||||
}
|
||||
|
||||
async function broadcast(msg: unknown): Promise<void> {
|
||||
const clients = await self.clients.matchAll();
|
||||
for (const client of clients) client.postMessage(msg);
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -13,7 +13,19 @@
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
122
tests/e2e/offline.spec.ts
Normal file
122
tests/e2e/offline.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { test as base, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||
|
||||
// Seed-Fixture: die Tests brauchen mindestens ein Rezept in der DB,
|
||||
// sonst gibt es nichts zu cachen/navigieren. Beim ersten Worker-Run
|
||||
// schauen wir in /api/recipes/all nach — wenn leer, legen wir ein
|
||||
// leeres Rezept per /api/recipes/blank an.
|
||||
//
|
||||
// Außerdem stellen wir sicher, dass ein Profil existiert (nötig für
|
||||
// den Favorit-Button-Test). Das Profil-ID wird als Fixture-Wert
|
||||
// weitergegeben, damit die Tests es in localStorage setzen können.
|
||||
const test = base.extend<{ profileId: number }, { seeded: void; workerProfileId: number }>({
|
||||
seeded: [
|
||||
async ({}, use) => {
|
||||
const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' });
|
||||
try {
|
||||
const res = await ctx.get('/api/recipes/all?sort=name&limit=1&offset=0');
|
||||
const body = await res.json();
|
||||
if (body.hits.length === 0) {
|
||||
await ctx.post('/api/recipes/blank');
|
||||
}
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
await use();
|
||||
},
|
||||
{ scope: 'worker', auto: true }
|
||||
],
|
||||
|
||||
workerProfileId: [
|
||||
async ({}, use) => {
|
||||
const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' });
|
||||
let id: number;
|
||||
try {
|
||||
const listRes = await ctx.get('/api/profiles');
|
||||
const profiles = await listRes.json();
|
||||
if (profiles.length > 0) {
|
||||
id = profiles[0].id;
|
||||
} else {
|
||||
const createRes = await ctx.post('/api/profiles', {
|
||||
data: { name: 'Test', avatar_emoji: null }
|
||||
});
|
||||
const p = await createRes.json();
|
||||
id = p.id;
|
||||
}
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
await use(id);
|
||||
},
|
||||
{ scope: 'worker', auto: false }
|
||||
],
|
||||
|
||||
// Test-scoped Alias — wird von Tests direkt per Destrukturierung genutzt
|
||||
profileId: async ({ workerProfileId }, use) => {
|
||||
await use(workerProfileId);
|
||||
}
|
||||
});
|
||||
|
||||
// Wartet, bis der Service Worker aktiv ist und der initiale Sync
|
||||
// wahrscheinlich durchgelaufen ist. Wir pollen den Status.
|
||||
async function waitForSync(page: Page) {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const r = await navigator.serviceWorker.ready;
|
||||
return !!r.active;
|
||||
},
|
||||
null,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
// Heuristik: 3 s reichen für den Pre-Cache eines einzelnen Seed-Rezepts.
|
||||
// Falls flaky, auf 5000 erhöhen oder .pill.syncing wegwarten.
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
test('offline navigation zeigt Rezept-Detail aus dem Cache', async ({ page, context }) => {
|
||||
await page.goto('/');
|
||||
await waitForSync(page);
|
||||
// Einen existierenden Rezept-Link finden — Seed-Fixture garantiert mindestens einen.
|
||||
await page.goto('/recipes');
|
||||
const firstLink = page.locator('a[href^="/recipes/"]').first();
|
||||
const href = await firstLink.getAttribute('href');
|
||||
expect(href).toBeTruthy();
|
||||
|
||||
await context.setOffline(true);
|
||||
await page.goto(href!);
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Offline-Schreib-Aktion zeigt Toast', async ({ page, context, profileId }) => {
|
||||
// Profil-ID vor dem ersten Navigieren setzen, damit profileStore.load()
|
||||
// das Profil aus localStorage liest und active != null ist.
|
||||
await page.addInitScript((id: number) => {
|
||||
localStorage.setItem('kochwas.activeProfileId', String(id));
|
||||
}, profileId);
|
||||
|
||||
await page.goto('/');
|
||||
await waitForSync(page);
|
||||
|
||||
// Rezept-Detail-Seite vorab besuchen, damit der SW sie cacht.
|
||||
await page.goto('/recipes');
|
||||
const firstLink = page.locator('a[href^="/recipes/"]').first();
|
||||
const href = await firstLink.getAttribute('href');
|
||||
await page.goto(href!);
|
||||
// Kurz warten damit die Detail-Seite im SW-Cache landet.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await context.setOffline(true);
|
||||
// Neu navigieren zur gecachten Detail-Seite — SW liefert aus dem Cache.
|
||||
await page.goto(href!, { waitUntil: 'commit' });
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await page.getByRole('button', { name: /Favorit/ }).first().click();
|
||||
await expect(page.locator('.toast.error')).toContainText(/Internet-Verbindung/);
|
||||
});
|
||||
|
||||
test('SyncIndicator zeigt Offline-Status', async ({ page, context }) => {
|
||||
await page.goto('/');
|
||||
await waitForSync(page);
|
||||
// Kein Reload nötig: network.svelte.ts lauscht auf den 'offline'-Browser-
|
||||
// Event, der sofort feuert wenn context.setOffline(true) gesetzt wird.
|
||||
await context.setOffline(true);
|
||||
await expect(page.locator('.wrap .pill.offline')).toContainText('Offline');
|
||||
});
|
||||
68
tests/e2e/remote/README.md
Normal file
68
tests/e2e/remote/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# E2E-Tests gegen kochwas-dev
|
||||
|
||||
Playwright-Smoketests gegen ein deployed Environment — standardmaessig
|
||||
`https://kochwas-dev.siegeln.net`. Loest die bisherigen manuellen
|
||||
MCP-Runs ab.
|
||||
|
||||
## Setup (einmalig)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
## Ausfuehren
|
||||
|
||||
```bash
|
||||
npm run test:e2e:remote # Headless, alle Tests
|
||||
npm run test:e2e:remote -- --ui # Mit Playwright-UI (Trace-Viewer)
|
||||
npm run test:e2e:remote -- --debug # Step-by-Step
|
||||
```
|
||||
|
||||
Alternative URL:
|
||||
|
||||
```bash
|
||||
E2E_REMOTE_URL=https://kochwas.siegeln.net npm run test:e2e:remote
|
||||
```
|
||||
|
||||
## Was abgedeckt ist
|
||||
|
||||
### Happy Paths (UI)
|
||||
|
||||
| Spec | Was |
|
||||
|---|---|
|
||||
| `homepage.spec.ts` | H1, Recents/Alle-Rezepte-Sektionen, Sort-Tabs rendern unterschiedlich, keine Console-Errors |
|
||||
| `search.spec.ts` | Lokaler Treffer, Web-Fallback, Empty-State, Deep-Link `?q=` |
|
||||
| `profile.spec.ts` | Switcher-Dialog, Auswahl persistiert, "Deine Favoriten" erscheint nach Login |
|
||||
| `recipe-detail.spec.ts` | Header, Portionen-Skalierung (4->6, Mengen proportional), Favorit-Toggle, Rating persistiert ueber Reload, Gekocht-Counter, Wunschliste-Toggle |
|
||||
| `comments.spec.ts` | Eigenen Kommentar erstellen + via UI-Button loeschen; fremder Kommentar hat keinen Delete-Button |
|
||||
| `wishlist.spec.ts` | Seite laedt, Sort-Tabs, Header-Badge spiegelt API-Zaehler |
|
||||
| `preview.spec.ts` | Guard ohne `?url=`, echte URL laedt JSON-LD-Parsing, unparsbare URL zeigt error-box |
|
||||
| `admin.spec.ts` | Alle 4 Admin-Subrouten laden mit Tab-Nav, `/admin` redirected |
|
||||
|
||||
### Negative Paths (API)
|
||||
|
||||
| Spec | Was |
|
||||
|---|---|
|
||||
| `api-errors.spec.ts` | `parsePositiveIntParam` → 400 `Invalid id` (4 Call-Sites), `validateBody` → 400 `{message, issues}` (4 Call-Sites), 404 auf missing Ressource, Positiv-Sanity fuer /health, /profiles, /domains |
|
||||
|
||||
## Design-Entscheidungen
|
||||
|
||||
**`workers: 1`.** Tests mutieren echte Daten auf `kochwas-dev` (Rating,
|
||||
Favorit, Wunschliste, Kommentare). Parallelitaet wuerde Race-Conditions
|
||||
geben. `afterEach` raeumt per API auf — idempotent.
|
||||
|
||||
**Hardcoded Test-Fixtures.** Rezept-ID 66 (Chicken Teriyaki) und
|
||||
Profile 1/2/3 (Hendrik/Verena/Leana) sind stabil auf dev. Bei
|
||||
DB-Reset muessen ggf. die Konstanten angepasst werden.
|
||||
|
||||
**Kein Build/Server-Start.** Im Gegensatz zur lokalen `playwright.config.ts`
|
||||
startet diese Config keinen Preview-Server — die Tests laufen gegen das
|
||||
CI-Build auf dev.
|
||||
|
||||
## Was NICHT hier ist
|
||||
|
||||
- **Service-Worker-Lifecycle / Offline** → `tests/e2e/offline.spec.ts` (lokal).
|
||||
- **Bild-Upload** — File-Dialog + echte Dateien; nur manuell sinnvoll.
|
||||
- **Drucken** — oeffnet `window.print()`, headless unzuverlaessig.
|
||||
- **Sync unter Last** — braucht dediziertes Harness, nicht Smoke-Scope.
|
||||
20
tests/e2e/remote/admin.spec.ts
Normal file
20
tests/e2e/remote/admin.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Admin-Routen', () => {
|
||||
const SUBROUTES = ['domains', 'profiles', 'backup', 'app'] as const;
|
||||
|
||||
for (const sub of SUBROUTES) {
|
||||
test(`/admin/${sub} laedt mit Nav-Tabs`, async ({ page }) => {
|
||||
await page.goto(`/admin/${sub}`);
|
||||
// Alle Admin-Subseiten haben dieselbe Tab-Leiste.
|
||||
for (const label of ['Domains', 'Profile', 'Backup', 'App']) {
|
||||
await expect(page.getByRole('link', { name: label })).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('/admin redirected auf /admin/domains', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await expect(page).toHaveURL(/\/admin\/domains$/);
|
||||
});
|
||||
});
|
||||
101
tests/e2e/remote/api-errors.spec.ts
Normal file
101
tests/e2e/remote/api-errors.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Negative-Path Tests fuer die api-helpers: parsePositiveIntParam und
|
||||
// validateBody. Jeder neue API-Handler sollte dieselben Error-Shapes
|
||||
// liefern — wenn dieser Suite-Block kippt, ist der Helper-Contract kaputt.
|
||||
|
||||
test.describe('API Error-Shapes', () => {
|
||||
test.describe('parsePositiveIntParam', () => {
|
||||
test('GET /api/recipes/abc -> 400 Invalid id', async ({ request }) => {
|
||||
const r = await request.get('/api/recipes/abc');
|
||||
expect(r.status()).toBe(400);
|
||||
expect(await r.json()).toEqual({ message: 'Invalid id' });
|
||||
});
|
||||
|
||||
test('GET /api/recipes/-1 -> 400 Invalid id', async ({ request }) => {
|
||||
const r = await request.get('/api/recipes/-1');
|
||||
expect(r.status()).toBe(400);
|
||||
expect(await r.json()).toEqual({ message: 'Invalid id' });
|
||||
});
|
||||
|
||||
test('GET /api/recipes/0 -> 400 Invalid id', async ({ request }) => {
|
||||
const r = await request.get('/api/recipes/0');
|
||||
expect(r.status()).toBe(400);
|
||||
expect(await r.json()).toEqual({ message: 'Invalid id' });
|
||||
});
|
||||
|
||||
test('POST /api/recipes/abc/comments -> 400 Invalid id', async ({ request }) => {
|
||||
const r = await request.post('/api/recipes/abc/comments', { data: {} });
|
||||
expect(r.status()).toBe(400);
|
||||
expect(await r.json()).toEqual({ message: 'Invalid id' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('validateBody', () => {
|
||||
test('POST /api/wishlist leer -> 400 {message, issues}', async ({ request }) => {
|
||||
const r = await request.post('/api/wishlist', { data: {} });
|
||||
expect(r.status()).toBe(400);
|
||||
const body = (await r.json()) as { message: string; issues?: unknown[] };
|
||||
expect(body.message).toBe('Invalid body');
|
||||
expect(Array.isArray(body.issues)).toBe(true);
|
||||
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(2); // recipe_id + profile_id
|
||||
});
|
||||
|
||||
test('POST /api/recipes/66/comments leer -> 400 {message, issues}', async ({ request }) => {
|
||||
const r = await request.post('/api/recipes/66/comments', { data: {} });
|
||||
expect(r.status()).toBe(400);
|
||||
const body = (await r.json()) as { message: string; issues?: unknown[] };
|
||||
expect(body.message).toBe('Invalid body');
|
||||
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1); // profile_id oder text
|
||||
});
|
||||
|
||||
test('PUT /api/recipes/66/favorite leer -> 400 {message, issues}', async ({ request }) => {
|
||||
const r = await request.put('/api/recipes/66/favorite', { data: {} });
|
||||
expect(r.status()).toBe(400);
|
||||
const body = (await r.json()) as { message: string; issues?: unknown[] };
|
||||
expect(body.message).toBe('Invalid body');
|
||||
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('POST /api/domains leer -> 400 {message, issues}', async ({ request }) => {
|
||||
const r = await request.post('/api/domains', { data: {} });
|
||||
expect(r.status()).toBe(400);
|
||||
const body = (await r.json()) as { message: string; issues?: unknown[] };
|
||||
expect(body.message).toBe('Invalid body');
|
||||
expect((body.issues ?? []).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('404 auf missing Ressourcen', () => {
|
||||
test('GET /api/recipes/99999 -> 404 Recipe not found', async ({ request }) => {
|
||||
const r = await request.get('/api/recipes/99999');
|
||||
expect(r.status()).toBe(404);
|
||||
expect(await r.json()).toEqual({ message: 'Recipe not found' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Positive Sanity-Checks', () => {
|
||||
test('GET /api/health -> 200 mit db:"ok"', async ({ request }) => {
|
||||
const r = await request.get('/api/health');
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as { db: string };
|
||||
expect(body.db).toBe('ok');
|
||||
});
|
||||
|
||||
test('GET /api/profiles -> drei Profile', async ({ request }) => {
|
||||
const r = await request.get('/api/profiles');
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as { id: number; name: string }[];
|
||||
expect(body.length).toBeGreaterThanOrEqual(3);
|
||||
const names = body.map((p) => p.name).sort();
|
||||
expect(names).toEqual(expect.arrayContaining(['Hendrik', 'Leana', 'Verena']));
|
||||
});
|
||||
|
||||
test('GET /api/domains -> liefert Array', async ({ request }) => {
|
||||
const r = await request.get('/api/domains');
|
||||
expect(r.status()).toBe(200);
|
||||
const body = await r.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
tests/e2e/remote/comments.spec.ts
Normal file
71
tests/e2e/remote/comments.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
|
||||
import { cleanupE2EComments, deleteComment } from './fixtures/api-cleanup';
|
||||
|
||||
const RECIPE_ID = 66;
|
||||
|
||||
test.describe('Kommentare', () => {
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await setActiveProfile(page, HENDRIK_ID);
|
||||
// Stray E2E-Kommentare aus abgebrochenen Runs wegraeumen.
|
||||
await cleanupE2EComments(request, RECIPE_ID, HENDRIK_ID);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await cleanupE2EComments(request, RECIPE_ID, HENDRIK_ID);
|
||||
});
|
||||
|
||||
test('Kommentar erstellen, Delete-Button erscheint, Loeschen via UI', async ({
|
||||
page
|
||||
}) => {
|
||||
const unique = `E2E ${Date.now()}`;
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
|
||||
await page.getByRole('textbox').filter({ hasText: '' }).last().fill(unique);
|
||||
await page.getByRole('button', { name: 'Kommentar speichern' }).click();
|
||||
|
||||
// Neuer Kommentar sichtbar
|
||||
await expect(page.getByText(unique)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Delete-Button NUR beim eigenen Kommentar
|
||||
const delBtn = page.getByRole('button', { name: 'Kommentar löschen' });
|
||||
await expect(delBtn).toBeVisible();
|
||||
|
||||
await delBtn.click();
|
||||
// ConfirmDialog "Kommentar loeschen?" mit Loeschen-Button.
|
||||
// Es gibt mehrere "Löschen"-Buttons auf der Seite (Rezept-Delete,
|
||||
// Kommentar-Trash, Dialog-Bestaetigung) — deshalb Locator auf den
|
||||
// Dialog einschraenken.
|
||||
const dialog = page.getByRole('dialog', { name: /Kommentar löschen/i });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Löschen' }).click();
|
||||
|
||||
await expect(page.getByText(unique)).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Fremder Kommentar zeigt KEINEN Delete-Button fuers aktuelle Profil', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
// Wir legen den Kommentar fuer ein anderes Profil (Leana, id=3) per API an.
|
||||
const text = `E2E fremd ${Date.now()}`;
|
||||
const res = await request.post(`/api/recipes/${RECIPE_ID}/comments`, {
|
||||
data: { profile_id: 3, text }
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const { id } = (await res.json()) as { id: number };
|
||||
|
||||
try {
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
const item = page
|
||||
.locator('.comments li')
|
||||
.filter({ hasText: text });
|
||||
await expect(item).toBeVisible();
|
||||
await expect(
|
||||
item.getByRole('button', { name: 'Kommentar löschen' })
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await deleteComment(request, RECIPE_ID, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
67
tests/e2e/remote/fixtures/api-cleanup.ts
Normal file
67
tests/e2e/remote/fixtures/api-cleanup.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
// Cleanup-Helfer fuer afterEach-Hooks. Alle sind idempotent — wenn der
|
||||
// Zustand schon weg ist (z. B. der Test ist zwischen Action und Check
|
||||
// abgebrochen), fliegt nichts.
|
||||
|
||||
export async function clearRating(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): Promise<void> {
|
||||
await api.delete(`/api/recipes/${recipeId}/rating`, {
|
||||
data: { profile_id: profileId }
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearFavorite(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): Promise<void> {
|
||||
await api.delete(`/api/recipes/${recipeId}/favorite`, {
|
||||
data: { profile_id: profileId }
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromWishlist(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): Promise<void> {
|
||||
await api.delete(`/api/wishlist/${recipeId}?profile_id=${profileId}`);
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
commentId: number
|
||||
): Promise<void> {
|
||||
await api.delete(`/api/recipes/${recipeId}/comments`, {
|
||||
data: { comment_id: commentId }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety-Net: loescht alle E2E-Kommentare eines Profils. Gedacht fuer
|
||||
* afterEach/afterAll, falls ein Test abbricht bevor der eigene Cleanup
|
||||
* greift. Markiert E2E-Kommentare am Prefix "E2E ".
|
||||
*/
|
||||
export async function cleanupE2EComments(
|
||||
api: APIRequestContext,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): Promise<void> {
|
||||
const res = await api.get(`/api/recipes/${recipeId}/comments`);
|
||||
if (!res.ok()) return;
|
||||
const list = (await res.json()) as {
|
||||
id: number;
|
||||
profile_id: number;
|
||||
text: string;
|
||||
}[];
|
||||
for (const c of list) {
|
||||
if (c.profile_id === profileId && c.text.startsWith('E2E ')) {
|
||||
await deleteComment(api, recipeId, c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
tests/e2e/remote/fixtures/profile.ts
Normal file
26
tests/e2e/remote/fixtures/profile.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Profil-IDs auf kochwas-dev: 1 = Hendrik, 2 = Verena, 3 = Leana.
|
||||
// Die Tests hardcoden Hendrik als Standard, weil die Dev-DB diese
|
||||
// Profile stabil enthaelt.
|
||||
export const HENDRIK_ID = 1;
|
||||
export const VERENA_ID = 2;
|
||||
export const LEANA_ID = 3;
|
||||
|
||||
/**
|
||||
* Setzt das aktive Profil in localStorage, BEVOR die Seite geladen wird.
|
||||
* addInitScript laeuft vor jedem Skript der Seite — damit ist das Profil
|
||||
* schon da, wenn profileStore.load() das erste Mal liest.
|
||||
*/
|
||||
export async function setActiveProfile(page: Page, id: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
(pid) => window.localStorage.setItem('kochwas.activeProfileId', String(pid)),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearActiveProfile(page: Page): Promise<void> {
|
||||
await page.addInitScript(() =>
|
||||
window.localStorage.removeItem('kochwas.activeProfileId')
|
||||
);
|
||||
}
|
||||
43
tests/e2e/remote/homepage.spec.ts
Normal file
43
tests/e2e/remote/homepage.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Startseite', () => {
|
||||
test('laedt mit H1, Zuletzt-hinzugefuegt und Alle-Rezepte', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/Kochwas/);
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Kochwas' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 2, name: 'Zuletzt hinzugefügt' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Alle Rezepte' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Sort-Tabs rendern unterschiedliche Top-Eintraege', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Liste unter "Alle Rezepte"
|
||||
const allSection = page.locator('section', { has: page.getByRole('heading', { name: 'Alle Rezepte' }) });
|
||||
const firstItem = () => allSection.locator('li a').first().innerText();
|
||||
|
||||
await page.getByRole('tab', { name: 'Name' }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const nameTop = await firstItem();
|
||||
|
||||
await page.getByRole('tab', { name: 'Hinzugefügt' }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const addedTop = await firstItem();
|
||||
|
||||
expect(nameTop).not.toEqual(addedTop);
|
||||
});
|
||||
|
||||
test('hat keine Console-Errors', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// 404s auf externen Bildern (chefkoch-cdn, cloudfront) ignorieren —
|
||||
// das ist kein App-Fehler, sondern externe Thumbnails.
|
||||
const appErrors = errors.filter((e) => !/Failed to load resource/i.test(e));
|
||||
expect(appErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
29
tests/e2e/remote/preview.spec.ts
Normal file
29
tests/e2e/remote/preview.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Preview-Route', () => {
|
||||
test('ohne ?url= zeigt Guard-Fehlermeldung', async ({ page }) => {
|
||||
await page.goto('/preview');
|
||||
await expect(page.getByText(/Kein \?url=-Parameter/)).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /kein Rezept/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('mit echter URL laedt Vorschau + Speichern-Button', async ({ page }) => {
|
||||
const u = encodeURIComponent('https://emmikochteinfach.de/chicken-teriyaki/');
|
||||
await page.goto(`/preview?url=${u}`);
|
||||
await expect(page.getByText('Vorschau — noch nicht gespeichert')).toBeVisible({
|
||||
timeout: 20000
|
||||
});
|
||||
await expect(page.getByRole('button', { name: /speichern/i })).toBeVisible();
|
||||
// Zutaten aus dem JSON-LD sollten geparst sein.
|
||||
await expect(page.getByText(/Hähnchenbrustfilet/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('mit unparsbarer URL zeigt error-box', async ({ page }) => {
|
||||
// google.com hat kein Recipe-JSON-LD -> Parser-Fehler.
|
||||
const u = encodeURIComponent('https://www.google.com');
|
||||
await page.goto(`/preview?url=${u}`);
|
||||
await expect(page.getByRole('heading', { name: /kein Rezept/i })).toBeVisible({
|
||||
timeout: 20000
|
||||
});
|
||||
});
|
||||
});
|
||||
40
tests/e2e/remote/profile.spec.ts
Normal file
40
tests/e2e/remote/profile.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { clearActiveProfile, setActiveProfile, HENDRIK_ID } from './fixtures/profile';
|
||||
|
||||
test.describe('Profil', () => {
|
||||
test('Switcher zeigt alle 3 Profile', async ({ page }) => {
|
||||
await clearActiveProfile(page);
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Profil wechseln' }).click();
|
||||
await expect(page.getByText('Wer kocht heute?')).toBeVisible();
|
||||
for (const name of ['Hendrik', 'Verena', 'Leana']) {
|
||||
await expect(
|
||||
page.locator('.profile-btn', { hasText: name })
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Profil-Auswahl persistiert im Header', async ({ page }) => {
|
||||
await clearActiveProfile(page);
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Profil wechseln' }).click();
|
||||
await page.locator('.profile-btn', { hasText: 'Hendrik' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Profil wechseln' })).toContainText('Hendrik');
|
||||
});
|
||||
|
||||
test('mit aktivem Profil: "Deine Favoriten"-Sektion erscheint', async ({ page }) => {
|
||||
await setActiveProfile(page, HENDRIK_ID);
|
||||
await page.goto('/');
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 2, name: 'Deine Favoriten' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('ohne Profil: Rating-Klick oeffnet Standard-Hinweis', async ({ page }) => {
|
||||
await clearActiveProfile(page);
|
||||
await page.goto('/recipes/66');
|
||||
await page.getByRole('button', { name: '5 Sterne' }).click();
|
||||
await expect(page.getByText('Kein Profil gewählt')).toBeVisible();
|
||||
await expect(page.getByText(/klappt die Aktion/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
84
tests/e2e/remote/recipe-detail.spec.ts
Normal file
84
tests/e2e/remote/recipe-detail.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
|
||||
import {
|
||||
clearFavorite,
|
||||
clearRating,
|
||||
removeFromWishlist
|
||||
} from './fixtures/api-cleanup';
|
||||
|
||||
// Chicken Teriyaki auf kochwas-dev: 4 Portionen, 500 g Haehnchen, 100 ml Soja.
|
||||
const RECIPE_ID = 66;
|
||||
|
||||
test.describe('Rezept-Detail', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setActiveProfile(page, HENDRIK_ID);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await clearRating(request, RECIPE_ID, HENDRIK_ID);
|
||||
await clearFavorite(request, RECIPE_ID, HENDRIK_ID);
|
||||
await removeFromWishlist(request, RECIPE_ID, HENDRIK_ID);
|
||||
});
|
||||
|
||||
test('Header + Zutaten sichtbar', async ({ page }) => {
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /Chicken Teriyaki/i })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Hähnchenbrustfilet').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Portionen-Scaler: 4 -> 6 skaliert Mengen proportional', async ({ page }) => {
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
// Start: 4 Portionen, 500 g Haehnchen, 100 ml Soja.
|
||||
await expect(page.locator('.srv-value strong').first()).toHaveText('4');
|
||||
await page.getByRole('button', { name: 'Mehr' }).first().click();
|
||||
await page.getByRole('button', { name: 'Mehr' }).first().click();
|
||||
await expect(page.locator('.srv-value strong').first()).toHaveText('6');
|
||||
// Skalierte Mengen 1.5x — ueber das Item-Name-Filter, robuster
|
||||
// gegenueber Whitespace-Quirks zwischen <span class="qty">-Teilen.
|
||||
await expect(
|
||||
page.locator('.ing-list li', { hasText: 'Hähnchenbrustfilet' })
|
||||
).toContainText('750 g');
|
||||
await expect(
|
||||
page.locator('.ing-list li', { hasText: 'Sojasauce' })
|
||||
).toContainText('150 ml');
|
||||
});
|
||||
|
||||
test('Favorit toggelt heart-Klasse sauber', async ({ page }) => {
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
const favBtn = page.getByRole('button', { name: 'Favorit' });
|
||||
await expect(favBtn).not.toHaveClass(/heart/);
|
||||
await favBtn.click();
|
||||
await expect(favBtn).toHaveClass(/heart/);
|
||||
await favBtn.click();
|
||||
await expect(favBtn).not.toHaveClass(/heart/);
|
||||
});
|
||||
|
||||
test('Rating persistiert ueber Reload', async ({ page }) => {
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
await page.getByRole('button', { name: '4 Sterne' }).click();
|
||||
await expect(page.getByRole('button', { name: '4 Sterne' })).toHaveClass(/filled/);
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: '4 Sterne' })).toHaveClass(/filled/);
|
||||
});
|
||||
|
||||
test('Heute gekocht inkrementiert Counter', async ({ page }) => {
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
const cookedBtn = page.getByRole('button', { name: /Heute gekocht/i });
|
||||
const before = (await cookedBtn.innerText()).trim();
|
||||
await cookedBtn.click();
|
||||
// Der Button bekommt einen "(N)"-Suffix bzw. der existierende zaehler
|
||||
// steigt. Wir pruefen nur, dass sich der Text aendert.
|
||||
await expect(cookedBtn).not.toHaveText(before);
|
||||
});
|
||||
|
||||
test('Auf Wunschliste-Toggle funktioniert', async ({ page }) => {
|
||||
await page.goto(`/recipes/${RECIPE_ID}`);
|
||||
const wishBtn = page.getByRole('button', { name: /Auf Wunschliste/i });
|
||||
const initialLabel = (await wishBtn.getAttribute('aria-label')) ?? '';
|
||||
await wishBtn.click();
|
||||
// aria-label wechselt zwischen "setzen" und "Von der Wunschliste entfernen"
|
||||
await expect(wishBtn).not.toHaveAttribute('aria-label', initialLabel);
|
||||
});
|
||||
});
|
||||
39
tests/e2e/remote/search.spec.ts
Normal file
39
tests/e2e/remote/search.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Suche', () => {
|
||||
test('lokaler Treffer erscheint live beim Tippen', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('searchbox', { name: 'Suchbegriff' }).fill('lasagne');
|
||||
await expect(page.getByRole('link', { name: /Pfannen Lasagne/i })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
|
||||
test('Web-Fallback bei unbekanntem Begriff', async ({ page }) => {
|
||||
// Direkt per URL — spart den Debounce-Timer.
|
||||
await page.goto('/?q=pizza+margherita');
|
||||
await expect(page.getByText(/Keine lokalen Rezepte/i)).toBeVisible({ timeout: 15000 });
|
||||
// Mindestens ein Web-Treffer mit einer Domain-Labeling.
|
||||
await expect(page.getByText(/chefkoch\.de|rezeptwelt\.de/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Nonsense-Query rendert Fallback ohne Crash', async ({ page }) => {
|
||||
// SearXNG matcht loose — selbst Nonsense gibt oft Fuzzy-Treffer.
|
||||
// Wir pruefen deshalb nur, dass die Seite sinnvoll reagiert
|
||||
// (entweder echter Empty-State ODER Web-Fallback) und kein JS-Fehler
|
||||
// fliegt.
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
await page.goto('/?q=xxyyzznotarecipexxxxxxxx');
|
||||
await expect(
|
||||
page.getByText(/Schaue unter den Topfdeckeln|Keine lokalen Rezepte/i)
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Deep-Link ?q=lasagne stellt Query im Input wieder her', async ({ page }) => {
|
||||
await page.goto('/?q=lasagne');
|
||||
const sb = page.getByRole('searchbox', { name: 'Suchbegriff' });
|
||||
await expect(sb).toHaveValue('lasagne');
|
||||
});
|
||||
});
|
||||
43
tests/e2e/remote/wishlist.spec.ts
Normal file
43
tests/e2e/remote/wishlist.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { clearActiveProfile, setActiveProfile, HENDRIK_ID } from './fixtures/profile';
|
||||
|
||||
test.describe('Wunschliste-Seite', () => {
|
||||
test('laedt Header + Sort-Tabs', async ({ page }) => {
|
||||
await setActiveProfile(page, HENDRIK_ID);
|
||||
await page.goto('/wishlist');
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Wunschliste' })).toBeVisible();
|
||||
for (const label of ['Meist gewünscht', 'Neueste', 'Älteste']) {
|
||||
await expect(page.getByRole('tab', { name: label })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Badge im Header stimmt mit Anzahl Eintraegen ueberein', async ({ page, request }) => {
|
||||
await setActiveProfile(page, HENDRIK_ID);
|
||||
await page.goto('/wishlist');
|
||||
// Die API zaehlt die Wunschlisten-Rezepte — der Header-Badge sollte
|
||||
// die gleiche Zahl zeigen.
|
||||
const res = await request.get('/api/wishlist?sort=popular');
|
||||
const body = (await res.json()) as { entries: unknown[] };
|
||||
const expected = body.entries.length;
|
||||
if (expected === 0) {
|
||||
// Kein Badge bei Null — der Link hat dann gar keine Zahl.
|
||||
return;
|
||||
}
|
||||
const badge = page.locator('a[href="/wishlist"]').first();
|
||||
await expect(badge).toContainText(String(expected));
|
||||
});
|
||||
|
||||
test('requireProfile zeigt Custom-Message "um mitzuwuenschen"', async ({ page }) => {
|
||||
await clearActiveProfile(page);
|
||||
await page.goto('/wishlist');
|
||||
// Erster "Ich will das auch"-Button eines beliebigen Eintrags.
|
||||
// Falls Wunschliste leer ist, ueberspringen.
|
||||
const btn = page.getByRole('button', { name: /Ich will das auch/i }).first();
|
||||
const count = await btn.count();
|
||||
test.skip(count === 0, 'Wunschliste leer — Custom-Message-Test uebersprungen');
|
||||
|
||||
await btn.click();
|
||||
await expect(page.getByText('Kein Profil gewählt')).toBeVisible();
|
||||
await expect(page.getByText('um mitzuwünschen')).toBeVisible();
|
||||
});
|
||||
});
|
||||
6
tests/e2e/smoke.spec.ts
Normal file
6
tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('home loads', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toContainText('Kochwas');
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||
import { addDomain } from '../../src/lib/server/domains/repository';
|
||||
import { importRecipe, previewRecipe, ImporterError } from '../../src/lib/server/recipes/importer';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -61,17 +60,9 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('previewRecipe', () => {
|
||||
it('throws DOMAIN_BLOCKED if host not whitelisted', async () => {
|
||||
it('accepts any domain — manuelle URL-Importe sind nicht auf die Whitelist beschränkt', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
// note: no domain added
|
||||
await expect(previewRecipe(db, `${baseUrl}/recipe`)).rejects.toMatchObject({
|
||||
code: 'DOMAIN_BLOCKED'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns parsed recipe for whitelisted domain', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, '127.0.0.1');
|
||||
// keine Domain in der Whitelist — preview muss trotzdem klappen
|
||||
const r = await previewRecipe(db, `${baseUrl}/recipe`);
|
||||
expect(r.title.toLowerCase()).toContain('schupfnudel');
|
||||
expect(r.source_url).toBe(`${baseUrl}/recipe`);
|
||||
@@ -80,17 +71,22 @@ describe('previewRecipe', () => {
|
||||
|
||||
it('throws NO_RECIPE_FOUND when HTML has no Recipe JSON-LD', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, '127.0.0.1');
|
||||
await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({
|
||||
code: 'NO_RECIPE_FOUND'
|
||||
});
|
||||
});
|
||||
|
||||
it('throws INVALID_URL for malformed input', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
await expect(previewRecipe(db, 'not a url')).rejects.toMatchObject({
|
||||
code: 'INVALID_URL'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('importRecipe', () => {
|
||||
it('imports, persists, and is idempotent', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, '127.0.0.1');
|
||||
const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`);
|
||||
expect(first.duplicate).toBe(false);
|
||||
expect(first.id).toBeGreaterThan(0);
|
||||
@@ -104,9 +100,9 @@ describe('importRecipe', () => {
|
||||
expect(second.id).toBe(first.id);
|
||||
});
|
||||
|
||||
it('surfaces ImporterError type', async () => {
|
||||
it('surfaces ImporterError type when no recipe on page', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
await expect(importRecipe(db, imgDir, `${baseUrl}/recipe`)).rejects.toBeInstanceOf(
|
||||
await expect(importRecipe(db, imgDir, `${baseUrl}/bare`)).rejects.toBeInstanceOf(
|
||||
ImporterError
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
updateDomain,
|
||||
getDomainById
|
||||
} from '../../src/lib/server/domains/repository';
|
||||
import { isDomainAllowed } from '../../src/lib/server/domains/whitelist';
|
||||
|
||||
describe('allowed domains', () => {
|
||||
it('round-trips domains', () => {
|
||||
@@ -19,18 +18,10 @@ describe('allowed domains', () => {
|
||||
expect(all.map((d) => d.domain).sort()).toEqual(['chefkoch.de', 'emmikochteinfach.de']);
|
||||
});
|
||||
|
||||
it('normalizes www. and case', () => {
|
||||
it('normalizes www. and case via addDomain', () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'WWW.Chefkoch.DE');
|
||||
expect(isDomainAllowed(db, 'https://chefkoch.de/abc')).toBe(true);
|
||||
expect(isDomainAllowed(db, 'https://www.chefkoch.de/abc')).toBe(true);
|
||||
expect(isDomainAllowed(db, 'https://fake.de/abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid urls', () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'chefkoch.de');
|
||||
expect(isDomainAllowed(db, 'not a url')).toBe(false);
|
||||
expect(listDomains(db)[0].domain).toBe('chefkoch.de');
|
||||
});
|
||||
|
||||
it('removes domains', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user