Compare commits
101 Commits
3b1950713f
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
854af2fc34 | ||
|
|
1bec054ec6 | ||
|
|
c2074c9768 | ||
|
|
858d4c1622 | ||
|
|
42f79f122b | ||
|
|
3d6f6393b3 | ||
|
|
0ede62dc8a | ||
|
|
1a4f7b5f20 | ||
|
|
528508a304 | ||
|
|
8bb208a613 | ||
|
|
3906781c4e | ||
|
|
447ff2be32 | ||
|
|
51a88a4c58 | ||
|
|
582d902c62 | ||
|
|
7c8edb9b92 | ||
|
|
d38992661c | ||
|
|
02df0331b7 | ||
|
|
d08cefa5c9 | ||
|
|
0c66bd677e | ||
|
|
04641355df | ||
|
|
0b12aa027f | ||
|
|
60f6db9091 | ||
|
|
303939a6ff | ||
|
|
2807dd1cab | ||
|
|
7233cc3a13 | ||
|
|
297281e201 | ||
|
|
194aee269e | ||
|
|
361164febd | ||
|
|
8e33b52f66 | ||
|
|
60d0cd7659 | ||
|
|
a10ebefb75 | ||
|
|
e56c1543d8 | ||
|
|
8c93099d91 | ||
|
|
f92ce677f6 | ||
|
|
cbf9b94aa3 | ||
|
|
7070a83991 | ||
|
|
a2b3c8981c | ||
|
|
68e27a6868 | ||
|
|
351434f43d | ||
|
|
49d4e60a1c | ||
| 553bf4f924 | |||
|
|
1b31a8ff1e | ||
|
|
c79cf8657d | ||
|
|
9a5c626890 | ||
|
|
ee783ff50b | ||
|
|
61c1b9558e | ||
|
|
340ab5e558 | ||
|
|
09c0270c64 | ||
|
|
a1d91943c6 | ||
|
|
9e471c7bf3 | ||
|
|
82e8371451 | ||
|
|
a8fdb8c3f9 | ||
|
|
3e41505b81 | ||
|
|
4465744838 | ||
|
|
3e3afc0102 | ||
|
|
272935034d | ||
|
|
c87196cd67 | ||
|
|
aad3ad689d | ||
|
|
ab2acb6437 | ||
|
|
d1ddd51da1 | ||
|
|
15442ff72b | ||
|
|
52858f94fe | ||
|
|
2e196b4834 | ||
|
|
15c15c8494 | ||
|
|
6c2b24d060 | ||
|
|
a590cf0a57 | ||
|
|
d004430854 | ||
|
|
864d113082 | ||
|
|
0992e51a5d | ||
|
|
d3c9bc5619 | ||
|
|
342ea0efc8 | ||
|
|
dbc9646caa | ||
|
|
c27c2dbc62 | ||
|
|
1b7c5c084e | ||
|
|
a62b32aa1e | ||
|
|
b4a7355b24 | ||
|
|
f72fe64d8e | ||
|
|
dd52e44f67 | ||
|
|
5e7e37cc3c | ||
|
|
018fc987cd | ||
|
|
60021b879f | ||
|
|
224352d051 | ||
|
|
8db67bd1a5 | ||
|
|
1055a670da | ||
|
|
7cac02de5a | ||
|
|
657d006441 | ||
|
|
cf31e79fb0 | ||
|
|
347b1de555 | ||
|
|
4d90d51501 | ||
|
|
1712263fd1 | ||
|
|
53e4815508 | ||
|
|
211d58ebec | ||
|
|
9bc4465061 | ||
|
|
6a784488f5 | ||
|
|
3cd22544d3 | ||
|
|
d693cb422d | ||
|
|
76110f9841 | ||
|
|
d737618312 | ||
|
|
84655151be | ||
| 4f7c76c908 | |||
| 1b9928f806 |
14
.env.example
14
.env.example
@@ -1,3 +1,17 @@
|
|||||||
|
# Kopiere zu .env und trage deine Werte ein.
|
||||||
|
# .env ist per .gitignore ausgenommen — Secrets landen nie im Repo.
|
||||||
|
|
||||||
|
# Kochwas-App (nur relevant, wenn du die App lokal startest; die Compose-
|
||||||
|
# Setups setzen ihre eigenen Pfade im Container).
|
||||||
DATABASE_PATH=./data/kochwas.db
|
DATABASE_PATH=./data/kochwas.db
|
||||||
IMAGE_DIR=./data/images
|
IMAGE_DIR=./data/images
|
||||||
SEARXNG_URL=http://localhost:8888
|
SEARXNG_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# Brave Search API-Key (https://api-dashboard.search.brave.com/).
|
||||||
|
# Leer lassen, wenn du ohne Brave testen willst — andere Engines laufen
|
||||||
|
# trotzdem. Fehlt der Key, antwortet die Brave-Engine nur mit 401.
|
||||||
|
BRAVE_API_KEY=
|
||||||
|
|
||||||
|
# SearXNG-Secret: beliebig lange Zufallskette. Für Prod mit
|
||||||
|
# `openssl rand -hex 32` generieren und in der Pi-.env ablegen.
|
||||||
|
SEARXNG_SECRET=dev-secret-change-me
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,3 +5,6 @@ data/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
74
CLAUDE.md
Normal file
74
CLAUDE.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Kochwas — Hinweise für Claude (Fortsetzung nach Session-Neustart)
|
||||||
|
|
||||||
|
> **Lies mich zuerst.** Wenn du eine neue Session öffnest und hier weiterarbeitest, steht hier das Wesentliche. Tiefer: `docs/ARCHITECTURE.md` (Code) und `docs/OPERATIONS.md` (Deployment).
|
||||||
|
|
||||||
|
## Was das ist
|
||||||
|
Selbstgehostete Rezept-PWA für die Familie Siegeln. Erreichbar unter `https://kochwas.siegeln.net`. Deutschsprachiges UI, ohne Login, Profile werden per Klick gewählt. Läuft in Docker auf einem Raspberry Pi 5 (arm64).
|
||||||
|
|
||||||
|
## Wichtigste Gotchas (wiederkehrende Stolpersteine)
|
||||||
|
|
||||||
|
| Thema | Regel |
|
||||||
|
|---|---|
|
||||||
|
| **Node-Binding** | `better-sqlite3` ist **synchron** und native — im `Dockerfile` gibt es einen Build-Stage, der das Native-Module explizit für arm64 baut. |
|
||||||
|
| **Healthcheck** | Muss `127.0.0.1` verwenden, nicht `localhost`. Node bindet nur IPv4; `localhost` wird oft zu `::1` aufgelöst und der Check schlägt fehl. Traefik filtert unhealthy Container raus → kein Routing, kein ACME. |
|
||||||
|
| **SearXNG Bot-Detection** | Bei Requests aus dem Docker-Netzwerk müssen `X-Forwarded-For: 127.0.0.1` und `X-Real-IP: 127.0.0.1` im Header stehen (`src/lib/server/http.ts` `extraHeaders`). Sonst 403. |
|
||||||
|
| **Traefik Cloudflare-Token** | Token muss `Edit zone DNS` Berechtigung für `siegeln.net` haben. Expired Tokens → DNS-Challenge failt → Let's-Encrypt-Rate-Limit nach 5 Versuchen in 1 h. |
|
||||||
|
| **Wildcard-Cert** | Für neue Subdomains auf siegeln.net sollten die Labels das Wildcard nutzen, nicht per-Host-Cert: `tls.domains[0].main=siegeln.net` + `sans=*.siegeln.net`. |
|
||||||
|
| **Migrations** | Werden via Vite `import.meta.glob('./migrations/*.sql', {eager, query:'?raw'})` gebundelt. Neue Migration einfach als `00N_name.sql` ablegen, kein Copy-in-Dockerfile nötig. |
|
||||||
|
| **$lib/server in Client** | Svelte-Import aus `$lib/server/*` in einem `.svelte`-Komponenten-Script bricht den Build. Pures JS/TS, das beidseitig funktioniert (z. B. Portionen-Scaler), gehört nach `$lib/`, nicht `$lib/server/`. |
|
||||||
|
| **Preview-Bilder** | `recipe.image_path` kann **absolute URL** (Preview-Modus) oder **lokaler Filename** sein. `RecipeView.svelte` prüft mit `/^https?:\/\//i`. |
|
||||||
|
| **Service Worker nur ab HTTPS** | `npm run dev` liefert HTTP → SW registriert nicht. Für PWA-Tests `npm run build && npm run preview` (localhost) oder Prod-Docker. |
|
||||||
|
| **Icon-Rendering** | `npm run render:icons` rendert `icon-192.png` + `icon-512.png` aus `static/icon.svg`. Nur nach SVG-Änderung erneut ausführen + committen. |
|
||||||
|
|
||||||
|
## Dateien, die man typischerweise anfasst
|
||||||
|
|
||||||
|
- `src/routes/+page.svelte` — Startseite mit Live-Search + Quote
|
||||||
|
- `src/routes/+layout.svelte` — Header, mobile expand, Dropdown-Search auf Rezeptseiten
|
||||||
|
- `src/routes/recipes/[id]/+page.svelte` — Rezept-Detail mit allen Actions (Rating, Favorit, Cooked, Wunschliste, Kommentar, Umbenennen, Löschen)
|
||||||
|
- `src/routes/preview/+page.svelte` — importierte Vorschau vor dem Speichern
|
||||||
|
- `src/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)
|
||||||
|
|
||||||
|
- **Terse Antworten auf Deutsch**; Code-Kommentare auf Englisch, sparsam.
|
||||||
|
- **Commits** kleinteilig, deutscher Body, englische Zeile, Subject unter 72 Zeichen.
|
||||||
|
- **Tests nach jeder Änderung**: `npm test` (vitest) + `npm run check` (svelte-check). Beides muss grün sein, bevor gepusht wird.
|
||||||
|
- **Push nach jedem Commit**, außer der Nutzer sagt explizit nein. CI baut dann das arm64-Image und published es nach `gitea.siegeln.net/claude/kochwas:latest`.
|
||||||
|
- **Keine Backwards-Compat-Krücken** für nicht-ausgelieferten Code. Direkt refactoren, alte Signaturen raus.
|
||||||
|
- **Nie mit `--no-verify`** committen. Wenn ein Hook fehlschlägt, den echten Grund beheben.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # erstes Mal
|
||||||
|
npm run dev # lokal auf http://localhost:5173
|
||||||
|
npm test # volle Vitest-Suite
|
||||||
|
npm run check # svelte-check Types
|
||||||
|
npm run format # Prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
Lokaler Docker-Test des Prod-Builds:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was NICHT tun
|
||||||
|
|
||||||
|
- Keine neuen Top-Level-Docs erzeugen, wenn ein bestehendes Dokument (Specs, Plans, ARCHITECTURE, OPERATIONS) passt.
|
||||||
|
- Keine Emojis in Code/Commits — außer UI-Icons (🍽️, ⚙️, 🥘 etc.) sind explizit im UX-Design.
|
||||||
|
- Keine `alert()`/`confirm()` — wir haben `alertAction()` / `confirmAction()` in `src/lib/client/confirm.svelte.ts`.
|
||||||
|
- Keine hardcoded `localhost` in Healthchecks → `127.0.0.1`.
|
||||||
|
- Keinen sensiblen Output in Commits (Cloudflare-Tokens, acme.json).
|
||||||
|
|
||||||
|
## Offene Themen / Stand
|
||||||
|
|
||||||
|
Siehe Session-Handoff-Dokumente unter `docs/superpowers/` und dort besonders `session-handoff-2026-04-17.md`. Die Roadmap-Phasen liegen als `docs/superpowers/plans/*.md`. Was als „Later" markiert ist, ist nicht beauftragt.
|
||||||
|
|
||||||
|
## Auto-Memory (lokal, nicht im Repo)
|
||||||
|
|
||||||
|
Persönliche Präferenzen / projektspezifische Entscheidungen landen in deinem Auto-Memory unter `~/.claude/projects/C--Users-Hendrik-Documents-projects-kochwas/memory/`. Der aktuelle Index (`MEMORY.md`) hält fest: Deployment-Target, Registry. Bei Bedarf erweitern — nicht in dieser Datei dokumentieren, da sie versioniert ist.
|
||||||
@@ -23,23 +23,55 @@ services:
|
|||||||
- "traefik.http.routers.kochwas.rule=Host(`kochwas.siegeln.net`)"
|
- "traefik.http.routers.kochwas.rule=Host(`kochwas.siegeln.net`)"
|
||||||
- "traefik.http.routers.kochwas.entrypoints=websecure"
|
- "traefik.http.routers.kochwas.entrypoints=websecure"
|
||||||
- "traefik.http.routers.kochwas.tls.certresolver=cloudflareResolver"
|
- "traefik.http.routers.kochwas.tls.certresolver=cloudflareResolver"
|
||||||
|
- "traefik.http.routers.kochwas.tls.domains[0].main=siegeln.net"
|
||||||
|
- "traefik.http.routers.kochwas.tls.domains[0].sans=*.siegeln.net"
|
||||||
# Specify which port Traefik should forward traffic to inside the container
|
# Specify which port Traefik should forward traffic to inside the container
|
||||||
- "traefik.http.services.kochwas.loadbalancer.server.port=3000"
|
- "traefik.http.services.kochwas.loadbalancer.server.port=3000"
|
||||||
# Explicitly tell Traefik which network to use (since kochwas is on two networks)
|
# Explicitly tell Traefik which network to use (since kochwas is on two networks)
|
||||||
- "traefik.docker.network=traefik_proxy"
|
- "traefik.docker.network=traefik_proxy"
|
||||||
|
|
||||||
|
# Ein-Shot-Init: expandiert ${…}-Platzhalter in der Source-settings.yml und
|
||||||
|
# legt das gerenderte File aufs searxng-config Named-Volume. Verwendet das
|
||||||
|
# gleiche SearXNG-Image — bereits gepullt, hat Python 3 an Bord. Kein
|
||||||
|
# zusätzliches Image, kein apk add gettext, kein fragiler entrypoint-Override
|
||||||
|
# am Hauptcontainer. FORCE_OWNERSHIP=false, damit der Init-Container nicht
|
||||||
|
# versucht den chown-Setup zu machen.
|
||||||
|
searxng-init:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
restart: 'no'
|
||||||
|
user: root
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
python3 -c "import os; open('/out/settings.yml','w').write(os.path.expandvars(open('/in/settings.yml').read()))"
|
||||||
|
volumes:
|
||||||
|
- ./searxng:/in:ro
|
||||||
|
- searxng-config:/out
|
||||||
|
environment:
|
||||||
|
- FORCE_OWNERSHIP=false
|
||||||
|
- BRAVE_API_KEY=${BRAVE_API_KEY:-}
|
||||||
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-dev-secret-change-in-prod}
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
# Absichtlich nur intern erreichbar — keine Traefik-Labels, kein externer Port.
|
# Absichtlich nur intern erreichbar — keine Traefik-Labels, kein externer Port.
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
volumes:
|
volumes:
|
||||||
- ./searxng:/etc/searxng
|
- searxng-config:/etc/searxng
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=http://searxng:8080/
|
- BASE_URL=http://searxng:8080/
|
||||||
- INSTANCE_NAME=kochwas-search
|
- INSTANCE_NAME=kochwas-search
|
||||||
|
depends_on:
|
||||||
|
searxng-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
searxng-config:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik_proxy:
|
traefik_proxy:
|
||||||
# Dasselbe externe Netz wie bei deinem Gitea-Compose.
|
# Dasselbe externe Netz wie bei deinem Gitea-Compose.
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
|
# Dev-Setup: nur SearXNG läuft im Container; Kochwas selbst startest du
|
||||||
|
# lokal mit `npm run dev`. SEARXNG_URL=http://localhost:8888 wird von der
|
||||||
|
# App automatisch erkannt (oder via .env gesetzt).
|
||||||
|
#
|
||||||
|
# Starten:
|
||||||
|
# cp .env.example .env # einmalig, Werte anpassen
|
||||||
|
# docker compose up -d
|
||||||
|
# npm run dev
|
||||||
|
#
|
||||||
|
# Der Init-Container expandiert ${BRAVE_API_KEY} und ${SEARXNG_SECRET} aus
|
||||||
|
# der .env genau wie prod — damit testet man lokal mit dem gleichen Flow.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
searxng-init:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
restart: 'no'
|
||||||
|
user: root
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
python3 -c "import os; open('/out/settings.yml','w').write(os.path.expandvars(open('/in/settings.yml').read()))"
|
||||||
|
volumes:
|
||||||
|
- ./searxng:/in:ro
|
||||||
|
- searxng-config:/out
|
||||||
|
environment:
|
||||||
|
- FORCE_OWNERSHIP=false
|
||||||
|
- BRAVE_API_KEY=${BRAVE_API_KEY:-}
|
||||||
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-dev-secret-change-me}
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
ports:
|
ports:
|
||||||
- '8888:8080'
|
- '8888:8080'
|
||||||
volumes:
|
volumes:
|
||||||
- ./searxng:/etc/searxng
|
- searxng-config:/etc/searxng
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=http://localhost:8888/
|
- BASE_URL=http://localhost:8888/
|
||||||
- INSTANCE_NAME=kochwas-search-dev
|
- INSTANCE_NAME=kochwas-search-dev
|
||||||
|
depends_on:
|
||||||
|
searxng-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
searxng-config:
|
||||||
|
|||||||
143
docs/ARCHITECTURE.md
Normal file
143
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Kochwas — Architektur
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **SvelteKit 2** + **Svelte 5 Runes** (`$state`, `$derived`, `$effect`, `$props`)
|
||||||
|
- **TypeScript strict**
|
||||||
|
- **SQLite** über `better-sqlite3` (synchron, native Binding arm64)
|
||||||
|
- **FTS5** Virtual Tables mit BM25-Ranking für Volltext-Suche
|
||||||
|
- **linkedom** für HTML-Parsing (JSON-LD-Extraktion, og:image-Enrichment)
|
||||||
|
- **zod** für API-Schema-Validierung
|
||||||
|
- Adapter: `@sveltejs/adapter-node` → Node 22 Alpine im Container
|
||||||
|
|
||||||
|
## Top-Level-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app.html, app.d.ts # Shell + Env-Types
|
||||||
|
├── service-worker.ts # PWA-Shell
|
||||||
|
├── lib/
|
||||||
|
│ ├── client/ # clientseitig: Profil-Store, Confirm-Dialog
|
||||||
|
│ ├── components/ # Svelte-Komponenten (RecipeView, StarRating, ConfirmDialog, ProfileSwitcher)
|
||||||
|
│ ├── recipes/ # shared: Portionen-Scaler (Client UND Server)
|
||||||
|
│ ├── server/ # nur Server-Code (nie in Client-Bundle!)
|
||||||
|
│ │ ├── db/ # openDb, Migrations, DB-Singleton
|
||||||
|
│ │ ├── domains/ # Whitelist-Repo
|
||||||
|
│ │ ├── http.ts # fetch-Wrapper mit Timeout / maxBytes / extraHeaders
|
||||||
|
│ │ ├── images/ # Download, SHA256-Dedup, Save
|
||||||
|
│ │ ├── parsers/ # json-ld-recipe.ts, iso8601-duration.ts
|
||||||
|
│ │ ├── profiles/ # Profile-Repo
|
||||||
|
│ │ ├── recipes/ # importer, actions, repository, search-local
|
||||||
|
│ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache)
|
||||||
|
│ │ ├── wishlist/ # Repo
|
||||||
|
│ │ └── backup/ # ZIP-Export via archiver, Import via yauzl
|
||||||
|
│ ├── quotes.ts # 49 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
|
||||||
|
└── api/ # REST-Endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenfluss
|
||||||
|
|
||||||
|
### Import (User klickt auf Web-Treffer)
|
||||||
|
|
||||||
|
1. User klickt auf Web-Hit → `/preview?url=...`
|
||||||
|
2. `/api/recipes/preview` → `importer.ts` lädt HTML, `parseHTML` von linkedom, `json-ld-recipe.ts` extrahiert `Recipe`-Objekt mit **externer** Bild-URL
|
||||||
|
3. Preview-Seite rendert das `Recipe` via `RecipeView.svelte` (erkennt externe URL und lädt direkt vom Original-CDN)
|
||||||
|
4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `recipe_ingredient` + `recipe_step` + `recipe_tag`
|
||||||
|
5. Redirect zu `/recipes/[id]`
|
||||||
|
|
||||||
|
### Web-Suche
|
||||||
|
|
||||||
|
1. User tippt → 300 ms Debounce → `/api/recipes/search?q=...` (lokal FTS5)
|
||||||
|
2. Wenn 0 Treffer: automatisch `/api/recipes/search/web?q=...`
|
||||||
|
3. `searxng.ts` → SearXNG-API mit `site:domain OR site:domain2 ...`-Filter aus Whitelist
|
||||||
|
4. Filtert Non-Recipe-Pfade (Foren, Magazin, Listings) via `NON_RECIPE_PATH_PATTERNS`
|
||||||
|
5. Pro Treffer: parallel (max 6) `enrichThumbnail`:
|
||||||
|
- SQLite-Cache hit → return
|
||||||
|
- Sonst: Seite holen (max 512 KB, 4 s), `extractPageImage`: og:image → link rel=image_src → JSON-LD → erstes Content-img
|
||||||
|
- Ergebnis (auch null) in `thumbnail_cache` persistieren (30 Tage TTL)
|
||||||
|
- **Überschreibt** bestehendes SearXNG-Thumbnail, weil das meist LowRes ist
|
||||||
|
|
||||||
|
### Confirm / Alert
|
||||||
|
|
||||||
|
Promise-basiert statt `window.confirm`/`window.alert`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
|
if (await confirmAction({ title: 'Löschen?', destructive: true })) { /* ... */ }
|
||||||
|
await alertAction({ title: 'Fehler', message: 'xyz' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemeinsame Komponente `ConfirmDialog.svelte` wird im Root-Layout einmal gemountet. Store (`confirmStore`) hält die Promise-Resolve-Funktion, Komponente rendert nur wenn `pending !== null`.
|
||||||
|
|
||||||
|
## Design-Entscheidungen
|
||||||
|
|
||||||
|
- **Kein Login, nur Profile**: Profile werden beim Start gewählt, in localStorage persistiert. Actions (Rating, Favorit, Cooked, Kommentar) brauchen aktives Profil → sonst Custom-Alert „Bitte Profil wählen".
|
||||||
|
- **FTS5 als Haupt-Suche**: statt externer Search-Engine-DB. Passt zu SQLite-only-Stack.
|
||||||
|
- **JSON-LD first**: Alle drei Ziel-Domains (Chefkoch, Emmi, Experimente) liefern `schema.org/Recipe` im JSON-LD. LLM-Fallback war geplant, aktuell nicht nötig.
|
||||||
|
- **SearXNG als Such-Engine**: Self-hosted, daher keine API-Keys. Das Bot-Detection-Theater wird mit gesetzten `X-Forwarded-For`-Headern aus Docker-IPs umgangen.
|
||||||
|
- **Thumbnail-Cache in SQLite**: 30 Tage TTL (per `KOCHWAS_THUMB_TTL_DAYS`). Negative Einträge (Seite ohne Bild) werden auch gecacht.
|
||||||
|
- **Svelte 5 Runes** — kein `$:` mehr, keine alten Stores außer `$app/stores`. Neue Stores via Klasse mit `$state`-Feldern.
|
||||||
|
- **Service Worker** rein zum Shell-Cachen für Offline-First-PWA, kein intelligentes Cache-Matching (keine externe Rezept-Seiten).
|
||||||
|
|
||||||
|
## Migrations-Workflow
|
||||||
|
|
||||||
|
Bei Schema-Änderung:
|
||||||
|
|
||||||
|
1. Neue Datei `src/lib/server/db/migrations/00N_beschreibung.sql` — nächste freie Nummer
|
||||||
|
2. SQL sollte nicht-destruktiv sein (nur `CREATE`, `ALTER ADD`); keine `DROP` auf bestehende Daten
|
||||||
|
3. `migrate.ts` liest via Vite-Glob und führt neue Einträge aus (über `schema_migrations`-Tabelle getrackt)
|
||||||
|
4. Tests anpassen: `db idempotent` zählt vorher/nachher — bleibt automatisch grün
|
||||||
|
|
||||||
|
## Test-Strategie
|
||||||
|
|
||||||
|
- **Unit**: `tests/unit/` — pure Funktionen (json-ld-recipe, iso8601-duration, quotes-random, smoke)
|
||||||
|
- **Integration**: `tests/integration/` — mit `openInMemoryForTest()` fresh SQLite pro Test. Externe HTTP via `node:http`-TestServer auf Port 0 gemockt.
|
||||||
|
- **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
|
||||||
|
- Print-View ist nur rudimentär, könnte ein eigenes Print-CSS bekommen
|
||||||
|
- Tag-Editor im Admin
|
||||||
|
- Export-Format JSON zusätzlich zu ZIP
|
||||||
173
docs/OPERATIONS.md
Normal file
173
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Kochwas — Deployment & Operations
|
||||||
|
|
||||||
|
## Deployment-Topologie
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
↓ HTTPS (kochwas.siegeln.net)
|
||||||
|
Cloudflare DNS (A-Record auf Pi-IP oder Tunnel)
|
||||||
|
↓
|
||||||
|
Raspberry Pi 5 (arm64, Debian/Ubuntu)
|
||||||
|
↓
|
||||||
|
Traefik v3 (Docker, Container "traefik" im Netz traefik_proxy)
|
||||||
|
↓ reverse proxy
|
||||||
|
Kochwas-Container (Node 22 Alpine, Port 3000, internal bridge)
|
||||||
|
↔ SearXNG-Container (Sidecar im gleichen Stack, Port 8080 intern)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Traefik** terminiert TLS mit Wildcard-Cert `*.siegeln.net` von Let's Encrypt (DNS-01 Challenge über Cloudflare-API).
|
||||||
|
- **SearXNG** läuft als Sidecar im kochwas-Compose. Kochwas spricht ihn über `http://searxng:8080` intern an.
|
||||||
|
- **Gitea Registry** `gitea.siegeln.net/claude/kochwas` hostet das arm64-Image.
|
||||||
|
- **Daten** liegen im Volume `/opt/docker/kochwas/data/` (SQLite + images/).
|
||||||
|
|
||||||
|
## Build & Publish (Gitea Actions)
|
||||||
|
|
||||||
|
Workflow in `.gitea/workflows/docker.yml`:
|
||||||
|
|
||||||
|
1. Trigger: push auf `main`
|
||||||
|
2. Checkout, Setup QEMU + Buildx
|
||||||
|
3. Login an `gitea.siegeln.net` mit Secret `REGISTRY_TOKEN` (PAT mit `write:package` + `read:package` Scope)
|
||||||
|
4. `docker/build-push-action` baut **nativ arm64** (nicht via emuliertem amd64!), mit `cache-from/to: type=registry,ref=...:buildcache`
|
||||||
|
5. Push als `:latest` und `:${commit}`
|
||||||
|
|
||||||
|
Wenn die Pipeline rot ist, häufig:
|
||||||
|
- `unauthorized`: Token fehlt oder ohne Package-Scope. PAT unter Gitea → Settings → Applications → Generate Token.
|
||||||
|
- Build-Cache i/o-Timeout: Registry-Cache benutzen, nicht GHA-Artifact-Cache.
|
||||||
|
|
||||||
|
## Deploy auf den Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@pi5
|
||||||
|
cd /opt/docker/kochwas
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
docker compose logs -f kochwas
|
||||||
|
```
|
||||||
|
|
||||||
|
Was der Pi braucht (einmalig):
|
||||||
|
- `/opt/docker/kochwas/docker-compose.prod.yml` — gespiegelt aus dem Repo
|
||||||
|
- `/opt/docker/kochwas/.env` mit `KOCHWAS_TAG=latest` (optional) und `SEARXNG_SECRET=...`
|
||||||
|
- `/opt/docker/kochwas/searxng/settings.yml` — aus dem Repo, mit `limiter: false` und `public_instance: false`
|
||||||
|
- `/opt/docker/kochwas/data/` existiert (für SQLite + images)
|
||||||
|
- Netzwerk `traefik_proxy` existiert, damit Traefik den Container sieht
|
||||||
|
|
||||||
|
## Traefik-Integration
|
||||||
|
|
||||||
|
Labels am kochwas-Container (siehe `docker-compose.prod.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik_proxy
|
||||||
|
- traefik.http.routers.kochwas.rule=Host(`kochwas.siegeln.net`)
|
||||||
|
- traefik.http.routers.kochwas.entrypoints=websecure
|
||||||
|
- traefik.http.routers.kochwas.tls.certresolver=cloudflareResolver
|
||||||
|
- traefik.http.routers.kochwas.tls.domains[0].main=siegeln.net
|
||||||
|
- traefik.http.routers.kochwas.tls.domains[0].sans=*.siegeln.net
|
||||||
|
- traefik.http.services.kochwas.loadbalancer.server.port=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `tls.domains`-Zeilen sorgen dafür, dass der Router das Wildcard-Cert nutzt statt einen neuen per-Host-Cert zu holen. **Nie per-Host für neue Subdomains** — Let's Encrypt Rate-Limit (5 failed Authorizations pro Identifier pro Stunde, 50 Certs pro Registered Domain pro Woche).
|
||||||
|
|
||||||
|
### Wenn Cert fehlt / TLS-Fehler
|
||||||
|
|
||||||
|
1. `echo | openssl s_client -servername kochwas.siegeln.net -connect kochwas.siegeln.net:443 2>/dev/null | openssl x509 -noout -issuer -subject` — ist der Issuer „TRAEFIK DEFAULT CERT"? Dann hat Traefik kein Cert.
|
||||||
|
2. `sudo jq '.cloudflareResolver.Certificates | map(.domain.main)' /opt/docker/traefik/letsencrypt/acme.json` — ist `siegeln.net` (mit SAN `*.siegeln.net`) dabei?
|
||||||
|
3. `docker logs traefik 2>&1 | grep -iE 'lego|acme|cloudflare|kochwas' | tail -60` — Fehler?
|
||||||
|
- `Invalid access token` → Cloudflare-API-Token abgelaufen, neu erstellen mit `Zone → DNS → Edit` Scope, `CF_DNS_API_TOKEN` im Traefik-Compose setzen, `docker compose up -d traefik`
|
||||||
|
- `429 rateLimited` → Warten (zeitangabe im Error) oder auf Wildcard umstellen
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container läuft, Traefik filtert ihn raus
|
||||||
|
|
||||||
|
Symptom: Traefik-Logs sagen `Filtering unhealthy or starting container`.
|
||||||
|
Ursache: Healthcheck schlägt fehl. Der Check ruft `wget 127.0.0.1:3000/api/health` (muss IPv4 sein!).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect kochwas-kochwas-1 --format '{{json .State.Health}}' | jq
|
||||||
|
docker exec kochwas-kochwas-1 wget -qO- 127.0.0.1:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### SearXNG gibt 403 zurück
|
||||||
|
|
||||||
|
Log: `Internet-Suche zurzeit nicht möglich: HTTP 403`
|
||||||
|
Ursache: Bot-Detection. Fix war schon einmal nötig — `src/lib/server/http.ts` setzt via `extraHeaders` `X-Forwarded-For: 127.0.0.1` und `X-Real-IP: 127.0.0.1`. Wenn trotzdem 403: `searxng/settings.yml` prüfen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
use_default_settings: true
|
||||||
|
server:
|
||||||
|
limiter: false
|
||||||
|
public_instance: false
|
||||||
|
secret_key: ${SEARXNG_SECRET:-dev-secret-change-in-prod}
|
||||||
|
search:
|
||||||
|
formats: [html, json]
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Server-Container muss diese Datei per Volume Mount sehen. Nach Änderung: `docker compose restart searxng`.
|
||||||
|
|
||||||
|
### Thumbnail-Cache leeren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec kochwas-kochwas-1 sqlite3 /data/kochwas.db 'DELETE FROM thumbnail_cache;'
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder gezielt eine URL:
|
||||||
|
```bash
|
||||||
|
docker exec kochwas-kochwas-1 sqlite3 /data/kochwas.db \
|
||||||
|
"DELETE FROM thumbnail_cache WHERE url = 'https://www.chefkoch.de/rezepte/...';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank-Backup manuell
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@pi5 'docker exec kochwas-kochwas-1 sqlite3 /data/kochwas.db ".backup /data/backup.db"'
|
||||||
|
scp admin@pi5:/opt/docker/kochwas/data/backup.db ./kochwas-$(date +%F).db
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App hat ein eingebautes Backup unter `/admin` (ZIP-Export mit DB + Bildern). Restore via `/admin` ebenfalls.
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Name | Default | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `SEARXNG_URL` | `http://localhost:8888` | SearXNG-Endpoint, im Compose auf `http://searxng:8080` |
|
||||||
|
| `KOCHWAS_THUMB_TTL_DAYS` | `30` | TTL für Thumbnail-Cache in der SQLite |
|
||||||
|
| `DATABASE_PATH` | `data/kochwas.db` | Pfad zur SQLite, relativ oder absolut |
|
||||||
|
| `IMAGES_PATH` | `data/images` | Pfad für lokale Bild-Dateien |
|
||||||
|
| `PORT` | `3000` | Node-HTTP-Port (adapter-node) |
|
||||||
|
|
||||||
|
Siehe `.env.example` im Repo.
|
||||||
|
|
||||||
|
## Häufige Commits als Referenz
|
||||||
|
|
||||||
|
- **Healthcheck-Fix** → `Dockerfile` (localhost → 127.0.0.1, tightened interval)
|
||||||
|
- **SearXNG-Bot-Bypass** → `src/lib/server/http.ts` (extraHeaders)
|
||||||
|
- **Traefik-Wildcard** → `docker-compose.prod.yml` (tls.domains Labels)
|
||||||
|
- **Thumbnail-Cache in SQLite** → `003_thumbnail_cache.sql` + `searxng.ts`
|
||||||
|
|
||||||
|
Git-Log ist die Wahrheit; diese Datei ist eine Orientierung.
|
||||||
|
|
||||||
|
## PWA / Offline-Modus
|
||||||
|
|
||||||
|
Kochwas ist eine installierbare PWA. Erkennbar an:
|
||||||
|
- `static/manifest.webmanifest` (Manifest + Icons: SVG + 192×192 + 512×512, alle maskable)
|
||||||
|
- `src/service-worker.ts` (Cache + Sync)
|
||||||
|
|
||||||
|
Caches im Browser (siehe DevTools → Application → Cache Storage):
|
||||||
|
- `kochwas-shell-<version>` — App-Shell (JS/CSS/Static-Icons), cache-first
|
||||||
|
- `kochwas-data-v1` — Rezept-HTMLs + API-JSON (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
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 |
|
||||||
1182
package-lock.json
generated
1182
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,22 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"render:icons": "node scripts/render-icons.mjs",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.7",
|
"prettier-plugin-svelte": "^3.2.7",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"svelte": "^5.1.0",
|
"svelte": "^5.1.0",
|
||||||
"svelte-check": "^4.0.5",
|
"svelte-check": "^4.0.5",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
@@ -33,6 +39,7 @@
|
|||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"linkedom": "^0.18.5",
|
"linkedom": "^0.18.5",
|
||||||
|
"lucide-svelte": "^1.0.1",
|
||||||
"yauzl": "^3.3.0",
|
"yauzl": "^3.3.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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',
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
19
scripts/render-icons.mjs
Normal file
19
scripts/render-icons.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Rendert PWA-Icons aus static/icon.svg in die Größen, die Android/iOS
|
||||||
|
// für Home-Screen-Icons bevorzugen. Einmal lokal ausführen und die
|
||||||
|
// PNGs committen — keine CI-Abhängigkeit.
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = join(here, '..');
|
||||||
|
const src = await readFile(join(root, 'static/icon.svg'));
|
||||||
|
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
await sharp(src, { density: 400 })
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 248, g: 250, b: 248, alpha: 1 } })
|
||||||
|
.png()
|
||||||
|
.toFile(join(root, `static/icon-${size}.png`));
|
||||||
|
console.log(`wrote static/icon-${size}.png`);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use_default_settings: true
|
use_default_settings: true
|
||||||
|
|
||||||
server:
|
server:
|
||||||
# In production override via env (see docker-compose.prod.yml).
|
# Platzhalter wird beim Container-Start per os.path.expandvars aus der
|
||||||
secret_key: ${SEARXNG_SECRET:-dev-secret-change-in-prod}
|
# SEARXNG_SECRET-Env-Variable gesetzt (Default im docker-compose.prod.yml).
|
||||||
|
secret_key: "${SEARXNG_SECRET}"
|
||||||
# Disables rate limiter + bot detection. This is a private internal service
|
# Disables rate limiter + bot detection. This is a private internal service
|
||||||
# called only by kochwas — no public exposure, no abuse risk.
|
# called only by kochwas — no public exposure, no abuse risk.
|
||||||
limiter: false
|
limiter: false
|
||||||
@@ -21,6 +22,12 @@ search:
|
|||||||
autocomplete: ''
|
autocomplete: ''
|
||||||
default_lang: 'de'
|
default_lang: 'de'
|
||||||
|
|
||||||
|
# Höhere Timeouts als Default (3s), weil der Pi und einige Upstream-Engines
|
||||||
|
# öfter knapp drüber liegen — lieber 8s warten als gar kein Ergebnis.
|
||||||
|
outgoing:
|
||||||
|
request_timeout: 8.0
|
||||||
|
max_request_timeout: 12.0
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
default_locale: de
|
default_locale: de
|
||||||
|
|
||||||
@@ -29,3 +36,66 @@ enabled_plugins:
|
|||||||
- 'Hash plugin'
|
- 'Hash plugin'
|
||||||
- 'Tracker URL remover'
|
- 'Tracker URL remover'
|
||||||
- 'Open Access DOI rewrite'
|
- '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.
|
||||||
|
- name: brave
|
||||||
|
engine: brave
|
||||||
|
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
|
||||||
|
|||||||
54
src/lib/client/confirm.svelte.ts
Normal file
54
src/lib/client/confirm.svelte.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export type ConfirmOptions = {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
/** If true, hide the cancel button — used for simple info/alert dialogs. */
|
||||||
|
infoOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = ConfirmOptions & {
|
||||||
|
resolve: (result: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ConfirmStore {
|
||||||
|
pending = $state<PendingRequest | null>(null);
|
||||||
|
|
||||||
|
ask(options: ConfirmOptions): Promise<boolean> {
|
||||||
|
// If another dialog is already open, close it as cancelled so we don't stack.
|
||||||
|
if (this.pending) this.pending.resolve(false);
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
this.pending = { ...options, resolve };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
answer(result: boolean): void {
|
||||||
|
if (!this.pending) return;
|
||||||
|
const p = this.pending;
|
||||||
|
this.pending = null;
|
||||||
|
p.resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confirmStore = new ConfirmStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a modal confirmation dialog. Resolves to true on confirm, false on cancel/Escape.
|
||||||
|
* Safe on the server: falls back to the native confirm() only in the browser.
|
||||||
|
*/
|
||||||
|
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve(false);
|
||||||
|
return confirmStore.ask(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a modal info dialog with a single OK button. Resolves when dismissed.
|
||||||
|
* Use instead of window.alert().
|
||||||
|
*/
|
||||||
|
export function alertAction(options: Omit<ConfirmOptions, 'destructive' | 'cancelLabel' | 'infoOnly'>): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve();
|
||||||
|
return confirmStore
|
||||||
|
.ask({ ...options, infoOnly: true, confirmLabel: options.confirmLabel ?? 'OK' })
|
||||||
|
.then(() => undefined);
|
||||||
|
}
|
||||||
44
src/lib/client/install-prompt.svelte.ts
Normal file
44
src/lib/client/install-prompt.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Captures the beforeinstallprompt event (Android Chrome) and holds it for
|
||||||
|
// manual triggering by the user. On iOS Safari this event does not exist —
|
||||||
|
// we detect the browser via UserAgent and show an info hint instead.
|
||||||
|
class InstallPromptStore {
|
||||||
|
available = $state(false);
|
||||||
|
platform = $state<'android' | 'ios' | 'other'>('other');
|
||||||
|
private deferred: BeforeInstallPromptEvent | null = null;
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
this.platform = detectPlatform();
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.deferred = e as BeforeInstallPromptEvent;
|
||||||
|
this.available = true;
|
||||||
|
});
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
this.deferred = null;
|
||||||
|
this.available = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(): Promise<void> {
|
||||||
|
if (!this.deferred) return;
|
||||||
|
await this.deferred.prompt();
|
||||||
|
this.deferred = null;
|
||||||
|
this.available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform(): 'android' | 'ios' | 'other' {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
|
||||||
|
if (/Android/i.test(ua)) return 'android';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal type for the Chrome-specific event
|
||||||
|
type BeforeInstallPromptEvent = Event & {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installPrompt = new InstallPromptStore();
|
||||||
14
src/lib/client/network.svelte.ts
Normal file
14
src/lib/client/network.svelte.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Reaktiver Online-Status, basierend auf navigator.onLine + events.
|
||||||
|
// Bewusst kein aktives Heuristik-Probing (Test-Fetches) — für unsere
|
||||||
|
// Zwecke reicht der Browser-Status.
|
||||||
|
class NetworkStore {
|
||||||
|
online = $state(typeof navigator === 'undefined' ? true : navigator.onLine);
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.addEventListener('online', () => (this.online = true));
|
||||||
|
window.addEventListener('offline', () => (this.online = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const network = new NetworkStore();
|
||||||
115
src/lib/client/pwa.svelte.ts
Normal file
115
src/lib/client/pwa.svelte.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
|
||||||
|
// skipWaiting im install-Handler, User bestätigt via Toast) mit
|
||||||
|
// zusätzlichem Zombie-Schutz.
|
||||||
|
//
|
||||||
|
// Warum der Zombie-Schutz nötig ist: Chromium hält auf diesem Deploy
|
||||||
|
// reproduzierbar nach einem SKIP_WAITING+Reload einen bit-identischen
|
||||||
|
// waiting-SW im Registration-Slot — wohl durch einen Race zwischen
|
||||||
|
// SW-Update-Check und activate. Der reine Workbox-Standard würde den
|
||||||
|
// als „neues Update" interpretieren und den Toast bei jedem Reload
|
||||||
|
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
|
||||||
|
// beiden SWs, vergleichen und räumen identische Bytes still auf.
|
||||||
|
//
|
||||||
|
// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick
|
||||||
|
// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der
|
||||||
|
// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue
|
||||||
|
// Seite wieder mit frischem Zombie ausstattet.
|
||||||
|
class PwaStore {
|
||||||
|
updateAvailable = $state(false);
|
||||||
|
private registration: ServiceWorkerRegistration | null = null;
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.registration = await navigator.serviceWorker.ready;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.registration) return;
|
||||||
|
|
||||||
|
if (this.registration.waiting && this.registration.active) {
|
||||||
|
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
||||||
|
|
||||||
|
// Alle 30 Minuten aktiv nach Updates fragen, damit der User sie auch
|
||||||
|
// mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren.
|
||||||
|
this.pollTimer = setInterval(() => {
|
||||||
|
void this.registration?.update().catch(() => {});
|
||||||
|
}, 30 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onUpdateFound(): void {
|
||||||
|
const installing = this.registration?.installing;
|
||||||
|
if (!installing) return;
|
||||||
|
installing.addEventListener('statechange', () => {
|
||||||
|
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
|
||||||
|
const active = this.registration?.active;
|
||||||
|
if (active && active !== installing) {
|
||||||
|
void this.evaluateWaiting(installing, active);
|
||||||
|
} else {
|
||||||
|
this.updateAvailable = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
|
||||||
|
const [waitingVersion, activeVersion] = await Promise.all([
|
||||||
|
queryVersion(waiting),
|
||||||
|
queryVersion(active)
|
||||||
|
]);
|
||||||
|
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
|
||||||
|
// Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite
|
||||||
|
// läuft nahtlos unter dem neuen SW weiter (funktional identisch).
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Versions-Unterschied oder unbekannt: User entscheidet via Toast.
|
||||||
|
this.updateAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
this.updateAvailable = false;
|
||||||
|
const waiting = this.registration?.waiting;
|
||||||
|
if (!waiting) {
|
||||||
|
// Kein wartender SW — reicht ein normaler Reload.
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange
|
||||||
|
// feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig.
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
() => location.reload(),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(): void {
|
||||||
|
this.updateAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryVersion(sw: ServiceWorker): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
const timer = setTimeout(() => resolve(null), 1500);
|
||||||
|
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;
|
||||||
|
}
|
||||||
86
src/lib/client/search-filter.svelte.ts
Normal file
86
src/lib/client/search-filter.svelte.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { AllowedDomain } from '$lib/types';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kochwas.filter.domains';
|
||||||
|
|
||||||
|
// Leere Menge = kein Filter aktiv (alle Domains werden gesucht). Damit fügt sich
|
||||||
|
// eine neu vom Admin freigeschaltete Domain automatisch ein, ohne dass der User
|
||||||
|
// sie extra aktivieren muss. Wenn der User aktiv auswählt, speichern wir die
|
||||||
|
// Auswahl als explizite Menge — und genau die wird dann gesucht.
|
||||||
|
class SearchFilterStore {
|
||||||
|
domains = $state<AllowedDomain[]>([]);
|
||||||
|
active = $state<Set<string>>(new Set());
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const arr = JSON.parse(raw) as string[];
|
||||||
|
if (Array.isArray(arr)) this.active = new Set(arr);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupted state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/domains');
|
||||||
|
if (res.ok) {
|
||||||
|
this.domains = (await res.json()) as AllowedDomain[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// offline / server error — leave domains empty, UI falls back to "no filter"
|
||||||
|
}
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
persist(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...this.active]));
|
||||||
|
} catch {
|
||||||
|
// ignore quota / disabled storage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(domain: string): void {
|
||||||
|
const next = new Set(this.active);
|
||||||
|
if (next.has(domain)) next.delete(domain);
|
||||||
|
else next.add(domain);
|
||||||
|
this.active = next;
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll(): void {
|
||||||
|
// "Alle" == leere Menge, damit neue Domains automatisch dabei sind.
|
||||||
|
this.active = new Set();
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOnly(domain: string): void {
|
||||||
|
this.active = new Set([domain]);
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Übernimmt eine vorbereitete Draft-Auswahl auf einmal — wird vom
|
||||||
|
// Filter-Dropdown genutzt, der Toggles erst lokal sammelt und erst beim
|
||||||
|
// „OK"-Klick committet. Triggert den active-$effect nur ein einziges Mal.
|
||||||
|
commit(next: Set<string>): void {
|
||||||
|
this.active = next;
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// True wenn der User die Suche eingeschränkt hat (mindestens eine aber nicht alle).
|
||||||
|
get isFiltered(): boolean {
|
||||||
|
return this.active.size > 0 && this.active.size < this.domains.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Als Query-Param-String. Leer = kein Filter.
|
||||||
|
get queryParam(): string {
|
||||||
|
if (this.active.size === 0) return '';
|
||||||
|
return [...this.active].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchFilterStore = new SearchFilterStore();
|
||||||
33
src/lib/client/sw-register.ts
Normal file
33
src/lib/client/sw-register.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Registriert den Service-Worker und verdrahtet ihn mit dem
|
||||||
|
// Sync-Status-Store. Im Dev-Modus läuft Kochwas über HTTP; die
|
||||||
|
// SW-API ist da nur auf localhost verfügbar. SvelteKit liefert den
|
||||||
|
// SW unter /service-worker.js im Production-Build.
|
||||||
|
import { syncStatus, type SWMessage } from '$lib/client/sync-status.svelte';
|
||||||
|
|
||||||
|
export async function registerServiceWorker(): Promise<void> {
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register('/service-worker.js', { type: 'module' });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('SW-Registrierung fehlgeschlagen', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
const data = event.data as SWMessage | undefined;
|
||||||
|
if (data && typeof data === 'object' && 'type' in data) {
|
||||||
|
syncStatus.handle(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beim App-Start: wenn wir einen aktiven SW haben, frage ihn, ob er
|
||||||
|
// neu synct (initial oder Delta).
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: 'sync-check' });
|
||||||
|
} else {
|
||||||
|
// Erste Session: SW kommt erst mit dem nächsten Reload zum Einsatz.
|
||||||
|
// Beim nächsten Start triggert sync-check dann den Initial-Sync.
|
||||||
|
navigator.serviceWorker.ready.then((reg) => {
|
||||||
|
reg.active?.postMessage({ type: 'sync-start' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/lib/client/sync-status.svelte.ts
Normal file
53
src/lib/client/sync-status.svelte.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// State, den der Service-Worker per postMessage befüllt. Die App
|
||||||
|
// spiegelt den Sync-Fortschritt im SyncIndicator.
|
||||||
|
export type SyncState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'syncing'; current: number; total: number }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
export type SWMessage =
|
||||||
|
| { type: 'sync-start'; total: number }
|
||||||
|
| { type: 'sync-progress'; current: number; total: number }
|
||||||
|
| { type: 'sync-done'; lastSynced: number }
|
||||||
|
| { type: 'sync-error'; message: string };
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kochwas.sw.lastSynced';
|
||||||
|
|
||||||
|
function loadLastSynced(): number | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLastSynced(ts: number): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncStatusStore {
|
||||||
|
state = $state<SyncState>({ kind: 'idle' });
|
||||||
|
lastSynced = $state<number | null>(loadLastSynced());
|
||||||
|
|
||||||
|
handle(msg: SWMessage): void {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'sync-start':
|
||||||
|
this.state = { kind: 'syncing', current: 0, total: msg.total };
|
||||||
|
break;
|
||||||
|
case 'sync-progress':
|
||||||
|
this.state = { kind: 'syncing', current: msg.current, total: msg.total };
|
||||||
|
break;
|
||||||
|
case 'sync-done':
|
||||||
|
this.state = { kind: 'idle' };
|
||||||
|
this.lastSynced = msg.lastSynced;
|
||||||
|
saveLastSynced(msg.lastSynced);
|
||||||
|
break;
|
||||||
|
case 'sync-error':
|
||||||
|
this.state = { kind: 'error', message: msg.message };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncStatus = new SyncStatusStore();
|
||||||
25
src/lib/client/toast.svelte.ts
Normal file
25
src/lib/client/toast.svelte.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type ToastKind = 'info' | 'error' | 'success';
|
||||||
|
export type Toast = { id: number; kind: ToastKind; message: string };
|
||||||
|
|
||||||
|
class ToastStore {
|
||||||
|
toasts = $state<Toast[]>([]);
|
||||||
|
private nextId = 1;
|
||||||
|
private readonly dismissMs = 3000;
|
||||||
|
|
||||||
|
private push(kind: ToastKind, message: string): number {
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.toasts = [...this.toasts, { id, kind, message }];
|
||||||
|
setTimeout(() => this.dismiss(id), this.dismissMs);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string): number { return this.push('info', message); }
|
||||||
|
error(message: string): number { return this.push('error', message); }
|
||||||
|
success(message: string): number { return this.push('success', message); }
|
||||||
|
|
||||||
|
dismiss(id: number): void {
|
||||||
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toastStore = new ToastStore();
|
||||||
16
src/lib/client/wishlist.svelte.ts
Normal file
16
src/lib/client/wishlist.svelte.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class WishlistStore {
|
||||||
|
count = $state(0);
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/wishlist/count');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
this.count = typeof body.count === 'number' ? body.count : 0;
|
||||||
|
} catch {
|
||||||
|
// keep last known count on network error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wishlistStore = new WishlistStore();
|
||||||
149
src/lib/components/ConfirmDialog.svelte
Normal file
149
src/lib/components/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { confirmStore } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
|
let confirmButton = $state<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (confirmStore.pending) {
|
||||||
|
void tick().then(() => confirmButton?.focus());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (!confirmStore.pending) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmStore.answer(false);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmStore.answer(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if confirmStore.pending}
|
||||||
|
{@const p = confirmStore.pending}
|
||||||
|
<div class="backdrop" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||||
|
<button
|
||||||
|
class="backdrop-close"
|
||||||
|
aria-label="Abbrechen"
|
||||||
|
onclick={() => confirmStore.answer(false)}
|
||||||
|
></button>
|
||||||
|
<div class="dialog" role="document">
|
||||||
|
<h2 id="confirm-title">{p.title}</h2>
|
||||||
|
{#if p.message}
|
||||||
|
<p class="message">{p.message}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
{#if !p.infoOnly}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn cancel"
|
||||||
|
onclick={() => confirmStore.answer(false)}
|
||||||
|
>
|
||||||
|
{p.cancelLabel ?? 'Abbrechen'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn confirm"
|
||||||
|
class:destructive={p.destructive}
|
||||||
|
bind:this={confirmButton}
|
||||||
|
onclick={() => confirmStore.answer(true)}
|
||||||
|
>
|
||||||
|
{p.confirmLabel ?? 'Bestätigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.backdrop-close {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.4rem 1.25rem 1.1rem;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);
|
||||||
|
animation: pop 0.14s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin: 0 0 1.1rem;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.7rem 1.1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.cancel {
|
||||||
|
background: white;
|
||||||
|
color: #444;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
}
|
||||||
|
.cancel:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.confirm {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.confirm.destructive {
|
||||||
|
background: #c53030;
|
||||||
|
}
|
||||||
|
.confirm:focus-visible,
|
||||||
|
.cancel:focus-visible {
|
||||||
|
outline: 2px solid #1a1a1a;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { CircleUser } from 'lucide-svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
|
import { alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
let showModal = $state(false);
|
let showModal = $state(false);
|
||||||
let newName = $state('');
|
let newName = $state('');
|
||||||
let newEmoji = $state('🍳');
|
let newEmoji = $state('');
|
||||||
|
|
||||||
async function createAndSelect() {
|
async function createAndSelect() {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
@@ -13,17 +15,19 @@
|
|||||||
newName = '';
|
newName = '';
|
||||||
showModal = false;
|
showModal = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert((e as Error).message);
|
await alertAction({
|
||||||
|
title: 'Profil konnte nicht angelegt werden',
|
||||||
|
message: (e as Error).message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="chip" onclick={() => (showModal = true)} aria-label="Profil wechseln">
|
<button class="chip" onclick={() => (showModal = true)} aria-label="Profil wechseln">
|
||||||
|
<span class="icon"><CircleUser size={20} strokeWidth={1.75} /></span>
|
||||||
{#if profileStore.active}
|
{#if profileStore.active}
|
||||||
<span class="emoji">{profileStore.active.avatar_emoji ?? '🙂'}</span>
|
|
||||||
<span class="name">{profileStore.active.name}</span>
|
<span class="name">{profileStore.active.name}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="emoji">👤</span>
|
|
||||||
<span class="name">Profil wählen</span>
|
<span class="name">Profil wählen</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -53,7 +57,11 @@
|
|||||||
showModal = false;
|
showModal = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="emoji-lg">{p.avatar_emoji ?? '🙂'}</span>
|
{#if p.avatar_emoji}
|
||||||
|
<span class="emoji-lg">{p.avatar_emoji}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="icon-lg"><CircleUser size={28} strokeWidth={1.5} /></span>
|
||||||
|
{/if}
|
||||||
<span>{p.name}</span>
|
<span>{p.name}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -65,7 +73,8 @@
|
|||||||
<div class="new-row">
|
<div class="new-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Emoji"
|
placeholder="🙂"
|
||||||
|
aria-label="Emoji (optional)"
|
||||||
bind:value={newEmoji}
|
bind:value={newEmoji}
|
||||||
maxlength="8"
|
maxlength="8"
|
||||||
class="emoji-input"
|
class="emoji-input"
|
||||||
@@ -100,8 +109,10 @@
|
|||||||
.chip:hover {
|
.chip:hover {
|
||||||
background: #f4f8f5;
|
background: #f4f8f5;
|
||||||
}
|
}
|
||||||
.emoji {
|
.icon {
|
||||||
font-size: 1.1rem;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #2b6a3d;
|
||||||
}
|
}
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -165,6 +176,11 @@
|
|||||||
.emoji-lg {
|
.emoji-lg {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
.icon-lg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #e4eae7;
|
border-top: 1px solid #e4eae7;
|
||||||
|
|||||||
403
src/lib/components/RecipeEditor.svelte
Normal file
403
src/lib/components/RecipeEditor.svelte
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Plus, Trash2, GripVertical } from 'lucide-svelte';
|
||||||
|
import type { Recipe, Ingredient, Step } from '$lib/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
recipe: Recipe;
|
||||||
|
saving?: boolean;
|
||||||
|
onsave: (patch: {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
servings_default: number | null;
|
||||||
|
prep_time_min: number | null;
|
||||||
|
cook_time_min: number | null;
|
||||||
|
total_time_min: number | null;
|
||||||
|
ingredients: Ingredient[];
|
||||||
|
steps: Step[];
|
||||||
|
}) => void | Promise<void>;
|
||||||
|
oncancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { recipe, saving = false, onsave, oncancel }: 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 ?? '');
|
||||||
|
|
||||||
|
type DraftIng = {
|
||||||
|
qty: string;
|
||||||
|
unit: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
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 ?? ''
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
let steps = $state<DraftStep[]>(
|
||||||
|
recipe.steps.map((s) => ({ text: s.text }))
|
||||||
|
);
|
||||||
|
|
||||||
|
function addIngredient() {
|
||||||
|
ingredients = [...ingredients, { qty: '', unit: '', name: '', note: '' }];
|
||||||
|
}
|
||||||
|
function removeIngredient(idx: number) {
|
||||||
|
ingredients = ingredients.filter((_, i) => i !== idx);
|
||||||
|
}
|
||||||
|
function addStep() {
|
||||||
|
steps = [...steps, { text: '' }];
|
||||||
|
}
|
||||||
|
function removeStep(idx: number) {
|
||||||
|
steps = steps.filter((_, i) => i !== idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQty(raw: string): number | null {
|
||||||
|
const cleaned = raw.trim().replace(',', '.');
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const n = Number(cleaned);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumOrNull(v: number | ''): number | null {
|
||||||
|
return v === '' ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const cleanedIngredients: Ingredient[] = ingredients
|
||||||
|
.filter((i) => i.name.trim())
|
||||||
|
.map((i, idx) => {
|
||||||
|
const qty = parseQty(i.qty);
|
||||||
|
const unit = i.unit.trim() || null;
|
||||||
|
const name = i.name.trim();
|
||||||
|
const note = i.note.trim() || null;
|
||||||
|
const rawParts: string[] = [];
|
||||||
|
if (qty !== null) rawParts.push(String(qty).replace('.', ','));
|
||||||
|
if (unit) rawParts.push(unit);
|
||||||
|
rawParts.push(name);
|
||||||
|
return {
|
||||||
|
position: idx + 1,
|
||||||
|
quantity: qty,
|
||||||
|
unit,
|
||||||
|
name,
|
||||||
|
note,
|
||||||
|
raw_text: rawParts.join(' ')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const cleanedSteps: Step[] = steps
|
||||||
|
.filter((s) => s.text.trim())
|
||||||
|
.map((s, idx) => ({ position: idx + 1, text: s.text.trim() }));
|
||||||
|
|
||||||
|
await onsave({
|
||||||
|
title: title.trim() || recipe.title,
|
||||||
|
description: description.trim() || null,
|
||||||
|
servings_default: toNumOrNull(servings),
|
||||||
|
prep_time_min: toNumOrNull(prepMin),
|
||||||
|
cook_time_min: toNumOrNull(cookMin),
|
||||||
|
total_time_min: toNumOrNull(totalMin),
|
||||||
|
ingredients: cleanedIngredients,
|
||||||
|
steps: cleanedSteps
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor">
|
||||||
|
<div class="meta">
|
||||||
|
<label class="field">
|
||||||
|
<span class="lbl">Titel</span>
|
||||||
|
<input type="text" bind:value={title} placeholder="Rezeptname" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="lbl">Beschreibung</span>
|
||||||
|
<textarea bind:value={description} rows="2" placeholder="Kurze Beschreibung (optional)"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Portionen</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={servings}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Vorb. (min)</span>
|
||||||
|
<input type="number" min="0" bind:value={prepMin} placeholder="—" />
|
||||||
|
</label>
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Kochen (min)</span>
|
||||||
|
<input type="number" min="0" bind:value={cookMin} placeholder="—" />
|
||||||
|
</label>
|
||||||
|
<label class="field small">
|
||||||
|
<span class="lbl">Gesamt (min)</span>
|
||||||
|
<input type="number" min="0" bind:value={totalMin} placeholder="—" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>Zutaten</h2>
|
||||||
|
<ul class="ing-list">
|
||||||
|
{#each ingredients as ing, idx (idx)}
|
||||||
|
<li class="ing-row">
|
||||||
|
<span class="grip" aria-hidden="true"><GripVertical size={16} /></span>
|
||||||
|
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
|
||||||
|
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
|
||||||
|
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
|
||||||
|
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
|
||||||
|
<button class="del" type="button" aria-label="Zutat entfernen" onclick={() => removeIngredient(idx)}>
|
||||||
|
<Trash2 size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<button class="add" type="button" onclick={addIngredient}>
|
||||||
|
<Plus size={16} strokeWidth={2} />
|
||||||
|
<span>Zutat hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>Zubereitung</h2>
|
||||||
|
<ol class="step-list">
|
||||||
|
{#each steps as step, idx (idx)}
|
||||||
|
<li class="step-row">
|
||||||
|
<span class="num">{idx + 1}</span>
|
||||||
|
<textarea
|
||||||
|
bind:value={step.text}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Schritt beschreiben …"
|
||||||
|
></textarea>
|
||||||
|
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => removeStep(idx)}>
|
||||||
|
<Trash2 size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
<button class="add" type="button" onclick={addStep}>
|
||||||
|
<Plus size={16} strokeWidth={2} />
|
||||||
|
<span>Schritt hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
<button class="btn ghost" type="button" onclick={oncancel} disabled={saving}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" type="button" onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Speichere …' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: white;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.block h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.ing-list,
|
||||||
|
.step-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.ing-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px 70px 70px 1fr 90px 40px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.grip {
|
||||||
|
color: #bbb;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ing-row input {
|
||||||
|
padding: 0.5rem 0.55rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 38px;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.step-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr 40px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.step-row textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
.del {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #f1b4b4;
|
||||||
|
background: white;
|
||||||
|
color: #c53030;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.del:hover {
|
||||||
|
background: #fdf3f3;
|
||||||
|
}
|
||||||
|
.add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border: 1px dashed #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.add:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ing-row {
|
||||||
|
grid-template-columns: 70px 1fr 40px;
|
||||||
|
grid-template-areas:
|
||||||
|
'qty name del'
|
||||||
|
'unit unit del'
|
||||||
|
'note note note';
|
||||||
|
}
|
||||||
|
.grip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ing-row .qty {
|
||||||
|
grid-area: qty;
|
||||||
|
}
|
||||||
|
.ing-row .unit {
|
||||||
|
grid-area: unit;
|
||||||
|
}
|
||||||
|
.ing-row .name {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
.ing-row .note {
|
||||||
|
grid-area: note;
|
||||||
|
}
|
||||||
|
.ing-row .del {
|
||||||
|
grid-area: del;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,9 +6,10 @@
|
|||||||
recipe: Recipe;
|
recipe: Recipe;
|
||||||
showActions?: import('svelte').Snippet;
|
showActions?: import('svelte').Snippet;
|
||||||
banner?: import('svelte').Snippet;
|
banner?: import('svelte').Snippet;
|
||||||
|
titleSlot?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { recipe, showActions, banner }: Props = $props();
|
let { recipe, showActions, banner, titleSlot }: Props = $props();
|
||||||
|
|
||||||
const defaultServings = $derived(recipe.servings_default ?? 4);
|
const defaultServings = $derived(recipe.servings_default ?? 4);
|
||||||
let servingsOverride = $state<number | null>(null);
|
let servingsOverride = $state<number | null>(null);
|
||||||
@@ -61,7 +62,11 @@
|
|||||||
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
|
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="hdr-body">
|
<div class="hdr-body">
|
||||||
<h1>{recipe.title}</h1>
|
{#if titleSlot}
|
||||||
|
{@render titleSlot()}
|
||||||
|
{:else}
|
||||||
|
<h1>{recipe.title}</h1>
|
||||||
|
{/if}
|
||||||
{#if recipe.description}
|
{#if recipe.description}
|
||||||
<p class="desc">{recipe.description}</p>
|
<p class="desc">{recipe.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -112,8 +117,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if tab === 'ing'}
|
<div class="panes">
|
||||||
<section class="ingredients" role="tabpanel">
|
<section
|
||||||
|
class="ingredients"
|
||||||
|
role="tabpanel"
|
||||||
|
class:hidden-mobile={tab !== 'ing'}
|
||||||
|
>
|
||||||
<div class="servings">
|
<div class="servings">
|
||||||
<button class="srv-btn" aria-label="Weniger" onclick={decr}>−</button>
|
<button class="srv-btn" aria-label="Weniger" onclick={decr}>−</button>
|
||||||
<div class="srv-value">
|
<div class="srv-value">
|
||||||
@@ -141,15 +150,18 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
<section
|
||||||
<section class="steps" role="tabpanel">
|
class="steps"
|
||||||
|
role="tabpanel"
|
||||||
|
class:hidden-mobile={tab !== 'prep'}
|
||||||
|
>
|
||||||
<ol>
|
<ol>
|
||||||
{#each recipe.steps as step (step.position)}
|
{#each recipe.steps as step (step.position)}
|
||||||
<li>{step.text}</li>
|
<li>{step.text}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -163,6 +175,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
|
/* Nie mehr als 30% der Bildschirmhöhe — auf schmalen Screens würde das
|
||||||
|
Bild sonst alles Wichtige wegdrücken, auf breiten Desktops wäre es
|
||||||
|
unverhältnismäßig groß. */
|
||||||
|
max-height: 30vh;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: #eef3ef;
|
background: #eef3ef;
|
||||||
}
|
}
|
||||||
@@ -321,4 +337,35 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.hidden-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Querformat-Tablets und Desktop: Zutaten + Zubereitung nebeneinander,
|
||||||
|
Tabs ausgeblendet. Zutaten sticky, damit sie beim Scrollen der
|
||||||
|
Zubereitung oben bleiben. */
|
||||||
|
@media (min-width: 820px) {
|
||||||
|
.tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.panes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) 1.6fr;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.hidden-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.ingredients {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
360
src/lib/components/SearchFilter.svelte
Normal file
360
src/lib/components/SearchFilter.svelte
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SlidersHorizontal, Check, X, ChevronDown } from 'lucide-svelte';
|
||||||
|
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||||
|
|
||||||
|
// inline: Button wird transparent und ohne eigenen Border gestylt,
|
||||||
|
// damit er sich in einen umgebenden Such-Container einpassen lässt.
|
||||||
|
let { inline = false }: { inline?: boolean } = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let container: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// Draft-Auswahl: wird beim Öffnen vom Store initialisiert und nur bei „OK"
|
||||||
|
// in den Store committet. Dadurch bleibt die laufende Suche unangetastet,
|
||||||
|
// solange der User im Menu herumklickt, und ein versehentlicher Klick
|
||||||
|
// daneben verwirft die Auswahl (statt sie halbfertig anzuwenden).
|
||||||
|
let draft = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
function snapshotActive(): Set<string> {
|
||||||
|
// Leere Menge heißt im Store „alle aktiv". Für die Draft machen wir
|
||||||
|
// das explizit, damit toggle() ein vorhersehbares Verhalten hat.
|
||||||
|
if (searchFilterStore.active.size === 0) {
|
||||||
|
return new Set(searchFilterStore.domains.map((d) => d.domain));
|
||||||
|
}
|
||||||
|
return new Set(searchFilterStore.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
draft = snapshotActive();
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
// Wenn alle gewählt sind, speichern wir die leere Menge — damit sind
|
||||||
|
// neu zur Whitelist hinzugefügte Domains automatisch dabei.
|
||||||
|
const allSelected =
|
||||||
|
draft.size === searchFilterStore.domains.length &&
|
||||||
|
searchFilterStore.domains.every((d) => draft.has(d.domain));
|
||||||
|
searchFilterStore.commit(allSelected ? new Set() : draft);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTrigger() {
|
||||||
|
if (open) cancel();
|
||||||
|
else openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && open) cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein Klick-außerhalb-Handler: die Liste schließt sich nur noch explizit
|
||||||
|
// über OK/Abbrechen. Früher wurde bei Re-Render einer Checkbox-Zeile
|
||||||
|
// gelegentlich ein click-Target gesehen, das nicht mehr im container hing,
|
||||||
|
// was das Menu fälschlich schloss.
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onToggleDomain(domain: string) {
|
||||||
|
const next = new Set(draft);
|
||||||
|
if (next.has(domain)) next.delete(domain);
|
||||||
|
else next.add(domain);
|
||||||
|
draft = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllDraft() {
|
||||||
|
draft = new Set(searchFilterStore.domains.map((d) => d.domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNoneDraft() {
|
||||||
|
draft = new Set();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrap" bind:this={container}>
|
||||||
|
<button
|
||||||
|
class="trigger"
|
||||||
|
class:filtered={searchFilterStore.isFiltered}
|
||||||
|
class:inline
|
||||||
|
type="button"
|
||||||
|
aria-label="Suchfilter"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggleTrigger}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={16} strokeWidth={2} />
|
||||||
|
<ChevronDown size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
<div class="menu-head">
|
||||||
|
<span class="head-title">Gefunden auf</span>
|
||||||
|
<div class="quicks">
|
||||||
|
<button class="quick" type="button" onclick={selectAllDraft}>Alle</button>
|
||||||
|
<button class="quick" type="button" onclick={selectNoneDraft}>Keine</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if searchFilterStore.domains.length === 0}
|
||||||
|
<p class="empty">Keine Domains in der Whitelist.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each searchFilterStore.domains as d (d.id)}
|
||||||
|
{@const isOn = draft.has(d.domain)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="row"
|
||||||
|
type="button"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={isOn}
|
||||||
|
onclick={() => onToggleDomain(d.domain)}
|
||||||
|
>
|
||||||
|
<span class="box" class:on={isOn}>
|
||||||
|
{#if isOn}<Check size={14} strokeWidth={3} />{/if}
|
||||||
|
</span>
|
||||||
|
{#if d.favicon_path}
|
||||||
|
<img class="favicon" src={`/images/${d.favicon_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="favicon fallback" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="dom">{d.display_name ?? d.domain}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="menu-foot">
|
||||||
|
<button class="btn ghost" type="button" onclick={cancel}>
|
||||||
|
<X size={16} strokeWidth={2} />
|
||||||
|
<span>Abbrechen</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" type="button" onclick={apply}>
|
||||||
|
<Check size={16} strokeWidth={2.5} />
|
||||||
|
<span>OK</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #2b6a3d;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
min-height: 44px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.trigger:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.trigger.filtered {
|
||||||
|
background: #eaf4ed;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
}
|
||||||
|
/* In der Suchmaske: kein eigener Rahmen/Hintergrund, der Container drumherum
|
||||||
|
trägt die visuelle Form. Hover füllt die volle Container-Höhe. */
|
||||||
|
.wrap:has(.trigger.inline) {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.trigger.inline {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid #e4eae7;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0 0.85rem 0 0.65rem;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.trigger.inline:first-child {
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
border-bottom-left-radius: 12px;
|
||||||
|
}
|
||||||
|
.trigger.inline.filtered {
|
||||||
|
background: transparent;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.trigger.inline:hover {
|
||||||
|
background: rgba(43, 106, 61, 0.06);
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
left: 0;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 80;
|
||||||
|
padding: 0.35rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.menu-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #f0f3f1;
|
||||||
|
}
|
||||||
|
.head-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.quick {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.quick:hover {
|
||||||
|
background: #eaf4ed;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 44px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1.5px solid #cfd9d1;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
background: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.box.on {
|
||||||
|
background: #2b6a3d;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.favicon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.favicon.fallback {
|
||||||
|
background: #eef3ef;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.dom {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 0.8rem 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.menu-foot {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 0.5rem 0.35rem;
|
||||||
|
border-top: 1px solid #f0f3f1;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.quicks {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
min-height: 40px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn.ghost:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: #235532;
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.trigger {
|
||||||
|
padding: 0.5rem 0.55rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
left: -0.25rem;
|
||||||
|
min-width: calc(100vw - 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
180
src/lib/components/SearchLoader.svelte
Normal file
180
src/lib/components/SearchLoader.svelte
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
type Scope = 'local' | 'web';
|
||||||
|
type Size = 'sm' | 'md';
|
||||||
|
let { scope = 'local', size = 'md' }: { scope?: Scope; size?: Size } = $props();
|
||||||
|
|
||||||
|
const LOCAL_MESSAGES = [
|
||||||
|
'Stöbere im Rezeptbuch …',
|
||||||
|
'Schaue unter den Topfdeckeln …',
|
||||||
|
'Krame in den Gewürzregalen …',
|
||||||
|
'Durchsuche Omas Geheimrezepte …'
|
||||||
|
];
|
||||||
|
const WEB_MESSAGES = [
|
||||||
|
'Schnuppere in fremden Küchen …',
|
||||||
|
'Befrage Chefkoch, Emmi und Co. …',
|
||||||
|
'Durchforste die Kochblog-Gassen …',
|
||||||
|
'Klopfe an Internet-Kochtöpfe …'
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMOJIS = ['🍳', '🥘', '🍲', '🍜', '🥣'];
|
||||||
|
|
||||||
|
const messages = $derived(scope === 'web' ? WEB_MESSAGES : LOCAL_MESSAGES);
|
||||||
|
let msgIdx = $state(0);
|
||||||
|
let emojiIdx = $state(0);
|
||||||
|
|
||||||
|
let msgTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let emojiTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
msgTimer = setInterval(() => {
|
||||||
|
msgIdx = (msgIdx + 1) % messages.length;
|
||||||
|
}, 1800);
|
||||||
|
emojiTimer = setInterval(() => {
|
||||||
|
emojiIdx = (emojiIdx + 1) % EMOJIS.length;
|
||||||
|
}, 900);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (msgTimer) clearInterval(msgTimer);
|
||||||
|
if (emojiTimer) clearInterval(emojiTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="loader" class:sm={size === 'sm'}>
|
||||||
|
<div class="pot-wrap" aria-hidden="true">
|
||||||
|
<span class="steam s1">·</span>
|
||||||
|
<span class="steam s2">·</span>
|
||||||
|
<span class="steam s3">·</span>
|
||||||
|
<span class="pot">{EMOJIS[emojiIdx]}</span>
|
||||||
|
</div>
|
||||||
|
<p class="caption" aria-live="polite">{messages[msgIdx]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.75rem 0;
|
||||||
|
}
|
||||||
|
.loader.sm {
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.pot-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
.loader.sm .pot-wrap {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
.pot {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transform-origin: 50% 85%;
|
||||||
|
animation: wobble 1.4s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.loader.sm .pot {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
.steam {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #8fb097;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 55%;
|
||||||
|
opacity: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.s1 {
|
||||||
|
left: 22%;
|
||||||
|
animation: rise 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
.s2 {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
animation: rise 2.4s ease-out infinite 0.6s;
|
||||||
|
}
|
||||||
|
.s3 {
|
||||||
|
left: 72%;
|
||||||
|
animation: rise 2.4s ease-out infinite 1.2s;
|
||||||
|
}
|
||||||
|
@keyframes wobble {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%) rotate(-7deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(-50%) rotate(7deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 0) scale(0.6);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -34px) scale(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.s1,
|
||||||
|
.s3 {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.s1 {
|
||||||
|
animation-name: rise-left;
|
||||||
|
}
|
||||||
|
.s3 {
|
||||||
|
animation-name: rise-right;
|
||||||
|
}
|
||||||
|
@keyframes rise-left {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, 0) scale(0.6);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-8px, -34px) scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes rise-right {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, 0) scale(0.6);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(8px, -34px) scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.caption {
|
||||||
|
color: #6a7670;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 1.3em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.loader.sm .caption {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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: 999px;
|
||||||
|
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>
|
||||||
110
src/lib/components/UpdateToast.svelte
Normal file
110
src/lib/components/UpdateToast.svelte
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { RefreshCw, X } from 'lucide-svelte';
|
||||||
|
import { pwaStore } from '$lib/client/pwa.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pwaStore.updateAvailable}
|
||||||
|
<div class="toast" role="status" aria-live="polite">
|
||||||
|
<span class="msg">Neue Kochwas-Version verfügbar</span>
|
||||||
|
<button class="reload" onclick={() => pwaStore.reload()}>
|
||||||
|
<RefreshCw size={16} strokeWidth={2.2} />
|
||||||
|
<span>Neu laden</span>
|
||||||
|
</button>
|
||||||
|
<button class="dismiss" aria-label="Später" onclick={() => pwaStore.dismiss()}>
|
||||||
|
<X size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.85rem 0.6rem 1.1rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 500;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
animation: slide-up 0.3s ease-out;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, 130%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.reload {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.reload:hover {
|
||||||
|
background: #235532;
|
||||||
|
}
|
||||||
|
.dismiss {
|
||||||
|
background: transparent;
|
||||||
|
color: #aaa;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dismiss:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.toast {
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
transform: none;
|
||||||
|
max-width: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
flex: 1;
|
||||||
|
white-space: normal;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(130%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
157
src/lib/quotes.ts
Normal file
157
src/lib/quotes.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
export const QUOTES: readonly string[] = [
|
||||||
|
'Weil Pizza bestellen auch keine Lösung ist.',
|
||||||
|
'Kochen für Menschen, die eigentlich lieber essen würden.',
|
||||||
|
'Rezepte, bei denen sogar der Rauchmelder mitsingt.',
|
||||||
|
'Endlich Schluss mit „Was koch ich heute?"-Depressionen.',
|
||||||
|
'Für alle, die ihre Pfanne schon beim Namen nennen.',
|
||||||
|
'Weil Mama nicht immer ans Telefon geht.',
|
||||||
|
'Kochen ohne Tränen (Zwiebeln ausgenommen).',
|
||||||
|
'Rezepte, die sogar dein Ex hinkriegen würde.',
|
||||||
|
'Hier wird gekocht, nicht diskutiert.',
|
||||||
|
'Gut genug für Instagram, ehrlich genug für dich.',
|
||||||
|
'Weil Tiefkühlpizza auch nur Teig mit Problemen ist.',
|
||||||
|
'Rezepte für Erwachsene, die sich nicht so fühlen.',
|
||||||
|
'Kochen ist wie Liebe – man sollte es nicht halbherzig tun.',
|
||||||
|
'Für Menschen mit Hunger und wenig Geduld.',
|
||||||
|
'Das Kochbuch deiner Oma, nur ohne Augenrollen.',
|
||||||
|
'Weil „Toast mit Käse" kein Abendessen ist. Oder doch.',
|
||||||
|
'Rezepte, die halten, was dein Magen verspricht.',
|
||||||
|
"Hier gibt's Butter. Viel Butter.",
|
||||||
|
'Küchenchaos mit Anleitung.',
|
||||||
|
'Weil Lieferando deine Adresse schon auswendig kann.',
|
||||||
|
'Kochen für Profis, Anfänger und Katastrophen.',
|
||||||
|
'Das einzige Rezept-Buch, das nicht beleidigt ist, wenn du blätterst.',
|
||||||
|
'Für alle, die „al dente" endlich mal richtig aussprechen wollen.',
|
||||||
|
'Rezepte ohne 4.000 Wörter Einleitung über Omas Garten.',
|
||||||
|
'Heute kochen, morgen angeben.',
|
||||||
|
'Weil Hunger ein schlechter Lebenslauf ist.',
|
||||||
|
'Essen wie bei Muttern, nur ohne Nachfragen.',
|
||||||
|
'Rezepte, die deinen Kühlschrank endlich ernst nehmen.',
|
||||||
|
'Für Hobbyköche und Hoffnungsvolle.',
|
||||||
|
'Nicht perfekt. Aber lecker.',
|
||||||
|
'Die Küche ruft. Nimm ab.',
|
||||||
|
'Kochen ist günstiger als Therapie. Meistens.',
|
||||||
|
'Rezepte für das Chaos, das sich Alltag nennt.',
|
||||||
|
'Weil Wasser kochen allein nicht reicht.',
|
||||||
|
'Damit dein Dinner-Date nicht zum Escape-Room wird.',
|
||||||
|
'Essen, das besser schmeckt als es aussieht. Und besser aussieht als gedacht.',
|
||||||
|
'Kochbuch war gestern. Heute ist Browser.',
|
||||||
|
'Für Menschen, die Salz für eine Persönlichkeit halten.',
|
||||||
|
'Weil deine Mikrowelle auch mal Urlaub braucht.',
|
||||||
|
'Hier werden Träume wahr. Und Teller leer.',
|
||||||
|
'Weil guter Geschmack kein Zufall sein sollte.',
|
||||||
|
'Kochen für Leute, deren Rauchmelder zu sensibel ist.',
|
||||||
|
'Das Beste, was deiner Küche seit der Spülmaschine passiert ist.',
|
||||||
|
'Rezepte ohne „Eine Prise Liebe"-Quatsch.',
|
||||||
|
'Für Abende, an denen Netflix nicht reicht.',
|
||||||
|
'Weniger Bestellapps, mehr Bestellerrezepte.',
|
||||||
|
'Weil Essen eine Sprache ist, die jeder versteht.',
|
||||||
|
'Für die, die googeln, ob man Wasser anbrennen lassen kann.',
|
||||||
|
'Rezepte, die sogar dein WG-Mitbewohner nicht klaut. Okay, vielleicht doch.',
|
||||||
|
'Kochen. Essen. Wiederholen.',
|
||||||
|
'Weil Nudeln-mit-Pesto kein Lebensmodell ist.',
|
||||||
|
'Rezepte, an die sich selbst die Pfanne erinnert.',
|
||||||
|
'Mehr Kochen, weniger Ratlosigkeit um 18 Uhr.',
|
||||||
|
'Endlich Abendessen ohne Hintergedanken.',
|
||||||
|
'Rezepte, die dein Gemüsefach endlich rechtfertigen.',
|
||||||
|
'Weil „Irgendwas mit Reis" keine Antwort ist.',
|
||||||
|
'Für alle, die das Salz bisher nur falsch dosiert haben.',
|
||||||
|
'Das kulinarische Äquivalent zu einer Umarmung.',
|
||||||
|
'Weil Kochen die einzige Show ist, bei der du Hauptrolle spielst.',
|
||||||
|
'Abendessen, das sich nicht entschuldigen muss.',
|
||||||
|
'Für Tage, an denen sogar Tiefkühlpizza aufgibt.',
|
||||||
|
'Rezepte, die deinen Rauchmelder schonen.',
|
||||||
|
'Weil „Da war doch noch was im Kühlschrank" kein Plan ist.',
|
||||||
|
'Kochen für Menschen, deren Fantasie im Supermarkt endet.',
|
||||||
|
'Für alle, die „Prise" schon mal gegoogelt haben.',
|
||||||
|
'Rezepte, die dein Sonntagabend-Ich dir danken wird.',
|
||||||
|
'Weil jede gute Küche mit einem „Ups" anfängt.',
|
||||||
|
'Für die, die ihren Kochlöffel lieber als die Kollegen mögen.',
|
||||||
|
'Kochen ist das neue Meditieren. Aber mit Geräuschen.',
|
||||||
|
'Rezepte, die halten, auch wenn du mal nicht.',
|
||||||
|
'Für alle, die „zart-schmelzend" als Lebensziel ansehen.',
|
||||||
|
'Abendessen mit Charakter. Manchmal auch Charakterkrise.',
|
||||||
|
'Weil Essen zubereiten billiger ist als Therapie-Stunden.',
|
||||||
|
'Rezepte für Menschen mit hohen Erwartungen und kleiner Pfanne.',
|
||||||
|
'Für Momente, in denen der Hunger größer ist als die Geduld.',
|
||||||
|
'Koch-Erinnerungen, ohne Oma anzurufen.',
|
||||||
|
'Weil nichts so verbindet wie ein geteilter Löffel.',
|
||||||
|
'Rezepte, bei denen der Käse nicht fragt, ob er darf.',
|
||||||
|
'Für alle, die „kurz ins Kochbuch schauen" für drei Stunden halten.',
|
||||||
|
'Essen, das dich nicht bei Instagram bloßstellt.',
|
||||||
|
'Rezepte ohne „Zuerst das Chaos sortieren"-Schritt.',
|
||||||
|
'Weil jedes gute Essen eine kleine Rebellion ist.',
|
||||||
|
'Für die, die Kochen als Sport zählen.',
|
||||||
|
'Abends kochen ist günstiger als Achtsamkeitskurse.',
|
||||||
|
'Rezepte, die dein Kaufhaus-Kochbuch alt aussehen lassen.',
|
||||||
|
'Für alle, die „Ich kann nicht kochen" als Feature, nicht Bug nutzen.',
|
||||||
|
'Weil Butter manchmal die Antwort ist. Und manchmal die Frage.',
|
||||||
|
'Hunger. Hinweise. Happy End.',
|
||||||
|
'Rezepte für Leute, die ihren Kaffee auch ernst nehmen.',
|
||||||
|
'Weil Kochen ein gutes Gespräch ersetzt. Manchmal.',
|
||||||
|
'Abendessen ohne Ausrede.',
|
||||||
|
'Rezepte, die der Küchenuhr einen Grund geben.',
|
||||||
|
'Für alle, die „Salz und Pfeffer nach Geschmack" als Lebensweisheit sehen.',
|
||||||
|
'Kochen gegen die Uhr, gewinnen gegen den Kühlschrank.',
|
||||||
|
'Rezepte, die sogar das Spülbecken beeindrucken.',
|
||||||
|
'Weil „Was gibt\'s?" eine Freundschaftsfrage ist.',
|
||||||
|
'Für Tage, an denen alles gelingt – außer Google Maps.',
|
||||||
|
'Essen, das dich wieder zum Esser macht.',
|
||||||
|
'Rezepte, die in weniger Zeit klappen als ein Staffelfinale.',
|
||||||
|
'Weil dein Magen kein Demokrat ist.',
|
||||||
|
'Kochen ist, was passiert, während du andere Pläne machst.',
|
||||||
|
'Rezepte für die Küche, nicht für die Galerie.',
|
||||||
|
'Für alle, die beim Würzen Gefühle haben.',
|
||||||
|
'Weil jeder Topf mal sein Abenteuer braucht.',
|
||||||
|
'Rezepte, die auch bei Regen funktionieren.',
|
||||||
|
'Abendessen ohne Nachspielzeit.',
|
||||||
|
'Für die, die „Zutaten nach Augenmaß" als Lifestyle führen.',
|
||||||
|
'Kochen: die einzige App, die wirklich offline läuft.',
|
||||||
|
'Rezepte, die dein Besteck wieder in Bewegung bringen.',
|
||||||
|
'Für Menschen mit Küche, aber ohne Plan.',
|
||||||
|
'Weil Lorbeer kein Zufall ist.',
|
||||||
|
'Rezepte, die auch deine Nachbarn hören lassen.',
|
||||||
|
'Für alle, die beim Schnippeln Podcasts brauchen.',
|
||||||
|
'Abendessen, bei dem sich der Kühlschrank freut.',
|
||||||
|
'Rezepte, die deine Pfanne streicheln.',
|
||||||
|
'Weil Essen ohne Geschichte nur Kalorien ist.',
|
||||||
|
'Für Menschen, die ihre Kochschürze mit Stolz tragen.',
|
||||||
|
'Rezepte für den inneren Gourmet und den äußeren Alltag.',
|
||||||
|
'Weil jeder Abend einen guten Duft verdient hat.',
|
||||||
|
'Für alle, die Lieferheld auswendig können, aber nicht mehr wollen.',
|
||||||
|
'Kochen ist Sport für Menschen, die gerne sitzen.',
|
||||||
|
'Rezepte, bei denen dein Teller dich anlacht.',
|
||||||
|
'Für Tage, an denen nur Butter versteht.',
|
||||||
|
'Weil Pasta keine Jahreszeit kennt.',
|
||||||
|
'Rezepte, die dein „Kann nicht kochen"-Etikett abkratzen.',
|
||||||
|
'Abendessen für Optimisten und Realisten.',
|
||||||
|
'Für alle, die „kurz umrühren" als Kardio zählen.',
|
||||||
|
'Weil jede gute Mahlzeit mit „Kann ich helfen?" anfängt.',
|
||||||
|
'Rezepte, die dein Bauchgefühl bestätigen.',
|
||||||
|
'Für Küchen mit Charakter und Besitzer mit Hunger.',
|
||||||
|
'Kochen ist wie Atmen, nur mit Soße.',
|
||||||
|
'Rezepte, die keine Ausreden akzeptieren.',
|
||||||
|
'Für alle, die „al forno" schon fast richtig sprechen.',
|
||||||
|
'Weil Soße die Antwort auf fast jede Frage ist.',
|
||||||
|
'Abendessen ohne Drama. Außer beim Zwiebelschneiden.',
|
||||||
|
'Rezepte für den Herd und fürs Herz.',
|
||||||
|
'Für die, die „nur eine Kleinigkeit" mit drei Gängen übersetzen.',
|
||||||
|
'Weil Kochen der kürzeste Weg zu „Kannst du nochmal?" ist.',
|
||||||
|
'Rezepte, die auch dein Nachbar riechen darf.',
|
||||||
|
'Für alle, die Käse als Bindfaden der Freundschaft sehen.',
|
||||||
|
'Kochen schlägt Scrollen. Meistens.',
|
||||||
|
'Rezepte, die dein Küchentuch endlich rehabilitieren.',
|
||||||
|
'Für Menschen mit wenig Zeit und viel Hunger.',
|
||||||
|
'Weil Olivenöl zwar kein Grundnahrungsmittel ist, aber fast.',
|
||||||
|
'Rezepte, bei denen deine Waage nicht mitredet.',
|
||||||
|
'Abendessen ohne Kompromiss.',
|
||||||
|
'Für alle, die beim Kochen tanzen und beim Tanzen kochen.',
|
||||||
|
'Rezepte für den Alltag, die nicht nach Alltag schmecken.',
|
||||||
|
'Weil jede gute Mahlzeit einen Moment der Stille verdient.',
|
||||||
|
'Kochen: alte Tradition, neue Ergebnisse.',
|
||||||
|
'Weil „Ich hole nur Wasser" nie bei nur Wasser bleibt.'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function randomQuote(): string {
|
||||||
|
return QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
||||||
|
}
|
||||||
10
src/lib/server/db/migrations/003_thumbnail_cache.sql
Normal file
10
src/lib/server/db/migrations/003_thumbnail_cache.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Long-term cache for page → image URL mappings extracted via og:image,
|
||||||
|
-- JSON-LD, or first content <img>. Fetching every recipe page on every
|
||||||
|
-- search is expensive; store the mapping with a 30-day default TTL.
|
||||||
|
CREATE TABLE thumbnail_cache (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
image TEXT, -- NULL = page has no image (cache the negative too)
|
||||||
|
expires_at TEXT NOT NULL -- ISO-8601 UTC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_thumbnail_cache_expires ON thumbnail_cache(expires_at);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Let the user dismiss individual recipes from the "Zuletzt hinzugefügt"
|
||||||
|
-- list on the homepage. The recipe itself stays searchable and fully
|
||||||
|
-- functional — only its appearance in the "recent" list is suppressed.
|
||||||
|
ALTER TABLE recipe ADD COLUMN hidden_from_recent INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX idx_recipe_hidden_from_recent ON recipe(hidden_from_recent, created_at);
|
||||||
29
src/lib/server/db/migrations/005_wishlist_per_user.sql
Normal file
29
src/lib/server/db/migrations/005_wishlist_per_user.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Wishlist: from "one entry per recipe" to "per-user membership".
|
||||||
|
-- Multiple profiles can now wish for the same recipe. The old wishlist_like
|
||||||
|
-- table merges into this — liking WAS already "me too", so existing likes
|
||||||
|
-- become wishlist memberships.
|
||||||
|
|
||||||
|
CREATE TABLE wishlist_new (
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
|
||||||
|
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (recipe_id, profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Preserve existing explicit additions (only if a profile was attached)
|
||||||
|
INSERT OR IGNORE INTO wishlist_new (recipe_id, profile_id, added_at)
|
||||||
|
SELECT recipe_id, added_by_profile_id, added_at
|
||||||
|
FROM wishlist
|
||||||
|
WHERE added_by_profile_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Likes become memberships
|
||||||
|
INSERT OR IGNORE INTO wishlist_new (recipe_id, profile_id, added_at)
|
||||||
|
SELECT recipe_id, profile_id, created_at
|
||||||
|
FROM wishlist_like;
|
||||||
|
|
||||||
|
DROP TABLE wishlist_like;
|
||||||
|
DROP TABLE wishlist;
|
||||||
|
ALTER TABLE wishlist_new RENAME TO wishlist;
|
||||||
|
|
||||||
|
CREATE INDEX idx_wishlist_profile ON wishlist(profile_id);
|
||||||
|
CREATE INDEX idx_wishlist_recipe ON wishlist(recipe_id);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Frühere Versionen haben '🍳' als Default im "Neues Profil"-Emoji-Feld
|
||||||
|
-- vorausgefüllt — die meisten User haben das einfach so stehen lassen,
|
||||||
|
-- ohne bewusst ein Emoji zu wählen. Ergebnis: alle Profile sehen gleich aus.
|
||||||
|
-- Wir räumen das auf: alle avatar_emoji='🍳'-Einträge werden zu NULL,
|
||||||
|
-- was die UI als "kein Emoji, Lucide-Icon nehmen" interpretiert.
|
||||||
|
UPDATE profile SET avatar_emoji = NULL WHERE avatar_emoji = '🍳';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Erweitert thumbnail_cache um ein has_recipe-Flag. Beim Thumbnail-
|
||||||
|
-- Enrichment checken wir, ob die Seite überhaupt ein schema.org/Recipe
|
||||||
|
-- JSON-LD enthält — sonst kann der Importer das Rezept später sowieso
|
||||||
|
-- nicht extrahieren, und der User sieht nur die „Diese Seite enthält
|
||||||
|
-- kein Rezept"-Fehlermeldung.
|
||||||
|
--
|
||||||
|
-- NULL = unbekannt (vor dieser Migration gecached oder Fetch schlug fehl,
|
||||||
|
-- dann behalten wir den Treffer konservativ);
|
||||||
|
-- 0 = gesicherter Nicht-Treffer (ausblenden);
|
||||||
|
-- 1 = Rezept vorhanden.
|
||||||
|
ALTER TABLE thumbnail_cache ADD COLUMN has_recipe INTEGER;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Bei Migration 007 war `allowTruncate` in fetchText noch nicht implementiert,
|
||||||
|
-- weshalb Seiten >512 KB einen Fehler warfen und hasRecipe als NULL (unbekannt)
|
||||||
|
-- gespeichert wurde. Diese Einträge würden weitere 30 Tage nicht revalidiert
|
||||||
|
-- und Treffer ohne schema.org/Recipe-Markup fälschlich durchlassen. Wir
|
||||||
|
-- räumen sie jetzt einmalig ab, damit sie beim nächsten Fetch korrekt
|
||||||
|
-- klassifiziert werden. Ein reines Cache-Flush, keine User-Daten betroffen.
|
||||||
|
DELETE FROM thumbnail_cache WHERE has_recipe IS NULL;
|
||||||
5
src/lib/server/db/migrations/009_domain_favicon.sql
Normal file
5
src/lib/server/db/migrations/009_domain_favicon.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Speichert das Favicon-Dateiname für jede Whitelist-Domain, damit die
|
||||||
|
-- UI (Filter-Dropdown, Karten) das Site-Icon neben dem Domain-Namen
|
||||||
|
-- anzeigen kann. NULL = noch nicht geladen; wird beim nächsten GET
|
||||||
|
-- /api/domains automatisch nachgezogen.
|
||||||
|
ALTER TABLE allowed_domain ADD COLUMN favicon_path TEXT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Der Recipe-Detektor prüft ab jetzt zusätzlich zu JSON-LD auch Microdata
|
||||||
|
-- (itemtype=schema.org/Recipe). Der Cache kann has_recipe=0-Einträge
|
||||||
|
-- enthalten, die mit dem alten Check falsch-negativ waren (z.B. rezeptwelt.de,
|
||||||
|
-- das Microdata statt JSON-LD nutzt). Einmalig wegräumen, damit die Seiten
|
||||||
|
-- beim nächsten Search neu klassifiziert werden. Reiner Cache-Flush.
|
||||||
|
DELETE FROM thumbnail_cache WHERE has_recipe = 0;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Der Favicon-Fetcher versucht ab jetzt zuerst die <link rel="icon">-Tags
|
||||||
|
-- aus der Homepage, weil WordPress-Seiten (z.B. Emmi kocht einfach) unter
|
||||||
|
-- /favicon.ico ein generisches Zahnrad-Default des Hosters ausliefern und
|
||||||
|
-- das eigentliche Site-Icon erst im <head> auftaucht. Einmalig alle
|
||||||
|
-- gespeicherten Favicon-Pfade zurücksetzen, damit sie mit der neuen
|
||||||
|
-- Heuristik neu geladen werden. Alte Dateien bleiben als Orphans im
|
||||||
|
-- IMAGE_DIR, sind aber harmlos.
|
||||||
|
UPDATE allowed_domain SET favicon_path = NULL;
|
||||||
166
src/lib/server/domains/favicons.ts
Normal file
166
src/lib/server/domains/favicons.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fetchBuffer, fetchText } from '../http';
|
||||||
|
import { listDomains, setDomainFavicon } from './repository';
|
||||||
|
|
||||||
|
const EXT_BY_CONTENT_TYPE: Record<string, string> = {
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico'
|
||||||
|
};
|
||||||
|
|
||||||
|
function extensionFor(contentType: string | null): string {
|
||||||
|
if (!contentType) return '.ico';
|
||||||
|
const base = contentType.split(';')[0].trim().toLowerCase();
|
||||||
|
return EXT_BY_CONTENT_TYPE[base] ?? '.ico';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryFetch(url: string): Promise<{ data: Uint8Array; contentType: string | null } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetchBuffer(url, { timeoutMs: 3_000, maxBytes: 256 * 1024 });
|
||||||
|
if (res.data.byteLength === 0) return null;
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parst <link rel="…icon">-Tags aus dem <head>. WordPress-Seiten liefern
|
||||||
|
// oft ein generisches /favicon.ico (Zahnrad-Default vom Hoster oder Plugin),
|
||||||
|
// während das eigentliche Site-Icon per <link rel="icon"> eingebunden ist.
|
||||||
|
// Darum zuerst den Head durchsehen, nicht blind /favicon.ico nehmen.
|
||||||
|
type IconLink = { href: string; size: number; isApple: boolean };
|
||||||
|
|
||||||
|
function extractIconLinks(html: string, baseUrl: string): IconLink[] {
|
||||||
|
const head = html.slice(0, 300_000);
|
||||||
|
const icons: IconLink[] = [];
|
||||||
|
const linkRe = /<link\b[^>]*>/gi;
|
||||||
|
for (const m of head.matchAll(linkRe)) {
|
||||||
|
const tag = m[0];
|
||||||
|
const relMatch = tag.match(/\brel\s*=\s*["']([^"']+)["']/i);
|
||||||
|
if (!relMatch) continue;
|
||||||
|
const rel = relMatch[1].toLowerCase();
|
||||||
|
const isApple = rel.includes('apple-touch-icon');
|
||||||
|
if (!isApple && !/\b(shortcut\s+icon|icon)\b/.test(rel)) continue;
|
||||||
|
const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i);
|
||||||
|
if (!hrefMatch) continue;
|
||||||
|
const raw = hrefMatch[1].trim();
|
||||||
|
if (!raw || raw.startsWith('data:')) continue;
|
||||||
|
let href: string;
|
||||||
|
try {
|
||||||
|
href = new URL(raw, baseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = 0;
|
||||||
|
const sizesMatch = tag.match(/\bsizes\s*=\s*["']([^"']+)["']/i);
|
||||||
|
if (sizesMatch) {
|
||||||
|
const sm = sizesMatch[1].match(/(\d+)\s*x\s*\d+/i);
|
||||||
|
if (sm) size = Number(sm[1]);
|
||||||
|
}
|
||||||
|
if (!size && isApple) size = 180;
|
||||||
|
icons.push({ href, size, isApple });
|
||||||
|
}
|
||||||
|
return icons;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holt Icon-Kandidaten per HTML-Parse. 32–192 px bevorzugt (für 24×24-Darstellung
|
||||||
|
// ist das sharp genug, ohne SVG-Wahnsinn); alles außerhalb landet am Ende.
|
||||||
|
async function resolveIconsFromHtml(domain: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const baseUrl = `https://${domain}/`;
|
||||||
|
const html = await fetchText(baseUrl, {
|
||||||
|
timeoutMs: 3_500,
|
||||||
|
maxBytes: 256 * 1024,
|
||||||
|
allowTruncate: true
|
||||||
|
});
|
||||||
|
const icons = extractIconLinks(html, baseUrl);
|
||||||
|
if (icons.length === 0) return [];
|
||||||
|
const sweet = (s: number) => s >= 32 && s <= 192;
|
||||||
|
icons.sort((a, b) => {
|
||||||
|
if (sweet(a.size) && !sweet(b.size)) return -1;
|
||||||
|
if (!sweet(a.size) && sweet(b.size)) return 1;
|
||||||
|
return b.size - a.size;
|
||||||
|
});
|
||||||
|
return icons.map((i) => i.href);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFaviconBytes(
|
||||||
|
domain: string
|
||||||
|
): Promise<{ data: Uint8Array; contentType: string | null } | null> {
|
||||||
|
// 1. Aus der Homepage die <link rel="icon">-Kandidaten ziehen — das
|
||||||
|
// ist normalerweise das "echte" Site-Icon, nicht der Hoster-Default.
|
||||||
|
const htmlIcons = await resolveIconsFromHtml(domain);
|
||||||
|
for (const url of htmlIcons) {
|
||||||
|
const got = await tryFetch(url);
|
||||||
|
if (got) return got;
|
||||||
|
}
|
||||||
|
// 2. Klassiker: /favicon.ico. Viele ältere Seiten haben nur den.
|
||||||
|
const direct = await tryFetch(`https://${domain}/favicon.ico`);
|
||||||
|
if (direct) return direct;
|
||||||
|
// 3. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas.
|
||||||
|
return tryFetch(`https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(
|
||||||
|
data: Uint8Array,
|
||||||
|
contentType: string | null,
|
||||||
|
imageDir: string
|
||||||
|
): Promise<string> {
|
||||||
|
const hash = createHash('sha256').update(data).digest('hex');
|
||||||
|
const ext = extensionFor(contentType);
|
||||||
|
const filename = `favicon-${hash}${ext}`;
|
||||||
|
const target = join(imageDir, filename);
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
await mkdir(imageDir, { recursive: true });
|
||||||
|
await writeFile(target, data);
|
||||||
|
}
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAndStoreFavicon(
|
||||||
|
domain: string,
|
||||||
|
imageDir: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const result = await fetchFaviconBytes(domain);
|
||||||
|
if (!result) return null;
|
||||||
|
try {
|
||||||
|
return await persist(result.data, result.contentType, imageDir);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lädt Favicons für alle Whitelist-Domains, bei denen noch keines gespeichert
|
||||||
|
// ist. Parallel mit Limit 8. Bleibt bewusst sync vom Aufrufer aus gesehen,
|
||||||
|
// damit der erste GET /api/domains eine vollständige Liste zurückgibt.
|
||||||
|
// Beim zweiten Request ist nichts mehr zu tun.
|
||||||
|
export async function ensureFavicons(
|
||||||
|
db: Database.Database,
|
||||||
|
imageDir: string
|
||||||
|
): Promise<void> {
|
||||||
|
const domains = listDomains(db).filter((d) => !d.favicon_path);
|
||||||
|
if (domains.length === 0) return;
|
||||||
|
const queue = [...domains];
|
||||||
|
const LIMIT = 8;
|
||||||
|
const workers = Array.from({ length: Math.min(LIMIT, queue.length) }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const d = queue.shift();
|
||||||
|
if (!d) break;
|
||||||
|
const path = await fetchAndStoreFavicon(d.domain, imageDir);
|
||||||
|
if (path) setDomainFavicon(db, d.id, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ export function normalizeDomain(raw: string): string {
|
|||||||
|
|
||||||
export function listDomains(db: Database.Database): AllowedDomain[] {
|
export function listDomains(db: Database.Database): AllowedDomain[] {
|
||||||
return db
|
return db
|
||||||
.prepare('SELECT id, domain, display_name FROM allowed_domain ORDER BY domain')
|
.prepare(
|
||||||
|
'SELECT id, domain, display_name, favicon_path FROM allowed_domain ORDER BY domain'
|
||||||
|
)
|
||||||
.all() as AllowedDomain[];
|
.all() as AllowedDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ export function addDomain(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO allowed_domain(domain, display_name, added_by_profile_id)
|
`INSERT INTO allowed_domain(domain, display_name, added_by_profile_id)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
RETURNING id, domain, display_name`
|
RETURNING id, domain, display_name, favicon_path`
|
||||||
)
|
)
|
||||||
.get(normalized, displayName, addedByProfileId) as AllowedDomain;
|
.get(normalized, displayName, addedByProfileId) as AllowedDomain;
|
||||||
return row;
|
return row;
|
||||||
@@ -31,3 +33,46 @@ export function addDomain(
|
|||||||
export function removeDomain(db: Database.Database, id: number): void {
|
export function removeDomain(db: Database.Database, id: number): void {
|
||||||
db.prepare('DELETE FROM allowed_domain WHERE id = ?').run(id);
|
db.prepare('DELETE FROM allowed_domain WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setDomainFavicon(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number,
|
||||||
|
faviconPath: string | null
|
||||||
|
): void {
|
||||||
|
db.prepare('UPDATE allowed_domain SET favicon_path = ? WHERE id = ?').run(
|
||||||
|
faviconPath,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDomainById(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number
|
||||||
|
): AllowedDomain | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, domain, display_name, favicon_path FROM allowed_domain WHERE id = ?'
|
||||||
|
)
|
||||||
|
.get(id) as AllowedDomain | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDomain(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number,
|
||||||
|
patch: { domain?: string; display_name?: string | null }
|
||||||
|
): AllowedDomain | null {
|
||||||
|
const current = getDomainById(db, id);
|
||||||
|
if (!current) return null;
|
||||||
|
const nextDomain =
|
||||||
|
patch.domain !== undefined ? normalizeDomain(patch.domain) : current.domain;
|
||||||
|
const nextLabel =
|
||||||
|
patch.display_name !== undefined ? patch.display_name : current.display_name;
|
||||||
|
// Wenn sich die Domain ändert: favicon_path zurücksetzen, damit der Caller
|
||||||
|
// es neu laden kann. Sonst zeigen wir fälschlich das alte Icon.
|
||||||
|
const nextFavicon = nextDomain !== current.domain ? null : current.favicon_path;
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE allowed_domain SET domain = ?, display_name = ?, favicon_path = ? WHERE id = ?'
|
||||||
|
).run(nextDomain, nextLabel, nextFavicon, id);
|
||||||
|
return getDomainById(db, id);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type Database from 'better-sqlite3';
|
|
||||||
import { normalizeDomain } from './repository';
|
|
||||||
|
|
||||||
export function isDomainAllowed(db: Database.Database, urlString: string): boolean {
|
|
||||||
let host: string;
|
|
||||||
try {
|
|
||||||
host = new URL(urlString).hostname;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const normalized = normalizeDomain(host);
|
|
||||||
const row = db
|
|
||||||
.prepare('SELECT 1 AS ok FROM allowed_domain WHERE domain = ? LIMIT 1')
|
|
||||||
.get(normalized);
|
|
||||||
return row !== undefined;
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,8 @@ import { ImporterError } from './recipes/importer';
|
|||||||
export function mapImporterError(e: unknown): never {
|
export function mapImporterError(e: unknown): never {
|
||||||
if (e instanceof ImporterError) {
|
if (e instanceof ImporterError) {
|
||||||
const status =
|
const status =
|
||||||
e.code === 'INVALID_URL' || e.code === 'DOMAIN_BLOCKED'
|
e.code === 'INVALID_URL'
|
||||||
? e.code === 'DOMAIN_BLOCKED'
|
? 400
|
||||||
? 403
|
|
||||||
: 400
|
|
||||||
: e.code === 'NO_RECIPE_FOUND'
|
: e.code === 'NO_RECIPE_FOUND'
|
||||||
? 422
|
? 422
|
||||||
: 502; // FETCH_FAILED
|
: 502; // FETCH_FAILED
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ export type FetchOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
extraHeaders?: Record<string, string>;
|
extraHeaders?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* When true, return the data read up to `maxBytes` instead of throwing.
|
||||||
|
* Useful when we only care about the page head (og:image, JSON-LD) — most
|
||||||
|
* recipe sites are >1 MB today because of inlined bundles, but the head is
|
||||||
|
* usually well under 512 KB.
|
||||||
|
*/
|
||||||
|
allowTruncate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULTS: Required<Omit<FetchOptions, 'extraHeaders'>> = {
|
const DEFAULTS: Required<Omit<FetchOptions, 'extraHeaders' | 'allowTruncate'>> = {
|
||||||
maxBytes: 10 * 1024 * 1024,
|
maxBytes: 10 * 1024 * 1024,
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
userAgent: 'Kochwas/0.1'
|
userAgent: 'Kochwas/0.1'
|
||||||
@@ -25,16 +32,23 @@ function assertSafeUrl(url: string): void {
|
|||||||
|
|
||||||
async function readBody(
|
async function readBody(
|
||||||
response: Response,
|
response: Response,
|
||||||
maxBytes: number
|
maxBytes: number,
|
||||||
): Promise<{ data: Uint8Array; total: number }> {
|
allowTruncate: boolean
|
||||||
|
): Promise<{ data: Uint8Array; total: number; truncated: boolean }> {
|
||||||
const reader = response.body?.getReader();
|
const reader = response.body?.getReader();
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
const buf = new Uint8Array(await response.arrayBuffer());
|
const buf = new Uint8Array(await response.arrayBuffer());
|
||||||
if (buf.byteLength > maxBytes) throw new Error(`Response exceeds ${maxBytes} bytes`);
|
if (buf.byteLength > maxBytes) {
|
||||||
return { data: buf, total: buf.byteLength };
|
if (allowTruncate) {
|
||||||
|
return { data: buf.slice(0, maxBytes), total: maxBytes, truncated: true };
|
||||||
|
}
|
||||||
|
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||||
|
}
|
||||||
|
return { data: buf, total: buf.byteLength, truncated: false };
|
||||||
}
|
}
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
let truncated = false;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
@@ -42,6 +56,14 @@ async function readBody(
|
|||||||
total += value.byteLength;
|
total += value.byteLength;
|
||||||
if (total > maxBytes) {
|
if (total > maxBytes) {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
|
if (allowTruncate) {
|
||||||
|
// keep what we have up to the chunk boundary; good enough for HTML head
|
||||||
|
const keep = value.byteLength - (total - maxBytes);
|
||||||
|
if (keep > 0) chunks.push(value.slice(0, keep));
|
||||||
|
total = maxBytes;
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||||
}
|
}
|
||||||
chunks.push(value);
|
chunks.push(value);
|
||||||
@@ -53,7 +75,7 @@ async function readBody(
|
|||||||
merged.set(c, offset);
|
merged.set(c, offset);
|
||||||
offset += c.byteLength;
|
offset += c.byteLength;
|
||||||
}
|
}
|
||||||
return { data: merged, total };
|
return { data: merged, total, truncated };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
||||||
@@ -82,7 +104,7 @@ async function doFetch(url: string, opts: FetchOptions): Promise<Response> {
|
|||||||
export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string> {
|
export async function fetchText(url: string, opts: FetchOptions = {}): Promise<string> {
|
||||||
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||||
const res = await doFetch(url, opts);
|
const res = await doFetch(url, opts);
|
||||||
const { data } = await readBody(res, maxBytes);
|
const { data } = await readBody(res, maxBytes, opts.allowTruncate ?? false);
|
||||||
return new TextDecoder('utf-8').decode(data);
|
return new TextDecoder('utf-8').decode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +114,6 @@ export async function fetchBuffer(
|
|||||||
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
): Promise<{ data: Uint8Array; contentType: string | null }> {
|
||||||
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
const maxBytes = opts.maxBytes ?? DEFAULTS.maxBytes;
|
||||||
const res = await doFetch(url, opts);
|
const res = await doFetch(url, opts);
|
||||||
const { data } = await readBody(res, maxBytes);
|
const { data } = await readBody(res, maxBytes, opts.allowTruncate ?? false);
|
||||||
return { data, contentType: res.headers.get('content-type') };
|
return { data, contentType: res.headers.get('content-type') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,9 +106,252 @@ function findRecipeNode(html: string): JsonLdNode | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Microdata-Alternative zum JSON-LD: viele SSR-Sites (inkl. rezeptwelt.de)
|
||||||
|
// nutzen <div itemtype="https://schema.org/Recipe"> statt application/ld+json.
|
||||||
|
// Ein einfacher Regex reicht — wir brauchen nur das Flag, nicht die Daten.
|
||||||
|
const MICRODATA_RECIPE = /itemtype\s*=\s*["']https?:\/\/schema\.org\/Recipe["']/i;
|
||||||
|
|
||||||
|
export function hasRecipeMarkup(html: string): boolean {
|
||||||
|
if (MICRODATA_RECIPE.test(html)) return true;
|
||||||
|
try {
|
||||||
|
return findRecipeNode(html) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @deprecated use hasRecipeMarkup
|
||||||
|
export function hasRecipeJsonLd(html: string): boolean {
|
||||||
|
return hasRecipeMarkup(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function microdataValueOf(el: Element): string {
|
||||||
|
if (el.hasAttribute('content')) return (el.getAttribute('content') ?? '').trim();
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (tag === 'meta') return (el.getAttribute('content') ?? '').trim();
|
||||||
|
if (tag === 'a' || tag === 'link' || tag === 'area')
|
||||||
|
return (el.getAttribute('href') ?? '').trim();
|
||||||
|
if (
|
||||||
|
tag === 'img' ||
|
||||||
|
tag === 'source' ||
|
||||||
|
tag === 'video' ||
|
||||||
|
tag === 'audio' ||
|
||||||
|
tag === 'embed' ||
|
||||||
|
tag === 'iframe' ||
|
||||||
|
tag === 'track'
|
||||||
|
)
|
||||||
|
return (el.getAttribute('src') ?? '').trim();
|
||||||
|
if (tag === 'object') return (el.getAttribute('data') ?? '').trim();
|
||||||
|
if (tag === 'data' || tag === 'meter')
|
||||||
|
return (el.getAttribute('value') ?? '').trim();
|
||||||
|
if (tag === 'time')
|
||||||
|
return (el.getAttribute('datetime') ?? el.textContent ?? '').trim();
|
||||||
|
return (el.textContent ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type MicroProps = Map<string, Element[]>;
|
||||||
|
|
||||||
|
function gatherMicrodataProps(scope: Element): MicroProps {
|
||||||
|
// Alle itemprop-Descendants sammeln, dabei aber nicht in verschachtelte
|
||||||
|
// itemscopes einsteigen (sonst landen z.B. HowToStep.text im Haupt-Scope).
|
||||||
|
const map: MicroProps = new Map();
|
||||||
|
function walk(el: Element) {
|
||||||
|
for (const child of Array.from(el.children) as Element[]) {
|
||||||
|
const hasProp = child.hasAttribute('itemprop');
|
||||||
|
const hasScope = child.hasAttribute('itemscope');
|
||||||
|
if (hasProp) {
|
||||||
|
const names = (child.getAttribute('itemprop') ?? '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const name of names) {
|
||||||
|
const arr = map.get(name) ?? [];
|
||||||
|
arr.push(child);
|
||||||
|
map.set(name, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasScope) walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(scope);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function microText(map: MicroProps, name: string): string | null {
|
||||||
|
const els = map.get(name);
|
||||||
|
if (!els || els.length === 0) return null;
|
||||||
|
const v = microdataValueOf(els[0]);
|
||||||
|
return v || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function microAllTexts(map: MicroProps, name: string): string[] {
|
||||||
|
const els = map.get(name) ?? [];
|
||||||
|
return els.map(microdataValueOf).filter((v) => v !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rausholen von Text mit erhaltenen Zeilenumbrüchen — <br> → \n, Block-
|
||||||
|
// Elemente (<p>, <li> …) bekommen ebenfalls Newline-Grenzen. <img>, <script>,
|
||||||
|
// <style> werden komplett übersprungen, damit alt-Attribute und andere
|
||||||
|
// Nicht-Text-Content nicht in den Rezepttext bluten.
|
||||||
|
function textWithLineBreaks(el: Element): string {
|
||||||
|
const BLOCK = new Set(['p', 'div', 'li', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'tr']);
|
||||||
|
const SKIP = new Set(['script', 'style', 'img', 'noscript']);
|
||||||
|
let out = '';
|
||||||
|
const walk = (node: Node): void => {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
out += node.nodeValue ?? '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
const e = node as Element;
|
||||||
|
const tag = e.tagName.toLowerCase();
|
||||||
|
if (SKIP.has(tag)) return;
|
||||||
|
const block = BLOCK.has(tag);
|
||||||
|
if (tag === 'br') {
|
||||||
|
out += '\n';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (block && out && !out.endsWith('\n')) out += '\n';
|
||||||
|
for (const child of Array.from(node.childNodes)) walk(child);
|
||||||
|
if (block && out && !out.endsWith('\n')) out += '\n';
|
||||||
|
};
|
||||||
|
walk(el);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teilt extrahierten Rezepttext in einzelne Schritte auf. Rezeptwelt und
|
||||||
|
// andere SSR-Sites liefern oft einen einzigen HowToStep-Block, der intern
|
||||||
|
// mit "1. …<br>2. …<br>3. …" mehrere Schritte vereint.
|
||||||
|
function splitStepText(raw: string): string[] {
|
||||||
|
const numbered = /^(\d+)[.)]\s+(.+)$/;
|
||||||
|
const lines = raw
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((l) => l.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
const numberedCount = lines.filter((l) => numbered.test(l)).length;
|
||||||
|
if (numberedCount >= 2) {
|
||||||
|
// Mehrere nummerierte Zeilen → jede ist ein eigener Schritt. Nicht-
|
||||||
|
// nummerierte Folgezeilen gehören zum vorherigen Schritt.
|
||||||
|
const out: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
for (const l of lines) {
|
||||||
|
const m = l.match(numbered);
|
||||||
|
if (m) {
|
||||||
|
if (current) out.push(current);
|
||||||
|
current = m[2];
|
||||||
|
} else {
|
||||||
|
current += current ? ' ' + l : l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) out.push(current);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return [lines.join(' ')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepsFromElement(el: Element): string[] {
|
||||||
|
const textEl = el.querySelector('[itemprop="text"]') ?? el;
|
||||||
|
const raw = textWithLineBreaks(textEl);
|
||||||
|
return splitStepText(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function microSteps(scope: Element): Step[] {
|
||||||
|
const out: Step[] = [];
|
||||||
|
let pos = 1;
|
||||||
|
const containers = Array.from(scope.querySelectorAll('[itemprop="recipeInstructions"]'));
|
||||||
|
for (const el of containers) {
|
||||||
|
const itemtype = (el.getAttribute('itemtype') ?? '').toLowerCase();
|
||||||
|
if (itemtype.includes('howtosection')) {
|
||||||
|
// HowToSection enthält HowToStep-Kinder als itemListElement.
|
||||||
|
const steps = Array.from(
|
||||||
|
el.querySelectorAll(
|
||||||
|
'[itemprop="itemListElement"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const step of steps) {
|
||||||
|
for (const t of stepsFromElement(step)) out.push({ position: pos++, text: t });
|
||||||
|
}
|
||||||
|
} else if (itemtype.includes('howtostep')) {
|
||||||
|
for (const t of stepsFromElement(el)) out.push({ position: pos++, text: t });
|
||||||
|
} else if (el.hasAttribute('itemscope')) {
|
||||||
|
// Anderer unbekannter Scope — trotzdem Text versuchen.
|
||||||
|
for (const t of stepsFromElement(el)) out.push({ position: pos++, text: t });
|
||||||
|
} else {
|
||||||
|
const lis = Array.from(el.querySelectorAll('li'));
|
||||||
|
if (lis.length > 0) {
|
||||||
|
for (const li of lis) {
|
||||||
|
for (const t of splitStepText(textWithLineBreaks(li))) {
|
||||||
|
out.push({ position: pos++, text: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const t of splitStepText(textWithLineBreaks(el))) {
|
||||||
|
out.push({ position: pos++, text: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRecipeFromMicrodata(html: string): Recipe | null {
|
||||||
|
let document: Document;
|
||||||
|
try {
|
||||||
|
({ document } = parseHTML(html));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const scope = document.querySelector(
|
||||||
|
'[itemtype*="schema.org/Recipe" i]'
|
||||||
|
);
|
||||||
|
if (!scope) return null;
|
||||||
|
const props = gatherMicrodataProps(scope);
|
||||||
|
|
||||||
|
const title = microText(props, 'name');
|
||||||
|
if (!title) return null;
|
||||||
|
|
||||||
|
const ingredients = microAllTexts(props, 'recipeIngredient')
|
||||||
|
.map((raw, i) => parseIngredient(raw, i + 1))
|
||||||
|
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||||
|
|
||||||
|
const steps = microSteps(scope);
|
||||||
|
const prep = parseIso8601Duration(microText(props, 'prepTime') ?? undefined);
|
||||||
|
const cook = parseIso8601Duration(microText(props, 'cookTime') ?? undefined);
|
||||||
|
const total = parseIso8601Duration(microText(props, 'totalTime') ?? undefined);
|
||||||
|
|
||||||
|
const tags = new Set<string>([
|
||||||
|
...microAllTexts(props, 'recipeCategory'),
|
||||||
|
...microAllTexts(props, 'recipeCuisine'),
|
||||||
|
...microAllTexts(props, 'keywords')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
title,
|
||||||
|
description: microText(props, 'description'),
|
||||||
|
source_url: microText(props, 'url'),
|
||||||
|
source_domain: null,
|
||||||
|
image_path: microText(props, 'image'),
|
||||||
|
servings_default: toServings(microText(props, 'recipeYield')),
|
||||||
|
servings_unit: null,
|
||||||
|
prep_time_min: prep,
|
||||||
|
cook_time_min: cook,
|
||||||
|
total_time_min: total,
|
||||||
|
cuisine: microText(props, 'recipeCuisine'),
|
||||||
|
category: microText(props, 'recipeCategory'),
|
||||||
|
ingredients,
|
||||||
|
steps,
|
||||||
|
tags: [...tags]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function extractRecipeFromHtml(html: string): Recipe | null {
|
export function extractRecipeFromHtml(html: string): Recipe | null {
|
||||||
const node = findRecipeNode(html);
|
const node = findRecipeNode(html);
|
||||||
if (!node) return null;
|
if (!node) {
|
||||||
|
// Fallback auf Microdata — rezeptwelt.de & andere SSR-Sites nutzen das
|
||||||
|
// anstatt application/ld+json.
|
||||||
|
return extractRecipeFromMicrodata(html);
|
||||||
|
}
|
||||||
|
|
||||||
const title = toText(node.name) ?? '';
|
const title = toText(node.name) ?? '';
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ export function isFavorite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listFavoriteProfiles(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number
|
||||||
|
): number[] {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.prepare('SELECT profile_id FROM favorite WHERE recipe_id = ?')
|
||||||
|
.all(recipeId) as { profile_id: number }[]
|
||||||
|
).map((r) => r.profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
export function logCooked(
|
export function logCooked(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
@@ -139,3 +150,14 @@ export function renameRecipe(
|
|||||||
recipeId
|
recipeId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setRecipeHiddenFromRecent(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number,
|
||||||
|
hidden: boolean
|
||||||
|
): void {
|
||||||
|
db.prepare('UPDATE recipe SET hidden_from_recent = ? WHERE id = ?').run(
|
||||||
|
hidden ? 1 : 0,
|
||||||
|
recipeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type Database from 'better-sqlite3';
|
|||||||
import type { Recipe } from '$lib/types';
|
import type { Recipe } from '$lib/types';
|
||||||
import { fetchText } from '../http';
|
import { fetchText } from '../http';
|
||||||
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
|
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
|
||||||
import { isDomainAllowed } from '../domains/whitelist';
|
|
||||||
import { downloadImage } from '../images/image-downloader';
|
import { downloadImage } from '../images/image-downloader';
|
||||||
import {
|
import {
|
||||||
getRecipeById,
|
getRecipeById,
|
||||||
@@ -14,7 +13,6 @@ export class ImporterError extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly code:
|
public readonly code:
|
||||||
| 'INVALID_URL'
|
| 'INVALID_URL'
|
||||||
| 'DOMAIN_BLOCKED'
|
|
||||||
| 'FETCH_FAILED'
|
| 'FETCH_FAILED'
|
||||||
| 'NO_RECIPE_FOUND',
|
| 'NO_RECIPE_FOUND',
|
||||||
message: string
|
message: string
|
||||||
@@ -32,11 +30,12 @@ function hostnameOrThrow(url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewRecipe(db: Database.Database, url: string): Promise<Recipe> {
|
// Manuelle URL-Importe sind absichtlich NICHT mehr auf die allowed_domain-
|
||||||
|
// Whitelist beschränkt — der User pastet bewusst eine URL und erwartet,
|
||||||
|
// dass der Import klappt. Die Whitelist bleibt für die Web-Suche (searxng)
|
||||||
|
// relevant, weil dort ein breites Crawl-Feld eingeschränkt werden soll.
|
||||||
|
export async function previewRecipe(_db: Database.Database, url: string): Promise<Recipe> {
|
||||||
const host = hostnameOrThrow(url);
|
const host = hostnameOrThrow(url);
|
||||||
if (!isDomainAllowed(db, url)) {
|
|
||||||
throw new ImporterError('DOMAIN_BLOCKED', `Domain not allowed: ${host}`);
|
|
||||||
}
|
|
||||||
let html: string;
|
let html: string;
|
||||||
try {
|
try {
|
||||||
html = await fetchText(url);
|
html = await fetchText(url);
|
||||||
|
|||||||
@@ -155,3 +155,79 @@ export function getRecipeIdBySourceUrl(
|
|||||||
export function deleteRecipe(db: Database.Database, id: number): void {
|
export function deleteRecipe(db: Database.Database, id: number): void {
|
||||||
db.prepare('DELETE FROM recipe WHERE id = ?').run(id);
|
db.prepare('DELETE FROM recipe WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecipeMetaPatch = {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
servings_default?: number | null;
|
||||||
|
servings_unit?: string | null;
|
||||||
|
prep_time_min?: number | null;
|
||||||
|
cook_time_min?: number | null;
|
||||||
|
total_time_min?: number | null;
|
||||||
|
cuisine?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function updateRecipeMeta(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number,
|
||||||
|
patch: RecipeMetaPatch
|
||||||
|
): void {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
for (const key of [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'servings_default',
|
||||||
|
'servings_unit',
|
||||||
|
'prep_time_min',
|
||||||
|
'cook_time_min',
|
||||||
|
'total_time_min',
|
||||||
|
'cuisine',
|
||||||
|
'category'
|
||||||
|
] as const) {
|
||||||
|
if (patch[key] !== undefined) {
|
||||||
|
fields.push(`${key} = ?`);
|
||||||
|
values.push(patch[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceIngredients(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number,
|
||||||
|
ingredients: Ingredient[]
|
||||||
|
): void {
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM ingredient WHERE recipe_id = ?').run(recipeId);
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text);
|
||||||
|
}
|
||||||
|
refreshFts(db, recipeId);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceSteps(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number,
|
||||||
|
steps: Step[]
|
||||||
|
): void {
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM step WHERE recipe_id = ?').run(recipeId);
|
||||||
|
const ins = db.prepare(
|
||||||
|
'INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
for (const step of steps) {
|
||||||
|
ins.run(recipeId, step.position, step.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,15 +29,17 @@ function buildFtsQuery(q: string): string | null {
|
|||||||
export function searchLocal(
|
export function searchLocal(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
query: string,
|
query: string,
|
||||||
limit = 30
|
limit = 30,
|
||||||
|
offset = 0,
|
||||||
|
domains: string[] = []
|
||||||
): SearchHit[] {
|
): SearchHit[] {
|
||||||
const fts = buildFtsQuery(query);
|
const fts = buildFtsQuery(query);
|
||||||
if (!fts) return [];
|
if (!fts) return [];
|
||||||
|
|
||||||
// bm25: lower is better. Use weights: title > tags > ingredients > description
|
// bm25: lower is better. Use weights: title > tags > ingredients > description
|
||||||
return db
|
const hasFilter = domains.length > 0;
|
||||||
.prepare(
|
const placeholders = hasFilter ? domains.map(() => '?').join(',') : '';
|
||||||
`SELECT r.id,
|
const sql = `SELECT r.id,
|
||||||
r.title,
|
r.title,
|
||||||
r.description,
|
r.description,
|
||||||
r.image_path,
|
r.image_path,
|
||||||
@@ -47,10 +49,13 @@ export function searchLocal(
|
|||||||
FROM recipe r
|
FROM recipe r
|
||||||
JOIN recipe_fts f ON f.rowid = r.id
|
JOIN recipe_fts f ON f.rowid = r.id
|
||||||
WHERE recipe_fts MATCH ?
|
WHERE recipe_fts MATCH ?
|
||||||
|
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
|
||||||
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
||||||
LIMIT ?`
|
LIMIT ? OFFSET ?`;
|
||||||
)
|
const params = hasFilter
|
||||||
.all(fts, limit) as SearchHit[];
|
? [fts, ...domains, limit, offset]
|
||||||
|
: [fts, limit, offset];
|
||||||
|
return db.prepare(sql).all(...params) as SearchHit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRecentRecipes(
|
export function listRecentRecipes(
|
||||||
@@ -67,8 +72,83 @@ export function listRecentRecipes(
|
|||||||
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
||||||
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
||||||
FROM recipe r
|
FROM recipe r
|
||||||
|
WHERE r.hidden_from_recent = 0
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
)
|
)
|
||||||
.all(limit) as SearchHit[];
|
.all(limit) as SearchHit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listAllRecipes(db: Database.Database): SearchHit[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id,
|
||||||
|
r.title,
|
||||||
|
r.description,
|
||||||
|
r.image_path,
|
||||||
|
r.source_domain,
|
||||||
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
||||||
|
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
||||||
|
FROM recipe r
|
||||||
|
ORDER BY r.title COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all() as SearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created';
|
||||||
|
|
||||||
|
export function listAllRecipesPaginated(
|
||||||
|
db: Database.Database,
|
||||||
|
sort: AllRecipesSort,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): SearchHit[] {
|
||||||
|
// NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST
|
||||||
|
// zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und
|
||||||
|
// CASE ist überall zuverlässig.
|
||||||
|
const orderBy: Record<AllRecipesSort, string> = {
|
||||||
|
name: 'r.title COLLATE NOCASE ASC',
|
||||||
|
rating:
|
||||||
|
'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
|
||||||
|
'(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
|
||||||
|
cooked:
|
||||||
|
'CASE WHEN (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
|
||||||
|
'(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
|
||||||
|
created: 'r.created_at DESC, r.id DESC'
|
||||||
|
};
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id,
|
||||||
|
r.title,
|
||||||
|
r.description,
|
||||||
|
r.image_path,
|
||||||
|
r.source_domain,
|
||||||
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
||||||
|
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
||||||
|
FROM recipe r
|
||||||
|
ORDER BY ${orderBy[sort]}
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(limit, offset) as SearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFavoritesForProfile(
|
||||||
|
db: Database.Database,
|
||||||
|
profileId: number
|
||||||
|
): SearchHit[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id,
|
||||||
|
r.title,
|
||||||
|
r.description,
|
||||||
|
r.image_path,
|
||||||
|
r.source_domain,
|
||||||
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
|
||||||
|
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
|
||||||
|
FROM recipe r
|
||||||
|
JOIN favorite f ON f.recipe_id = r.id
|
||||||
|
WHERE f.profile_id = ?
|
||||||
|
ORDER BY r.title COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all(profileId) as SearchHit[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
import { parseHTML } from 'linkedom';
|
||||||
import { listDomains, normalizeDomain } from '../domains/repository';
|
import { listDomains, normalizeDomain } from '../domains/repository';
|
||||||
import { fetchText } from '../http';
|
import { fetchText } from '../http';
|
||||||
|
import { hasRecipeMarkup } from '../parsers/json-ld-recipe';
|
||||||
|
|
||||||
export type WebHit = {
|
export type WebHit = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -77,24 +79,240 @@ function looksLikeRecipePage(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveUrl(href: string, baseUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(href, baseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFromJsonLd(data: unknown): string | null {
|
||||||
|
if (!data) return null;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (const d of data) {
|
||||||
|
const img = imageFromJsonLd(d);
|
||||||
|
if (img) return img;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof data !== 'object') return null;
|
||||||
|
const node = data as Record<string, unknown>;
|
||||||
|
if (Array.isArray(node['@graph'])) {
|
||||||
|
for (const d of node['@graph']) {
|
||||||
|
const img = imageFromJsonLd(d);
|
||||||
|
if (img) return img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const image = node.image;
|
||||||
|
if (typeof image === 'string') return image;
|
||||||
|
if (Array.isArray(image) && image.length > 0) {
|
||||||
|
const first = image[0];
|
||||||
|
if (typeof first === 'string') return first;
|
||||||
|
if (first && typeof first === 'object' && 'url' in first) {
|
||||||
|
const url = (first as Record<string, unknown>).url;
|
||||||
|
if (typeof url === 'string') return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (image && typeof image === 'object' && 'url' in image) {
|
||||||
|
const url = (image as Record<string, unknown>).url;
|
||||||
|
if (typeof url === 'string') return url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const META_IMAGE_KEYS = new Set([
|
||||||
|
'og:image',
|
||||||
|
'og:image:url',
|
||||||
|
'og:image:secure_url',
|
||||||
|
'twitter:image',
|
||||||
|
'twitter:image:src'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractPageImage(html: string, baseUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
// 1. OpenGraph / Twitter meta tags
|
||||||
|
for (const m of Array.from(document.querySelectorAll('meta'))) {
|
||||||
|
const key = (m.getAttribute('property') ?? m.getAttribute('name') ?? '').toLowerCase();
|
||||||
|
if (!META_IMAGE_KEYS.has(key)) continue;
|
||||||
|
const content = m.getAttribute('content');
|
||||||
|
if (!content) continue;
|
||||||
|
const resolved = resolveUrl(content, baseUrl);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
// 2. <link rel="image_src">
|
||||||
|
const link = document.querySelector('link[rel="image_src"]');
|
||||||
|
if (link) {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (href) {
|
||||||
|
const resolved = resolveUrl(href, baseUrl);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. JSON-LD image (Recipe schema etc.)
|
||||||
|
for (const s of Array.from(document.querySelectorAll('script[type="application/ld+json"]'))) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(s.textContent ?? '');
|
||||||
|
const img = imageFromJsonLd(data);
|
||||||
|
if (img) {
|
||||||
|
const resolved = resolveUrl(img, baseUrl);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// malformed JSON-LD — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. First content image in article/main
|
||||||
|
const contentImg = document.querySelector(
|
||||||
|
'article img[src], main img[src], .entry-content img[src], .post-content img[src], figure img[src]'
|
||||||
|
);
|
||||||
|
if (contentImg) {
|
||||||
|
const src = contentImg.getAttribute('src') ?? contentImg.getAttribute('data-src');
|
||||||
|
if (src) {
|
||||||
|
const resolved = resolveUrl(src, baseUrl);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const THUMB_TTL_DAYS = Number(process.env.KOCHWAS_THUMB_TTL_DAYS ?? 30);
|
||||||
|
const THUMB_TTL_MS = THUMB_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
type PageMeta = {
|
||||||
|
image: string | null;
|
||||||
|
hasRecipe: 0 | 1 | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readCachedPageMeta(
|
||||||
|
db: Database.Database,
|
||||||
|
url: string
|
||||||
|
): PageMeta | null {
|
||||||
|
const row = db
|
||||||
|
.prepare<
|
||||||
|
[string, string],
|
||||||
|
{ image: string | null; has_recipe: 0 | 1 | null }
|
||||||
|
>(
|
||||||
|
'SELECT image, has_recipe FROM thumbnail_cache WHERE url = ? AND expires_at > ?'
|
||||||
|
)
|
||||||
|
.get(url, new Date().toISOString());
|
||||||
|
if (!row) return null;
|
||||||
|
return { image: row.image, hasRecipe: row.has_recipe };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCachedPageMeta(
|
||||||
|
db: Database.Database,
|
||||||
|
url: string,
|
||||||
|
meta: PageMeta
|
||||||
|
): void {
|
||||||
|
const expiresAt = new Date(Date.now() + THUMB_TTL_MS).toISOString();
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO thumbnail_cache (url, image, expires_at, has_recipe) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(url, meta.image, expiresAt, meta.hasRecipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichPageMeta(
|
||||||
|
db: Database.Database,
|
||||||
|
url: string
|
||||||
|
): Promise<PageMeta> {
|
||||||
|
const cached = readCachedPageMeta(db, url);
|
||||||
|
if (cached) return cached;
|
||||||
|
let meta: PageMeta = { image: null, hasRecipe: null };
|
||||||
|
try {
|
||||||
|
// allowTruncate: moderne Rezeptseiten sind oft >1 MB (eingebettete
|
||||||
|
// Bundles, base64-Bilder). Das og:image und JSON-LD steht praktisch
|
||||||
|
// immer im <head>, was locker in die ersten 512 KB passt. Früher
|
||||||
|
// warf fetchText auf Überschreitung und hasRecipe blieb NULL, sodass
|
||||||
|
// Nicht-Rezept-Seiten fälschlich durchgingen.
|
||||||
|
const html = await fetchText(url, {
|
||||||
|
timeoutMs: 8_000,
|
||||||
|
maxBytes: 512 * 1024,
|
||||||
|
allowTruncate: true
|
||||||
|
});
|
||||||
|
meta = {
|
||||||
|
image: extractPageImage(html, url),
|
||||||
|
hasRecipe: hasRecipeMarkup(html) ? 1 : 0
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Fetch failed — leave hasRecipe null (unknown) so we don't permanently
|
||||||
|
// hide a temporary-network-error URL.
|
||||||
|
}
|
||||||
|
writeCachedPageMeta(db, url, meta);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichAndFilterHits(
|
||||||
|
db: Database.Database,
|
||||||
|
hits: WebHit[]
|
||||||
|
): Promise<WebHit[]> {
|
||||||
|
// Always fetch the page even when SearXNG gave us a thumbnail — we need
|
||||||
|
// the HTML anyway for the high-res og:image AND to confirm a Recipe
|
||||||
|
// JSON-LD actually exists. The thumbnail_cache table (default 30-day TTL)
|
||||||
|
// makes repeat searches instant.
|
||||||
|
if (hits.length === 0) return hits;
|
||||||
|
// Lazy cleanup of expired entries — O(log n) index scan, cheap.
|
||||||
|
db.prepare('DELETE FROM thumbnail_cache WHERE expires_at <= ?').run(
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
const metas = new Map<string, PageMeta>();
|
||||||
|
const queue = [...hits];
|
||||||
|
const LIMIT = 6;
|
||||||
|
const workers = Array.from({ length: Math.min(LIMIT, queue.length) }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const h = queue.shift();
|
||||||
|
if (!h) break;
|
||||||
|
metas.set(h.url, await enrichPageMeta(db, h.url));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
// Drop confirmed-non-recipe pages (hasRecipe === 0). Keep unknown (null)
|
||||||
|
// and confirmed recipes (1).
|
||||||
|
return hits
|
||||||
|
.filter((h) => metas.get(h.url)?.hasRecipe !== 0)
|
||||||
|
.map((h) => {
|
||||||
|
const image = metas.get(h.url)?.image;
|
||||||
|
return image ? { ...h, thumbnail: image } : h;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchWeb(
|
export async function searchWeb(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
query: string,
|
query: string,
|
||||||
opts: { searxngUrl?: string; limit?: number } = {}
|
opts: {
|
||||||
|
searxngUrl?: string;
|
||||||
|
limit?: number;
|
||||||
|
enrichThumbnails?: boolean;
|
||||||
|
pageno?: number;
|
||||||
|
domains?: string[];
|
||||||
|
} = {}
|
||||||
): Promise<WebHit[]> {
|
): Promise<WebHit[]> {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return [];
|
if (!trimmed) return [];
|
||||||
const domains = listDomains(db).map((d) => d.domain);
|
const allDomains = listDomains(db).map((d) => d.domain);
|
||||||
if (domains.length === 0) return [];
|
if (allDomains.length === 0) return [];
|
||||||
|
// Optionaler Domain-Filter: Intersection mit der Whitelist, damit der
|
||||||
|
// Filter nie außerhalb der erlaubten Domains sucht.
|
||||||
|
const whitelist = new Set(allDomains);
|
||||||
|
const filtered = opts.domains?.filter((d) => whitelist.has(d)) ?? [];
|
||||||
|
const domains = filtered.length > 0 ? filtered : allDomains;
|
||||||
|
|
||||||
const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888';
|
const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888';
|
||||||
const limit = opts.limit ?? 20;
|
const limit = opts.limit ?? 20;
|
||||||
|
const pageno = Math.max(1, opts.pageno ?? 1);
|
||||||
const siteFilter = domains.map((d) => `site:${d}`).join(' OR ');
|
const siteFilter = domains.map((d) => `site:${d}`).join(' OR ');
|
||||||
const q = `${trimmed} (${siteFilter})`;
|
const q = `${trimmed} (${siteFilter})`;
|
||||||
const endpoint = new URL('/search', searxngUrl);
|
const endpoint = new URL('/search', searxngUrl);
|
||||||
endpoint.searchParams.set('q', q);
|
endpoint.searchParams.set('q', q);
|
||||||
endpoint.searchParams.set('format', 'json');
|
endpoint.searchParams.set('format', 'json');
|
||||||
endpoint.searchParams.set('language', 'de');
|
endpoint.searchParams.set('language', 'de');
|
||||||
|
// 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');
|
||||||
|
if (pageno > 1) endpoint.searchParams.set('pageno', String(pageno));
|
||||||
|
|
||||||
const body = await fetchText(endpoint.toString(), {
|
const body = await fetchText(endpoint.toString(), {
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
@@ -116,11 +334,23 @@ export async function searchWeb(
|
|||||||
const allowed = new Set(domains);
|
const allowed = new Set(domains);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const hits: WebHit[] = [];
|
const hits: WebHit[] = [];
|
||||||
|
let dropNonWhitelist = 0;
|
||||||
|
let dropNonRecipeUrl = 0;
|
||||||
|
let dropDup = 0;
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const host = hostnameFromUrl(r.url);
|
const host = hostnameFromUrl(r.url);
|
||||||
if (!host || !allowed.has(host)) continue;
|
if (!host || !allowed.has(host)) {
|
||||||
if (!looksLikeRecipePage(r.url)) continue;
|
dropNonWhitelist += 1;
|
||||||
if (seen.has(r.url)) continue;
|
continue;
|
||||||
|
}
|
||||||
|
if (!looksLikeRecipePage(r.url)) {
|
||||||
|
dropNonRecipeUrl += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(r.url)) {
|
||||||
|
dropDup += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seen.add(r.url);
|
seen.add(r.url);
|
||||||
hits.push({
|
hits.push({
|
||||||
url: r.url,
|
url: r.url,
|
||||||
@@ -131,5 +361,25 @@ export async function searchWeb(
|
|||||||
});
|
});
|
||||||
if (hits.length >= limit) break;
|
if (hits.length >= limit) break;
|
||||||
}
|
}
|
||||||
|
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}`
|
||||||
|
);
|
||||||
|
if (opts.enrichThumbnails !== false) {
|
||||||
|
const enriched = await enrichAndFilterHits(db, hits);
|
||||||
|
const droppedUrls = hits
|
||||||
|
.filter((h) => !enriched.find((e) => e.url === h.url))
|
||||||
|
.map((h) => h.url);
|
||||||
|
console.log(
|
||||||
|
`[searxng] q=${JSON.stringify(trimmed)} pageno=${pageno} enrich=${hits.length} dropped_non_recipe=${droppedUrls.length} final=${enriched.length}`
|
||||||
|
);
|
||||||
|
// Nur die ersten 3 URLs mitloggen, damit das Log nicht explodiert. Genug
|
||||||
|
// um eine Seite manuell zu analysieren („warum wurde die abgelehnt?").
|
||||||
|
if (droppedUrls.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[searxng] dropped samples: ${droppedUrls.slice(0, 3).join(' | ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
return hits;
|
return hits;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ export type WishlistEntry = {
|
|||||||
title: string;
|
title: string;
|
||||||
image_path: string | null;
|
image_path: string | null;
|
||||||
source_domain: string | null;
|
source_domain: string | null;
|
||||||
added_by_profile_id: number | null;
|
added_at: string; // earliest per recipe
|
||||||
added_by_name: string | null;
|
wanted_by_count: number;
|
||||||
added_at: string;
|
wanted_by_names: string; // comma-joined profile names
|
||||||
like_count: number;
|
on_my_wishlist: 0 | 1;
|
||||||
liked_by_me: 0 | 1;
|
|
||||||
avg_stars: number | null;
|
avg_stars: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,9 +20,9 @@ export function listWishlist(
|
|||||||
sort: SortKey = 'popular'
|
sort: SortKey = 'popular'
|
||||||
): WishlistEntry[] {
|
): WishlistEntry[] {
|
||||||
const orderBy = {
|
const orderBy = {
|
||||||
popular: 'like_count DESC, w.added_at DESC',
|
popular: 'wanted_by_count DESC, first_added DESC',
|
||||||
newest: 'w.added_at DESC',
|
newest: 'first_added DESC',
|
||||||
oldest: 'w.added_at ASC'
|
oldest: 'first_added ASC'
|
||||||
}[sort];
|
}[sort];
|
||||||
|
|
||||||
return db
|
return db
|
||||||
@@ -33,66 +32,83 @@ export function listWishlist(
|
|||||||
r.title,
|
r.title,
|
||||||
r.image_path,
|
r.image_path,
|
||||||
r.source_domain,
|
r.source_domain,
|
||||||
w.added_by_profile_id,
|
MIN(w.added_at) AS first_added,
|
||||||
p.name AS added_by_name,
|
MIN(w.added_at) AS added_at,
|
||||||
w.added_at,
|
COUNT(w.profile_id) AS wanted_by_count,
|
||||||
(SELECT COUNT(*) FROM wishlist_like wl WHERE wl.recipe_id = w.recipe_id) AS like_count,
|
COALESCE(GROUP_CONCAT(p.name, ', '), '') AS wanted_by_names,
|
||||||
CASE
|
CASE
|
||||||
WHEN ? IS NULL THEN 0
|
WHEN ? IS NULL THEN 0
|
||||||
WHEN EXISTS (SELECT 1 FROM wishlist_like wl
|
WHEN EXISTS (SELECT 1 FROM wishlist w2
|
||||||
WHERE wl.recipe_id = w.recipe_id AND wl.profile_id = ?)
|
WHERE w2.recipe_id = w.recipe_id AND w2.profile_id = ?)
|
||||||
THEN 1
|
THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END AS liked_by_me,
|
END AS on_my_wishlist,
|
||||||
(SELECT AVG(stars) FROM rating WHERE recipe_id = w.recipe_id) AS avg_stars
|
(SELECT AVG(stars) FROM rating WHERE recipe_id = w.recipe_id) AS avg_stars
|
||||||
FROM wishlist w
|
FROM wishlist w
|
||||||
JOIN recipe r ON r.id = w.recipe_id
|
JOIN recipe r ON r.id = w.recipe_id
|
||||||
LEFT JOIN profile p ON p.id = w.added_by_profile_id
|
LEFT JOIN profile p ON p.id = w.profile_id
|
||||||
|
GROUP BY w.recipe_id
|
||||||
ORDER BY ${orderBy}`
|
ORDER BY ${orderBy}`
|
||||||
)
|
)
|
||||||
.all(activeProfileId, activeProfileId) as WishlistEntry[];
|
.all(activeProfileId, activeProfileId) as WishlistEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOnWishlist(db: Database.Database, recipeId: number): boolean {
|
export function listWishlistProfileIds(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number
|
||||||
|
): number[] {
|
||||||
return (
|
return (
|
||||||
db
|
db
|
||||||
.prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ?')
|
.prepare('SELECT profile_id FROM wishlist WHERE recipe_id = ?')
|
||||||
.get(recipeId) !== undefined
|
.all(recipeId) as { profile_id: number }[]
|
||||||
);
|
).map((r) => r.profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countWishlistRecipes(db: Database.Database): number {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT COUNT(DISTINCT recipe_id) AS n FROM wishlist')
|
||||||
|
.get() as { n: number };
|
||||||
|
return row.n;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addToWishlist(
|
export function addToWishlist(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
profileId: number | null
|
profileId: number
|
||||||
): void {
|
): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO wishlist(recipe_id, added_by_profile_id)
|
`INSERT INTO wishlist(recipe_id, profile_id)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(recipe_id) DO NOTHING`
|
ON CONFLICT(recipe_id, profile_id) DO NOTHING`
|
||||||
).run(recipeId, profileId);
|
).run(recipeId, profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeFromWishlist(db: Database.Database, recipeId: number): void {
|
export function removeFromWishlist(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number,
|
||||||
|
profileId: number
|
||||||
|
): void {
|
||||||
|
db.prepare('DELETE FROM wishlist WHERE recipe_id = ? AND profile_id = ?').run(
|
||||||
|
recipeId,
|
||||||
|
profileId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromWishlistForAll(
|
||||||
|
db: Database.Database,
|
||||||
|
recipeId: number
|
||||||
|
): void {
|
||||||
db.prepare('DELETE FROM wishlist WHERE recipe_id = ?').run(recipeId);
|
db.prepare('DELETE FROM wishlist WHERE recipe_id = ?').run(recipeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function likeWish(
|
export function isOnMyWishlist(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
profileId: number
|
profileId: number
|
||||||
): void {
|
): boolean {
|
||||||
db.prepare(
|
return (
|
||||||
'INSERT OR IGNORE INTO wishlist_like(recipe_id, profile_id) VALUES (?, ?)'
|
db
|
||||||
).run(recipeId, profileId);
|
.prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ? AND profile_id = ?')
|
||||||
}
|
.get(recipeId, profileId) !== undefined
|
||||||
|
);
|
||||||
export function unlikeWish(
|
|
||||||
db: Database.Database,
|
|
||||||
recipeId: number,
|
|
||||||
profileId: number
|
|
||||||
): void {
|
|
||||||
db.prepare(
|
|
||||||
'DELETE FROM wishlist_like WHERE recipe_id = ? AND profile_id = ?'
|
|
||||||
).run(recipeId, profileId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
||||||
|
|
||||||
|
export 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.
|
||||||
|
export 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 };
|
||||||
|
}
|
||||||
@@ -41,4 +41,5 @@ export type AllowedDomain = {
|
|||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
|
favicon_path: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,383 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
|
import { Settings, CookingPot, Utensils, Menu, BookOpen, ArrowLeft } from 'lucide-svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
|
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||||
|
import { pwaStore } from '$lib/client/pwa.svelte';
|
||||||
|
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||||
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
|
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||||
|
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
||||||
|
import UpdateToast from '$lib/components/UpdateToast.svelte';
|
||||||
|
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 type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
|
import type { WebHit } from '$lib/server/search/searxng';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
const NAV_PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const showHeaderSearch = $derived(
|
||||||
|
$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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 submitNav(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = navQuery.trim();
|
||||||
|
if (!q) return;
|
||||||
|
navOpen = false;
|
||||||
|
void goto(`/?q=${encodeURIComponent(q)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (navContainer && !navContainer.contains(e.target as Node)) {
|
||||||
|
navOpen = false;
|
||||||
|
}
|
||||||
|
if (menuContainer && !menuContainer.contains(e.target as Node)) {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (navOpen) navOpen = false;
|
||||||
|
if (menuOpen) menuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHit() {
|
||||||
|
navOpen = false;
|
||||||
|
navQuery = '';
|
||||||
|
navHits = [];
|
||||||
|
navWebHits = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
afterNavigate(() => {
|
||||||
|
navQuery = '';
|
||||||
|
navHits = [];
|
||||||
|
navWebHits = [];
|
||||||
|
navOpen = false;
|
||||||
|
menuOpen = false;
|
||||||
|
// Badge nach jeder Client-Navigation frisch halten — sonst kann er
|
||||||
|
// hinter den tatsächlichen Wunschliste-Einträgen herlaufen, wenn
|
||||||
|
// auf einem anderen Gerät oder in einem anderen Tab etwas geändert
|
||||||
|
// wurde.
|
||||||
|
void wishlistStore.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
profileStore.load();
|
profileStore.load();
|
||||||
|
void wishlistStore.refresh();
|
||||||
|
void searchFilterStore.load();
|
||||||
|
void pwaStore.init();
|
||||||
|
network.init();
|
||||||
|
installPrompt.init();
|
||||||
|
void registerServiceWorker();
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<Toast />
|
||||||
|
<SyncIndicator />
|
||||||
|
<ConfirmDialog />
|
||||||
|
<UpdateToast />
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<a href="/" class="brand">Kochwas</a>
|
<div class="bar-inner">
|
||||||
<div class="bar-right">
|
{#if $page.url.pathname === '/'}
|
||||||
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽️</a>
|
<a href="/" class="brand">Kochwas</a>
|
||||||
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
|
{:else}
|
||||||
<ProfileSwitcher />
|
<a href="/" class="home-back" aria-label="Zurück zur Startseite">
|
||||||
|
<ArrowLeft size={22} strokeWidth={2} />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if showHeaderSearch}
|
||||||
|
<div class="nav-search-wrap" bind:this={navContainer}>
|
||||||
|
<form class="nav-search" onsubmit={submitNav} role="search">
|
||||||
|
<SearchFilter inline />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:value={navQuery}
|
||||||
|
onfocus={() => {
|
||||||
|
if (navHits.length > 0 || navQuery.trim().length > 3) navOpen = true;
|
||||||
|
}}
|
||||||
|
placeholder="Rezept suchen…"
|
||||||
|
autocomplete="off"
|
||||||
|
inputmode="search"
|
||||||
|
aria-label="Suchbegriff"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{#if navOpen}
|
||||||
|
<div class="dropdown" role="listbox">
|
||||||
|
{#if navSearching && navHits.length === 0 && navWebHits.length === 0}
|
||||||
|
<SearchLoader scope="local" size="sm" />
|
||||||
|
{:else}
|
||||||
|
{#if navHits.length > 0}
|
||||||
|
<ul class="dd-list">
|
||||||
|
{#each navHits as r (r.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`/recipes/${r.id}`}
|
||||||
|
class="dd-item"
|
||||||
|
onclick={pickHit}
|
||||||
|
role="option"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
{#if r.image_path}
|
||||||
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="dd-placeholder"><CookingPot size={22} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="dd-body">
|
||||||
|
<div class="dd-title">{r.title}</div>
|
||||||
|
{#if r.source_domain}
|
||||||
|
<div class="dd-domain">{r.source_domain}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if navWebHits.length > 0}
|
||||||
|
{#if navHits.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)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`/preview?url=${encodeURIComponent(w.url)}`}
|
||||||
|
class="dd-item"
|
||||||
|
onclick={pickHit}
|
||||||
|
role="option"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
{#if w.thumbnail}
|
||||||
|
<img src={w.thumbnail} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="dd-placeholder"><Utensils size={22} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="dd-body">
|
||||||
|
<div class="dd-title">{w.title}</div>
|
||||||
|
<div class="dd-domain">{w.domain}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if navWebSearching}
|
||||||
|
<SearchLoader scope="web" size="sm" />
|
||||||
|
{:else if navWebError && navWebHits.length === 0}
|
||||||
|
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
|
||||||
|
{:else if navHits.length === 0 && navWebHits.length === 0 && !navSearching}
|
||||||
|
<p class="dd-status">Auch im Internet nichts gefunden.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !(navLocalExhausted && navWebExhausted) && (navHits.length > 0 || navWebHits.length > 0)}
|
||||||
|
<button
|
||||||
|
class="dd-web"
|
||||||
|
type="button"
|
||||||
|
onclick={loadMoreNav}
|
||||||
|
disabled={navLoadingMore || navWebSearching}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>{navLoadingMore || navWebSearching
|
||||||
|
? 'Lade …'
|
||||||
|
: '+ weitere Ergebnisse'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="bar-right">
|
||||||
|
<a
|
||||||
|
href="/wishlist"
|
||||||
|
class="nav-link wishlist-link"
|
||||||
|
aria-label={wishlistStore.count > 0
|
||||||
|
? `Wunschliste (${wishlistStore.count})`
|
||||||
|
: 'Wunschliste'}
|
||||||
|
>
|
||||||
|
<CookingPot size={20} strokeWidth={2} />
|
||||||
|
{#if wishlistStore.count > 0}
|
||||||
|
<span class="badge">{wishlistStore.count}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
<div class="menu-wrap" bind:this={menuContainer}>
|
||||||
|
<button
|
||||||
|
class="nav-link"
|
||||||
|
aria-label="Menü"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onclick={() => (menuOpen = !menuOpen)}
|
||||||
|
>
|
||||||
|
<Menu size={22} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{#if menuOpen}
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
<a href="/recipes" class="menu-item" role="menuitem" onclick={() => (menuOpen = false)}>
|
||||||
|
<BookOpen size={18} strokeWidth={2} />
|
||||||
|
<span>Register</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin" class="menu-item" role="menuitem" onclick={() => (menuOpen = false)}>
|
||||||
|
<Settings size={18} strokeWidth={2} />
|
||||||
|
<span>Einstellungen</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ProfileSwitcher />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -42,23 +404,216 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: white;
|
background: white;
|
||||||
border-bottom: 1px solid #e4eae7;
|
border-bottom: 1px solid #e4eae7;
|
||||||
}
|
}
|
||||||
|
.bar-inner {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.home-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #2b6a3d;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.home-back:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.nav-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.nav-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.nav-search:focus-within {
|
||||||
|
outline: 2px solid #2b6a3d;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.nav-search input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.nav-search input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.dd-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.dd-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
.dd-item:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.dd-item img,
|
||||||
|
.dd-placeholder {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef3ef;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dd-body {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.dd-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dd-domain {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
.dd-status {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 0.9rem 0.6rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.dd-error {
|
||||||
|
color: #c53030;
|
||||||
|
}
|
||||||
|
.dd-section {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.6rem 0.85rem 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
border-bottom: 1px solid #f0f3f1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.dd-web {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #e4eae7;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: #fafdfb;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dd-web:hover:not(:disabled) {
|
||||||
|
background: #eaf4ed;
|
||||||
|
}
|
||||||
|
.dd-web:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
}
|
}
|
||||||
.bar-right {
|
.bar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.menu-wrap > .nav-link {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 0.3rem;
|
||||||
|
z-index: 55;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
}
|
}
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -69,13 +624,55 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background: #f4f8f5;
|
background: #f4f8f5;
|
||||||
}
|
}
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #c53030;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 2px white;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
main {
|
main {
|
||||||
padding: 0 1rem 4rem;
|
padding: 0 1rem 4rem;
|
||||||
max-width: 760px;
|
max-width: 1040px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
/* App-Icon auf engen Screens komplett aus — die Suche bekommt den Platz. */
|
||||||
|
.brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
/* Beim Tippen auf engen Screens nach rechts ausdehnen
|
||||||
|
und die Action-Icons dahinter verschwinden lassen. */
|
||||||
|
.nav-search-wrap:focus-within {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
.nav-search-wrap:focus-within .nav-search input {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,55 +1,576 @@
|
|||||||
<script lang="ts">
|
<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 { 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';
|
||||||
|
|
||||||
|
const LOCAL_PAGE = 30;
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
|
let quote = $state('');
|
||||||
let recent = $state<SearchHit[]>([]);
|
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;
|
||||||
|
|
||||||
onMount(async () => {
|
const ALL_PAGE = 10;
|
||||||
|
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
|
||||||
|
const ALL_SORTS: { value: AllSort; label: string }[] = [
|
||||||
|
{ value: 'name', label: 'Name' },
|
||||||
|
{ value: 'rating', label: 'Bewertung' },
|
||||||
|
{ value: 'cooked', label: 'Zuletzt gekocht' },
|
||||||
|
{ value: 'created', label: 'Hinzugefügt' }
|
||||||
|
];
|
||||||
|
let allRecipes = $state<SearchHit[]>([]);
|
||||||
|
let allSort = $state<AllSort>('name');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadRecent() {
|
||||||
const res = await fetch('/api/recipes/search');
|
const res = await fetch('/api/recipes/search');
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
recent = body.hits;
|
recent = body.hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllMore() {
|
||||||
|
if (allLoading || allExhausted) return;
|
||||||
|
allLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
const more = body.hits as SearchHit[];
|
||||||
|
const seen = new Set(allRecipes.map((h) => h.id));
|
||||||
|
const deduped = more.filter((h) => !seen.has(h.id));
|
||||||
|
allRecipes = [...allRecipes, ...deduped];
|
||||||
|
if (more.length < ALL_PAGE) allExhausted = true;
|
||||||
|
} finally {
|
||||||
|
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) {
|
||||||
|
const res = await fetch(`/api/recipes/favorites?profile_id=${profileId}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
favorites = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
favorites = body.hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
quote = randomQuote();
|
||||||
|
// 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;
|
||||||
|
void loadRecent();
|
||||||
|
void searchFilterStore.load();
|
||||||
|
const saved = localStorage.getItem('kochwas.allSort');
|
||||||
|
if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) {
|
||||||
|
allSort = saved as AllSort;
|
||||||
|
}
|
||||||
|
void loadAllMore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!allSentinel) return;
|
||||||
|
if (allExhausted) return;
|
||||||
|
if (allObserver) allObserver.disconnect();
|
||||||
|
allObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) void loadAllMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
allObserver.observe(allSentinel);
|
||||||
|
return () => {
|
||||||
|
allObserver?.disconnect();
|
||||||
|
allObserver = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bei Änderung der Domain-Auswahl: laufende Suche neu ausführen,
|
||||||
|
// damit der User nicht manuell re-tippen muss.
|
||||||
|
$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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync current query back into the URL as ?q=... via replaceState,
|
||||||
|
// without spamming the history stack. Pushing a new entry happens only
|
||||||
|
// when the user clicks a result or otherwise navigates away.
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const q = 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());
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const active = profileStore.active;
|
||||||
|
if (!active) {
|
||||||
|
favorites = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = query.trim();
|
||||||
|
if (q.length <= 3) return;
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
searching = true;
|
||||||
|
void 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',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hidden_from_recent: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSearch = $derived(query.trim().length > 3);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>Kochwas</h1>
|
<h1>Kochwas</h1>
|
||||||
<form method="GET" action="/search">
|
<p class="tagline" aria-live="polite">{quote || '\u00a0'}</p>
|
||||||
<input
|
<form class="search-form" onsubmit={submit}>
|
||||||
type="search"
|
<div class="search-box">
|
||||||
name="q"
|
<SearchFilter inline />
|
||||||
bind:value={query}
|
<input
|
||||||
placeholder="Rezept suchen…"
|
type="search"
|
||||||
autocomplete="off"
|
bind:value={query}
|
||||||
inputmode="search"
|
placeholder="Rezept suchen…"
|
||||||
aria-label="Suchbegriff"
|
autocomplete="off"
|
||||||
/>
|
inputmode="search"
|
||||||
<button type="submit" aria-label="Suchen">Suchen</button>
|
aria-label="Suchbegriff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if recent.length > 0}
|
{#if activeSearch}
|
||||||
<section class="recent">
|
<section class="results">
|
||||||
<h2>Zuletzt hinzugefügt</h2>
|
{#if searching && hits.length === 0 && webHits.length === 0}
|
||||||
<ul class="cards">
|
<SearchLoader scope="local" />
|
||||||
{#each recent as r (r.id)}
|
{:else}
|
||||||
<li>
|
{#if hits.length > 0}
|
||||||
<a href={`/recipes/${r.id}`} class="card">
|
<ul class="cards">
|
||||||
{#if r.image_path}
|
{#each hits as r (r.id)}
|
||||||
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
<li>
|
||||||
{:else}
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
<div class="placeholder">🥘</div>
|
{#if r.image_path}
|
||||||
{/if}
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
<div class="card-body">
|
{:else}
|
||||||
<div class="title">{r.title}</div>
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
{#if r.source_domain}
|
{/if}
|
||||||
<div class="domain">{r.source_domain}</div>
|
<div class="card-body">
|
||||||
|
<div class="title">{r.title}</div>
|
||||||
|
{#if r.source_domain}
|
||||||
|
<div class="domain">{r.source_domain}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if webHits.length > 0}
|
||||||
|
{#if hits.length > 0}
|
||||||
|
<h3 class="sep">Aus dem Internet</h3>
|
||||||
|
{:else if searchedFor === query.trim()}
|
||||||
|
<p class="muted no-local-msg">
|
||||||
|
Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<ul class="cards">
|
||||||
|
{#each webHits as w (w.url)}
|
||||||
|
<li>
|
||||||
|
<a class="card" href={`/preview?url=${encodeURIComponent(w.url)}`}>
|
||||||
|
{#if w.thumbnail}
|
||||||
|
<img src={w.thumbnail} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="title">{w.title}</div>
|
||||||
|
<div class="domain">{w.domain}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if webSearching}
|
||||||
|
<SearchLoader scope="web" />
|
||||||
|
{:else if webError && webHits.length === 0}
|
||||||
|
<p class="error">Internet-Suche zurzeit nicht möglich: {webError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if searchedFor === query.trim() && !(localExhausted && webExhausted) && !(searching && hits.length === 0)}
|
||||||
|
<div class="more-cta">
|
||||||
|
<button class="more-btn" onclick={loadMore} disabled={loadingMore || webSearching}>
|
||||||
|
{loadingMore || webSearching ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
{#if profileStore.active && favorites.length > 0}
|
||||||
|
<section class="listing">
|
||||||
|
<h2>Deine Favoriten</h2>
|
||||||
|
<ul class="cards">
|
||||||
|
{#each favorites as r (r.id)}
|
||||||
|
<li class="card-wrap">
|
||||||
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
|
{#if r.image_path}
|
||||||
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<div class="card-body">
|
||||||
</a>
|
<div class="title">{r.title}</div>
|
||||||
</li>
|
{#if r.source_domain}
|
||||||
|
<div class="domain">{r.source_domain}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if recent.length > 0}
|
||||||
|
<section class="listing">
|
||||||
|
<h2>Zuletzt hinzugefügt</h2>
|
||||||
|
<ul class="cards">
|
||||||
|
{#each recent as r (r.id)}
|
||||||
|
<li class="card-wrap">
|
||||||
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
|
{#if r.image_path}
|
||||||
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="title">{r.title}</div>
|
||||||
|
{#if r.source_domain}
|
||||||
|
<div class="domain">{r.source_domain}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="dismiss"
|
||||||
|
aria-label="Aus Zuletzt-hinzugefügt entfernen"
|
||||||
|
onclick={(e) => dismissFromRecent(r.id, e)}
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
<section class="listing">
|
||||||
|
<div class="listing-head">
|
||||||
|
<h2>Alle Rezepte</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="sort-chips"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Sortierung"
|
||||||
|
bind:this={allChips}
|
||||||
|
>
|
||||||
|
{#each ALL_SORTS as s (s.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={allSort === s.value}
|
||||||
|
class="chip"
|
||||||
|
class:active={allSort === s.value}
|
||||||
|
onclick={() => setAllSort(s.value)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
|
{#if allRecipes.length === 0 && allExhausted}
|
||||||
|
<p class="muted">Noch keine Rezepte gespeichert.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="cards">
|
||||||
|
{#each allRecipes as r (r.id)}
|
||||||
|
<li>
|
||||||
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
|
{#if r.image_path}
|
||||||
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder"><CookingPot size={36} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="title">{r.title}</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
{#if r.avg_stars !== null}
|
||||||
|
<span class="stars">★ {r.avg_stars.toFixed(1)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if r.source_domain}
|
||||||
|
<span class="domain">{r.source_domain}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if !allExhausted}
|
||||||
|
<div bind:this={allSentinel} class="sentinel" aria-hidden="true">
|
||||||
|
{#if allLoading}<span class="loading">Lade …</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -60,45 +581,132 @@
|
|||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: clamp(2.2rem, 8vw, 3.5rem);
|
font-size: clamp(2.2rem, 8vw, 3.5rem);
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
.tagline {
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
max-width: 36rem;
|
||||||
|
color: #6a7670;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
min-height: 1.4rem;
|
||||||
|
}
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
}
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 52px;
|
||||||
|
/* Kein overflow:hidden — sonst clippt der Filter-Dropdown. */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.search-box:focus-within {
|
||||||
|
outline: 2px solid #2b6a3d;
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
border: 1px solid #cfd9d1;
|
border: 0;
|
||||||
border-radius: 10px;
|
background: transparent;
|
||||||
background: white;
|
min-width: 0;
|
||||||
min-height: 48px;
|
|
||||||
}
|
}
|
||||||
input[type='search']:focus {
|
input[type='search']:focus {
|
||||||
outline: 2px solid #2b6a3d;
|
outline: none;
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
}
|
||||||
button {
|
.results,
|
||||||
padding: 0.9rem 1.25rem;
|
.listing {
|
||||||
font-size: 1rem;
|
margin-top: 1.5rem;
|
||||||
border-radius: 10px;
|
|
||||||
border: 0;
|
|
||||||
background: #2b6a3d;
|
|
||||||
color: white;
|
|
||||||
min-height: 48px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.recent {
|
.listing h2 {
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
.recent h2 {
|
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
color: #444;
|
color: #444;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
.listing-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.listing-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sort-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 999px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.meta-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stars {
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sentinel {
|
||||||
|
min-height: 40px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.no-local-msg {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #c53030;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
.cards {
|
.cards {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -107,6 +715,9 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.card-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.card {
|
.card {
|
||||||
display: block;
|
display: block;
|
||||||
background: white;
|
background: white;
|
||||||
@@ -128,7 +739,7 @@
|
|||||||
background: #eef3ef;
|
background: #eef3ef;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 2rem;
|
color: #8fb097;
|
||||||
}
|
}
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 0.6rem 0.75rem 0.75rem;
|
padding: 0.6rem 0.75rem 0.75rem;
|
||||||
@@ -143,4 +754,55 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.dismiss {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #444;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.dismiss:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #c53030;
|
||||||
|
}
|
||||||
|
.sep {
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
border-bottom: 1px solid #e4eae7;
|
||||||
|
}
|
||||||
|
.more-cta {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.more-btn {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: white;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.more-btn:hover:not(:disabled) {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.more-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { Globe, Users, DatabaseBackup, Smartphone, type Icon } from 'lucide-svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const items = [
|
const items: { href: string; label: string; icon: typeof Icon }[] = [
|
||||||
{ href: '/admin/domains', label: '🌐 Domains' },
|
{ href: '/admin/domains', label: 'Domains', icon: Globe },
|
||||||
{ href: '/admin/profiles', label: '👥 Profile' },
|
{ href: '/admin/profiles', label: 'Profile', icon: Users },
|
||||||
{ href: '/admin/backup', label: '💾 Backup' }
|
{ href: '/admin/backup', label: 'Backup', icon: DatabaseBackup },
|
||||||
|
{ href: '/admin/app', label: 'App', icon: Smartphone }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
{#each items as item (item.href)}
|
{#each items as item (item.href)}
|
||||||
|
{@const Icon = item.icon}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="tab"
|
class="tab"
|
||||||
class:active={$page.url.pathname.startsWith(item.href)}
|
class:active={$page.url.pathname.startsWith(item.href)}
|
||||||
>
|
>
|
||||||
{item.label}
|
<Icon size={16} strokeWidth={2} />
|
||||||
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.5rem 0.95rem 0.5rem 0.8rem;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e4eae7;
|
border: 1px solid #e4eae7;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -46,6 +50,7 @@
|
|||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: #2b6a3d;
|
background: #2b6a3d;
|
||||||
|
|||||||
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>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
||||||
import type { AllowedDomain } from '$lib/types';
|
import type { AllowedDomain } from '$lib/types';
|
||||||
|
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
|
||||||
let domains = $state<AllowedDomain[]>([]);
|
let domains = $state<AllowedDomain[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -9,6 +12,11 @@
|
|||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
let errored = $state<string | null>(null);
|
let errored = $state<string | null>(null);
|
||||||
|
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let editDomain = $state('');
|
||||||
|
let editLabel = $state('');
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await fetch('/api/domains');
|
const res = await fetch('/api/domains');
|
||||||
domains = await res.json();
|
domains = await res.json();
|
||||||
@@ -18,6 +26,7 @@
|
|||||||
async function add() {
|
async function add() {
|
||||||
errored = null;
|
errored = null;
|
||||||
if (!newDomain.trim()) return;
|
if (!newDomain.trim()) return;
|
||||||
|
if (!requireOnline('Das Hinzufügen')) return;
|
||||||
adding = true;
|
adding = true;
|
||||||
const res = await fetch('/api/domains', {
|
const res = await fetch('/api/domains', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -38,9 +47,56 @@
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
function startEdit(d: AllowedDomain) {
|
||||||
if (!confirm('Domain entfernen?')) return;
|
editingId = d.id;
|
||||||
await fetch(`/api/domains/${id}`, { method: 'DELETE' });
|
editDomain = d.domain;
|
||||||
|
editLabel = d.display_name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editDomain = '';
|
||||||
|
editLabel = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
cancelEdit();
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(d: AllowedDomain) {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Domain entfernen?',
|
||||||
|
message: `${d.domain} wird nicht mehr durchsucht. Gespeicherte Rezepte bleiben erhalten.`,
|
||||||
|
confirmLabel: 'Entfernen',
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Entfernen')) return;
|
||||||
|
await fetch(`/api/domains/${d.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +132,59 @@
|
|||||||
<ul class="list">
|
<ul class="list">
|
||||||
{#each domains as d (d.id)}
|
{#each domains as d (d.id)}
|
||||||
<li>
|
<li>
|
||||||
<div>
|
{#if d.favicon_path}
|
||||||
<div class="dom">{d.domain}</div>
|
<img class="fav" src={`/images/${d.favicon_path}`} alt="" loading="lazy" />
|
||||||
{#if d.display_name}<div class="label">{d.display_name}</div>{/if}
|
{:else}
|
||||||
</div>
|
<span class="fav fallback" aria-hidden="true"><Globe size={18} strokeWidth={1.8} /></span>
|
||||||
<button class="btn danger" onclick={() => remove(d.id)}>Löschen</button>
|
{/if}
|
||||||
|
{#if editingId === d.id}
|
||||||
|
<div class="edit-fields">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editDomain}
|
||||||
|
placeholder="chefkoch.de"
|
||||||
|
aria-label="Domain"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editLabel}
|
||||||
|
placeholder="Anzeigename (optional)"
|
||||||
|
aria-label="Anzeigename"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
class="btn primary icon-btn"
|
||||||
|
aria-label="Speichern"
|
||||||
|
disabled={saving}
|
||||||
|
onclick={() => saveEdit(d)}
|
||||||
|
>
|
||||||
|
<Check size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-btn"
|
||||||
|
aria-label="Abbrechen"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="info">
|
||||||
|
<div class="dom">{d.domain}</div>
|
||||||
|
{#if d.display_name}<div class="label">{d.display_name}</div>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
class="btn icon-btn"
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
onclick={() => startEdit(d)}
|
||||||
|
>
|
||||||
|
<Pencil size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button class="btn danger" onclick={() => remove(d)}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -143,11 +247,15 @@
|
|||||||
.list li {
|
.list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.75rem;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e4eae7;
|
border: 1px solid #e4eae7;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.7rem 0.85rem;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.dom {
|
.dom {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -156,6 +264,48 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.fav {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fav.fallback {
|
||||||
|
background: #eef3ef;
|
||||||
|
color: #8fb097;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.edit-fields {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.edit-fields input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
.error {
|
.error {
|
||||||
color: #c53030;
|
color: #c53030;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
|
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
|
||||||
let newName = $state('');
|
let newName = $state('');
|
||||||
let newEmoji = $state('🍳');
|
let newEmoji = $state('🍳');
|
||||||
@@ -9,6 +11,7 @@
|
|||||||
async function add() {
|
async function add() {
|
||||||
errored = null;
|
errored = null;
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
|
if (!requireOnline('Das Anlegen')) return;
|
||||||
adding = true;
|
adding = true;
|
||||||
try {
|
try {
|
||||||
await profileStore.create(newName.trim(), newEmoji || null);
|
await profileStore.create(newName.trim(), newEmoji || null);
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
async function rename(id: number, currentName: string) {
|
async function rename(id: number, currentName: string) {
|
||||||
const next = prompt('Neuer Name:', currentName);
|
const next = prompt('Neuer Name:', currentName);
|
||||||
if (!next || next === currentName) return;
|
if (!next || next === currentName) return;
|
||||||
|
if (!requireOnline('Das Umbenennen')) return;
|
||||||
const res = await fetch(`/api/profiles/${id}`, {
|
const res = await fetch(`/api/profiles/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -30,14 +34,25 @@
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
alert(`Fehler: ${body.message ?? res.status}`);
|
await alertAction({
|
||||||
|
title: 'Umbenennen fehlgeschlagen',
|
||||||
|
message: body.message ?? `HTTP ${res.status}`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await profileStore.load();
|
await profileStore.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
async function remove(id: number, name: string) {
|
||||||
if (!confirm('Profil wirklich löschen? Bewertungen, Favoriten und Kochjournal dieses Profils werden mit gelöscht.')) return;
|
const ok = await confirmAction({
|
||||||
|
title: `Profil „${name}" löschen?`,
|
||||||
|
message:
|
||||||
|
'Bewertungen, Favoriten und Kochjournal-Einträge dieses Profils werden mit gelöscht. Rezepte und Kommentare bleiben erhalten.',
|
||||||
|
confirmLabel: 'Löschen',
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Löschen')) return;
|
||||||
await fetch(`/api/profiles/${id}`, { method: 'DELETE' });
|
await fetch(`/api/profiles/${id}`, { method: 'DELETE' });
|
||||||
if (profileStore.activeId === id) profileStore.clear();
|
if (profileStore.activeId === id) profileStore.clear();
|
||||||
await profileStore.load();
|
await profileStore.load();
|
||||||
@@ -82,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" onclick={() => rename(p.id, p.name)}>Umbenennen</button>
|
<button class="btn" onclick={() => rename(p.id, p.name)}>Umbenennen</button>
|
||||||
<button class="btn danger" onclick={() => remove(p.id)}>Löschen</button>
|
<button class="btn danger" onclick={() => remove(p.id, p.name)}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { RequestHandler } from './$types';
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
import { addDomain, listDomains } from '$lib/server/domains/repository';
|
import { addDomain, listDomains, setDomainFavicon } from '$lib/server/domains/repository';
|
||||||
|
import { ensureFavicons, fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||||
|
|
||||||
const CreateSchema = z.object({
|
const CreateSchema = z.object({
|
||||||
domain: z.string().min(3).max(253),
|
domain: z.string().min(3).max(253),
|
||||||
@@ -10,8 +11,13 @@ const CreateSchema = z.object({
|
|||||||
added_by_profile_id: z.number().int().positive().nullable().optional()
|
added_by_profile_id: z.number().int().positive().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
return json(listDomains(getDb()));
|
const db = getDb();
|
||||||
|
// Favicons lazy nachziehen — beim zweiten Aufruf gibt es nichts mehr zu tun.
|
||||||
|
await ensureFavicons(db, IMAGE_DIR);
|
||||||
|
return json(listDomains(db));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
@@ -19,12 +25,20 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const parsed = CreateSchema.safeParse(body);
|
const parsed = CreateSchema.safeParse(body);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||||
try {
|
try {
|
||||||
|
const db = getDb();
|
||||||
const d = addDomain(
|
const d = addDomain(
|
||||||
getDb(),
|
db,
|
||||||
parsed.data.domain,
|
parsed.data.domain,
|
||||||
parsed.data.display_name ?? null,
|
parsed.data.display_name ?? null,
|
||||||
parsed.data.added_by_profile_id ?? null
|
parsed.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.
|
||||||
|
const favicon = await fetchAndStoreFavicon(d.domain, IMAGE_DIR);
|
||||||
|
if (favicon) {
|
||||||
|
setDomainFavicon(db, d.id, favicon);
|
||||||
|
d.favicon_path = favicon;
|
||||||
|
}
|
||||||
return json(d, { status: 201 });
|
return json(d, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(409, { message: (e as Error).message });
|
error(409, { message: (e as Error).message });
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
import { removeDomain } from '$lib/server/domains/repository';
|
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';
|
||||||
|
|
||||||
|
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' });
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const updated = updateDomain(db, id, parsed.data);
|
||||||
|
if (!updated) error(404, { message: 'Not found' });
|
||||||
|
// Wenn updateDomain favicon_path genullt hat (Domain geändert), frisch laden.
|
||||||
|
if (updated.favicon_path === null) {
|
||||||
|
const path = await fetchAndStoreFavicon(updated.domain, IMAGE_DIR);
|
||||||
|
if (path) {
|
||||||
|
setDomainFavicon(db, updated.id, path);
|
||||||
|
updated.favicon_path = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(updated);
|
||||||
|
} catch (e) {
|
||||||
|
error(409, { message: (e as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params }) => {
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
const id = Number(params.id);
|
const id = parseId(params.id!);
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
removeDomain(getDb(), id);
|
removeDomain(getDb(), id);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,15 +2,51 @@ import type { RequestHandler } from './$types';
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
import { deleteRecipe, getRecipeById } from '$lib/server/recipes/repository';
|
import {
|
||||||
|
deleteRecipe,
|
||||||
|
getRecipeById,
|
||||||
|
replaceIngredients,
|
||||||
|
replaceSteps,
|
||||||
|
updateRecipeMeta
|
||||||
|
} from '$lib/server/recipes/repository';
|
||||||
import {
|
import {
|
||||||
listComments,
|
listComments,
|
||||||
listCookingLog,
|
listCookingLog,
|
||||||
listRatings,
|
listRatings,
|
||||||
renameRecipe
|
renameRecipe,
|
||||||
|
setRecipeHiddenFromRecent
|
||||||
} from '$lib/server/recipes/actions';
|
} from '$lib/server/recipes/actions';
|
||||||
|
|
||||||
const RenameSchema = z.object({ title: z.string().min(1).max(200) });
|
const IngredientSchema = z.object({
|
||||||
|
position: z.number().int().nonnegative(),
|
||||||
|
quantity: z.number().nullable(),
|
||||||
|
unit: z.string().max(30).nullable(),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
note: z.string().max(300).nullable(),
|
||||||
|
raw_text: z.string().max(500)
|
||||||
|
});
|
||||||
|
|
||||||
|
const StepSchema = z.object({
|
||||||
|
position: z.number().int().positive(),
|
||||||
|
text: z.string().min(1).max(4000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const PatchSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().max(2000).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(),
|
||||||
|
total_time_min: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
cuisine: z.string().max(60).nullable().optional(),
|
||||||
|
category: z.string().max(60).nullable().optional(),
|
||||||
|
ingredients: z.array(IngredientSchema).optional(),
|
||||||
|
steps: z.array(StepSchema).optional(),
|
||||||
|
hidden_from_recent: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.refine((v) => Object.keys(v).length > 0, { message: 'Empty patch' });
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
function parseId(raw: string): number {
|
||||||
const id = Number(raw);
|
const id = Number(raw);
|
||||||
@@ -34,10 +70,54 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parseId(params.id!);
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
const parsed = RenameSchema.safeParse(body);
|
const parsed = PatchSchema.safeParse(body);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||||
renameRecipe(getDb(), id, parsed.data.title);
|
const db = getDb();
|
||||||
return json({ ok: true });
|
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) {
|
||||||
|
renameRecipe(db, id, p.title);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
if (p.hidden_from_recent !== undefined && Object.keys(p).length === 1) {
|
||||||
|
setRecipeHiddenFromRecent(db, id, p.hidden_from_recent);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
// Voller Edit-Modus-Patch.
|
||||||
|
const hasMeta =
|
||||||
|
p.title !== undefined ||
|
||||||
|
p.description !== undefined ||
|
||||||
|
p.servings_default !== undefined ||
|
||||||
|
p.servings_unit !== undefined ||
|
||||||
|
p.prep_time_min !== undefined ||
|
||||||
|
p.cook_time_min !== undefined ||
|
||||||
|
p.total_time_min !== undefined ||
|
||||||
|
p.cuisine !== undefined ||
|
||||||
|
p.category !== undefined;
|
||||||
|
if (hasMeta) {
|
||||||
|
updateRecipeMeta(db, id, {
|
||||||
|
title: p.title,
|
||||||
|
description: p.description,
|
||||||
|
servings_default: p.servings_default,
|
||||||
|
servings_unit: p.servings_unit,
|
||||||
|
prep_time_min: p.prep_time_min,
|
||||||
|
cook_time_min: p.cook_time_min,
|
||||||
|
total_time_min: p.total_time_min,
|
||||||
|
cuisine: p.cuisine,
|
||||||
|
category: p.category
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (p.ingredients !== undefined) {
|
||||||
|
replaceIngredients(db, id, p.ingredients);
|
||||||
|
}
|
||||||
|
if (p.steps !== undefined) {
|
||||||
|
replaceSteps(db, id, p.steps);
|
||||||
|
}
|
||||||
|
if (p.hidden_from_recent !== undefined) {
|
||||||
|
setRecipeHiddenFromRecent(db, id, p.hidden_from_recent);
|
||||||
|
}
|
||||||
|
return json({ ok: true, recipe: getRecipeById(db, id) });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params }) => {
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
import { logCooked } from '$lib/server/recipes/actions';
|
import { logCooked } from '$lib/server/recipes/actions';
|
||||||
|
import { removeFromWishlistForAll } from '$lib/server/wishlist/repository';
|
||||||
|
|
||||||
const Schema = z.object({ profile_id: z.number().int().positive() });
|
const Schema = z.object({ profile_id: z.number().int().positive() });
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
|||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
const parsed = Schema.safeParse(body);
|
const parsed = Schema.safeParse(body);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||||
const entry = logCooked(getDb(), id, parsed.data.profile_id);
|
const db = getDb();
|
||||||
return json(entry, { status: 201 });
|
const entry = logCooked(db, id, parsed.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.
|
||||||
|
removeFromWishlistForAll(db, id);
|
||||||
|
return json({ ...entry, removed_from_wishlist: true }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
18
src/routes/api/recipes/all/+server.ts
Normal file
18
src/routes/api/recipes/all/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getDb } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
listAllRecipesPaginated,
|
||||||
|
type AllRecipesSort
|
||||||
|
} from '$lib/server/recipes/search-local';
|
||||||
|
|
||||||
|
const VALID_SORTS = new Set<AllRecipesSort>(['name', 'rating', 'cooked', 'created']);
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort;
|
||||||
|
if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' });
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
|
||||||
|
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
||||||
|
const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset);
|
||||||
|
return json({ sort: sortRaw, limit, offset, hits });
|
||||||
|
};
|
||||||
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 });
|
||||||
|
};
|
||||||
14
src/routes/api/recipes/favorites/+server.ts
Normal file
14
src/routes/api/recipes/favorites/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { listFavoritesForProfile } from '$lib/server/recipes/search-local';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const raw = url.searchParams.get('profile_id');
|
||||||
|
const profileId = raw === null ? NaN : Number(raw);
|
||||||
|
if (!Number.isInteger(profileId) || profileId <= 0) {
|
||||||
|
error(400, { message: 'profile_id required' });
|
||||||
|
}
|
||||||
|
const hits = listFavoritesForProfile(getDb(), profileId);
|
||||||
|
return json({ hits });
|
||||||
|
};
|
||||||
@@ -6,6 +6,16 @@ import { listRecentRecipes, searchLocal } from '$lib/server/recipes/search-local
|
|||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const q = url.searchParams.get('q')?.trim() ?? '';
|
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||||
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
|
||||||
const hits = q.length >= 1 ? searchLocal(getDb(), q, limit) : listRecentRecipes(getDb(), limit);
|
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
||||||
|
const domains = (url.searchParams.get('domains') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const hits =
|
||||||
|
q.length >= 1
|
||||||
|
? searchLocal(getDb(), q, limit, offset, domains)
|
||||||
|
: offset === 0
|
||||||
|
? listRecentRecipes(getDb(), limit)
|
||||||
|
: [];
|
||||||
return json({ query: q, hits });
|
return json({ query: q, hits });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import { searchWeb } from '$lib/server/search/searxng';
|
|||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const q = url.searchParams.get('q')?.trim() ?? '';
|
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||||
if (!q) error(400, { message: 'Missing ?q=' });
|
if (!q) error(400, { message: 'Missing ?q=' });
|
||||||
|
const pageno = Math.max(1, Math.min(10, Number(url.searchParams.get('pageno') ?? 1)));
|
||||||
|
const domains = (url.searchParams.get('domains') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean);
|
||||||
try {
|
try {
|
||||||
const hits = await searchWeb(getDb(), q);
|
const hits = await searchWeb(getDb(), q, { pageno, domains });
|
||||||
return json({ query: q, hits });
|
return json({ query: q, pageno, hits });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(502, { message: `Web search unavailable: ${(e as Error).message}` });
|
error(502, { message: `Web search unavailable: ${(e as Error).message}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
|
|
||||||
const AddSchema = z.object({
|
const AddSchema = z.object({
|
||||||
recipe_id: z.number().int().positive(),
|
recipe_id: z.number().int().positive(),
|
||||||
profile_id: z.number().int().positive().nullable().optional()
|
profile_id: z.number().int().positive()
|
||||||
});
|
});
|
||||||
|
|
||||||
const VALID_SORTS: readonly SortKey[] = ['popular', 'newest', 'oldest'] as const;
|
const VALID_SORTS: readonly SortKey[] = ['popular', 'newest', 'oldest'] as const;
|
||||||
@@ -34,7 +34,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
const parsed = AddSchema.safeParse(body);
|
const parsed = AddSchema.safeParse(body);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' });
|
||||||
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id ?? null);
|
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id);
|
||||||
return json({ ok: true }, { status: 201 });
|
return json({ ok: true }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
import { removeFromWishlist } from '$lib/server/wishlist/repository';
|
import {
|
||||||
|
removeFromWishlist,
|
||||||
|
removeFromWishlistForAll
|
||||||
|
} from '$lib/server/wishlist/repository';
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
function parsePositiveInt(raw: string | null, field: string): number {
|
||||||
const id = Number(raw);
|
const n = raw === null ? NaN : Number(raw);
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid recipe_id' });
|
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
|
||||||
return id;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params }) => {
|
// DELETE /api/wishlist/:id?profile_id=X → entfernt nur den eigenen Wunsch
|
||||||
const id = parseId(params.recipe_id!);
|
// DELETE /api/wishlist/:id?all=true → entfernt für ALLE Profile
|
||||||
removeFromWishlist(getDb(), id);
|
export const DELETE: RequestHandler = async ({ params, url }) => {
|
||||||
|
const id = parsePositiveInt(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');
|
||||||
|
removeFromWishlist(db, id, profileId);
|
||||||
|
}
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { json, error } from '@sveltejs/kit';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { getDb } from '$lib/server/db';
|
|
||||||
import { likeWish, unlikeWish } 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 recipe_id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ params, request }) => {
|
|
||||||
const id = parseId(params.recipe_id!);
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
const parsed = Schema.safeParse(body);
|
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
likeWish(getDb(), id, parsed.data.profile_id);
|
|
||||||
return json({ ok: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
|
||||||
const id = parseId(params.recipe_id!);
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
const parsed = Schema.safeParse(body);
|
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
unlikeWish(getDb(), id, parsed.data.profile_id);
|
|
||||||
return json({ ok: true });
|
|
||||||
};
|
|
||||||
8
src/routes/api/wishlist/count/+server.ts
Normal file
8
src/routes/api/wishlist/count/+server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { countWishlistRecipes } from '$lib/server/wishlist/repository';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
return json({ count: countWishlistRecipes(getDb()) });
|
||||||
|
};
|
||||||
@@ -11,7 +11,9 @@ const MIME: Record<string, string> = {
|
|||||||
'.png': 'image/png',
|
'.png': 'image/png',
|
||||||
'.webp': 'image/webp',
|
'.webp': 'image/webp',
|
||||||
'.gif': 'image/gif',
|
'.gif': 'image/gif',
|
||||||
'.avif': 'image/avif'
|
'.avif': 'image/avif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.svg': 'image/svg+xml'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GET: RequestHandler = ({ params }) => {
|
export const GET: RequestHandler = ({ params }) => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { BookmarkPlus } from 'lucide-svelte';
|
||||||
import RecipeView from '$lib/components/RecipeView.svelte';
|
import RecipeView from '$lib/components/RecipeView.svelte';
|
||||||
|
import { alertAction } from '$lib/client/confirm.svelte';
|
||||||
import type { Recipe } from '$lib/types';
|
import type { Recipe } from '$lib/types';
|
||||||
|
|
||||||
let targetUrl = $state(($page.url.searchParams.get('url') ?? '').trim());
|
let targetUrl = $state(($page.url.searchParams.get('url') ?? '').trim());
|
||||||
@@ -45,7 +47,10 @@
|
|||||||
saving = false;
|
saving = false;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
alert(`Speichern fehlgeschlagen: ${body.message ?? res.status}`);
|
await alertAction({
|
||||||
|
title: 'Speichern fehlgeschlagen',
|
||||||
|
message: body.message ?? `HTTP ${res.status}`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -81,7 +86,12 @@
|
|||||||
{#snippet showActions()}
|
{#snippet showActions()}
|
||||||
<div class="save-bar">
|
<div class="save-bar">
|
||||||
<button class="btn primary" onclick={save} disabled={saving}>
|
<button class="btn primary" onclick={save} disabled={saving}>
|
||||||
{saving ? 'Speichern…' : '💾 In meine Sammlung speichern'}
|
{#if saving}
|
||||||
|
<span>Speichern…</span>
|
||||||
|
{:else}
|
||||||
|
<BookmarkPlus size={18} strokeWidth={2} />
|
||||||
|
<span>Rezept in Kochwas speichern</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn ghost" onclick={() => history.back()}>Zurück</button>
|
<button type="button" class="btn ghost" onclick={() => history.back()}>Zurück</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
src/routes/recipes/+page.server.ts
Normal file
8
src/routes/recipes/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { listAllRecipes } from '$lib/server/recipes/search-local';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const db = getDb();
|
||||||
|
return { recipes: listAllRecipes(db) };
|
||||||
|
};
|
||||||
539
src/routes/recipes/+page.svelte
Normal file
539
src/routes/recipes/+page.svelte
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 {
|
||||||
|
return s
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionKey(title: string): string {
|
||||||
|
const first = normalize(title.trim()).charAt(0).toUpperCase();
|
||||||
|
return /[A-Z]/.test(first) ? first : '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
|
||||||
|
|
||||||
|
type Hit = PageData['recipes'][number];
|
||||||
|
type Section = { letter: string; recipes: Hit[] };
|
||||||
|
|
||||||
|
const sections = $derived.by<Section[]>(() => {
|
||||||
|
const f = normalize(filter.trim());
|
||||||
|
const filtered = f
|
||||||
|
? data.recipes.filter((r) => normalize(r.title).includes(f))
|
||||||
|
: data.recipes;
|
||||||
|
const sorted = [...filtered].sort((a, b) => collator.compare(a.title, b.title));
|
||||||
|
const groups = new Map<string, Hit[]>();
|
||||||
|
for (const r of sorted) {
|
||||||
|
const key = sectionKey(r.title);
|
||||||
|
const arr = groups.get(key);
|
||||||
|
if (arr) arr.push(r);
|
||||||
|
else groups.set(key, [r]);
|
||||||
|
}
|
||||||
|
// '#' am Ende, sonst alphabetisch
|
||||||
|
return [...groups.entries()]
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
if (a === '#') return 1;
|
||||||
|
if (b === '#') return -1;
|
||||||
|
return collator.compare(a, b);
|
||||||
|
})
|
||||||
|
.map(([letter, recipes]) => ({ letter, recipes }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const letters = $derived(sections.map((s) => s.letter));
|
||||||
|
|
||||||
|
function scrollToLetter(letter: string) {
|
||||||
|
const el = document.getElementById(`sect-${letter}`);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{#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
|
||||||
|
type="search"
|
||||||
|
bind:value={filter}
|
||||||
|
placeholder="Rezepte filtern…"
|
||||||
|
autocomplete="off"
|
||||||
|
inputmode="search"
|
||||||
|
aria-label="Rezepte filtern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if letters.length > 1 && !filter.trim()}
|
||||||
|
<nav class="letters" aria-label="Buchstaben-Navigation">
|
||||||
|
{#each letters as l}
|
||||||
|
<button class="letter-chip" onclick={() => scrollToLetter(l)}>{l}</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if sections.length === 0}
|
||||||
|
<p class="empty">Nichts passt zu „{filter}".</p>
|
||||||
|
{:else}
|
||||||
|
{#each sections as sect (sect.letter)}
|
||||||
|
<section class="sect" id={`sect-${sect.letter}`}>
|
||||||
|
<h2 class="letter">{sect.letter}</h2>
|
||||||
|
<ul class="list">
|
||||||
|
{#each sect.recipes as r (r.id)}
|
||||||
|
<li>
|
||||||
|
<a class="item" href={`/recipes/${r.id}`}>
|
||||||
|
{#if r.image_path}
|
||||||
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder"><CookingPot size={22} /></div>
|
||||||
|
{/if}
|
||||||
|
<div class="body">
|
||||||
|
<div class="title">{r.title}</div>
|
||||||
|
<div class="meta">
|
||||||
|
{#if r.source_domain}<span>{r.source_domain}</span>{/if}
|
||||||
|
{#if r.avg_stars !== null}<span>· ★ {r.avg_stars.toFixed(1)}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.add-menu {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.add-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.add-btn:hover {
|
||||||
|
background: #235532;
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
position: sticky;
|
||||||
|
top: 57px;
|
||||||
|
z-index: 5;
|
||||||
|
background: #f8faf8;
|
||||||
|
padding: 0.75rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.filter-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: white;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.filter-wrap input:focus {
|
||||||
|
outline: 2px solid #2b6a3d;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.letters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.letter-chip {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.letter-chip:hover {
|
||||||
|
background: #eaf4ed;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
.sect {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
scroll-margin-top: 115px;
|
||||||
|
}
|
||||||
|
.sect h2.letter {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem 0.1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-bottom: 1px solid #e4eae7;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.55rem 0.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border-bottom: 1px solid #f0f3f1;
|
||||||
|
min-height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.item:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.item img,
|
||||||
|
.placeholder {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef3ef;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #8fb097;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,8 +5,10 @@ import { getRecipeById } from '$lib/server/recipes/repository';
|
|||||||
import {
|
import {
|
||||||
listComments,
|
listComments,
|
||||||
listCookingLog,
|
listCookingLog,
|
||||||
|
listFavoriteProfiles,
|
||||||
listRatings
|
listRatings
|
||||||
} from '$lib/server/recipes/actions';
|
} from '$lib/server/recipes/actions';
|
||||||
|
import { listWishlistProfileIds } from '$lib/server/wishlist/repository';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
@@ -17,7 +19,17 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
const ratings = listRatings(db, id);
|
const ratings = listRatings(db, id);
|
||||||
const comments = listComments(db, id);
|
const comments = listComments(db, id);
|
||||||
const cooking_log = listCookingLog(db, id);
|
const cooking_log = listCookingLog(db, id);
|
||||||
|
const favorite_profile_ids = listFavoriteProfiles(db, id);
|
||||||
|
const wishlist_profile_ids = listWishlistProfileIds(db, id);
|
||||||
const avg_stars =
|
const avg_stars =
|
||||||
ratings.length === 0 ? null : ratings.reduce((s, r) => s + r.stars, 0) / ratings.length;
|
ratings.length === 0 ? null : ratings.reduce((s, r) => s + r.stars, 0) / ratings.length;
|
||||||
return { recipe, ratings, comments, cooking_log, avg_stars };
|
return {
|
||||||
|
recipe,
|
||||||
|
ratings,
|
||||||
|
comments,
|
||||||
|
cooking_log,
|
||||||
|
favorite_profile_ids,
|
||||||
|
wishlist_profile_ids,
|
||||||
|
avg_stars
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import {
|
||||||
|
Heart,
|
||||||
|
Utensils,
|
||||||
|
Printer,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ChefHat,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Lightbulb,
|
||||||
|
LightbulbOff
|
||||||
|
} from 'lucide-svelte';
|
||||||
import RecipeView from '$lib/components/RecipeView.svelte';
|
import RecipeView from '$lib/components/RecipeView.svelte';
|
||||||
|
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
|
||||||
import StarRating from '$lib/components/StarRating.svelte';
|
import StarRating from '$lib/components/StarRating.svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
|
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||||
|
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
import type { CommentRow } from '$lib/server/recipes/actions';
|
import type { CommentRow } from '$lib/server/recipes/actions';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -12,14 +29,82 @@
|
|||||||
let ratings = $state<typeof data.ratings>([]);
|
let ratings = $state<typeof data.ratings>([]);
|
||||||
let comments = $state<CommentRow[]>([]);
|
let comments = $state<CommentRow[]>([]);
|
||||||
let cookingLog = $state<typeof data.cooking_log>([]);
|
let cookingLog = $state<typeof data.cooking_log>([]);
|
||||||
let isFav = $state(false);
|
let favoriteProfileIds = $state<number[]>([]);
|
||||||
let onWishlist = $state(false);
|
let wishlistProfileIds = $state<number[]>([]);
|
||||||
let newComment = $state('');
|
let newComment = $state('');
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let editingTitle = $state(false);
|
||||||
|
let titleDraft = $state('');
|
||||||
|
let titleInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
let editMode = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let recipeState = $state(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;
|
||||||
|
description: string | null;
|
||||||
|
servings_default: number | null;
|
||||||
|
prep_time_min: number | null;
|
||||||
|
cook_time_min: number | null;
|
||||||
|
total_time_min: number | null;
|
||||||
|
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 body = await res.json();
|
||||||
|
if (body.recipe) {
|
||||||
|
recipeState = body.recipe;
|
||||||
|
title = body.recipe.title;
|
||||||
|
}
|
||||||
|
editMode = false;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
ratings = [...data.ratings];
|
ratings = [...data.ratings];
|
||||||
comments = [...data.comments];
|
comments = [...data.comments];
|
||||||
cookingLog = [...data.cooking_log];
|
cookingLog = [...data.cooking_log];
|
||||||
|
favoriteProfileIds = [...data.favorite_profile_ids];
|
||||||
|
wishlistProfileIds = [...data.wishlist_profile_ids];
|
||||||
|
title = data.recipe.title;
|
||||||
|
recipeState = data.recipe;
|
||||||
});
|
});
|
||||||
|
|
||||||
const myRating = $derived(
|
const myRating = $derived(
|
||||||
@@ -28,20 +113,23 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
async function checkFavorite() {
|
const isFav = $derived(
|
||||||
if (!profileStore.active) {
|
profileStore.active ? favoriteProfileIds.includes(profileStore.active.id) : false
|
||||||
isFav = false;
|
);
|
||||||
return;
|
|
||||||
}
|
const onMyWishlist = $derived(
|
||||||
// Fetch favorite status via list endpoint (quick hack: GET not implemented, infer from no-op)
|
profileStore.active ? wishlistProfileIds.includes(profileStore.active.id) : false
|
||||||
// Not critical for MVP — we mutate state on toggle.
|
);
|
||||||
}
|
|
||||||
|
|
||||||
async function setRating(stars: number) {
|
async function setRating(stars: number) {
|
||||||
if (!profileStore.active) {
|
if (!profileStore.active) {
|
||||||
alert('Bitte erst Profil wählen.');
|
await alertAction({
|
||||||
|
title: 'Kein Profil gewählt',
|
||||||
|
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!requireOnline('Das Rating')) return;
|
||||||
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
|
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -54,23 +142,36 @@
|
|||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
if (!profileStore.active) {
|
if (!profileStore.active) {
|
||||||
alert('Bitte erst Profil wählen.');
|
await alertAction({
|
||||||
|
title: 'Kein Profil gewählt',
|
||||||
|
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const method = isFav ? 'DELETE' : 'PUT';
|
if (!requireOnline('Das Favorit-Setzen')) return;
|
||||||
|
const profileId = profileStore.active.id;
|
||||||
|
const wasFav = isFav;
|
||||||
|
const method = wasFav ? 'DELETE' : 'PUT';
|
||||||
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
|
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
|
||||||
method,
|
method,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ profile_id: profileStore.active.id })
|
body: JSON.stringify({ profile_id: profileId })
|
||||||
});
|
});
|
||||||
isFav = !isFav;
|
favoriteProfileIds = wasFav
|
||||||
|
? favoriteProfileIds.filter((id) => id !== profileId)
|
||||||
|
: [...favoriteProfileIds, profileId];
|
||||||
|
if (!wasFav) void firePulse('fav');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logCooked() {
|
async function logCooked() {
|
||||||
if (!profileStore.active) {
|
if (!profileStore.active) {
|
||||||
alert('Bitte erst Profil wählen.');
|
await alertAction({
|
||||||
|
title: 'Kein Profil gewählt',
|
||||||
|
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!requireOnline('Der Kochjournal-Eintrag')) return;
|
||||||
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
|
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -78,13 +179,21 @@
|
|||||||
});
|
});
|
||||||
const entry = await res.json();
|
const entry = await res.json();
|
||||||
cookingLog = [entry, ...cookingLog];
|
cookingLog = [entry, ...cookingLog];
|
||||||
|
if (entry.removed_from_wishlist) {
|
||||||
|
wishlistProfileIds = [];
|
||||||
|
void wishlistStore.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addComment() {
|
async function addComment() {
|
||||||
if (!profileStore.active) {
|
if (!profileStore.active) {
|
||||||
alert('Bitte erst Profil wählen.');
|
await alertAction({
|
||||||
|
title: 'Kein Profil gewählt',
|
||||||
|
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!requireOnline('Das Speichern des Kommentars')) return;
|
||||||
const text = newComment.trim();
|
const text = newComment.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
|
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
|
||||||
@@ -109,76 +218,196 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRecipe() {
|
async function deleteRecipe() {
|
||||||
if (!confirm(`Rezept „${data.recipe.title}" wirklich löschen?`)) return;
|
const ok = await confirmAction({
|
||||||
|
title: 'Rezept löschen?',
|
||||||
|
message: `„${title}" wird endgültig entfernt — mit Bewertungen, Kommentaren und Kochjournal-Einträgen.`,
|
||||||
|
confirmLabel: 'Löschen',
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Löschen')) return;
|
||||||
await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' });
|
await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' });
|
||||||
goto('/');
|
goto('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameRecipe() {
|
async function startEditTitle() {
|
||||||
const newTitle = prompt('Neuer Titel:', data.recipe.title);
|
titleDraft = title;
|
||||||
if (!newTitle || newTitle === data.recipe.title) return;
|
editingTitle = true;
|
||||||
await fetch(`/api/recipes/${data.recipe.id}`, {
|
await tick();
|
||||||
|
titleInput?.focus();
|
||||||
|
titleInput?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditTitle() {
|
||||||
|
editingTitle = false;
|
||||||
|
titleDraft = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTitle() {
|
||||||
|
const next = titleDraft.trim();
|
||||||
|
if (!next || next === title) {
|
||||||
|
editingTitle = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!requireOnline('Das Umbenennen')) return;
|
||||||
|
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ title: newTitle })
|
body: JSON.stringify({ title: next })
|
||||||
});
|
});
|
||||||
location.reload();
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
await alertAction({
|
||||||
|
title: 'Umbenennen fehlgeschlagen',
|
||||||
|
message: body.message ?? `HTTP ${res.status}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
title = next;
|
||||||
|
editingTitle = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTitleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveTitle();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEditTitle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleWishlist() {
|
async function toggleWishlist() {
|
||||||
if (onWishlist) {
|
if (!profileStore.active) {
|
||||||
await fetch(`/api/wishlist/${data.recipe.id}`, { method: 'DELETE' });
|
await alertAction({
|
||||||
onWishlist = false;
|
title: 'Kein Profil gewählt',
|
||||||
|
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!requireOnline('Das Wunschlisten-Setzen')) return;
|
||||||
|
const profileId = profileStore.active.id;
|
||||||
|
const wasOn = onMyWishlist;
|
||||||
|
if (wasOn) {
|
||||||
|
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profileId);
|
||||||
} else {
|
} else {
|
||||||
await fetch('/api/wishlist', {
|
await fetch('/api/wishlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profileId })
|
||||||
recipe_id: data.recipe.id,
|
|
||||||
profile_id: profileStore.active?.id ?? null
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
onWishlist = true;
|
wishlistProfileIds = [...wishlistProfileIds, profileId];
|
||||||
}
|
}
|
||||||
|
void wishlistStore.refresh();
|
||||||
|
if (!wasOn) void firePulse('wish');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshWishlistState() {
|
// Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen.
|
||||||
// No dedicated GET for a single entry; scan the list and check.
|
// Browser-API navigator.wakeLock.request('screen') verhindert auto-lock
|
||||||
const res = await fetch('/api/wishlist?sort=newest');
|
// und -dimmen, solange der Tab sichtbar ist. Sobald der Tab in den
|
||||||
if (!res.ok) return;
|
// Hintergrund geht, verliert der Sentinel seine Wirkung von selbst; wir
|
||||||
const body = await res.json();
|
// re-requesten bei visibilitychange.
|
||||||
onWishlist = body.entries.some((e: { recipe_id: number }) => e.recipe_id === data.recipe.id);
|
let wakeLockEnabled = $state(true);
|
||||||
}
|
|
||||||
|
|
||||||
// Wake-Lock
|
|
||||||
let wakeLock: WakeLockSentinel | null = null;
|
let wakeLock: WakeLockSentinel | null = null;
|
||||||
async function requestWakeLock() {
|
|
||||||
|
async function acquireWakeLock() {
|
||||||
|
if (wakeLock || !wakeLockEnabled) return;
|
||||||
try {
|
try {
|
||||||
if ('wakeLock' in navigator) {
|
if ('wakeLock' in navigator) {
|
||||||
wakeLock = await navigator.wakeLock.request('screen');
|
wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
wakeLock.addEventListener('release', () => {
|
||||||
|
wakeLock = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore
|
// User hat es gecancelt oder Browser unterstützt es nicht — ignorieren
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function releaseWakeLock() {
|
||||||
|
if (!wakeLock) return;
|
||||||
|
try {
|
||||||
|
await wakeLock.release();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWakeLock() {
|
||||||
|
wakeLockEnabled = !wakeLockEnabled;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('kochwas.wakeLock', wakeLockEnabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
if (wakeLockEnabled) void acquireWakeLock();
|
||||||
|
else void releaseWakeLock();
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void requestWakeLock();
|
// Wenn wir über "Manuell anlegen" hier landen, ist ?edit=1 gesetzt
|
||||||
void checkFavorite();
|
// und wir starten direkt im Editor. Den Param danach aus der URL
|
||||||
void refreshWishlistState();
|
// 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();
|
||||||
const onVisibility = () => {
|
const onVisibility = () => {
|
||||||
if (document.visibilityState === 'visible' && !wakeLock) void requestWakeLock();
|
if (document.visibilityState === 'visible' && wakeLockEnabled && !wakeLock) {
|
||||||
|
void acquireWakeLock();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', onVisibility);
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
return () => document.removeEventListener('visibilitychange', onVisibility);
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (wakeLock) void wakeLock.release();
|
void releaseWakeLock();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<RecipeView recipe={data.recipe}>
|
{#if editMode}
|
||||||
|
<RecipeEditor
|
||||||
|
recipe={recipeState}
|
||||||
|
{saving}
|
||||||
|
onsave={saveRecipe}
|
||||||
|
oncancel={() => (editMode = false)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<RecipeView recipe={recipeState}>
|
||||||
|
{#snippet titleSlot()}
|
||||||
|
<div class="title-row">
|
||||||
|
{#if editingTitle}
|
||||||
|
<input
|
||||||
|
bind:this={titleInput}
|
||||||
|
bind:value={titleDraft}
|
||||||
|
class="title-input"
|
||||||
|
onkeydown={onTitleKey}
|
||||||
|
aria-label="Rezept-Titel"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
<button class="icon-btn save" aria-label="Titel speichern" onclick={saveTitle}>
|
||||||
|
<Check size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn cancel" aria-label="Abbrechen" onclick={cancelEditTitle}>
|
||||||
|
<X size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<h1 class="title-heading">{title}</h1>
|
||||||
|
<button class="icon-btn edit" aria-label="Titel umbenennen" onclick={startEditTitle}>
|
||||||
|
<Pencil size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet showActions()}
|
{#snippet showActions()}
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<div class="rating-row">
|
<div class="rating-row">
|
||||||
@@ -188,26 +417,74 @@
|
|||||||
<span class="avg">⌀ {data.avg_stars.toFixed(1)} ({ratings.length})</span>
|
<span class="avg">⌀ {data.avg_stars.toFixed(1)} ({ratings.length})</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<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}
|
||||||
|
class:pulse={pulseWish}
|
||||||
|
onclick={toggleWishlist}
|
||||||
|
onanimationend={() => (pulseWish = false)}
|
||||||
|
>
|
||||||
|
{#if onMyWishlist}
|
||||||
|
<Check size={18} strokeWidth={2.5} />
|
||||||
|
<span>Auf Wunschliste</span>
|
||||||
|
{:else}
|
||||||
|
<Utensils size={18} strokeWidth={2} />
|
||||||
|
<span>Auf Wunschliste setzen</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="btn" onclick={() => window.print()}>
|
||||||
|
<Printer size={18} strokeWidth={2} />
|
||||||
|
<span>Drucken</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn" onclick={logCooked}>
|
<button class="btn" onclick={logCooked}>
|
||||||
🍳 Heute gekocht
|
<ChefHat size={18} strokeWidth={2} />
|
||||||
|
<span>Heute gekocht</span>
|
||||||
{#if cookingLog.length > 0}
|
{#if cookingLog.length > 0}
|
||||||
<span class="count">({cookingLog.length})</span>
|
<span class="count">({cookingLog.length})</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
<button
|
||||||
{isFav ? '♥' : '♡'} Favorit
|
class="btn"
|
||||||
|
class:screen-on={wakeLockEnabled}
|
||||||
|
onclick={toggleWakeLock}
|
||||||
|
aria-label={wakeLockEnabled
|
||||||
|
? 'Bildschirm bleibt an — zum Deaktivieren klicken'
|
||||||
|
: 'Bildschirm darf dimmen — zum Aktivieren klicken'}
|
||||||
|
>
|
||||||
|
{#if wakeLockEnabled}
|
||||||
|
<Lightbulb size={18} strokeWidth={2} />
|
||||||
|
<span>Bildschirm an</span>
|
||||||
|
{:else}
|
||||||
|
<LightbulbOff size={18} strokeWidth={2} />
|
||||||
|
<span>Bildschirm aus</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" class:wish={onWishlist} onclick={toggleWishlist}>
|
<button class="btn" onclick={() => (editMode = true)}>
|
||||||
{onWishlist ? '✓' : '🍽️'} {onWishlist ? 'Auf Wunschliste' : 'Auf Wunschliste setzen'}
|
<Pencil size={18} strokeWidth={2} />
|
||||||
|
<span>Bearbeiten</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn danger" onclick={deleteRecipe}>
|
||||||
|
<Trash2 size={18} strokeWidth={2} />
|
||||||
|
<span>Löschen</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" onclick={() => window.print()}>🖨 Drucken</button>
|
|
||||||
<button class="btn" onclick={renameRecipe}>✎ Umbenennen</button>
|
|
||||||
<button class="btn danger" onclick={deleteRecipe}>🗑 Löschen</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</RecipeView>
|
</RecipeView>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="comments">
|
<section class="comments">
|
||||||
<h2>Kommentare</h2>
|
<h2>Kommentare</h2>
|
||||||
@@ -252,6 +529,62 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.title-heading {
|
||||||
|
font-size: clamp(1.5rem, 5.5vw, 2rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.title-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: clamp(1.3rem, 5vw, 1.8rem);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 2px solid #2b6a3d;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.title-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #444;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.icon-btn.save {
|
||||||
|
background: #2b6a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.icon-btn.save:hover {
|
||||||
|
background: #235532;
|
||||||
|
}
|
||||||
|
.icon-btn.cancel {
|
||||||
|
color: #c53030;
|
||||||
|
border-color: #f1b4b4;
|
||||||
|
}
|
||||||
.action-bar {
|
.action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -281,6 +614,9 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
padding: 0.6rem 0.85rem;
|
padding: 0.6rem 0.85rem;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
@@ -288,6 +624,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: #f4f8f5;
|
background: #f4f8f5;
|
||||||
@@ -296,11 +633,43 @@
|
|||||||
color: #c53030;
|
color: #c53030;
|
||||||
border-color: #f1b4b4;
|
border-color: #f1b4b4;
|
||||||
background: #fdf3f3;
|
background: #fdf3f3;
|
||||||
|
--pulse-color: rgba(197, 48, 48, 0.45);
|
||||||
}
|
}
|
||||||
.btn.wish {
|
.btn.wish {
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
border-color: #b7d6c2;
|
border-color: #b7d6c2;
|
||||||
background: #eaf4ed;
|
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;
|
||||||
|
border-color: #e6d48a;
|
||||||
|
background: #fff6d7;
|
||||||
}
|
}
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: #2b6a3d;
|
background: #2b6a3d;
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
|
||||||
|
|
||||||
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
|
||||||
let hits = $state<SearchHit[]>([]);
|
|
||||||
let loading = $state(false);
|
|
||||||
let searched = $state(false);
|
|
||||||
let canWebSearch = $state(false);
|
|
||||||
|
|
||||||
async function run(q: string) {
|
|
||||||
loading = true;
|
|
||||||
searched = true;
|
|
||||||
canWebSearch = true;
|
|
||||||
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
|
|
||||||
const body = await res.json();
|
|
||||||
hits = body.hits;
|
|
||||||
loading = false;
|
|
||||||
if (hits.length === 0) {
|
|
||||||
// Kein lokaler Treffer → automatisch im Internet weitersuchen.
|
|
||||||
// replaceState, damit die Zurück-Taste nicht zwischen leerer Liste und Web-Suche pingt.
|
|
||||||
void goto(`/search/web?q=${encodeURIComponent(q)}`, { replaceState: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
|
||||||
query = q;
|
|
||||||
if (q) void run(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
function submit(e: SubmitEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
goto(`/search?q=${encodeURIComponent(query.trim())}`);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="search-bar" onsubmit={submit}>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder="Rezept suchen…"
|
|
||||||
autocomplete="off"
|
|
||||||
inputmode="search"
|
|
||||||
aria-label="Suchbegriff"
|
|
||||||
/>
|
|
||||||
<button type="submit">Suchen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="muted">Suche läuft …</p>
|
|
||||||
{:else if searched && hits.length === 0}
|
|
||||||
<section class="empty">
|
|
||||||
<p>Kein lokales Rezept für „{query}" — suche jetzt im Internet …</p>
|
|
||||||
</section>
|
|
||||||
{:else if hits.length > 0}
|
|
||||||
<ul class="hits">
|
|
||||||
{#each hits as r (r.id)}
|
|
||||||
<li>
|
|
||||||
<a href={`/recipes/${r.id}`} class="hit">
|
|
||||||
{#if r.image_path}
|
|
||||||
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<div class="placeholder">🥘</div>
|
|
||||||
{/if}
|
|
||||||
<div class="hit-body">
|
|
||||||
<div class="title">{r.title}</div>
|
|
||||||
<div class="meta">
|
|
||||||
{#if r.source_domain}<span>{r.source_domain}</span>{/if}
|
|
||||||
{#if r.avg_stars !== null}
|
|
||||||
<span>★ {r.avg_stars.toFixed(1)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if r.last_cooked_at}
|
|
||||||
<span>Zuletzt: {new Date(r.last_cooked_at).toLocaleDateString('de-DE')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{#if canWebSearch}
|
|
||||||
<div class="web-cta">
|
|
||||||
<a class="web-btn" href={`/search/web?q=${encodeURIComponent(query)}`}>
|
|
||||||
🌐 Im Internet weitersuchen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.search-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: #f8faf8;
|
|
||||||
}
|
|
||||||
input[type='search'] {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
border: 1px solid #cfd9d1;
|
|
||||||
border-radius: 10px;
|
|
||||||
min-height: 48px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 0.8rem 1.2rem;
|
|
||||||
background: #2b6a3d;
|
|
||||||
color: white;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
min-height: 48px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: #888;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
.hits {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.hit {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e4eae7;
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
min-height: 96px;
|
|
||||||
}
|
|
||||||
.hit img,
|
|
||||||
.placeholder {
|
|
||||||
width: 104px;
|
|
||||||
min-height: 96px;
|
|
||||||
object-fit: cover;
|
|
||||||
background: #eef3ef;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
.hit-body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.75rem 0.9rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.web-cta {
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.web-btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.8rem 1.25rem;
|
|
||||||
background: #2b6a3d;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-height: 48px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import type { WebHit } from '$lib/server/search/searxng';
|
|
||||||
|
|
||||||
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
|
||||||
let hits = $state<WebHit[]>([]);
|
|
||||||
let loading = $state(false);
|
|
||||||
let errored = $state<string | null>(null);
|
|
||||||
let searched = $state(false);
|
|
||||||
|
|
||||||
async function run(q: string) {
|
|
||||||
loading = true;
|
|
||||||
searched = true;
|
|
||||||
errored = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
errored = body.message ?? `HTTP ${res.status}`;
|
|
||||||
hits = [];
|
|
||||||
} else {
|
|
||||||
const body = await res.json();
|
|
||||||
hits = body.hits;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errored = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
|
||||||
query = q;
|
|
||||||
if (q) void run(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
function submit(e: SubmitEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
goto(`/search/web?q=${encodeURIComponent(query.trim())}`);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="crumbs">
|
|
||||||
<a href={`/search?q=${encodeURIComponent(query)}`}>← Lokale Suche</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<form class="search-bar" onsubmit={submit}>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder="Im Internet suchen…"
|
|
||||||
autocomplete="off"
|
|
||||||
inputmode="search"
|
|
||||||
/>
|
|
||||||
<button type="submit">🌐 Suchen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="muted">Suche im Internet läuft …</p>
|
|
||||||
{:else if errored}
|
|
||||||
<section class="empty">
|
|
||||||
<p class="error">Internet-Suche zurzeit nicht möglich: {errored}</p>
|
|
||||||
<p class="hint">
|
|
||||||
SearXNG-Container läuft nicht? <code>docker compose up -d searxng</code>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
{:else if searched && hits.length === 0}
|
|
||||||
<section class="empty">
|
|
||||||
<p>Keine Treffer im Internet für „{query}".</p>
|
|
||||||
<p class="hint">
|
|
||||||
Prüfe, ob Whitelist-Domains gepflegt sind (Einstellungen folgen).
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
{:else if hits.length > 0}
|
|
||||||
<p class="muted count">{hits.length} Treffer aus {new Set(hits.map((h) => h.domain)).size} Quellen</p>
|
|
||||||
<ul class="hits">
|
|
||||||
{#each hits as h (h.url)}
|
|
||||||
<li>
|
|
||||||
<a class="hit" href={`/preview?url=${encodeURIComponent(h.url)}`}>
|
|
||||||
{#if h.thumbnail}
|
|
||||||
<img src={h.thumbnail} alt="" loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<div class="placeholder">🍽️</div>
|
|
||||||
{/if}
|
|
||||||
<div class="hit-body">
|
|
||||||
<div class="title">{h.title}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span class="domain">{h.domain}</span>
|
|
||||||
</div>
|
|
||||||
{#if h.snippet}
|
|
||||||
<p class="snippet">{h.snippet}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.crumbs {
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.crumbs a {
|
|
||||||
color: #666;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.search-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
input[type='search'] {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
border: 1px solid #cfd9d1;
|
|
||||||
border-radius: 10px;
|
|
||||||
min-height: 48px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 0.8rem 1.2rem;
|
|
||||||
background: #2b6a3d;
|
|
||||||
color: white;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
min-height: 48px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
.count {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: #c53030;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.hint code {
|
|
||||||
background: #f4f8f5;
|
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.hits {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.hit {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e4eae7;
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
.hit img,
|
|
||||||
.placeholder {
|
|
||||||
width: 100px;
|
|
||||||
object-fit: cover;
|
|
||||||
background: #eef3ef;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 2rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.hit-body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.75rem 0.9rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
}
|
|
||||||
.domain {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.1rem 0.5rem;
|
|
||||||
background: #eaf4ed;
|
|
||||||
color: #2b6a3d;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
.snippet {
|
|
||||||
margin: 0.4rem 0 0;
|
|
||||||
color: #555;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
|
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||||
|
import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
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 entries = $state<WishlistEntry[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let sort = $state<SortKey>('popular');
|
let sort = $state<SortKey>('popular');
|
||||||
@@ -24,27 +34,49 @@
|
|||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function toggleLike(entry: WishlistEntry) {
|
async function toggleMine(entry: WishlistEntry) {
|
||||||
if (!profileStore.active) {
|
if (!profileStore.active) {
|
||||||
alert('Bitte Profil wählen, um zu liken.');
|
await alertAction({
|
||||||
|
title: 'Kein Profil gewählt',
|
||||||
|
message: 'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const method = entry.liked_by_me ? 'DELETE' : 'PUT';
|
if (!requireOnline('Die Wunschlisten-Aktion')) return;
|
||||||
await fetch(`/api/wishlist/${entry.recipe_id}/like`, {
|
const profileId = profileStore.active.id;
|
||||||
method,
|
if (entry.on_my_wishlist) {
|
||||||
headers: { 'content-type': 'application/json' },
|
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
|
||||||
body: JSON.stringify({ profile_id: profileStore.active.id })
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetch('/api/wishlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ recipe_id: entry.recipe_id, profile_id: profileId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
void wishlistStore.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeForAll(entry: WishlistEntry) {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Von der Wunschliste entfernen?',
|
||||||
|
message: `„${entry.title}" wird für alle Profile aus der Wunschliste gestrichen. Das Rezept selbst bleibt erhalten.`,
|
||||||
|
confirmLabel: 'Entfernen',
|
||||||
|
destructive: true
|
||||||
});
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Entfernen')) return;
|
||||||
|
await fetch(`/api/wishlist/${entry.recipe_id}?all=true`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
|
void wishlistStore.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(entry: WishlistEntry) {
|
onMount(() => {
|
||||||
if (!confirm(`„${entry.title}" von der Wunschliste entfernen?`)) return;
|
void load();
|
||||||
await fetch(`/api/wishlist/${entry.recipe_id}`, { method: 'DELETE' });
|
void wishlistStore.refresh();
|
||||||
await load();
|
});
|
||||||
}
|
|
||||||
|
|
||||||
onMount(load);
|
|
||||||
|
|
||||||
function resolveImage(p: string | null): string | null {
|
function resolveImage(p: string | null): string | null {
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
@@ -57,22 +89,26 @@
|
|||||||
<p class="sub">Das wollen wir bald mal essen.</p>
|
<p class="sub">Das wollen wir bald mal essen.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="sort-chips" role="tablist" aria-label="Sortierung">
|
||||||
<label>
|
{#each SORT_OPTIONS as s (s.value)}
|
||||||
Sortieren:
|
<button
|
||||||
<select bind:value={sort}>
|
type="button"
|
||||||
<option value="popular">Am meisten gewünscht</option>
|
role="tab"
|
||||||
<option value="newest">Neueste zuerst</option>
|
aria-selected={sort === s.value}
|
||||||
<option value="oldest">Älteste zuerst</option>
|
class="chip"
|
||||||
</select>
|
class:active={sort === s.value}
|
||||||
</label>
|
onclick={() => (sort = s.value)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="muted">Lädt …</p>
|
<p class="muted">Lädt …</p>
|
||||||
{:else if entries.length === 0}
|
{:else if entries.length === 0}
|
||||||
<section class="empty">
|
<section class="empty">
|
||||||
<p class="big">🥘</p>
|
<div class="big"><CookingPot size={48} strokeWidth={1.5} /></div>
|
||||||
<p>Noch nichts gewünscht.</p>
|
<p>Noch nichts gewünscht.</p>
|
||||||
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
|
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -84,16 +120,16 @@
|
|||||||
{#if resolveImage(e.image_path)}
|
{#if resolveImage(e.image_path)}
|
||||||
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
|
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder">🥘</div>
|
<div class="placeholder"><CookingPot size={32} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<div class="title">{e.title}</div>
|
<div class="title">{e.title}</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if e.added_by_name}
|
{#if e.wanted_by_names}
|
||||||
<span>von {e.added_by_name}</span>
|
<span class="wanted-by">{e.wanted_by_names}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if e.source_domain}
|
{#if e.source_domain}
|
||||||
<span>· {e.source_domain}</span>
|
<span class="src">· {e.source_domain}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if e.avg_stars !== null}
|
{#if e.avg_stars !== null}
|
||||||
<span>· ★ {e.avg_stars.toFixed(1)}</span>
|
<span>· ★ {e.avg_stars.toFixed(1)}</span>
|
||||||
@@ -104,16 +140,22 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<button
|
||||||
class="like"
|
class="like"
|
||||||
class:active={e.liked_by_me}
|
class:active={e.on_my_wishlist}
|
||||||
aria-label={e.liked_by_me ? 'Unlike' : 'Like'}
|
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
|
||||||
onclick={() => toggleLike(e)}
|
onclick={() => toggleMine(e)}
|
||||||
>
|
>
|
||||||
{e.liked_by_me ? '♥' : '♡'}
|
<Utensils size={18} strokeWidth={2} />
|
||||||
{#if e.like_count > 0}
|
{#if e.wanted_by_count > 0}
|
||||||
<span class="count">{e.like_count}</span>
|
<span class="count">{e.wanted_by_count}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>🗑</button>
|
<button
|
||||||
|
class="del"
|
||||||
|
aria-label="Für alle entfernen"
|
||||||
|
onclick={() => removeForAll(e)}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -133,24 +175,32 @@
|
|||||||
margin: 0.2rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
.controls {
|
.sort-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
flex-wrap: wrap;
|
||||||
padding: 0.5rem 0 1rem;
|
gap: 0.35rem;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
}
|
}
|
||||||
.controls label {
|
.chip {
|
||||||
display: inline-flex;
|
padding: 0.4rem 0.85rem;
|
||||||
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;
|
|
||||||
background: white;
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 999px;
|
||||||
|
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 {
|
.muted {
|
||||||
color: #888;
|
color: #888;
|
||||||
@@ -162,7 +212,8 @@
|
|||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
}
|
}
|
||||||
.big {
|
.big {
|
||||||
font-size: 3rem;
|
color: #8fb097;
|
||||||
|
display: inline-flex;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
.hint {
|
.hint {
|
||||||
@@ -202,7 +253,7 @@
|
|||||||
background: #eef3ef;
|
background: #eef3ef;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 2rem;
|
color: #8fb097;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.text {
|
.text {
|
||||||
@@ -226,17 +277,22 @@
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.wanted-by {
|
||||||
|
color: #2b6a3d;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.5rem 0.6rem 0.5rem 0;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 0.5rem 0.6rem 0.5rem 0;
|
||||||
}
|
}
|
||||||
.like,
|
.like,
|
||||||
.del {
|
.del {
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
min-height: 44px;
|
min-height: 40px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid #e4eae7;
|
border: 1px solid #e4eae7;
|
||||||
background: white;
|
background: white;
|
||||||
@@ -246,11 +302,17 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
color: #444;
|
||||||
}
|
}
|
||||||
.like.active {
|
.like.active {
|
||||||
|
color: #2b6a3d;
|
||||||
|
background: #eaf4ed;
|
||||||
|
border-color: #b7d6c2;
|
||||||
|
}
|
||||||
|
.del:hover {
|
||||||
color: #c53030;
|
color: #c53030;
|
||||||
background: #fdf3f3;
|
|
||||||
border-color: #f1b4b4;
|
border-color: #f1b4b4;
|
||||||
|
background: #fdf3f3;
|
||||||
}
|
}
|
||||||
.count {
|
.count {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -2,88 +2,258 @@
|
|||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference lib="esnext" />
|
/// <reference lib="esnext" />
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
import { build, files, version } from '$service-worker';
|
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 SHELL_CACHE = `kochwas-shell-${version}`;
|
||||||
const IMAGE_CACHE = `kochwas-images-v1`;
|
const DATA_CACHE = 'kochwas-data-v1';
|
||||||
const APP_ASSETS = [...build, ...files];
|
const IMAGES_CACHE = 'kochwas-images-v1';
|
||||||
|
|
||||||
sw.addEventListener('install', (event) => {
|
// App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt)
|
||||||
event.waitUntil(
|
const SHELL_ASSETS = [...build, ...files];
|
||||||
caches.open(APP_CACHE).then((cache) => cache.addAll(APP_ASSETS))
|
|
||||||
);
|
|
||||||
// Activate new worker without waiting for old clients to close.
|
|
||||||
void sw.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
sw.addEventListener('activate', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const keys = await caches.keys();
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
await Promise.all(
|
await cache.addAll(SHELL_ASSETS);
|
||||||
keys
|
// Kein self.skipWaiting() hier — der Client (pwaStore) fragt den
|
||||||
.filter((k) => k.startsWith('kochwas-app-') && k !== APP_CACHE)
|
// User via UpdateToast, ob der neue SW sofort übernehmen soll, und
|
||||||
.map((k) => caches.delete(k))
|
// schickt dann eine SKIP_WAITING-Message. Ohne diese Trennung
|
||||||
);
|
// würde pwaStore beim Install-Event fälschlich "Neue Version"
|
||||||
await sw.clients.claim();
|
// 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;
|
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);
|
const strategy = resolveStrategy({ url: req.url, method: req.method });
|
||||||
if (url.origin !== location.origin) return;
|
if (strategy === 'network-only') return;
|
||||||
|
|
||||||
// Images served from /images/* — cache-first with background update
|
if (strategy === 'shell') {
|
||||||
if (url.pathname.startsWith('/images/')) {
|
event.respondWith(cacheFirst(req, SHELL_CACHE));
|
||||||
event.respondWith(
|
} else if (strategy === 'images') {
|
||||||
(async () => {
|
event.respondWith(cacheFirst(req, IMAGES_CACHE));
|
||||||
const cache = await caches.open(IMAGE_CACHE);
|
} else if (strategy === 'swr') {
|
||||||
const cached = await cache.match(req);
|
event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
|
||||||
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 });
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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",
|
"src": "/icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"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');
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
35
tests/integration/favicons.test.ts
Normal file
35
tests/integration/favicons.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||||
|
import { addDomain, listDomains } from '../../src/lib/server/domains/repository';
|
||||||
|
import { ensureFavicons } from '../../src/lib/server/domains/favicons';
|
||||||
|
|
||||||
|
let imageDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
imageDir = mkdtempSync(join(tmpdir(), 'kochwas-favicon-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(imageDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureFavicons', () => {
|
||||||
|
it('is a no-op when every domain already has a favicon_path', async () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const d = addDomain(db, 'example.com');
|
||||||
|
// simulate already-stored favicon
|
||||||
|
db.prepare('UPDATE allowed_domain SET favicon_path = ? WHERE id = ?').run(
|
||||||
|
'favicon-abc.png',
|
||||||
|
d.id
|
||||||
|
);
|
||||||
|
await ensureFavicons(db, imageDir);
|
||||||
|
// No file written, no DB state changed
|
||||||
|
expect(readdirSync(imageDir).length).toBe(0);
|
||||||
|
const domains = listDomains(db);
|
||||||
|
expect(domains[0].favicon_path).toBe('favicon-abc.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -45,6 +45,20 @@ describe('fetchText', () => {
|
|||||||
});
|
});
|
||||||
await expect(fetchText(`${baseUrl}/`, { timeoutMs: 150 })).rejects.toThrow();
|
await expect(fetchText(`${baseUrl}/`, { timeoutMs: 150 })).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allowTruncate returns first maxBytes instead of throwing', async () => {
|
||||||
|
const head = '<html><head><title>hi</title></head>';
|
||||||
|
const filler = 'x'.repeat(2000);
|
||||||
|
server.on('request', (_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html' });
|
||||||
|
res.end(head + filler);
|
||||||
|
});
|
||||||
|
const text = await fetchText(`${baseUrl}/`, { maxBytes: 100, allowTruncate: true });
|
||||||
|
// First 100 bytes of body — should contain the <head> opening at least
|
||||||
|
expect(text.length).toBeLessThanOrEqual(2048); // chunk boundary may overshoot exact bytes slightly
|
||||||
|
expect(text).toContain('<html>');
|
||||||
|
expect(text).toContain('<head>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchBuffer', () => {
|
describe('fetchBuffer', () => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { tmpdir } from 'node:os';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
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';
|
import { importRecipe, previewRecipe, ImporterError } from '../../src/lib/server/recipes/importer';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -61,17 +60,9 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('previewRecipe', () => {
|
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();
|
const db = openInMemoryForTest();
|
||||||
// note: no domain added
|
// keine Domain in der Whitelist — preview muss trotzdem klappen
|
||||||
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');
|
|
||||||
const r = await previewRecipe(db, `${baseUrl}/recipe`);
|
const r = await previewRecipe(db, `${baseUrl}/recipe`);
|
||||||
expect(r.title.toLowerCase()).toContain('schupfnudel');
|
expect(r.title.toLowerCase()).toContain('schupfnudel');
|
||||||
expect(r.source_url).toBe(`${baseUrl}/recipe`);
|
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 () => {
|
it('throws NO_RECIPE_FOUND when HTML has no Recipe JSON-LD', async () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
addDomain(db, '127.0.0.1');
|
|
||||||
await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({
|
await expect(previewRecipe(db, `${baseUrl}/bare`)).rejects.toMatchObject({
|
||||||
code: 'NO_RECIPE_FOUND'
|
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', () => {
|
describe('importRecipe', () => {
|
||||||
it('imports, persists, and is idempotent', async () => {
|
it('imports, persists, and is idempotent', async () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
addDomain(db, '127.0.0.1');
|
|
||||||
const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`);
|
const first = await importRecipe(db, imgDir, `${baseUrl}/recipe`);
|
||||||
expect(first.duplicate).toBe(false);
|
expect(first.duplicate).toBe(false);
|
||||||
expect(first.id).toBeGreaterThan(0);
|
expect(first.id).toBeGreaterThan(0);
|
||||||
@@ -104,9 +100,9 @@ describe('importRecipe', () => {
|
|||||||
expect(second.id).toBe(first.id);
|
expect(second.id).toBe(first.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces ImporterError type', async () => {
|
it('surfaces ImporterError type when no recipe on page', async () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
await expect(importRecipe(db, imgDir, `${baseUrl}/recipe`)).rejects.toBeInstanceOf(
|
await expect(importRecipe(db, imgDir, `${baseUrl}/bare`)).rejects.toBeInstanceOf(
|
||||||
ImporterError
|
ImporterError
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
insertRecipe,
|
insertRecipe,
|
||||||
getRecipeById,
|
getRecipeById,
|
||||||
getRecipeIdBySourceUrl,
|
getRecipeIdBySourceUrl,
|
||||||
deleteRecipe
|
deleteRecipe,
|
||||||
|
updateRecipeMeta,
|
||||||
|
replaceIngredients,
|
||||||
|
replaceSteps
|
||||||
} from '../../src/lib/server/recipes/repository';
|
} from '../../src/lib/server/recipes/repository';
|
||||||
import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe';
|
import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe';
|
||||||
import type { Recipe } from '../../src/lib/types';
|
import type { Recipe } from '../../src/lib/types';
|
||||||
@@ -97,4 +100,58 @@ describe('recipe repository', () => {
|
|||||||
deleteRecipe(db, id);
|
deleteRecipe(db, id);
|
||||||
expect(getRecipeById(db, id)).toBeNull();
|
expect(getRecipeById(db, id)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updateRecipeMeta patches only supplied fields', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const id = insertRecipe(db, baseRecipe({ title: 'A', prep_time_min: 10 }));
|
||||||
|
updateRecipeMeta(db, id, { description: 'neu', prep_time_min: 15 });
|
||||||
|
const loaded = getRecipeById(db, id);
|
||||||
|
expect(loaded?.title).toBe('A'); // unverändert
|
||||||
|
expect(loaded?.description).toBe('neu');
|
||||||
|
expect(loaded?.prep_time_min).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaceIngredients swaps full list and rebuilds FTS', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const id = insertRecipe(
|
||||||
|
db,
|
||||||
|
baseRecipe({
|
||||||
|
title: 'Pasta',
|
||||||
|
ingredients: [
|
||||||
|
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
replaceIngredients(db, id, [
|
||||||
|
{ position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln' },
|
||||||
|
{ position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier' }
|
||||||
|
]);
|
||||||
|
const loaded = getRecipeById(db, id);
|
||||||
|
expect(loaded?.ingredients.length).toBe(2);
|
||||||
|
expect(loaded?.ingredients[0].name).toBe('Nudeln');
|
||||||
|
// FTS index should reflect new ingredient
|
||||||
|
const hit = db
|
||||||
|
.prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'nudeln'")
|
||||||
|
.all();
|
||||||
|
expect(hit.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaceSteps swaps full list', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const id = insertRecipe(
|
||||||
|
db,
|
||||||
|
baseRecipe({
|
||||||
|
title: 'S',
|
||||||
|
steps: [
|
||||||
|
{ position: 1, text: 'Alt' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
replaceSteps(db, id, [
|
||||||
|
{ position: 1, text: 'Erst' },
|
||||||
|
{ position: 2, text: 'Dann' }
|
||||||
|
]);
|
||||||
|
const loaded = getRecipeById(db, id);
|
||||||
|
expect(loaded?.steps.map((s) => s.text)).toEqual(['Erst', 'Dann']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||||
import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
||||||
import { searchLocal, listRecentRecipes } from '../../src/lib/server/recipes/search-local';
|
import {
|
||||||
|
searchLocal,
|
||||||
|
listRecentRecipes,
|
||||||
|
listAllRecipes,
|
||||||
|
listAllRecipesPaginated
|
||||||
|
} from '../../src/lib/server/recipes/search-local';
|
||||||
import type { Recipe } from '../../src/lib/types';
|
import type { Recipe } from '../../src/lib/types';
|
||||||
|
|
||||||
function recipe(overrides: Partial<Recipe> = {}): Recipe {
|
function recipe(overrides: Partial<Recipe> = {}): Recipe {
|
||||||
@@ -64,6 +69,37 @@ describe('searchLocal', () => {
|
|||||||
expect(searchLocal(db, ' ')).toEqual([]);
|
expect(searchLocal(db, ' ')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters by domain when supplied', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
||||||
|
const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']);
|
||||||
|
expect(hits.length).toBe(1);
|
||||||
|
expect(hits[0].source_domain).toBe('chefkoch.de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no domain filter when array is empty', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
||||||
|
const hits = searchLocal(db, 'apfel', 10, 0, []);
|
||||||
|
expect(hits.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates via limit + offset', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
insertRecipe(db, recipe({ title: `Pizza ${i}` }));
|
||||||
|
}
|
||||||
|
const first = searchLocal(db, 'pizza', 2, 0);
|
||||||
|
const second = searchLocal(db, 'pizza', 2, 2);
|
||||||
|
expect(first.length).toBe(2);
|
||||||
|
expect(second.length).toBe(2);
|
||||||
|
// No overlap between pages
|
||||||
|
const firstIds = new Set(first.map((h) => h.id));
|
||||||
|
for (const h of second) expect(firstIds.has(h.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('aggregates avg_stars across profiles', () => {
|
it('aggregates avg_stars across profiles', () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
const id = insertRecipe(db, recipe({ title: 'Rated' }));
|
const id = insertRecipe(db, recipe({ title: 'Rated' }));
|
||||||
@@ -89,3 +125,72 @@ describe('listRecentRecipes', () => {
|
|||||||
expect(recent[0].title === 'New' || recent[0].title === 'Old').toBe(true);
|
expect(recent[0].title === 'New' || recent[0].title === 'Old').toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listAllRecipes', () => {
|
||||||
|
it('returns all recipes sorted alphabetically, case-insensitive', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'zuccini' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'Apfelkuchen' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'birnenkompott' }));
|
||||||
|
const all = listAllRecipes(db);
|
||||||
|
expect(all.map((r) => r.title)).toEqual([
|
||||||
|
'Apfelkuchen',
|
||||||
|
'birnenkompott',
|
||||||
|
'zuccini'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes hidden-from-recent recipes too', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const id = insertRecipe(db, recipe({ title: 'Versteckt' }));
|
||||||
|
db.prepare('UPDATE recipe SET hidden_from_recent = 1 WHERE id = ?').run(id);
|
||||||
|
const all = listAllRecipes(db);
|
||||||
|
expect(all.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listAllRecipesPaginated', () => {
|
||||||
|
it('sorts by name asc case-insensitive', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'zucchini' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'Apfel' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'birnen' }));
|
||||||
|
const page = listAllRecipesPaginated(db, 'name', 10, 0);
|
||||||
|
expect(page.map((h) => h.title)).toEqual(['Apfel', 'birnen', 'zucchini']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates with limit + offset', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
for (let i = 0; i < 15; i++) insertRecipe(db, recipe({ title: `R${i.toString().padStart(2, '0')}` }));
|
||||||
|
const first = listAllRecipesPaginated(db, 'name', 5, 0);
|
||||||
|
const second = listAllRecipesPaginated(db, 'name', 5, 5);
|
||||||
|
expect(first.length).toBe(5);
|
||||||
|
expect(second.length).toBe(5);
|
||||||
|
const overlap = first.filter((h) => second.some((s) => s.id === h.id));
|
||||||
|
expect(overlap.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by rating desc, unrated last', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const a = insertRecipe(db, recipe({ title: 'A' }));
|
||||||
|
const b = insertRecipe(db, recipe({ title: 'B' }));
|
||||||
|
const c = insertRecipe(db, recipe({ title: 'C' }));
|
||||||
|
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
|
||||||
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 3)').run(a);
|
||||||
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 5)').run(c);
|
||||||
|
const page = listAllRecipesPaginated(db, 'rating', 10, 0);
|
||||||
|
// C (5) > A (3) > B (null)
|
||||||
|
expect(page.map((h) => h.title)).toEqual(['C', 'A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by last_cooked_at desc, never-cooked last', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const a = insertRecipe(db, recipe({ title: 'A' }));
|
||||||
|
const b = insertRecipe(db, recipe({ title: 'B' }));
|
||||||
|
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
|
||||||
|
db.prepare('INSERT INTO cooking_log(recipe_id, profile_id) VALUES (?, 1)').run(a);
|
||||||
|
const page = listAllRecipesPaginated(db, 'cooked', 10, 0);
|
||||||
|
expect(page[0].title).toBe('A');
|
||||||
|
expect(page[1].title).toBe('B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('searchWeb', () => {
|
|||||||
content: 'blocked'
|
content: 'blocked'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
const hits = await searchWeb(db, 'carbonara', { searxngUrl: baseUrl });
|
const hits = await searchWeb(db, 'carbonara', { searxngUrl: baseUrl, enrichThumbnails: false });
|
||||||
expect(hits.length).toBe(1);
|
expect(hits.length).toBe(1);
|
||||||
expect(hits[0].domain).toBe('chefkoch.de');
|
expect(hits[0].domain).toBe('chefkoch.de');
|
||||||
expect(hits[0].title).toBe('Carbonara');
|
expect(hits[0].title).toBe('Carbonara');
|
||||||
@@ -55,23 +55,282 @@ describe('searchWeb', () => {
|
|||||||
{ url: 'https://www.chefkoch.de/a', title: 'A', content: '' },
|
{ url: 'https://www.chefkoch.de/a', title: 'A', content: '' },
|
||||||
{ url: 'https://www.chefkoch.de/a', title: 'A dup', content: '' }
|
{ url: 'https://www.chefkoch.de/a', title: 'A dup', content: '' }
|
||||||
]);
|
]);
|
||||||
const hits = await searchWeb(db, 'a', { searxngUrl: baseUrl });
|
const hits = await searchWeb(db, 'a', { searxngUrl: baseUrl, enrichThumbnails: false });
|
||||||
expect(hits.length).toBe(1);
|
expect(hits.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty list when no domains configured', async () => {
|
it('returns empty list when no domains configured', async () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
const hits = await searchWeb(db, 'x', { searxngUrl: baseUrl });
|
const hits = await searchWeb(db, 'x', { searxngUrl: baseUrl, enrichThumbnails: false });
|
||||||
expect(hits).toEqual([]);
|
expect(hits).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty for empty query', async () => {
|
it('returns empty for empty query', async () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
addDomain(db, 'chefkoch.de');
|
addDomain(db, 'chefkoch.de');
|
||||||
const hits = await searchWeb(db, ' ', { searxngUrl: baseUrl });
|
const hits = await searchWeb(db, ' ', { searxngUrl: baseUrl, enrichThumbnails: false });
|
||||||
expect(hits).toEqual([]);
|
expect(hits).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('domain filter restricts site:-query to supplied subset', async () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, 'chefkoch.de');
|
||||||
|
addDomain(db, 'rezeptwelt.de');
|
||||||
|
let receivedQ: string | null = null;
|
||||||
|
server.on('request', (req, res) => {
|
||||||
|
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||||
|
receivedQ = u.searchParams.get('q');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ results: [] }));
|
||||||
|
});
|
||||||
|
await searchWeb(db, 'apfel', {
|
||||||
|
searxngUrl: baseUrl,
|
||||||
|
enrichThumbnails: false,
|
||||||
|
domains: ['rezeptwelt.de']
|
||||||
|
});
|
||||||
|
expect(receivedQ).toMatch(/site:rezeptwelt\.de/);
|
||||||
|
expect(receivedQ).not.toMatch(/site:chefkoch\.de/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores domain filter entries that are not in whitelist', async () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, 'chefkoch.de');
|
||||||
|
let receivedQ: string | null = null;
|
||||||
|
server.on('request', (req, res) => {
|
||||||
|
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||||
|
receivedQ = u.searchParams.get('q');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ results: [] }));
|
||||||
|
});
|
||||||
|
// Only "evil.com" requested — not in whitelist → fall back to full whitelist.
|
||||||
|
await searchWeb(db, 'x', {
|
||||||
|
searxngUrl: baseUrl,
|
||||||
|
enrichThumbnails: false,
|
||||||
|
domains: ['evil.com']
|
||||||
|
});
|
||||||
|
expect(receivedQ).toMatch(/site:chefkoch\.de/);
|
||||||
|
expect(receivedQ).not.toMatch(/site:evil\.com/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes pageno to SearXNG when > 1', async () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, 'chefkoch.de');
|
||||||
|
let receivedPageno: string | null = 'not set';
|
||||||
|
server.on('request', (req, res) => {
|
||||||
|
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||||
|
receivedPageno = u.searchParams.get('pageno');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ results: [] }));
|
||||||
|
});
|
||||||
|
await searchWeb(db, 'x', { searxngUrl: baseUrl, enrichThumbnails: false, pageno: 3 });
|
||||||
|
expect(receivedPageno).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits pageno param when 1', async () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, 'chefkoch.de');
|
||||||
|
let receivedPageno: string | null = 'not set';
|
||||||
|
server.on('request', (req, res) => {
|
||||||
|
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||||
|
receivedPageno = u.searchParams.get('pageno');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ results: [] }));
|
||||||
|
});
|
||||||
|
await searchWeb(db, 'x', { searxngUrl: baseUrl, enrichThumbnails: false });
|
||||||
|
expect(receivedPageno).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops hits whose page lacks a Recipe JSON-LD', async () => {
|
||||||
|
const pageServer = createServer((req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
if (req.url === '/with-recipe') {
|
||||||
|
res.end(`<html><head>
|
||||||
|
<script type="application/ld+json">${JSON.stringify({
|
||||||
|
'@type': 'Recipe',
|
||||||
|
name: 'Pie',
|
||||||
|
image: 'https://cdn.example/pie.jpg'
|
||||||
|
})}</script>
|
||||||
|
</head></html>`);
|
||||||
|
} else {
|
||||||
|
// forum page: no Recipe JSON-LD
|
||||||
|
res.end('<html><head><title>Forum</title></head><body>Diskussion</body></html>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => pageServer.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = pageServer.address() as AddressInfo;
|
||||||
|
try {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
respondWith([
|
||||||
|
{ url: `http://127.0.0.1:${addr.port}/with-recipe`, title: 'Recipe', content: '' },
|
||||||
|
{ url: `http://127.0.0.1:${addr.port}/forum-thread`, title: 'Forum', content: '' }
|
||||||
|
]);
|
||||||
|
const hits = await searchWeb(db, 'x', { searxngUrl: baseUrl });
|
||||||
|
expect(hits.length).toBe(1);
|
||||||
|
expect(hits[0].url.endsWith('/with-recipe')).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => pageServer.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hit when page fetch fails (unknown recipe status)', async () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
// URL points to a port nobody listens on → fetch fails
|
||||||
|
respondWith([
|
||||||
|
{ url: 'http://127.0.0.1:1/unreachable', title: 'Unreachable', content: '' }
|
||||||
|
]);
|
||||||
|
const hits = await searchWeb(db, 'x', { searxngUrl: baseUrl });
|
||||||
|
expect(hits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal Recipe-JSON-LD stub so enrichAndFilterHits doesn't drop test hits
|
||||||
|
// as non-recipe pages. Used in tests that focus on thumbnail extraction.
|
||||||
|
const RECIPE_LD = `<script type="application/ld+json">${JSON.stringify({
|
||||||
|
'@type': 'Recipe',
|
||||||
|
name: 'stub'
|
||||||
|
})}</script>`;
|
||||||
|
|
||||||
|
it('enriches missing thumbnails from og:image', async () => {
|
||||||
|
const pageServer = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(
|
||||||
|
`<html><head><meta property="og:image" content="https://cdn.example/foo.jpg" />${RECIPE_LD}</head><body></body></html>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => pageServer.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = pageServer.address() as AddressInfo;
|
||||||
|
const pageUrl = `http://127.0.0.1:${addr.port}/rezept`;
|
||||||
|
try {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
respondWith([{ url: pageUrl, title: 'Kuchen', content: '' }]);
|
||||||
|
const hits = await searchWeb(db, 'kuchen', { searxngUrl: baseUrl });
|
||||||
|
expect(hits.length).toBe(1);
|
||||||
|
expect(hits[0].thumbnail).toBe('https://cdn.example/foo.jpg');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => pageServer.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to JSON-LD image when no og:image', async () => {
|
||||||
|
const pageServer = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`<html><head>
|
||||||
|
<script type="application/ld+json">${JSON.stringify({
|
||||||
|
'@type': 'Recipe',
|
||||||
|
name: 'Pie',
|
||||||
|
image: 'https://cdn.example/pie.jpg'
|
||||||
|
})}</script>
|
||||||
|
</head><body></body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => pageServer.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = pageServer.address() as AddressInfo;
|
||||||
|
const pageUrl = `http://127.0.0.1:${addr.port}/pie`;
|
||||||
|
try {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
respondWith([{ url: pageUrl, title: 'Pie', content: '' }]);
|
||||||
|
const hits = await searchWeb(db, 'pie', { searxngUrl: baseUrl });
|
||||||
|
expect(hits[0].thumbnail).toBe('https://cdn.example/pie.jpg');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => pageServer.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first content image when no meta/JSON-LD image', async () => {
|
||||||
|
const pageServer = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(
|
||||||
|
`<html><head>${RECIPE_LD}</head><body><article><img src="/uploads/dish.jpg" alt=""></article></body></html>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => pageServer.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = pageServer.address() as AddressInfo;
|
||||||
|
const pageUrl = `http://127.0.0.1:${addr.port}/article`;
|
||||||
|
try {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
respondWith([{ url: pageUrl, title: 'Dish', content: '' }]);
|
||||||
|
const hits = await searchWeb(db, 'dish', { searxngUrl: baseUrl });
|
||||||
|
expect(hits[0].thumbnail).toBe(`http://127.0.0.1:${addr.port}/uploads/dish.jpg`);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => pageServer.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades low-res SearXNG thumbnail with HQ og:image from page', async () => {
|
||||||
|
const pageServer = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(
|
||||||
|
`<html><head><meta property="og:image" content="https://cdn.example/hq.jpg" />${RECIPE_LD}</head></html>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => pageServer.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = pageServer.address() as AddressInfo;
|
||||||
|
const pageUrl = `http://127.0.0.1:${addr.port}/dish`;
|
||||||
|
try {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
respondWith([
|
||||||
|
{ url: pageUrl, title: 'Dish', thumbnail: 'https://searxng-cdn/small-thumb.jpg' }
|
||||||
|
]);
|
||||||
|
const hits = await searchWeb(db, 'dish', { searxngUrl: baseUrl });
|
||||||
|
expect(hits[0].thumbnail).toBe('https://cdn.example/hq.jpg');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => pageServer.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps SearXNG thumbnail when page has no image', async () => {
|
||||||
|
const pageServer = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`<html><head>${RECIPE_LD}</head><body>no images here</body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => pageServer.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = pageServer.address() as AddressInfo;
|
||||||
|
const pageUrl = `http://127.0.0.1:${addr.port}/noimg`;
|
||||||
|
try {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
respondWith([
|
||||||
|
{ url: pageUrl, title: 'X', thumbnail: 'https://searxng-cdn/fallback.jpg' }
|
||||||
|
]);
|
||||||
|
const hits = await searchWeb(db, 'x', { searxngUrl: baseUrl });
|
||||||
|
expect(hits[0].thumbnail).toBe('https://searxng-cdn/fallback.jpg');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => pageServer.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SQLite cache: second search does not re-fetch the page', async () => {
|
||||||
|
let pageHits = 0;
|
||||||
|
const pageServer = createServer((_req, res) => {
|
||||||
|
pageHits += 1;
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`<html><head><meta property="og:image" content="https://cdn.example/c.jpg">${RECIPE_LD}</head></html>`);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => pageServer.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = pageServer.address() as AddressInfo;
|
||||||
|
const pageUrl = `http://127.0.0.1:${addr.port}/cached`;
|
||||||
|
try {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
addDomain(db, '127.0.0.1');
|
||||||
|
respondWith([{ url: pageUrl, title: 'C', content: '' }]);
|
||||||
|
const first = await searchWeb(db, 'c', { searxngUrl: baseUrl });
|
||||||
|
const second = await searchWeb(db, 'c', { searxngUrl: baseUrl });
|
||||||
|
expect(first[0].thumbnail).toBe('https://cdn.example/c.jpg');
|
||||||
|
expect(second[0].thumbnail).toBe('https://cdn.example/c.jpg');
|
||||||
|
expect(pageHits).toBe(1); // second call read from SQLite cache
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT image FROM thumbnail_cache WHERE url = ?')
|
||||||
|
.get(pageUrl) as { image: string };
|
||||||
|
expect(row.image).toBe('https://cdn.example/c.jpg');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => pageServer.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('filters out forum/magazine/listing URLs', async () => {
|
it('filters out forum/magazine/listing URLs', async () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
addDomain(db, 'chefkoch.de');
|
addDomain(db, 'chefkoch.de');
|
||||||
@@ -83,7 +342,7 @@ describe('searchWeb', () => {
|
|||||||
{ url: 'https://www.chefkoch.de/themen/ravioli/', title: 'Themen' },
|
{ url: 'https://www.chefkoch.de/themen/ravioli/', title: 'Themen' },
|
||||||
{ url: 'https://www.chefkoch.de/rezepte/', title: 'Rezepte Übersicht' }
|
{ url: 'https://www.chefkoch.de/rezepte/', title: 'Rezepte Übersicht' }
|
||||||
]);
|
]);
|
||||||
const hits = await searchWeb(db, 'ravioli', { searxngUrl: baseUrl });
|
const hits = await searchWeb(db, 'ravioli', { searxngUrl: baseUrl, enrichThumbnails: false });
|
||||||
expect(hits.length).toBe(1);
|
expect(hits.length).toBe(1);
|
||||||
expect(hits[0].title).toBe('Ravioli');
|
expect(hits[0].title).toBe('Ravioli');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||||
import { addDomain, listDomains, removeDomain } from '../../src/lib/server/domains/repository';
|
import {
|
||||||
import { isDomainAllowed } from '../../src/lib/server/domains/whitelist';
|
addDomain,
|
||||||
|
listDomains,
|
||||||
|
removeDomain,
|
||||||
|
setDomainFavicon,
|
||||||
|
updateDomain,
|
||||||
|
getDomainById
|
||||||
|
} from '../../src/lib/server/domains/repository';
|
||||||
|
|
||||||
describe('allowed domains', () => {
|
describe('allowed domains', () => {
|
||||||
it('round-trips domains', () => {
|
it('round-trips domains', () => {
|
||||||
@@ -12,18 +18,10 @@ describe('allowed domains', () => {
|
|||||||
expect(all.map((d) => d.domain).sort()).toEqual(['chefkoch.de', 'emmikochteinfach.de']);
|
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();
|
const db = openInMemoryForTest();
|
||||||
addDomain(db, 'WWW.Chefkoch.DE');
|
addDomain(db, 'WWW.Chefkoch.DE');
|
||||||
expect(isDomainAllowed(db, 'https://chefkoch.de/abc')).toBe(true);
|
expect(listDomains(db)[0].domain).toBe('chefkoch.de');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes domains', () => {
|
it('removes domains', () => {
|
||||||
@@ -32,4 +30,35 @@ describe('allowed domains', () => {
|
|||||||
removeDomain(db, d.id);
|
removeDomain(db, d.id);
|
||||||
expect(listDomains(db)).toEqual([]);
|
expect(listDomains(db)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updateDomain changes label without touching favicon', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const d = addDomain(db, 'chefkoch.de', 'Chefkoch');
|
||||||
|
setDomainFavicon(db, d.id, 'favicon-abc.png');
|
||||||
|
const updated = updateDomain(db, d.id, { display_name: 'Chefkoch.de' });
|
||||||
|
expect(updated?.domain).toBe('chefkoch.de');
|
||||||
|
expect(updated?.display_name).toBe('Chefkoch.de');
|
||||||
|
expect(updated?.favicon_path).toBe('favicon-abc.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateDomain resets favicon when the domain itself changes', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const d = addDomain(db, 'chefkoch.de');
|
||||||
|
setDomainFavicon(db, d.id, 'favicon-abc.png');
|
||||||
|
const updated = updateDomain(db, d.id, { domain: 'rezeptwelt.de' });
|
||||||
|
expect(updated?.domain).toBe('rezeptwelt.de');
|
||||||
|
expect(updated?.favicon_path).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateDomain returns null for missing id', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
expect(updateDomain(db, 999, { domain: 'x.com' })).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDomainById fetches single row', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const d = addDomain(db, 'chefkoch.de');
|
||||||
|
expect(getDomainById(db, d.id)?.domain).toBe('chefkoch.de');
|
||||||
|
expect(getDomainById(db, 999)).toBe(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
|||||||
import {
|
import {
|
||||||
addToWishlist,
|
addToWishlist,
|
||||||
removeFromWishlist,
|
removeFromWishlist,
|
||||||
|
removeFromWishlistForAll,
|
||||||
listWishlist,
|
listWishlist,
|
||||||
isOnWishlist,
|
listWishlistProfileIds,
|
||||||
likeWish,
|
isOnMyWishlist,
|
||||||
unlikeWish
|
countWishlistRecipes
|
||||||
} from '../../src/lib/server/wishlist/repository';
|
} from '../../src/lib/server/wishlist/repository';
|
||||||
import type { Recipe } from '../../src/lib/types';
|
import type { Recipe } from '../../src/lib/types';
|
||||||
|
|
||||||
@@ -38,96 +39,105 @@ beforeEach(() => {
|
|||||||
db = openInMemoryForTest();
|
db = openInMemoryForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('wishlist add/remove', () => {
|
describe('per-user wishlist', () => {
|
||||||
it('adds and lists', () => {
|
it('adds and lists for a single profile', () => {
|
||||||
const r1 = insertRecipe(db, recipe('Carbonara'));
|
const r1 = insertRecipe(db, recipe('Carbonara'));
|
||||||
const p = createProfile(db, 'Hendrik');
|
const p = createProfile(db, 'Hendrik');
|
||||||
addToWishlist(db, r1, p.id);
|
addToWishlist(db, r1, p.id);
|
||||||
expect(isOnWishlist(db, r1)).toBe(true);
|
expect(isOnMyWishlist(db, r1, p.id)).toBe(true);
|
||||||
|
|
||||||
const list = listWishlist(db, p.id);
|
const list = listWishlist(db, p.id);
|
||||||
expect(list.length).toBe(1);
|
expect(list.length).toBe(1);
|
||||||
expect(list[0].title).toBe('Carbonara');
|
expect(list[0].title).toBe('Carbonara');
|
||||||
expect(list[0].added_by_name).toBe('Hendrik');
|
expect(list[0].wanted_by_count).toBe(1);
|
||||||
|
expect(list[0].wanted_by_names).toBe('Hendrik');
|
||||||
|
expect(list[0].on_my_wishlist).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is idempotent on double-add', () => {
|
it('aggregates multiple users per recipe', () => {
|
||||||
|
const r1 = insertRecipe(db, recipe('Pizza'));
|
||||||
|
const a = createProfile(db, 'Alice');
|
||||||
|
const b = createProfile(db, 'Bob');
|
||||||
|
const c = createProfile(db, 'Cara');
|
||||||
|
addToWishlist(db, r1, a.id);
|
||||||
|
addToWishlist(db, r1, b.id);
|
||||||
|
addToWishlist(db, r1, c.id);
|
||||||
|
|
||||||
|
const listFromA = listWishlist(db, a.id);
|
||||||
|
expect(listFromA.length).toBe(1);
|
||||||
|
expect(listFromA[0].wanted_by_count).toBe(3);
|
||||||
|
expect(listFromA[0].on_my_wishlist).toBe(1);
|
||||||
|
|
||||||
|
const ids = listWishlistProfileIds(db, r1);
|
||||||
|
expect(ids.sort()).toEqual([a.id, b.id, c.id].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent on double-add for same profile', () => {
|
||||||
const r1 = insertRecipe(db, recipe('Pizza'));
|
const r1 = insertRecipe(db, recipe('Pizza'));
|
||||||
const p = createProfile(db, 'A');
|
const p = createProfile(db, 'A');
|
||||||
addToWishlist(db, r1, p.id);
|
addToWishlist(db, r1, p.id);
|
||||||
addToWishlist(db, r1, p.id);
|
addToWishlist(db, r1, p.id);
|
||||||
expect(listWishlist(db, p.id).length).toBe(1);
|
const list = listWishlist(db, p.id);
|
||||||
|
expect(list[0].wanted_by_count).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes', () => {
|
it('removes only my entry, keeps others', () => {
|
||||||
const r1 = insertRecipe(db, recipe('X'));
|
const r1 = insertRecipe(db, recipe('Salad'));
|
||||||
addToWishlist(db, r1, null);
|
const a = createProfile(db, 'A');
|
||||||
removeFromWishlist(db, r1);
|
const b = createProfile(db, 'B');
|
||||||
expect(listWishlist(db, null).length).toBe(0);
|
addToWishlist(db, r1, a.id);
|
||||||
|
addToWishlist(db, r1, b.id);
|
||||||
|
removeFromWishlist(db, r1, a.id);
|
||||||
|
expect(isOnMyWishlist(db, r1, a.id)).toBe(false);
|
||||||
|
expect(isOnMyWishlist(db, r1, b.id)).toBe(true);
|
||||||
|
expect(listWishlist(db, b.id)[0].wanted_by_count).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cascades with recipe delete', () => {
|
it('on_my_wishlist is 0 for profiles that did not wish', () => {
|
||||||
|
const r1 = insertRecipe(db, recipe('Curry'));
|
||||||
|
const a = createProfile(db, 'A');
|
||||||
|
const b = createProfile(db, 'B');
|
||||||
|
addToWishlist(db, r1, a.id);
|
||||||
|
|
||||||
|
const listFromB = listWishlist(db, b.id);
|
||||||
|
expect(listFromB[0].on_my_wishlist).toBe(0);
|
||||||
|
expect(listFromB[0].wanted_by_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades when recipe is deleted', () => {
|
||||||
const r1 = insertRecipe(db, recipe('X'));
|
const r1 = insertRecipe(db, recipe('X'));
|
||||||
addToWishlist(db, r1, null);
|
const a = createProfile(db, 'A');
|
||||||
|
addToWishlist(db, r1, a.id);
|
||||||
db.prepare('DELETE FROM recipe WHERE id = ?').run(r1);
|
db.prepare('DELETE FROM recipe WHERE id = ?').run(r1);
|
||||||
|
expect(listWishlist(db, a.id).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades when profile is deleted', () => {
|
||||||
|
const r1 = insertRecipe(db, recipe('X'));
|
||||||
|
const a = createProfile(db, 'A');
|
||||||
|
addToWishlist(db, r1, a.id);
|
||||||
|
db.prepare('DELETE FROM profile WHERE id = ?').run(a.id);
|
||||||
expect(listWishlist(db, null).length).toBe(0);
|
expect(listWishlist(db, null).length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('wishlist likes + sort', () => {
|
it('countWishlistRecipes counts distinct recipes (not rows)', () => {
|
||||||
it('counts likes per entry and shows liked_by_me for active profile', () => {
|
|
||||||
const r1 = insertRecipe(db, recipe('R1'));
|
const r1 = insertRecipe(db, recipe('R1'));
|
||||||
const r2 = insertRecipe(db, recipe('R2'));
|
const r2 = insertRecipe(db, recipe('R2'));
|
||||||
const a = createProfile(db, 'A');
|
const a = createProfile(db, 'A');
|
||||||
const b = createProfile(db, 'B');
|
const b = createProfile(db, 'B');
|
||||||
const c = createProfile(db, 'C');
|
|
||||||
addToWishlist(db, r1, a.id);
|
addToWishlist(db, r1, a.id);
|
||||||
|
addToWishlist(db, r1, b.id); // same recipe, different user
|
||||||
addToWishlist(db, r2, a.id);
|
addToWishlist(db, r2, a.id);
|
||||||
likeWish(db, r1, a.id);
|
expect(countWishlistRecipes(db)).toBe(2);
|
||||||
likeWish(db, r1, b.id);
|
|
||||||
likeWish(db, r1, c.id);
|
|
||||||
likeWish(db, r2, a.id);
|
|
||||||
|
|
||||||
const listA = listWishlist(db, a.id, 'popular');
|
|
||||||
expect(listA[0].title).toBe('R1');
|
|
||||||
expect(listA[0].like_count).toBe(3);
|
|
||||||
expect(listA[0].liked_by_me).toBe(1);
|
|
||||||
expect(listA[1].title).toBe('R2');
|
|
||||||
expect(listA[1].like_count).toBe(1);
|
|
||||||
|
|
||||||
const listB = listWishlist(db, b.id);
|
|
||||||
expect(listB.find((e) => e.recipe_id === r1)!.liked_by_me).toBe(1);
|
|
||||||
expect(listB.find((e) => e.recipe_id === r2)!.liked_by_me).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unlike is idempotent and decrements count', () => {
|
it('removeFromWishlistForAll drops every profile', () => {
|
||||||
const r = insertRecipe(db, recipe('R'));
|
const r1 = insertRecipe(db, recipe('R1'));
|
||||||
const a = createProfile(db, 'A');
|
const a = createProfile(db, 'A');
|
||||||
addToWishlist(db, r, a.id);
|
const b = createProfile(db, 'B');
|
||||||
likeWish(db, r, a.id);
|
addToWishlist(db, r1, a.id);
|
||||||
unlikeWish(db, r, a.id);
|
addToWishlist(db, r1, b.id);
|
||||||
unlikeWish(db, r, a.id);
|
removeFromWishlistForAll(db, r1);
|
||||||
const [entry] = listWishlist(db, a.id);
|
expect(listWishlistProfileIds(db, r1)).toEqual([]);
|
||||||
expect(entry.like_count).toBe(0);
|
|
||||||
expect(entry.liked_by_me).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sort=newest orders by added_at desc, oldest asc', () => {
|
|
||||||
const r1 = insertRecipe(db, recipe('First'));
|
|
||||||
// Force different timestamps via raw insert with explicit added_at
|
|
||||||
db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-01 10:00:00')").run(r1);
|
|
||||||
const r2 = insertRecipe(db, recipe('Second'));
|
|
||||||
db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-02 10:00:00')").run(r2);
|
|
||||||
|
|
||||||
expect(listWishlist(db, null, 'newest').map((e) => e.title)).toEqual(['Second', 'First']);
|
|
||||||
expect(listWishlist(db, null, 'oldest').map((e) => e.title)).toEqual(['First', 'Second']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles anonymous (no active profile) — liked_by_me always 0', () => {
|
|
||||||
const r = insertRecipe(db, recipe('R'));
|
|
||||||
addToWishlist(db, r, null);
|
|
||||||
likeWish(db, r, createProfile(db, 'A').id);
|
|
||||||
const [entry] = listWishlist(db, null);
|
|
||||||
expect(entry.like_count).toBe(1);
|
|
||||||
expect(entry.liked_by_me).toBe(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user