104 Commits

Author SHA1 Message Date
hsiegeln
854af2fc34 fix(pwa): Reload-Loop beim Zombie-Cleanup beseitigt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
Der Commit 1bec054 hatte einen globalen controllerchange-Listener in
init(), der bei jedem Event location.reload() auslöste. In Kombination
mit der Zombie-Aufräumung (silent SKIP_WAITING) ergab das einen
Endlos-Loop: Seite lädt → Zombie erkannt → SKIP_WAITING → controller-
change → Reload → neue Seite mit frischem Zombie → usw.

Fix: Der controllerchange-Listener wird nur noch scoped aus reload()
heraus gesetzt ({ once: true }) — also genau dann, wenn der User auf
„Neu laden" geklickt hat und einen Reload tatsächlich will. Beim
silent Zombie-Cleanup gibt es keinen Listener, die Seite läuft
einfach nahtlos unter dem neuen (funktional identischen) SW weiter.

Regression-Test sichert ab, dass fireControllerChange() nach silent
SKIP_WAITING location.reload() NICHT aufruft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:12:19 +02:00
hsiegeln
1bec054ec6 fix(pwa): Zombie-waiting-SW via GET_VERSION erkennen (Live-Bug)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Das reine Workbox-Handshake-Pattern aus c2074c9 reicht für dieses
Deploy nicht. Live-Analyse mit Playwright ergibt reproduzierbar nach
dem Reload-Klick:
- active-SW: Version 1776527907402
- waiting-SW: Version 1776527907402 (bit-identisch!)
- Nur ein einziger shell-Cache
- Server-Response: gleiche Version
→ Toast kommt bei jedem Reload erneut.

Vermutung: Race zwischen Chromium-SW-Update-Check (der parallel
zum SKIP_WAITING läuft) und activate. Der Browser hält den zweiten
Installation-Versuch mit identischen Bytes im waiting-Slot.

Fix: SW bekommt GET_VERSION-Handler, Client fragt via MessageChannel
active und waiting nach Version. Bei Gleichheit räumt er den Zombie
stumm auf (SKIP_WAITING ohne Toast), bei Versions-Unterschied
zeigt er den Toast. Der refreshing-Flag-Reload-Guard aus c2074c9
bleibt erhalten.

Industry-Standard-Pattern bleibt die Basis; GET_VERSION ist ein
defensiver Zusatz für einen reproduzierbaren Browser-Edge-Case,
den Workbox nicht abfängt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:06:36 +02:00
hsiegeln
c2074c9768 refactor(pwa): auf Workbox-Standard vereinfacht, refreshing-Flag
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Der Zombie-Version-Check (858d4c1) ging über das Standard-Handshake-
Pattern hinaus. User will Industry-Standard: Workbox/web.dev-Pattern
ohne GET_VERSION-Sonderlocke.

Änderungen:
- service-worker.ts: GET_VERSION-Handler entfernt. SW reagiert nur
  noch auf SKIP_WAITING.
- pwa.svelte.ts: queryVersion + evaluateWaiting entfernt. init()
  zeigt Toast wieder schlicht bei registration.waiting (das ist
  kanonisch — bit-gleiche Bytes erzeugen keinen waiting-Slot).
- controllerchange-Listener wandert nach init() mit refreshing-Flag
  (CRA-Idiom): verhindert Doppel-Reload, wenn User zusätzlich F5
  drückt, und stellt sicher, dass der Listener in _jeder_ Session
  aktiv ist, nicht erst nach dem ersten reload()-Call.
- pwa-store.test.ts: Tests decken jetzt waiting→Toast, no-waiting→
  kein Toast, Handshake, refreshing-Flag und Sofort-Reload ab.

Der Zombie-Edge-Case (Browser-Quirk mit bit-identischem waiting-SW)
wird sich nach einmaligem Klick auflösen — erwarteter Trade-off
gegenüber der eingesparten Komplexität.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:57:51 +02:00
hsiegeln
858d4c1622 fix(pwa): Zombie-Waiting-SW erkennen und stumm aufräumen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m23s
Der vorige Fix (3d6f639) hat den Endlos-Toast "Neue Kochwas-Version
verfügbar" im Happy-Path beseitigt, aber das Grundproblem blieb:
pwaStore.init() hat blind `registration.waiting` als Update-Signal
verwendet.

Beobachtet auf der Live-PWA: Nach dem Reload-Klick existiert
registration.waiting weiter — als bit-identischer Zombie zum aktiven
SW (nur ein einziger shell-Cache `kochwas-shell-<version>`, Server-
Fetch liefert dieselbe Version-Konstante wie der active-SW). Der
Browser räumt diesen waiting-Slot nicht von selbst auf. Ergebnis:
beim nächsten init() steht `registration.waiting` wieder, Toast
kommt wieder.

Fix: SW bekommt einen GET_VERSION-MessageHandler. pwaStore fragt
active und waiting per MessageChannel nach ihrer Version. Sind sie
gleich, schickt er SKIP_WAITING silent an den Zombie und zeigt
KEINEN Toast. Nur bei echter Versions-Differenz erscheint das Update-
Angebot. Der alte onUpdateFound-Pfad geht den gleichen Weg.

Regression-Test: tests/unit/pwa-store.test.ts deckt Zombie-, Echt-
Update- und Fallback-Fall (alter SW ohne GET_VERSION) ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:48:55 +02:00
hsiegeln
42f79f122b fix(api): PATCH akzeptiert servings_default=0
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Das Schema hatte positive() statt nonnegative() — damit schlug
jedes Save fehl, bei dem der Importer keine Portionsangabe finden
konnte und 0 eingesetzt hatte (z.B. bei rezeptwelt.de-Rezepten).
Alle anderen Int-Felder im gleichen Schema nutzen nonnegative()
konsistent; servings_default war der Ausreißer. DB-Spalte erlaubt
0 ohnehin, insertRecipe akzeptiert 0 → nur die PATCH-Validierung
hat unnötig blockiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:30:57 +02:00
hsiegeln
3d6f6393b3 fix(pwa): Endlos-Loop "Neue Version verfügbar" beseitigt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Der SW rief bisher im Install-Handler self.skipWaiting() auf —
der neue SW übersprang damit die "waiting"-Phase und aktivierte
sofort. pwaStore.onUpdateFound feuerte trotzdem auf statechange=
"installed" + vorhandenem controller und setzte updateAvailable=
true. Ergebnis: Toast erschien, obwohl der SW bereits übernommen
hatte, und der Klick auf "Neu laden" löste durch das Timing einen
neuen Update-Zyklus aus → Endlosschleife, v.a. im Incognito-Mode
wo jede Session neu installiert.

Jetzt klassisches Pattern: SW wartet in "installed"-Zustand bis
der User den Toast bestätigt; pwaStore.reload() postet
SKIP_WAITING an den wartenden SW, lauscht auf controllerchange
und reloadet dann erst. Ohne diese Trennung ist der Toast
semantisch kaputt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:27:04 +02:00
hsiegeln
0ede62dc8a docs(pwa): CLAUDE.md, OPERATIONS.md, ARCHITECTURE.md aktualisiert
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
CLAUDE.md: zwei neue Gotchas (SW nur HTTPS, Icon-Rendering) +
Erweiterung der "Dateien, die man anfasst"-Liste um SW-Pfade und
Client-Stores.

OPERATIONS.md: neuer Abschnitt "PWA / Offline-Modus" mit Caches,
Sync-Verhalten, Debug-Pfad und E2E-Test-Kommando.

ARCHITECTURE.md: neuer Abschnitt "Service Worker (PWA)" mit
Zuständigkeiten, Cache-Strategien, Message-Protokoll, Stores und
Komponenten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:09:54 +02:00
hsiegeln
1a4f7b5f20 test(pwa): E2E für Offline-Navigation, -Toast, -Indikator
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 28s
Playwright-Spec mit drei Tests:
- Offline-Navigation zu einem Rezept-Detail (Cache-Lesefall)
- Schreib-Aktion offline zeigt Toast (Favorit-Klick → Fehler-Toast)
- SyncIndicator zeigt "Offline"-Pill bei deaktiviertem Netzwerk

Seed-Fixture legt per /api/recipes/blank ein Rezept an, falls die
DB leer ist. waitForSync pollt navigator.serviceWorker.ready und
wartet zusätzlich 3 s für den initialen Pre-Cache.

Profil-Fixture (worker-scoped) erstellt bei Bedarf ein Test-Profil
und setzt es per addInitScript in localStorage, damit der Favorit-
Button den requireOnline-Guard erreicht (statt alertAction-Dialog).

SyncIndicator-Test ohne Reload: network.svelte.ts lauscht direkt auf
den 'offline'-Browser-Event, der bei context.setOffline(true) feuert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:07:21 +02:00
hsiegeln
528508a304 chore(test): Playwright für PWA-E2E-Tests aufgesetzt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m33s
@playwright/test + chromium als devDep. playwright.config.ts
startet npm run preview (production build nötig für SW), baseURL
localhost:4173, fullyParallel off (SW-Installations-Timing). Ein
Smoke-Test smoke.spec.ts öffnet / und prüft den Titel. test-results/
und playwright-report/ in .gitignore ergänzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:01:39 +02:00
hsiegeln
8bb208a613 feat(pwa): Admin-Tab "App" mit Install + Sync + Cache-Reset
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Neuer vierter Admin-Tab (Smartphone-Icon) mit drei Karten:
1. Installieren — fängt beforeinstallprompt (Android), zeigt
   iOS-Teilen-Hinweis, sonst Info "nicht verfügbar".
2. Offline-Synchronisation — Status + "Jetzt synchronisieren"-
   Button, disabled wenn offline.
3. Cache — "Offline-Cache leeren" löscht alle kochwas-*-Caches
   via caches.keys() + delete.

install-prompt.svelte.ts hält das deferred-Event und die Plattform
(android/ios/other) per UA-Detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:57:49 +02:00
hsiegeln
3906781c4e feat(pwa): Schreib-Aktionen zeigen Offline-Toast statt stillem Fail
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Neuer Helper requireOnline(action) prüft vor jedem Schreib-Fetch
den Online-Status. Offline: ein Toast erscheint ("Die Aktion braucht
eine Internet-Verbindung."), Aktion bricht sauber ab. Der Button-
State bleibt unverändert (kein optimistisches Update, das gleich
wieder zurückgedreht werden müsste).

Eingebaut in Rezept-Detail (8 Handler), Register (2), Wunschliste
(2), Admin Domains/Profile/Backup, Home-Dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:54:03 +02:00
hsiegeln
447ff2be32 fix(pwa): SW-Manifest trackt nur wirklich gecachte Rezepte
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Vorher: saveCachedIds(currentIds) hat alle Server-IDs gespeichert,
auch wenn cacheRecipe still fehlschlug — beim nächsten sync-check
wurden die fehlenden übersprungen, offline gab's 404. Jetzt:
cacheRecipe + addToCache geben boolean zurück, nur die wirklich
erfolgreich gecachten IDs landen im Manifest. Bei Update-Sync wird
das Manifest aus (cached - toRemove + successful) berechnet.

Zusätzlich console.warn in addToCache, damit Cache-Misses auf dem
Pi debuggbar sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:50:26 +02:00
hsiegeln
51a88a4c58 feat(pwa): SW Pre-Cache-Orchestrator mit Fortschritt + Delta-Sync
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Message-Handler für sync-start (initial: alle Rezepte cachen) und
sync-check (delta: nur neue nachladen, gelöschte räumen). Vor dem
Sync ein Storage-Quota-Check (<100 MB frei → abbrechen mit Fehler-
Broadcast). Concurrency-Pool mit 4 parallelen Downloads pro
Rezept (HTML, API-JSON, Bild). Fortschritt per postMessage an
alle Clients, die über den sync-status-Store den SyncIndicator
füllen. Das Cache-Manifest wird als JSON-Response unter
/__cache-manifest__ im kochwas-meta Cache persistiert.

Client triggert beim App-Start entweder sync-check (bereits
kontrollierter SW) oder sync-start (erstmaliger SW-Install).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:44:48 +02:00
hsiegeln
582d902c62 feat(pwa): Service-Worker-Gerüst mit Shell-Cache + Fetch-Dispatch
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
src/service-worker.ts installiert die App-Shell-Assets (build +
files aus $service-worker) beim install-Event in kochwas-shell-
<version>, räumt alte Shell-Caches beim activate und dispatcht
jeden Fetch via resolveStrategy — shell/images cache-first, swr
stale-while-revalidate, network-only unangetastet. Pre-Cache-
Orchestrator kommt in Task 9.

Client-seitig registriert sw-register.ts den SW und verdrahtet
Messages vom SW in den sync-status-Store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:38:09 +02:00
hsiegeln
7c8edb9b92 feat(pwa): Cache-Manifest-Diff-Funktion + Tests
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Pure Funktion diffManifest(current, cached) → {toAdd, toRemove}.
Vom SW beim Update-Sync genutzt: neue Rezept-IDs nachladen,
gelöschte aus dem Cache räumen. 5 Tests decken add/remove/
beides/unchanged/empty-cache ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:34:39 +02:00
hsiegeln
d38992661c feat(pwa): Cache-Strategy-Entscheider + Unit-Tests
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Reine TS-Funktion resolveStrategy({url, method}) → 'shell' | 'swr'
| 'images' | 'network-only'. Kernregel:
  - Schreib-Methoden + import/preview/search/web → network-only
  - /images/* → images (cache-first)
  - /_app/, manifest, Icons, favicon, robots → shell (cache-first)
  - Alles andere same-origin-GET → swr
7 Tests decken alle Buckets ab. Wird vom SW in Task 8 aufgerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:32:30 +02:00
hsiegeln
02df0331b7 feat(pwa): SyncIndicator-Pill mit Overlay-Karte
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Bottom-right Pill zeigt Sync-Fortschritt (Sync N/M) oder Offline-
Status. Klick öffnet Overlay mit "Zuletzt synchronisiert: vor
N Min" + manuellem Refresh-Button (postMessage type=sync-check an
den SW). prefers-reduced-motion noch nicht gehandhabt — Spin-Icon
dreht sich bewusst; kein UX-Schaden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:29:31 +02:00
hsiegeln
d08cefa5c9 feat(pwa): Sync-Status-Store mit localStorage-Persistierung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Spiegelt SW-Messages (sync-start/progress/done/error) in einen
Svelte-State. lastSynced wird in localStorage persistiert, damit
der User nach einem Reload sieht, wann zuletzt synchronisiert
wurde. Wird vom SyncIndicator und der Admin-App-Tab konsumiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:25:35 +02:00
hsiegeln
0c66bd677e feat(pwa): Toast-Store + Renderer
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Toast-Queue als $state-Store mit Auto-Dismiss nach 3 s und manuellem
dismiss(id). Drei Kinds: info/error/success (Farbe). Renderer als
<Toast /> im Root-Layout, fix-positioniert oben mittig. Wird
vom Offline-Check der Schreib-Aktionen genutzt und später auch für
Sync-Abschluss-Meldungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:21:14 +02:00
hsiegeln
04641355df feat(pwa): Online-Status-Store
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m31s
Reaktiver Store basierend auf navigator.onLine und den window-
Events online/offline. Kein aktives Heuristik-Probing — für
unseren Offline-PWA-Use-Case reicht der Browser-Status. Wird von
SyncIndicator und require-online-Helper konsumiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:17:11 +02:00
hsiegeln
0b12aa027f feat(pwa): PNG-Icons 192/512 + Manifest maskable-fähig
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m35s
Android Chrome bevorzugt für den Home-Screen rasterbare PNG-Icons
über reines SVG. 192×192 und 512×512 werden aus static/icon.svg
per Sharp-Skript gerendert (npm run render:icons) und committet,
damit CI keine zusätzliche Abhängigkeit hat. Manifest referenziert
alle drei Icons mit purpose "any maskable" → rund-/squircle-sicher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:12:40 +02:00
hsiegeln
60f6db9091 docs(plan): v1.1 Offline-PWA Implementierungsplan
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
15 bite-sized Tasks mit exakten Pfaden und Code-Blöcken. Reihenfolge:
Icons/Manifest → Stores (Network/Toast/Sync-Status) → SyncIndicator →
Cache-Strategy/Diff pure Funktionen → SW-Gerüst → Pre-Cache-Orchestrator
→ Schreib-Aktionen mit Offline-Check → Admin-App-Tab → Playwright →
E2E-Tests → Docs-Updates → Final Manual Checks.

TDD wo sinnvoll (Pure Functions + Stores). SW selber manuell getestet
via preview + DevTools, plus Playwright-E2E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:07:11 +02:00
hsiegeln
303939a6ff docs(spec): v1.1 Offline-PWA Design
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 46s
Ergebnis des Brainstormings. Entscheidungen:
- Alle Rezepte + Bilder synchronisieren (~60 MB, ~200 Rezepte)
- SvelteKits eingebauter Service Worker, keine externe PWA-Abhängigkeit
- Hintergrund-Pre-Cache ohne Blocker, sichtbarer Fortschritt im
  dezenten Sync-Indikator unten rechts
- Stale-While-Revalidate für Rezept-Daten, Cache-First für Shell+Bilder
- Schreib-Aktionen offline: proaktiver Check + Toast, keine Queue
- Neuer Admin-Tab "App" für Install-Button, Sync-Status, Reset
- Unit-Tests für Cache-Strategy/Diff, Playwright-E2E für Offline-Flows

Bereit für Nutzer-Review und anschließende Plan-Erstellung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:59:39 +02:00
hsiegeln
2807dd1cab feat(import): manuelle URL-Importe von allen Domains zulassen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m14s
Der User pastet bewusst eine URL und erwartet, dass der Import
klappt — die Whitelist-Prüfung (DOMAIN_BLOCKED) im previewRecipe
war da nur Reibung. Die Whitelist bleibt für die Web-Suche relevant
(dort muss das Crawl-Feld eingeschränkt werden), für Imports nicht
mehr.

Dropped: isDomainAllowed + whitelist.ts, DOMAIN_BLOCKED-Code in
ImporterError, die zugehörige Branch in mapImporterError. Tests
entsprechend angepasst: statt "DOMAIN_BLOCKED wenn nicht whitelisted"
prüft der Preview-Test jetzt "klappt auch ohne Whitelist-Eintrag".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:18:46 +02:00
hsiegeln
7233cc3a13 style(wishlist): Chip-Label "Beliebteste" → "Meist gewünscht"
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
"Beliebteste" ist mehrdeutig (Sterne? Favoriten? Wünsche?). Der
Sort-Key popular sortiert nach wanted_by_count DESC, also der
Anzahl Profile mit dem Rezept auf ihrer Wunschliste. Das Label
macht das jetzt eindeutig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:12:22 +02:00
hsiegeln
297281e201 style(wishlist): Sortierung als Pill-Chips wie auf der Startseite
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Das native <select> fiel stilistisch aus dem App-Bild. Jetzt
identisch zur "Alle Rezepte"-Sortierung auf der Startseite: drei
grüne Pill-Chips (Beliebteste / Neueste / Älteste), aktive Pille
invertiert. Verhalten gleich, nur die Optik angepasst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:10:52 +02:00
hsiegeln
194aee269e feat(recipe): Pulse-Animation beim Aktivieren Favorit/Wunschliste
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Kurzer Scale-Bounce plus ausklingender Ring in der Aktionsfarbe
(rot für Favorit, grün für Wunschliste), sobald der Button eine
Markierung setzt. Beim Wieder-Abwählen bleibt es ruhig — hilft
die Bestätigung visuell abzusetzen.

Die Animation wird per tick()-Zwischenschritt (false → tick → true)
gestartet, damit mehrfache Klicks innerhalb weniger hundert ms die
Animation neu triggern. prefers-reduced-motion schaltet den Effekt
aus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:06:15 +02:00
hsiegeln
361164febd style(wishlist): Herz durch Utensils-Icon ersetzt
Das Herz auf der Wunschliste doppelte sich semantisch mit dem
Favoriten-Herz in der Rezeptansicht. Jetzt deckungsgleich mit dem
Wunschlisten-Icon in der Rezept-Action-Bar: Utensils. Aktiv-Farbe
von rot auf das Kochwas-Grün gewechselt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:06:07 +02:00
hsiegeln
8e33b52f66 feat(quotes): 100 weitere Sprüche für die Startseite
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Der Quote-Pool war bei 51 — mit jedem Start sah man schnell
Wiederholungen. 100 weitere im gleichen augenzwinkernden Ton
hinzugefügt. Alle non-offensive, keiner doppelt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:50:10 +02:00
hsiegeln
60d0cd7659 feat(register): Rezept-hinzufügen-Dropdown mit URL-Import + Manuell
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Der bisherige immer sichtbare URL-Importbalken ist durch einen
"Rezept hinzufügen"-Button rechts im Register-Head ersetzt. Klick
öffnet ein kleines Dropdown mit zwei Optionen:

  • Von URL importieren — öffnet einen Modal-Dialog zur URL-Eingabe
    und leitet wie bisher nach /preview weiter.
  • Leeres Rezept — POST /api/recipes/blank, Weiterleitung nach
    /recipes/{id}?edit=1; die Detailseite erkennt den Param und
    startet direkt im Editor, entfernt ihn nach Aktivierung wieder
    aus der URL.

Der neue Blank-Endpoint legt ein Rezept mit Platzhalter-Titel
"Neues Rezept", Portions-Default 4 und leeren Listen an. Der User
füllt direkt im Edit-Modus aus und speichert wie gewohnt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:40:57 +02:00
hsiegeln
a10ebefb75 fix(favicons): HTML-<link rel=icon>-Parsing vor /favicon.ico
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Emmi kocht einfach (und viele andere WordPress-Seiten) liefert unter
/favicon.ico ein Hoster-Default — Zahnrad-Artige Grafik — während das
eigentliche Site-Icon nur per <link rel="icon"> im <head> auftaucht.
Jetzt ziehen wir die Icon-Kandidaten erst aus der Homepage, sortieren
nach "sweet spot" 32–192 px und fallen bei Fehlschlag wie bisher auf
/favicon.ico und dann Google s2/favicons zurück.

Migration 011 setzt alle favicon_path=NULL, damit existierende
(falsche) Favicons beim nächsten Start neu geladen werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:34:17 +02:00
hsiegeln
e56c1543d8 fix(home): Sort-Chip-Wechsel behält Scroll-Position
Beim Klick auf eine andere Sort-Pille wurde allRecipes=[] eager
gesetzt; dadurch kollabierte der Block unter den Chips und der
Browser snapte nach oben. Jetzt laden wir erst die neuen Treffer,
tauschen dann atomar. Sollte sich die Block-Höhe trotzdem ändern
(z.B. von 50 geladenen Items zurück auf 10), korrigieren wir per
scrollBy-Delta, damit die Chips visuell an Ort bleiben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:34:08 +02:00
hsiegeln
8c93099d91 fix(recipe): Header-Bild auf allen Viewports max 30vh
Die Mobile-Only-Regel führte auf Desktop zu riesigen 16:10-Covers
(auf 1440px-Breite fast 900px hoch). Jetzt gilt der 30vh-Deckel
überall — Bild bleibt proportional, Zutaten/Zubereitung bleiben
sichtbar ohne Scroll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:34:01 +02:00
hsiegeln
f92ce677f6 fix(searxng): DuckDuckGo deaktiviert — liefert nur noch Captchas
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s
DDG erkennt die Pi-IP als Bot und antwortet bei jeder Anfrage mit
CAPTCHA (suspended_time=0, also sofort erneut, aber immer derselbe
Müll). Raus damit. Brave (API, stabil, kein Scraping-Limit) plus
Mojeek (eigener Index) liefern die Web-Treffer — das reicht für den
Kochwas-Scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:21:47 +02:00
hsiegeln
cbf9b94aa3 fix(searxng): Startpage + Tor + Wikidata deaktivieren
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m24s
Startpage lieferte Captcha-Redirects (1h suspended_time pro Fehler) —
bringt für Rezeptsuche gegenüber Brave/DDG keinen Mehrwert.

Gleich mitgenommen: ahmia/torch (brauchen Tor-Proxy, den wir nicht
haben) und wikidata (Cold-Start-KeyError in SearXNG 2026.4). Alle drei
produzierten nur Log-Noise, keine nützlichen Treffer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:19:45 +02:00
hsiegeln
7070a83991 feat(dev): docker-compose.yml als vollwertiges Dev-Setup
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m24s
Bisher lief der Dev-SearXNG über ein direktes bind-mount von
./searxng:/etc/searxng, was ${…}-Platzhalter im settings.yml als Literal
übernommen hat (Brave-Key konnte so nicht getestet werden).

Jetzt spiegelt der Dev-Compose das Prod-Pattern:
- searxng-init-Container expandiert die Platzhalter per Python und
  legt die gerenderte settings.yml auf ein named volume.
- searxng-Container mountet das volume statt bind.
- depends_on mit service_completed_successfully → sauberes Startup.
- Werte kommen aus .env (BRAVE_API_KEY, SEARXNG_SECRET); Default für
  SEARXNG_SECRET bleibt compose-seitig gesetzt, damit man ohne .env
  booten kann.

.env.example erweitert um BRAVE_API_KEY und SEARXNG_SECRET, mit kurzen
Kommentaren zu Beschaffung und Erzeugung.

Flow für einen Neu-Einsteiger:
  cp .env.example .env  # Key optional eintragen
  docker compose up -d  # bringt SearXNG hoch
  npm run dev           # startet die App lokal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:17:46 +02:00
hsiegeln
a2b3c8981c fix(searxng): Init-Container für Env-Substitution
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Der entrypoint-Override im vorherigen Commit scheiterte, weil der
erwartete Pfad /usr/local/searxng/dockerfiles/docker-entrypoint.sh im
aktuellen SearXNG-Image (granian-basiert) nicht existiert. Stattdessen
jetzt ein Ein-Shot-Init-Container mit dem gleichen SearXNG-Image:

- searxng-init: liest ./searxng/settings.yml read-only, expandiert
  ${VAR}-Platzhalter per Python os.path.expandvars, schreibt Ergebnis
  auf ein named volume (searxng-config).
- searxng: mountet searxng-config auf /etc/searxng und startet
  unverändert mit seinem Original-Entrypoint (kein Pfad-Raten).
- depends_on mit condition: service_completed_successfully → searxng
  wartet auf fertigen Init.

settings.yml: secret_key nutzt ${SEARXNG_SECRET} ohne :- default
(Python-expandvars kennt das nicht). Der Default landet als ENV im
Compose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:15:01 +02:00
hsiegeln
68e27a6868 style(admin): Emoji-Icons durch Lucide-Icons ersetzt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
🌐/👥/💾 waren die letzten verbliebenen Emoji-Icons in der App und
stachen gegen die Lucide-Icons im Rest heraus. Jetzt Globe/Users/
DatabaseBackup als SVG-Icons in den Admin-Tabs, passt zum übrigen
Design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:08:57 +02:00
hsiegeln
351434f43d refactor(home): URL-Import verzog ins Register
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Import-von-URL-Form war unter dem Hero-Suchfeld auf der Home-Seite —
dort etwas deplaziert, weil die Home-Seite primär Suche + Listen
zeigt. Jetzt sitzt das Feld oben auf der /recipes-Seite (Register),
dem natürlicheren Ort zum Verwalten der Sammlung. Link-Icon links,
grüner „Importieren"-Button rechts, auf allen Viewport-Größen sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:04:11 +02:00
hsiegeln
49d4e60a1c fix(searxng): Env-Substitution über Python statt !env-YAML-Tag
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
SearXNG v2026 kennt keinen !env-YAML-Constructor — Container crasht
mit „could not determine a constructor for the tag '!env'". Fix: wir
mounten settings.yml read-only auf /config-src, und ein Entrypoint-Hook
schreibt beim Start eine expandierte Fassung nach /etc/searxng/settings.yml
(mit os.path.expandvars — Python ist im Image, envsubst fehlt).

- settings.yml: api_key nutzt jetzt ${BRAVE_API_KEY} statt !env.
- docker-compose.prod.yml: searxng-Container bekommt entrypoint-
  Override, reicht BRAVE_API_KEY + SEARXNG_SECRET als Env durch und
  expandiert das YAML vor exec.

Leerer Key ist weiterhin ok — Brave antwortet dann mit 401, andere
Engines bleiben unberührt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:56:22 +02:00
553bf4f924 use wildcard tls cert
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
2026-04-18 13:06:14 +02:00
hsiegeln
1b31a8ff1e feat(searxng): Brave via API-Key, robustere Timeouts + Engine-Mix
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Nach den 403/Too-Many-Requests-Logs des Pi jetzt SearXNG-Setup auf
API-first + höhere Timeouts umgestellt:

- Brave läuft über den API-Key aus BRAVE_API_KEY (via !env in settings.yml
  gelesen). Kein Scraping-Ban-Spam mehr. Key wird im .env auf dem Pi
  gepflegt (nicht im Repo) und ans searxng-Container durchgereicht.
- outgoing.request_timeout 3s → 8s, max_request_timeout → 12s. Pi
  hängt gelegentlich knapp am Default-Limit, lieber warten als 0
  Treffer.
- DuckDuckGo-Timeout einzeln auf 8s, Mojeek als zusätzliche Quelle
  (eigener Index, selten Rate-Limits).
- Video-/News-/Image-Engines explizit disabled (Google/Bing/karmasearch
  videos etc.) — produzieren für Rezeptseiten nur 403-Noise.

docker-compose.prod.yml reicht BRAVE_API_KEY=${BRAVE_API_KEY:-} an den
searxng-Container weiter. Leerer Key ist ok — Brave meldet 401 bei der
ersten Query, andere Engines laufen unbeeindruckt weiter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:03:17 +02:00
hsiegeln
c79cf8657d style(recipe): Header-Bild auf Mobile max 30vh
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Das 16:10-Cover-Bild konnte auf Smartphones im Hochformat locker 40-50%
des Viewports füllen und Aktionen wie Favorit/Wunschliste fast aus dem
ersten Screen drücken. Auf <820px jetzt max-height:30vh — object-fit:
cover sorgt dafür, dass das Bild schön im gekürzten Rahmen sitzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:45:31 +02:00
hsiegeln
9a5c626890 feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.

Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
  inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
  Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.

Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
  replaceSteps — letztere in einer Transaction mit delete+insert und
  FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
  servings_default, servings_unit, prep_time_min, cook_time_min,
  total_time_min, cuisine, category, ingredients[], steps[]. Vorher
  nur title/hidden_from_recent; diese beiden bleiben als
  Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).

Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
hsiegeln
ee783ff50b feat(home): URL-Import-Shortcut auf Desktop
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Unter dem Hero-Suchfeld steht ab Viewport >=820px eine zweite kleine
Form mit „… oder Rezept-URL direkt einfügen"-Input und grünem
Importieren-Button. Beim Submit springt die App auf /preview?url=, wo
der bestehende Importer JSON-LD/Microdata extrahiert und die Vorschau
zum Bestätigen zeigt. Auf Mobile versteckt (per CSS), damit die
Hero-Area nicht überladen wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:37:10 +02:00
hsiegeln
61c1b9558e fix(searxng): nur Text-Engines via categories=general
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Die SearXNG-Logs waren voller 403-Errors von karmasearch (video-engine)
und gelegentlich Brave. Beide gehören nicht zur general-Kategorie und
bringen für Rezeptseiten nichts — sie werden nur noch vom SearXNG-Core
angefragt, weil wir die Kategorie nicht explizit eingrenzen.

categories=general im Query beschränkt jetzt auf Text-Web-Suche; die
problematischen Video-/News-Engines werden gar nicht erst konsultiert,
und die 403-Spam in den Container-Logs verschwindet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:32:11 +02:00
hsiegeln
340ab5e558 fix(home): „Alle Rezepte" Endless-Loop raus, Sort als Chips
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m28s
Der $effect auf allSort trackte implizit auch allRecipes.length und
triggerte damit bei jedem Append einen Reset + Reload — klassischer
Endless-Loop, Liste wurde ständig zurückgesetzt und nie gerendert.
Ersetzt durch einen expliziten setAllSort()-Handler, der nur beim echten
Klick des Users feuert.

Sort-Kontrolle außerdem vom nativen <select> auf App-eigene Pill-Chips
(Name / Bewertung / Zuletzt gekocht / Hinzugefügt) umgebaut —
konsistent zum Admin-Tabbar und Wunschliste-Sortierung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:19:56 +02:00
hsiegeln
09c0270c64 feat(home): „Alle Rezepte"-Sektion mit Sortierung und Endless-Scroll
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Neue Sektion unter „Zuletzt hinzugefügt": sortierbar nach Name,
Bewertung, zuletzt gekocht und Hinzugefügt. Auswahl persistiert in
localStorage (kochwas.allSort).

- Neuer Endpoint GET /api/recipes/all?sort=name&limit=10&offset=0.
- listAllRecipesPaginated(db, sort, limit, offset) im repository:
  NULLS-last-Emulation per CASE für rating/cooked — funktioniert auch
  auf älteren SQLite-Versionen.
- Endless Scroll per IntersectionObserver auf ein Sentinel-Element am
  Listen-Ende (rootMargin 200px, damit schon vor dem harten Rand
  nachgeladen wird). Pagesize 10.
- 4 neue Tests: Name-Sort, Rating-Sort, Cooked-Sort, Pagination-Offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:14:44 +02:00
hsiegeln
a1d91943c6 fix(home): Dismiss-X auf Recent-Karten immer sichtbar
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Die Hover-only-Anzeige war auf Touch-Devices eh nicht erreichbar, und
auf dem Desktop genauso wenig sinnvoll — der User musste raten wo das
Schließen-Icon ist. Opacity-Toggles und die 640px-Media-Query raus;
der kleine Cloud-weiße Circle-Button steht jetzt dauerhaft in der
rechten oberen Ecke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:10:47 +02:00
hsiegeln
9e471c7bf3 refactor(nav): Pfeil-Icon im Header statt großem Zurück-Pill
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Der neu eingefügte Admin-Pill war zu prominent für seinen Zweck. Raus
damit. Stattdessen zeigt der Haupt-Header auf allen Nicht-Hauptseiten
links einen ArrowLeft-Icon-Link zur Startseite — platzsparend und
konsistent über alle Sub-Seiten (Admin, Rezept, Preview, Wunschliste…).
Auf der Startseite selbst bleibt das „Kochwas"-Wortmarke stehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:06:52 +02:00
hsiegeln
82e8371451 feat(admin): „Zurück"-Button im Settings-Tabbar
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Auf Mobile <520px ist das Kochwas-App-Icon oben links ausgeblendet, und
aus den Settings gab es keinen sichtbaren Weg zur Hauptseite außer der
Browser-Zurück-Taste. Jetzt steht links vor den Admin-Tabs ein
ArrowLeft-Pill-Button, der direkt auf / führt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:02:43 +02:00
hsiegeln
a8fdb8c3f9 feat(recipe): Zwei-Spalten-Layout ab Tablet-Querformat
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Auf Viewports >=820px (Tablets im Querformat, Desktop) werden Zutaten
und Zubereitung nebeneinander angezeigt statt per Tab gewechselt. Der
Nutzer sieht beides gleichzeitig und nutzt die volle Display-Breite.

- Tabs bleiben für <820px (Smartphones + Tablet-Hochkant), dort schalten
  sie weiterhin zwischen den Panels um.
- Ab 820px: Tabs versteckt, Grid minmax(260px,1fr) 1.6fr. Zutaten links
  sticky top:1rem mit max-height 100vh-2rem, damit die Liste beim
  Scrollen der Zubereitung sichtbar bleibt.
- Main-Container max-width 760px → 1040px erhöht, damit auf großen
  Screens überhaupt Platz für zwei Spalten bleibt. Andere Listen-
  Ansichten (Cards, Index) nutzen den Zugewinn automatisch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:00:48 +02:00
hsiegeln
3e41505b81 fix(filter): Liste schließt nur noch bei OK/Abbrechen/Escape
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Der Klick-außerhalb-Handler fing gelegentlich Events von abgewählten
Checkbox-Zeilen ab, weil deren Layout beim Re-Render kurz verschob und
event.target außerhalb des Containers landete — das Menu schloss sich
dann mitten im Filtern. Handler komplett entfernt; Escape und die
expliziten Footer-Buttons bleiben die einzigen Wege zum Schließen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 10:51:56 +02:00
hsiegeln
4465744838 style(search): Header-Suche angeglichen an Home-Suche
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Die Header-Suche war Pill-förmig mit grünlichem Hintergrund, die Home-
Suche ein abgerundetes Rechteck mit weißem Hintergrund. Jetzt beide:
white background, border-radius: 12px, gleicher Border, gleicher Fokus-
Outline. Nur die Höhe bleibt unterschiedlich (40px Header vs. 52px
Home), damit der Header kompakt bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 09:06:22 +02:00
hsiegeln
3e3afc0102 fix(importer): Microdata-Steps bei HowToSection + mehrfach-Schritten
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Rezeptwelt lieferte Zubereitungs-Steps immer als einen einzigen Treffer,
oft mit vermischtem Icon-alt-Text. Zwei Ursachen, beide in der
generischen Microdata-Logik — kein rezeptwelt-spezifischer Parser nötig.

1. HowToSection wrappt HowToSteps als itemListElement, unser Parser sah
   nur das erste. Jetzt: recipeInstructions-Container mit itemtype=
   HowToSection werden abgestiegen, jedes itemListElement wird ein Step.

2. Ein einzelner HowToStep kann intern "1. …<br>2. …<br>3. …" enthalten.
   Neuer textWithLineBreaks(el) konvertiert <br>/Block-Grenzen zu \n und
   ignoriert <img>/<script>/<style>. splitStepText(raw) erkennt
   nummerierte Zeilen und erzeugt einen eigenen Step pro Nummer; Fort-
   setzungszeilen ohne Nummer hängen an den aktuellen Step an.

3 neue Tests: HowToSection-Kette, inline-nummerierter Multi-Step,
<img>-alt-Unterdrückung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 09:03:50 +02:00
hsiegeln
272935034d fix(filter): Trigger streckt sich auf volle Container-Höhe
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Der .wrap-Div zwischen .search-box und .trigger war display:block, was
align-self:stretch am Trigger komplett neutralisiert hat — Button war
nur 16px hoch in einem 52px-Container. Playwright hat das klar gezeigt.

- .wrap:has(.trigger.inline) wird jetzt zu display:flex, damit der
  Trigger darin überhaupt ein Flex-Item ist.
- .trigger.inline bekommt height:100% statt sich auf align-self zu
  verlassen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:59:21 +02:00
hsiegeln
c87196cd67 feat(header): Filter-Dropdown auch in der Header-Suche
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Der SearchFilter-Store galt schon global, aber das UI zum Ändern gab es
nur auf der Home-Seite — auf Rezept-/Preview-Seiten konnte man den
Filter nicht sehen, geschweige denn setzen. Jetzt zeigt die Header-Suche
denselben inline-Trigger links vom Input. Nav-Form bekommt Border +
Background als Container, Input wird transparent, Fokus-Outline landet
auf dem Container (wie auf der Home-Seite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:53:54 +02:00
hsiegeln
aad3ad689d feat(importer): Microdata-Fallback für Seiten ohne JSON-LD
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Bisher scheiterte der Import auf Seiten wie rezeptwelt.de mit „Diese Seite
enthält kein Rezept", obwohl unser Such-Filter die Treffer durchließ
(Microdata wird seit dem vorherigen Commit erkannt). Jetzt kann der
Importer die Daten auch tatsächlich extrahieren:

- extractRecipeFromMicrodata(html): parst [itemtype=schema.org/Recipe]-
  Scopes per linkedom, sammelt itemprop-Werte unter Beachtung der
  verschachtelten itemscope-Grenzen (HowToStep-Texts landen nicht im
  Haupt-Scope).
- Übernimmt Content-Attribute auf <meta>/<time> (z.B. prepTime="PT20M"),
  src auf <img>, textContent als Fallback — die Standard-Microdata-
  Value-Regeln.
- Behandelt HowToStep-Items UND einfache <li>/<ol>-Listen als
  recipeInstructions.
- extractRecipeFromHtml ruft JSON-LD zuerst, fällt nur bei null auf
  Microdata zurück — damit bleibt bestehendes Verhalten stabil.

Tests: Königsberger-Klopse-Fixture mit HowToSteps, einfache ol/li-
Variante und Priorität-JSON-LD-über-Microdata-Check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:52:00 +02:00
hsiegeln
ab2acb6437 fix(filter): Hover füllt ganzen Button, Abbrechen links / OK rechts, Alle+Keine
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
- Trigger nimmt volle Höhe des .search-box-Containers ein
  (align-self: stretch, min-height: 0), damit der Hover-Hintergrund
  bündig ausgefüllt wird statt nur innerhalb eines 44px-Rechtecks.
- Footer: justify-content: space-between — Abbrechen sitzt jetzt links,
  OK rechts (übliche Platform-Konvention).
- Quick-Actions: zusätzlicher „Keine"-Button neben „Alle", beide setzen
  den Draft-State ohne sofortigen Commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:49:57 +02:00
hsiegeln
d1ddd51da1 fix(filter): Counter-Badge aus dem Trigger entfernt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Der „2/5"-Badge neben dem Slider-Icon war visuell laut und wiederholte
nur, was der User im Dropdown direkt sieht. Raus damit — Trigger zeigt
jetzt nur noch das Icon und den Chevron.

Ungenutzten label-Getter aus dem Store auch entfernt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:43:35 +02:00
hsiegeln
15442ff72b fix(filter): overflow:hidden auf .search-box clippte den Dropdown
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m14s
Der Filter-Button war klickbar, aber das Dropdown-Menu blieb unsichtbar,
weil der umgebende .search-box-Container overflow:hidden hatte (ehemals
wegen Rounded-Corners). Entfernt — da Input und Filter-Trigger eh keinen
eigenen Background haben, ist die Rundung auch ohne Clip sauber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:42:17 +02:00
hsiegeln
52858f94fe feat(filter): Draft-Auswahl mit OK/Abbrechen-Buttons
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Der Filter-Dropdown sammelt Checkbox-Klicks jetzt nur noch lokal und
wendet sie erst beim „OK"-Klick auf den Store an. Solange der User
herumklickt, läuft die aktive Suche unverändert weiter. Abbrechen (per
Button, Klick außerhalb oder Escape) verwirft die Draft-Auswahl.

- Neuer searchFilterStore.commit(Set) für One-Shot-Apply (triggert den
  active-$effect nur ein einziges Mal).
- „Alle"-Quick-Action setzt draft = alle Domains explizit; erst beim
  Commit wird das wieder in die leere Menge überführt, damit neu
  freigeschaltete Admin-Domains weiterhin automatisch dabei sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:34:43 +02:00
hsiegeln
2e196b4834 feat(search): Microdata-Fallback erkennt rezeptwelt & Co.
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
Aus dem Log (q="Königsberger klopse"): 11 rezeptwelt-Treffer kamen durch
alle URL-Filter, wurden aber von hasRecipeJsonLd als non-recipe gedroppt.
Ursache: rezeptwelt.de nutzt Microdata (itemtype=schema.org/Recipe) statt
application/ld+json.

- hasRecipeJsonLd → hasRecipeMarkup: prüft jetzt zusätzlich per Regex
  auf itemtype=(https?://)schema.org/Recipe. Alter Export bleibt als
  Deprecated-Weiterleitung erhalten.
- Log zeigt jetzt auch die ersten 3 gedropten URLs als dropped samples,
  damit neue Problem-Domains einfach zu diagnostizieren sind.
- Migration 010 räumt alle thumbnail_cache-Einträge mit has_recipe=0 aus
  — die waren mit dem alten Check falsch-negativ und müssen neu
  klassifiziert werden.

Tests: 4 neue Cases für hasRecipeMarkup (JSON-LD, http/https Microdata,
Negativ-Fall).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:32:18 +02:00
hsiegeln
15c15c8494 feat(domains): Inline-Edit + Favicon in Settings + Filter IN Suchmaske
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Domain-Admin-Seite bekommt jetzt ein Favicon-Icon vor jedem Eintrag,
einen Pencil-Button zum Inline-Editieren von Domain und Anzeigename,
und Save/Cancel-Buttons. Beim Ändern des Domain-Namens wird das Favicon
zurückgesetzt und beim Speichern frisch nachgeladen (den Filter-Dropdown-
Icons reicht der neue favicon_path automatisch zu).

Der Filter-Button auf der Hauptseite sitzt jetzt IM weißen Suchfeld-
Container (neuer .search-box-Wrapper mit Border) statt daneben, analog
zum Referenz-Screenshot von rezeptwelt.de. Neue inline-Prop an
SearchFilter schaltet eigenen Border/Background ab und setzt stattdessen
einen vertikalen Divider nach rechts.

- Neuer PATCH /api/domains/[id] mit zod-Schema.
- Repository: updateDomain(id, patch) + getDomainById(id).
  domain-Change nullt favicon_path → Caller lädt neu.
- Tests für updateDomain-Fälle und getDomainById.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:28:02 +02:00
hsiegeln
6c2b24d060 feat(searxng): Suche-Pipeline loggen für Diagnose
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Statt eine separate Debug-Seite zu bauen: bei jeder Web-Suche werden
zwei kompakte Log-Zeilen nach stdout geschrieben, die den Filter-Verlust
pro Pipeline-Schritt zeigen. In den Pi-Docker-Logs (docker compose logs
kochwas) leicht über grep '[searxng]' zu finden.

Format:
[searxng] q="…" pageno=1 domains=3 raw=12 non_whitelist=2
         non_recipe_url=4 dup=0 kept_pre_enrich=6
[searxng] q="…" pageno=1 enrich=6 dropped_non_recipe=3 final=3

Damit lässt sich gezielt sehen, ob rezeptwelt-Treffer am looksLikeRecipePage-
Filter, am hasRecipe-Check oder schon bei SearXNG selbst verloren gehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:20:35 +02:00
hsiegeln
a590cf0a57 feat(domains): Favicons laden und im Filter anzeigen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Für jede Whitelist-Domain wird das Favicon jetzt einmalig geladen und
im image-Verzeichnis abgelegt. SearchFilter zeigt das Icon neben dem
Domain-Namen im Filter-Dropdown.

- Migration 009: allowed_domain.favicon_path (NULL = noch nicht geladen).
- Neues Modul $lib/server/domains/favicons.ts:
  fetchAndStoreFavicon(domain, imageDir) + ensureFavicons(db, imageDir)
  für Bulk-Nachzug; 8 parallele Worker mit 3s-Timeout.
- Reihenfolge: erst /favicon.ico der Domain, Fallback Google-Service.
- GET /api/domains zieht fehlende Favicons auf Abruf nach;
  POST /api/domains lädt direkt im selben Call.
- .ico + .svg jetzt in der /images/[filename]-Route erlaubt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:17:44 +02:00
hsiegeln
d004430854 feat(search): Domain-Filter als Dropdown im Suchfeld
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Links im großen Suchfeld ein Slider-Icon mit Badge („Alle" oder „2/5"),
das ein Dropdown-Menü mit allen Whitelist-Domains als Checkboxen öffnet.
Auswahl wird per localStorage persistiert und gilt global — Header-Such-
Dropdown konsumiert den gleichen Store und sendet den domains-Parameter
bei jedem Fetch mit.

Leere Menge heißt „alle aktiv", damit neu vom Admin freigeschaltete
Domains automatisch dabei sind. Aktive Auswahl landet als explizite
Intersection mit der Whitelist serverseitig.

- searchLocal nimmt jetzt optional string[] domains → `source_domain IN (…)`.
- searchWeb nimmt jetzt opts.domains → site:-Filter auf die Auswahl
  eingeschränkt. Nicht-Whitelist-Einträge werden ignoriert.
- API-Endpoints: `?domains=a.de,b.de`.
- Neuer Client-Store $lib/client/search-filter.svelte.ts.
- Neue Komponente $lib/components/SearchFilter.svelte (mobile-tauglich,
  44px Touch-Targets, Badge auf engen Screens versteckt).

Home-Seite re-runt die Suche bei Filter-Änderung automatisch (150ms debounce),
ohne dass der User neu tippen muss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:13:33 +02:00
hsiegeln
864d113082 feat(header-search): „+ weitere Ergebnisse" lädt inline im Dropdown
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Vorher war der „+ weitere"-Link im Header-Dropdown ein Navigations-Link
auf /?q=. Jetzt blättert der Button stattdessen im offenen Dropdown
direkt nach — erst weitere lokale Treffer, dann (wenn lokal erschöpft)
SearXNG-Seiten. Lokale und Web-Treffer werden beide im Dropdown
angezeigt, getrennt durch „Aus dem Internet"-Zwischenüberschrift.

Identische Logik wie auf der Home-Seite, nur im Dropdown-Scope. Dedup
per ID (lokal) bzw. URL (web) gegen SearXNG-Doppler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:03:52 +02:00
hsiegeln
0992e51a5d fix(search): Filter zuverlässiger durch allowTruncate
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Vorher warf fetchText einen Fehler, sobald eine Seite >512 KB war —
bei modernen Rezeptseiten (eingebettete Bundles, base64-Bilder) läuft
das praktisch immer voll. Der Catch-Block hat dann hasRecipe auf NULL
gelassen, und der Treffer ging ungefiltert durch.

Neue FetchOptions.allowTruncate: true → wir bekommen die ersten 512 KB
(das reicht für <head> mit og:image und JSON-LD) statt eines Throws.
Timeout auf 8s erhöht, weil der Pi manchmal langsamer ist.

Migration 008 räumt alte NULL-has_recipe-Einträge aus dem Cache, damit
sie beim nächsten Search frisch klassifiziert werden statt weitere
30 Tage falsch gecached zu bleiben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:33:55 +02:00
hsiegeln
d3c9bc5619 feat(cooked): „Heute gekocht" räumt Wunschliste für das Rezept
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Wenn ein Rezept heute gekocht wurde, ist der Wunsch eingelöst — raus
damit aus der Wunschliste aller Profile. Server tut das beim POST in
einem Rutsch (removeFromWishlistForAll) und meldet removed_from_wishlist
in der Response zurück. Der Client räumt daraufhin den lokalen
wishlistProfileIds-State und refresht den Badge-Zähler, damit der
Wunschliste-Button und das Header-Badge sofort passen — kein Reload nötig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:23:08 +02:00
hsiegeln
342ea0efc8 feat(search): Treffer ohne Recipe-JSON-LD rausfiltern
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Wir fetchen die Trefferseite sowieso schon fürs Thumbnail — prüfen
jetzt in der gleichen HTML-Parse-Runde, ob überhaupt ein
schema.org/Recipe JSON-LD vorhanden ist. Fehlt es, wird der Treffer
aus der Liste entfernt, weil der Importer auf dieser Seite später
sowieso mit „Diese Seite enthält kein Rezept" scheitern würde.

- Migration 007: thumbnail_cache.has_recipe (NULL=unbekannt, 0=nein, 1=ja).
- Fetch-Fehler hinterlassen NULL → Treffer bleibt konservativ sichtbar.
- Neue export `hasRecipeJsonLd(html)` in json-ld-recipe.ts.
- Alle Cache-Reads/Writes nehmen den neuen Wert mit.

Tests: +2 für Filter/Failover, bestehende Thumbnail-Tests mit
Recipe-JSON-LD-Stub ergänzt, damit sie nicht selber rausgefiltert
werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:20:22 +02:00
hsiegeln
dbc9646caa fix(nav): Hamburger-Menü in Kochwas-Grün statt Schwarz
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Der Menü-Button ist ein <button>, nicht <a>, und hat deshalb nicht
vom globalen Link-Color geerbt. Farbe jetzt explizit auf #2b6a3d
gesetzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:14:21 +02:00
hsiegeln
c27c2dbc62 feat(search): „+ weitere Ergebnisse" auch im Header-Dropdown
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Der Suchfeld-Dropdown auf Rezept-/Preview-Seiten hatte nur bei lokalen
Treffern einen Fuß-Link. Bei reinem Web-Ergebnis fehlte die Weiterführung.
Jetzt steht „+ weitere Ergebnisse" unter jeder Trefferliste und
navigiert auf /?q=, wo die Hauptseite inline weiter paginieren kann.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:13:03 +02:00
hsiegeln
1b7c5c084e feat(search): Home als einzige Suchseite, inline „+ weitere Ergebnisse"
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Die separaten /search und /search/web Routen sind weg. Auf der Hauptseite
gibt es jetzt einen einzigen „+ weitere Ergebnisse"-Button am Ende der
Trefferliste, der erst weitere lokale Treffer lädt und — sobald die
erschöpft sind — auf die SearXNG-Web-Suche umschaltet und dort Seite für
Seite weiterblättert. Web-Treffer werden unter die lokalen angehängt,
getrennt durch eine „Aus dem Internet"-Zwischenüberschrift.

Alte Layout-Links auf /search bzw. /search/web zeigen jetzt auf /?q=.

Der Snapshot der Suche merkt sich auch Paginations-Zustand, damit
Rücknavigation vom Rezept/Preview die volle Liste wiederherstellt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:08:00 +02:00
hsiegeln
a62b32aa1e feat(search): „+ weitere Ergebnisse"-Button für lokale und Web-Suche
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Die Ergebnislisten waren oft kurz, weil lokale Suche auf LIMIT 30 und
die Web-Suche auf die erste SearXNG-Seite beschränkt war. Jetzt lässt
sich beides nachladen.

- `searchLocal` nimmt jetzt einen `offset` und der `/api/recipes/search`-
  Endpoint einen `?offset=`-Parameter.
- `searchWeb` nimmt jetzt eine `pageno`-Option und reicht sie als
  `pageno`-Parameter an SearXNG weiter. `pageno=1` wird weggelassen,
  damit bestehendes Verhalten unverändert bleibt.
- `/search` und `/search/web` zeigen unterhalb der Liste einen
  „+ weitere Ergebnisse"-Button. Beide deduplizieren nachgeladene
  Hits (ID bzw. URL), weil SearXNG das gleiche Ergebnis auf zwei
  Seiten liefern kann.

Kein Endless-Scroll: expliziter Button ist mobil robuster und spart
die teure Thumbnail-Enrichment-Roundtrip-Zeit, die bei jeder neuen
Web-Seite anfällt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:58:47 +02:00
hsiegeln
b4a7355b24 feat(nav): Hamburger-Menü mit Register statt Settings-Icon
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Ersetzt das Settings-Zahnrad im Header durch ein Dreistriche-Menü. Das
Menü enthält zwei Punkte: „Register" führt zu einer neuen /recipes-Route
mit allen Rezepten alphabetisch gruppiert (A-Z-Buchstabenchips zum
Scrollen, Live-Filter oben, Umlaut-normalisiert). „Einstellungen" zeigt
wie bisher /admin.

Auf Mobile <520px wird das App-Icon komplett ausgeblendet, damit die
Suchleiste mehr Platz bekommt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:54:04 +02:00
hsiegeln
f72fe64d8e feat(pwa): Update-Toast zeigt neue Version an
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
pwaStore ($lib/client/pwa.svelte.ts):
- Hängt sich an navigator.serviceWorker.ready, hört auf updatefound und
  setzt updateAvailable = true, sobald ein neuer SW im Status 'installed'
  ist UND es einen aktiven controller gibt (= Update eines bestehenden
  Tabs, nicht die erste Installation).
- Polling alle 30 Minuten via registration.update(), damit der User den
  Toast auch sieht, wenn er die Seite lange offen hat ohne zu navigieren.
- reload() ruft location.reload(); dismiss() schließt den Toast nur.

UpdateToast.svelte:
- Schwarzer Pill-Toast unten zentriert, mit Text, grünem "Neu laden"-
  Button (RefreshCw-Icon) und X zum Wegklicken.
- Slide-Up-Animation beim Erscheinen.
- Responsive: auf Mobile (<420px) wird's zum vollbreiten Banner statt
  Pill.

Root-Layout mountet <UpdateToast /> direkt neben <ConfirmDialog />.
onMount ruft pwaStore.init().

Status-Check der Live-Instanz https://kochwas.siegeln.net:
- manifest.webmanifest wird korrekt als JSON ausgeliefert
- service-worker.js (3.4 KB) ist verfügbar
- iOS Apple-Meta-Tags + Android theme-color sind im HTML <head>
PWA selbst funktioniert also bereits; der Toast war das fehlende Teil
für transparente User-seitige Updates.
2026-04-17 19:38:00 +02:00
hsiegeln
dd52e44f67 fix(ui): Emoji-Reste im Profil-Modal + Wunschliste-Icon → Kochtopf
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
Profil-Modal:
- Default-Emoji '🍳' im "Neues Profil"-Input entfernt (war ein ver-
  sehentlicher Platzhalter, den die meisten nicht überschrieben haben
  → alle Profile sahen gleich aus). Jetzt leer, mit 🙂 als Hint im
  placeholder.
- Profil-Liste: avatar_emoji wird nur gezeigt, wenn wirklich gesetzt.
  Sonst CircleUser-Lucide statt 🙂-Fallback.

Migration 006_clear_default_profile_emoji.sql räumt bestehende DB-
Einträge auf: UPDATE profile SET avatar_emoji = NULL WHERE avatar_emoji
= '🍳'. User, die wirklich einen Pfannen-Avatar wollten, können das in
/admin/profiles neu setzen.

Wunschliste-Header-Icon: Heart → CookingPot. Der Kontext ist "was wir
essen wollen", also passt ein Topf besser als ein Herz. Heart bleibt
im Rezept als "Favorit" und in der Wunschliste als "ich will auch"-
Toggle, keine Kollision.

Ungenutzten Heart-Import aus +layout.svelte entfernt.
2026-04-17 19:31:24 +02:00
hsiegeln
5e7e37cc3c feat(wishlist): "für alle löschen" + Badge refresht auf jede Navigation
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m14s
1) Trash-Button auf Wunschliste wieder da. Im Gegensatz zum Heart
   entfernt er den Eintrag NICHT nur für das aktive Profil, sondern
   löscht alle Memberships auf diesem Rezept. Bestätigungsdialog macht
   das explizit ("wird für alle Profile aus der Wunschliste gestrichen").

   - repository.ts: neue Funktion removeFromWishlistForAll(recipeId)
   - DELETE /api/wishlist/:id?all=true → family-wide
     DELETE /api/wishlist/:id?profile_id=X → nur mein Eintrag
   - UI: zwei Action-Buttons untereinander (Heart, Trash)

2) wishlistStore.refresh() läuft jetzt in afterNavigate des Root-Layouts.
   Vorher wurde der Badge nur aktualisiert, wenn derselbe Tab die Aktion
   ausgelöst hat. Wenn ein anderer Tab / anderes Gerät etwas ändert,
   bleibt der Badge stale bis zum nächsten Full-Reload. Mit afterNavigate
   reicht eine Client-Navigation, um ihn zu aktualisieren — was deutlich
   näher an dem liegt, was der User erwartet.
2026-04-17 19:29:00 +02:00
hsiegeln
018fc987cd feat(recipe): Wake-Lock-Schalter + Profil-Chip nur Lucide + Save-Text
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
1) ProfileSwitcher-Chip: Profil-Emoji (avatar_emoji) ist jetzt aus dem
   Header-Badge raus — immer CircleUser-Icon vor dem Namen. Im Profil-
   Auswahl-Modal bleiben die individuellen Emojis erhalten, damit User
   ihr Profil dort weiterhin erkennen. Unused .emoji CSS entfernt.

2) Preview-Button: "In meine Sammlung speichern" → "Rezept in Kochwas
   speichern". Klarer, was die App heißt.

3) Wake-Lock-Schalter:
   - Erklärung: navigator.wakeLock.request('screen') hindert Android/iOS
     daran, das Display zu dimmen/zu sperren, solange der Tab sichtbar
     ist. Beim Kochen sehr nützlich — Hände sind klebrig.
   - Neuer Toggle-Button im Rezept-Detail, zweite Aktion-Zeile zwischen
     "Heute gekocht" und "Löschen": Lightbulb (an, gelb-gehighlighted)
     oder LightbulbOff (aus).
   - Preference wird in localStorage persistiert (kochwas.wakeLock),
     Default an. Gilt für alle Rezepte.
   - visibilitychange-Handler re-requestet den Sentinel, wenn User den
     Tab wieder nach vorne holt und die Pref an ist.
   - release-Event räumt wakeLock-Variable sauber auf.
2026-04-17 19:21:28 +02:00
hsiegeln
60021b879f feat(wishlist): per-user Wünsche + Header-Badge mit Gesamtzahl
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Schema-Änderung (Migration 005):
- Tabelle wishlist umgestellt auf PK (recipe_id, profile_id)
- wishlist_like-Tabelle zusammengelegt — Liken WAR schon "will ich auch",
  also werden alle bestehenden Likes Memberships auf der neuen Tabelle.
- Alt-Einträge mit added_by_profile_id werden migriert, anonyme gehen
  verloren (war inkonsistent, jetzt erzwingen wir profile_id NOT NULL).

Repository:
- listWishlist aggregiert pro Rezept: wanted_by_count, wanted_by_names
  (kommagetrennt), on_my_wishlist für das aktive Profil
- listWishlistProfileIds(recipeId) für den Recipe-Page-Loader
- countWishlistRecipes für das Header-Badge (DISTINCT recipe_id)
- addToWishlist/removeFromWishlist/isOnMyWishlist alle mit profile_id
  als Pflicht

API:
- POST /api/wishlist: profile_id jetzt Pflicht (nullable raus)
- DELETE /api/wishlist/[recipe_id]?profile_id=X (nur eigenes Entry)
- /api/wishlist/[recipe_id]/like komplett entfernt (Konzept obsolet)
- Neu: GET /api/wishlist/count → { count: <distinct recipes> }

UI:
- Header-Heart bekommt rotes Badge mit Zahl der Wunschliste-Rezepte.
  wishlistStore in $lib/client/wishlist.svelte.ts hält den Count reaktiv;
  Refresh auf Mount, nach Add/Remove, beim Öffnen der Wunschliste.
- Recipe-Detail: Loader liefert wishlist_profile_ids; onMyWishlist ist
  ein $derived. Toggle fragt aktives Profil (alertAction sonst), mutiert
  die lokale Liste + ruft wishlistStore.refresh.
- Wunschliste-Seite: Heart toggelt eigenen Wunsch, Count zeigt Gesamt-
  wünsche, kommagetrennte Namen zeigen "wer will". Trash-Button
  entfernt — Heart-off reicht jetzt.

Tests (99 → 99, 8 neu geschrieben):
- Per-User-Add/Remove, aggregierte Counts, on_my_wishlist, Cascades bei
  Recipe/Profile-Delete, countWishlistRecipes = DISTINCT.
2026-04-17 19:16:19 +02:00
hsiegeln
224352d051 feat(profile): CircleUser-Icon statt 👤 im Profil-Chip
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
2026-04-17 19:08:35 +02:00
hsiegeln
8db67bd1a5 feat(home): SvelteKit snapshot für echte State-Preservation beim Back
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
Vorher (Commit 1055a67): Query in URL gespiegelt, Back triggerte einen
frischen Request — zwar schnell, aber Scroll-Position + Ergebnisreihen-
folge nicht garantiert konstant.

Jetzt: SvelteKit's snapshot-API speichert query + hits + webHits +
searchedFor + webError in der History-Entry. Beim Zurück-Navigieren aus
/preview oder /recipes/[id] stellt restore() den exakten UI-Zustand
wieder her — ohne Fetch. Scroll-Position wird von SvelteKit ohnehin auto-
restored.

Der Debounce-Effekt hat jetzt einen skipNextSearch-Flag, der beim
Restore true gesetzt wird, damit das Setzen von query keinen neuen
Search-Request auslöst. Erstmaliges Tippen nach dem Restore arbeitet
wieder ganz normal mit Debounce.

Die URL-Synchronisation (?q=…) bleibt bestehen — für Bookmarks und
Share-Links. Snapshot überlagert den URL-Load in der Ordnung:
restore → onMount sieht query bereits korrekt gesetzt → kein Double-Work.
2026-04-17 19:06:58 +02:00
hsiegeln
1055a670da fix(preview): Save-Icon als Lucide, Query in URL persistent
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
1. "💾 In meine Sammlung speichern" → Lucide BookmarkPlus + Text.
   Letzter Emoji auf den wichtigen UI-Flächen ist damit weg.

2. Query auf der Startseite wird per history.replaceState nach ?q=…
   in die URL gespiegelt. Folge:
   - User tippt "Pizza" → Ergebnisse erscheinen
   - Klick auf Vorschau pusht neue History-Entry /preview?url=…
   - "Zurück" landet wieder auf /?q=Pizza
   - onMount liest q aus URL, setzt query, Debounce-Effekt feuert,
     Ergebnisse sind wieder da.
   replaceState statt pushState beim Tippen → keine History-Spam-Einträge.
2026-04-17 19:03:50 +02:00
hsiegeln
7cac02de5a feat(ui): Favoriten-Liste, Dismiss-from-Recent, Inline-Rename, Lucide-Icons
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m31s
Homepage:
- Neue Sektion "Deine Favoriten" über "Zuletzt hinzugefügt" (alphabetisch
  sortiert, lädt wenn Profil aktiv ist; versteckt sonst)
- Jede Karte in "Zuletzt hinzugefügt" hat jetzt oben-rechts ein X-Icon
  zum Ausblenden. Das Rezept selbst bleibt in der DB — nur die
  Anzeige in der Recent-Liste wird per recipe.hidden_from_recent = 1
  unterdrückt. Section versteckt sich, wenn die Liste leer wird.

DB:
- Neue Migration 004_recipe_hidden_from_recent.sql (+Index)
- listFavoritesForProfile in search-local.ts (ORDER BY title NOCASE)
- setRecipeHiddenFromRecent in actions.ts

API:
- GET /api/recipes/favorites?profile_id=X
- PATCH /api/recipes/[id] akzeptiert jetzt title und/oder
  hidden_from_recent (Zod-Schema mit refine)

Rezept-Detail:
- Titel ist jetzt inline editierbar: kleines Stift-Icon rechts neben
  H1. Click öffnet Input, Enter speichert (PATCH), Escape bricht ab.
  Kein location.reload() mehr.
- RecipeView bekommt neuen Snippet-Prop titleSlot für Title-Override.
- Neue Aktionsreihenfolge:
  Zeile 1: Favorit | Wunschliste | Drucken
  Zeile 2: Heute gekocht | Löschen
  (Umbenennen ist jetzt am Titel statt in der Leiste.)

Icons (lucide-svelte, neues Dep):
- Emoji-Icons durch Lucide-SVGs ersetzt auf Startseite, Header,
  Rezept-Detail, Wunschliste, Header-Dropdown:
    🍽️→Heart/Utensils, ⚙️→Settings, 🥘→CookingPot, 🌐→Globe,
    ♥/♡→Heart(filled), 🖨→Printer, ✎→Pencil, 🗑→Trash2, ✓→Check,
    🍳→ChefHat, X→X
- Header-Brand-Badge auf Mobile behält sein 🍳 (ist im ::after-Pseudo,
  Lucide käme da nicht sauber rein).
- SearchLoader-Emojis bleiben — die sind Teil der Animations-Charme.

Tests: 99/99 grün (bestehend), Typecheck 0 Fehler.
2026-04-17 18:57:17 +02:00
hsiegeln
657d006441 fix(recipe): Favoriten-Markierung persistiert beim Neuladen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 50s
Bug: Beim Neuanzeigen einer Rezeptseite war der Favoriten-Button immer
grau — isFav wurde als local $state(false) initialisiert und die
checkFavorite()-Funktion war eine Stub-Implementation, die nichts
gemacht hat. State lebte nur innerhalb einer Session.

Fix:
- Neue Server-Funktion listFavoriteProfiles(db, recipeId): number[]
  in $lib/server/recipes/actions.ts
- +page.server.ts lädt favorite_profile_ids mit in die Page-Daten
- +page.svelte macht isFav zum $derived aus favoriteProfileIds +
  aktivem Profil. toggleFavorite mutiert die lokale Liste (Add/Remove
  der aktiven Profil-ID) — beim nächsten Load ist die Server-Liste
  wieder Source of Truth.
- Alte Stub-Funktion checkFavorite() entfernt (inkl. Aufruf in
  onMount).
2026-04-17 18:43:38 +02:00
hsiegeln
cf31e79fb0 feat(loader): SearchLoader mit wackelnder Pfanne und rotierenden Sprüchen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s
Neue Komponente src/lib/components/SearchLoader.svelte ersetzt die
stumpfen "Suche läuft …"-Zeilen an allen vier Stellen:
- Startseite (scope=local und scope=web)
- Header-Dropdown (size=sm, beide Scopes)

Was passiert:
- Ein Pfannen-Emoji (🍳🥘🍲🍜🥣) wechselt alle 900 ms
- Wobble-Animation kippt es im 1.4-s-Takt hin und her
- Drei Dampf-Punkte steigen zeitversetzt auf und fadeen
- Caption unten rotiert alle 1.8 s durch vier passende Sprüche
  (lokal: "Stöbere im Rezeptbuch …", web: "Schnuppere in fremden
  Küchen …" etc.)

Zwei Size-Varianten: md (Homepage) und sm (Header-Dropdown).
2026-04-17 18:40:38 +02:00
hsiegeln
347b1de555 docs: CLAUDE.md + Architecture + Operations für Session-Fortsetzung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
Drei fokussierte Dokumente, damit eine frische Claude-Session direkt
weiterarbeiten kann, ohne den gesamten Session-Kontext zu brauchen:

- CLAUDE.md (Root): "read me first" — Gotchas, Workflow, No-Gos,
  Quickstart, Verweise auf die Tiefen-Docs.
- docs/ARCHITECTURE.md: Stack, Verzeichnisbaum, Datenfluss (Import,
  Web-Suche, Confirm/Alert), Design-Entscheidungen, Test-Strategie.
- docs/OPERATIONS.md: Deployment-Topologie (Cloudflare → Pi →
  Traefik → kochwas), Gitea-CI-Pipeline, Traefik-Labels mit
  Wildcard-Cert, Troubleshooting (TLS, SearXNG-403, Healthcheck,
  Thumbnail-Cache leeren, Backup), Env-Vars.

Die bestehenden Specs und Plans unter docs/superpowers/ bleiben
unangetastet — die sind Planungs-Artefakte, nicht Betriebsdoku.
2026-04-17 18:38:00 +02:00
hsiegeln
4d90d51501 feat(search): persistenter Thumbnail-Cache in SQLite, Default-TTL 30 Tage
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s
Vorher: In-Memory-Map, TTL 30 Minuten. Container-Neustart verwarf den
kompletten Cache, also musste nach jedem Deploy jede Suche wieder alle
Seiten laden.

Jetzt:
- Neue Tabelle thumbnail_cache (url PK, image, expires_at)
- Default-TTL 30 Tage, per Env KOCHWAS_THUMB_TTL_DAYS konfigurierbar
  (7, 365, was der User will — is alles ok laut Nutzer)
- Negative Cache: Seiten ohne Bild werden mit image=NULL gespeichert,
  damit wir nicht jede Suche die gleiche kaputte Seite wieder laden
- Lazy-Cleanup: pro searchWeb-Aufruf werden abgelaufene Zeilen via
  DELETE ... WHERE expires_at <= now() weggeräumt (Index-Scan, billig)

Migration 003_thumbnail_cache.sql: nicht-destruktiv, nur neue Tabelle.
Bestehende DB bekommt sie beim nächsten Start automatisch dazu.

Tests (99/99):
- Neuer Cache-Test: zweiter searchWeb für dieselbe URL macht keinen
  Page-Fetch mehr und liest die image-Spalte aus SQLite.
2026-04-17 18:34:29 +02:00
hsiegeln
1712263fd1 feat(search): HQ-Thumbnails durch immer aktive og:image-Extraktion
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s
Vorher: nur Treffer ohne SearXNG-Thumbnail wurden mit dem Seiten-Bild
angereichert. Treffer mit Thumbnail behielten das kleine 150-200 px-
Bildchen aus dem Such-Engine-Index.

Jetzt: Alle Treffer durchlaufen die Enrichment-Pipeline. Wenn die Seite
ein og:image/JSON-LD/Content-Bild hat (und das hat sie bei Rezept-Seiten
praktisch immer), wird das kleine SearXNG-Thumbnail damit überschrieben.
Wenn die Seite kein Bild liefert, bleibt das SearXNG-Thumbnail als
Fallback erhalten.

Das ist das gleiche Bild, das auch die Vorschau anzeigt — Suchergebnis
und Vorschau sind jetzt visuell konsistent.

Performance: Pro erster Suche bis zu ~6 Sekunden zusätzliche Latenz
(max 6 parallel, je 4 s Timeout). Der 30-min In-Memory-Cache macht
Wiederholsuchen instant.

Tests (98/98):
- Neu: SearXNG-Thumbnail wird durch og:image ersetzt.
- Neu: SearXNG-Thumbnail bleibt erhalten, wenn Seite kein Bild hat.
- Alt ("leaves existing thumbnails untouched") entfernt — Verhalten
  hat sich bewusst umgekehrt.
2026-04-17 18:31:42 +02:00
hsiegeln
53e4815508 chore(quotes): Ex-Spruch durch Mikrowellen-Pointe ersetzt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 50s
2026-04-17 18:07:32 +02:00
hsiegeln
211d58ebec feat(search): Enter bleibt auf Seite + robustere Thumbnail-Erkennung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s
Startseite:
- Enter/Return löst die Suche jetzt sofort aus (cancelt den Debounce),
  navigiert aber NICHT mehr auf /search. Der Anwender bleibt auf der
  gleichen Seite mit Inline-Ergebnissen.

Thumbnail-Enrichment (searxng.ts):
- Regex-basierte og:image-Extraktion durch linkedom-parseHTML ersetzt.
- Neue Fallback-Kette (in dieser Reihenfolge):
    1. <meta property/name = og:image | og:image:url | og:image:secure_url
                           | twitter:image | twitter:image:src>
    2. <link rel="image_src" href="...">
    3. JSON-LD image (auch tief in @graph; "image" als String, Array,
       Objekt-mit-url)
    4. Erstes <img> in article/main/.entry-content/.post-content/figure
- Relative URLs werden gegen die Seiten-URL zu absoluten aufgelöst
  (z.B. /uploads/foo.jpg → http://host/uploads/foo.jpg).
- maxBytes von 256 KB auf 512 KB angehoben, damit JSON-LD-lastige
  Recipe-Seiten nicht mitten im Script abgeschnitten werden.

Tests (97/97):
- Neu: JSON-LD-Image-Fallback-Test.
- Neu: Content-<img>-Fallback-Test mit relativer URL, die zur
  absoluten aufgelöst wird.
2026-04-17 18:04:59 +02:00
hsiegeln
9bc4465061 feat(home): zufälliger Spruch zwischen Titel und Suche
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 50s
Eine der 49 Flachwitze wird beim Laden der Startseite zufällig gewählt
und in kursiv unter "Kochwas" angezeigt. Die Auswahl passiert auf dem
Client (onMount), damit SSR und Hydration nicht miteinander streiten —
beim ersten Frame ist ein nicht-umbrechender Leerraum drin, damit das
Layout nicht springt.
2026-04-17 17:58:27 +02:00
hsiegeln
6a784488f5 fix(search): enrich missing SearXNG thumbnails with og:image
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s
SearXNG liefert je nach Seite mal ein thumbnail/img_src mit, mal nicht —
bei Chefkoch-Treffern hatten deshalb zufällig die Hälfte der Kacheln
einen Platzhalter, obwohl die Vorschau dann sehr wohl ein Bild fand.

searchWeb() holt jetzt für jeden Treffer ohne Thumbnail parallel
(max. 6 gleichzeitig, 4 s Timeout pro Request) die Seite und extrahiert
das og:image- oder twitter:image-Meta-Tag. Ergebnis wird 30 min
in-memory gecacht, damit wiederholte Suchen nicht wieder die gleichen
Seiten laden.

Tests:
- Neuer Test: Treffer ohne Thumbnail wird via og:image angereichert.
- Neuer Test: Treffer mit Thumbnail bleibt unverändert (keine Fetch).
- Bestehende Tests deaktivieren Enrichment via enrichThumbnails:false,
  damit sie keine echten Chefkoch-URLs aufrufen.
2026-04-17 17:55:53 +02:00
hsiegeln
3cd22544d3 feat(search): mobile header search expands on focus; drop hero button
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 52s
Home:
- "Suchen"-Button entfernt. Die Suche feuert ohnehin debounced beim
  Tippen; der Button war ein Relikt aus dem Submit-Modell. Enter auf
  dem Input löst weiterhin einen Submit aus (geht zur /search-Seite).

Header (< 520 px):
- Sobald das Suchfeld fokussiert wird, wandert das nav-search-wrap
  via :focus-within auf position: absolute und dehnt sich bis zum
  rechten Rand (1 rem Abstand) aus. Die Action-Icons werden dabei
  vom Suchfeld überlagert (z-index 60), sodass der Anwender auf
  engen Displays deutlich mehr Platz zum Tippen hat.
- Bar-Inner bekam position: relative, damit das absolute Ausdehnen
  innerhalb der Header-Zeile greift.
2026-04-17 17:52:51 +02:00
hsiegeln
d693cb422d feat(search): auto web search when no local hits, offer link otherwise
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s
Homepage (/):
- Keine lokalen Treffer → automatisch die Internet-Suche auslösen und
  die Ergebnisse als Karten unterhalb der Suche anzeigen.
- Mindestens ein lokaler Treffer → Karten zeigen + darunter ein
  dezenter Link "🌐 Im Internet weitersuchen" (geht zur /search/web
  Vollseite), keine automatische Internet-Suche.

Header-Dropdown (auf Rezept- und Vorschau-Seiten):
- Gleiche Logik: lokale Treffer oben + Fuß-Link; keine lokalen
  Treffer → Internet-Ergebnisse werden direkt im Dropdown angezeigt.
- Abschnittsüberschrift "Keine lokalen Rezepte – aus dem Internet:"
  trennt den Fallback visuell ab.

Race-Safety bleibt bestehen: Query-Vergleich vor jedem State-Write,
sodass spät ankommende Antworten keinen neueren Suchstand überschreiben.
2026-04-17 17:47:26 +02:00
hsiegeln
76110f9841 fix(nav): right-align action icons even when search is hidden
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
Auf Nicht-Rezept-Seiten (Home, Wishlist, Admin) ist das Header-Suchfeld
ausgeblendet. Ohne flex-Spacer rutschten 🍽️/⚙️/Profil direkt neben das
Brand-Badge — besonders auffällig im Mobile-Layout.

margin-left: auto auf .bar-right schiebt die Action-Icons immer an den
rechten Rand, unabhängig davon ob das Suchfeld sichtbar ist.
2026-04-17 17:43:08 +02:00
hsiegeln
d737618312 feat(search): live debounced search with inline hits and header dropdown
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
Homepage (/):
- Tippen > 3 Zeichen + 300 ms Debounce → lokale Suche feuert automatisch
- Treffer erscheinen direkt unter dem Suchfeld als Karten-Grid
- "Zuletzt hinzugefügt" wird ausgeblendet, sobald aktiv gesucht wird
- 0 Treffer + fertig gesucht → Inline-Button "Im Internet weitersuchen"

Header (nur auf /recipes/[id] und /preview):
- Gleiche Debounce-Logik, aber Treffer in einem Dropdown unterm Feld
- Dropdown: kompakte Zeilen mit Thumbnail, Titel, Domain
- Fußzeile des Dropdown: "Im Internet weitersuchen"
- Click-outside und Escape schließen das Dropdown
- afterNavigate setzt Query nach dem Klick auf einen Treffer zurück
- Header-Breite ist jetzt auf 760 px begrenzt (gleich wie Rezept-Content),
  damit die Suchleiste nie breiter wird als das Rezept darunter

Race-Safety: Ein zweites Tippen während laufender Fetch überschreibt
die Ergebnisse des ersten Requests nicht (Query-Vergleich vor Write).
2026-04-17 17:41:10 +02:00
hsiegeln
84655151be feat(nav): search lives in the header on all non-home pages
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
- "← Lokale Suche"-Breadcrumb auf der Web-Suchseite entfernt (überflüssig,
  da die lokale Suche automatisch zur Web-Suche weiterleitet, wenn leer).
- Header-Bar enthält jetzt ein Pill-Suchfeld, das von jeder Unterseite
  aus direkt auf /search?q=... navigiert — kein Zurück mehr nötig,
  wenn man aus einem offenen Rezept weiter sucht.
- Auf der Startseite bleibt die große Hero-Suche; das Header-Feld ist
  dort ausgeblendet, damit es keine doppelte Eingabestelle gibt.
- Auf /search und /search/web spiegelt das Header-Feld die aktuelle
  Query wider, sodass man den Begriff verfeinern kann.
- Mobile < 520px: Brand schrumpft zu einem 🍳-Badge, damit Platz für
  das Suchfeld + Icons bleibt.
2026-04-17 17:31:08 +02:00
4f7c76c908 feat(ui): custom dialog replaces all remaining window.alert() calls
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
alertAction({title, message}) returns Promise<void> and renders the
same ConfirmDialog with infoOnly:true — single OK button, no Abbrechen.
Replaces:
- 'Bitte Profil wählen.' (recipe rating / favorite / cooked / comment)
- 'Bitte Profil wählen, um zu liken.' (wishlist)
- 'Profil konnte nicht angelegt werden' (ProfileSwitcher)
- 'Umbenennen fehlgeschlagen' (admin/profiles)
- 'Speichern fehlgeschlagen' (preview)

No window.alert() or window.confirm() left in the codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:23:07 +02:00
1b9928f806 feat(ui): custom confirmation dialog replacing native window.confirm
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 51s
Single reusable dialog with a promise-based API: confirmAction({...})
returns Promise<boolean>. Supports title, optional message body,
confirm/cancel labels, and a 'destructive' flag that paints the confirm
button red.

Accessibility: Escape cancels, Enter confirms, confirm button auto-focus,
role=dialog + aria-labelledby, backdrop click = cancel.

Rolled out to: recipe delete, domain remove, profile delete, wishlist
remove. Native confirm() is gone from the codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:15:21 +02:00
3b1950713f feat(ui): wishlist page, recipe toggle button, header link
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s
- /wishlist renders cards with avatar-badge of who added it, like count,
  heart toggle for active profile, delete button. Sort dropdown switches
  between popular / newest / oldest.
- /recipes/[id] gets 'Auf Wunschliste (setzen)' button alongside favorite.
- Layout header shows 🍽️ link to /wishlist next to the admin ⚙️.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:08:22 +02:00
28e40d763d feat(api): wishlist endpoints (list, add, remove, like, unlike)
GET /api/wishlist?sort=popular|newest|oldest&profile_id=…
POST /api/wishlist { recipe_id, profile_id? }
DELETE /api/wishlist/[recipe_id]
PUT    /api/wishlist/[recipe_id]/like { profile_id }
DELETE /api/wishlist/[recipe_id]/like { profile_id }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:08:22 +02:00
18547a7301 feat(wishlist): add shared family wishlist with likes
Each recipe appears at most once on the wishlist. Any profile can add,
remove, like, and unlike. Ratings and cooking log stay independent.

Data model: wishlist(recipe_id PK, added_by_profile_id, added_at)
            wishlist_like(recipe_id, profile_id, created_at)

Why: 'das will ich essen' — family members pick candidates, everyone
can +1 to signal agreement, cook decides based on popularity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:08:22 +02:00
108 changed files with 12137 additions and 758 deletions

View File

@@ -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
View File

@@ -5,3 +5,6 @@ data/
.env .env
.env.local .env.local
*.log *.log
test-results/
playwright-report/
.playwright-mcp/

74
CLAUDE.md Normal file
View 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.

View File

@@ -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.

View File

@@ -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
View 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
View 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).

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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`);
}

View File

@@ -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

View 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);
}

View 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();

View 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();

View 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();

View 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;
}

View 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();

View 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' });
});
}
}

View 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();

View 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();

View 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();

View 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>

View File

@@ -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;

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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)];
}

View File

@@ -0,0 +1,19 @@
-- Shared family wishlist: recipes someone wants to cook next.
-- Each recipe appears at most once; anyone can add/remove and like/unlike.
CREATE TABLE IF NOT EXISTS wishlist (
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_wishlist_added_at ON wishlist(added_at DESC);
CREATE TABLE IF NOT EXISTS wishlist_like (
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (recipe_id, profile_id)
);
CREATE INDEX IF NOT EXISTS ix_wishlist_like_recipe ON wishlist_like(recipe_id);

View 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);

View File

@@ -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);

View 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);

View File

@@ -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 = '🍳';

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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. 32192 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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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') };
} }

View File

@@ -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;

View File

@@ -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
);
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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[];
}

View File

@@ -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;
} }

View File

@@ -0,0 +1,114 @@
import type Database from 'better-sqlite3';
export type WishlistEntry = {
recipe_id: number;
title: string;
image_path: string | null;
source_domain: string | null;
added_at: string; // earliest per recipe
wanted_by_count: number;
wanted_by_names: string; // comma-joined profile names
on_my_wishlist: 0 | 1;
avg_stars: number | null;
};
export type SortKey = 'popular' | 'newest' | 'oldest';
export function listWishlist(
db: Database.Database,
activeProfileId: number | null,
sort: SortKey = 'popular'
): WishlistEntry[] {
const orderBy = {
popular: 'wanted_by_count DESC, first_added DESC',
newest: 'first_added DESC',
oldest: 'first_added ASC'
}[sort];
return db
.prepare(
`SELECT
w.recipe_id,
r.title,
r.image_path,
r.source_domain,
MIN(w.added_at) AS first_added,
MIN(w.added_at) AS added_at,
COUNT(w.profile_id) AS wanted_by_count,
COALESCE(GROUP_CONCAT(p.name, ', '), '') AS wanted_by_names,
CASE
WHEN ? IS NULL THEN 0
WHEN EXISTS (SELECT 1 FROM wishlist w2
WHERE w2.recipe_id = w.recipe_id AND w2.profile_id = ?)
THEN 1
ELSE 0
END AS on_my_wishlist,
(SELECT AVG(stars) FROM rating WHERE recipe_id = w.recipe_id) AS avg_stars
FROM wishlist w
JOIN recipe r ON r.id = w.recipe_id
LEFT JOIN profile p ON p.id = w.profile_id
GROUP BY w.recipe_id
ORDER BY ${orderBy}`
)
.all(activeProfileId, activeProfileId) as WishlistEntry[];
}
export function listWishlistProfileIds(
db: Database.Database,
recipeId: number
): number[] {
return (
db
.prepare('SELECT profile_id FROM wishlist WHERE recipe_id = ?')
.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(
db: Database.Database,
recipeId: number,
profileId: number
): void {
db.prepare(
`INSERT INTO wishlist(recipe_id, profile_id)
VALUES (?, ?)
ON CONFLICT(recipe_id, profile_id) DO NOTHING`
).run(recipeId, profileId);
}
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);
}
export function isOnMyWishlist(
db: Database.Database,
recipeId: number,
profileId: number
): boolean {
return (
db
.prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ? AND profile_id = ?')
.get(recipeId, profileId) !== undefined
);
}

View 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';
}

View 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 };
}

View File

@@ -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;
}; };

View File

@@ -1,20 +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="/admin" class="admin-link" aria-label="Einstellungen">⚙️</a> <a href="/" class="brand">Kochwas</a>
<ProfileSwitcher /> {:else}
<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>
@@ -41,25 +404,218 @@
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;
} }
.admin-link { .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 {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -68,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;
} }
.admin-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>

View File

@@ -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>

View File

@@ -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;

View 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>

View File

@@ -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;

View File

@@ -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}

View File

@@ -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 });

View File

@@ -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 });
}; };

View File

@@ -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 }) => {

View File

@@ -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 });
}; };

View 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 });
};

View 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 });
};

View 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 });
};

View File

@@ -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 });
}; };

View File

@@ -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}` });
} }

View File

@@ -0,0 +1,40 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import {
addToWishlist,
listWishlist,
type SortKey
} from '$lib/server/wishlist/repository';
const AddSchema = z.object({
recipe_id: z.number().int().positive(),
profile_id: z.number().int().positive()
});
const VALID_SORTS: readonly SortKey[] = ['popular', 'newest', 'oldest'] as const;
function parseSort(raw: string | null): SortKey {
return VALID_SORTS.includes(raw as SortKey) ? (raw as SortKey) : 'popular';
}
function parseProfileId(raw: string | null): number | null {
if (!raw) return null;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
}
export const GET: RequestHandler = async ({ url }) => {
const sort = parseSort(url.searchParams.get('sort'));
const profileId = parseProfileId(url.searchParams.get('profile_id'));
return json({ sort, entries: listWishlist(getDb(), profileId, sort) });
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = AddSchema.safeParse(body);
if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' });
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id);
return json({ ok: true }, { status: 201 });
};

View File

@@ -0,0 +1,27 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import {
removeFromWishlist,
removeFromWishlistForAll
} from '$lib/server/wishlist/repository';
function parsePositiveInt(raw: string | null, field: string): number {
const n = raw === null ? NaN : Number(raw);
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
return n;
}
// DELETE /api/wishlist/:id?profile_id=X → entfernt nur den eigenen Wunsch
// DELETE /api/wishlist/:id?all=true → entfernt für ALLE Profile
export const DELETE: RequestHandler = async ({ params, url }) => {
const id = parsePositiveInt(params.recipe_id!, 'recipe_id');
const 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 });
};

View 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()) });
};

View File

@@ -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 }) => {

View File

@@ -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>

View 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) };
};

View 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>

View File

@@ -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
};
}; };

View File

@@ -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,13 +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 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(
@@ -27,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' },
@@ -53,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' },
@@ -77,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`, {
@@ -108,50 +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();
method: 'PATCH', titleInput?.focus();
headers: { 'content-type': 'application/json' }, titleInput?.select();
body: JSON.stringify({ title: newTitle })
});
location.reload();
} }
// Wake-Lock function cancelEditTitle() {
let wakeLock: WakeLockSentinel | null = null; editingTitle = false;
async function requestWakeLock() { titleDraft = '';
try { }
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen'); async function saveTitle() {
} const next = titleDraft.trim();
} catch { if (!next || next === title) {
// silently ignore editingTitle = false;
return;
}
if (!requireOnline('Das Umbenennen')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: next })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
await alertAction({
title: 'Umbenennen fehlgeschlagen',
message: body.message ?? `HTTP ${res.status}`
});
return;
}
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() {
if (!profileStore.active) {
await alertAction({
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 {
await fetch('/api/wishlist', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profileId })
});
wishlistProfileIds = [...wishlistProfileIds, profileId];
}
void wishlistStore.refresh();
if (!wasOn) void firePulse('wish');
}
// Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen.
// Browser-API navigator.wakeLock.request('screen') verhindert auto-lock
// und -dimmen, solange der Tab sichtbar ist. Sobald der Tab in den
// Hintergrund geht, verliert der Sentinel seine Wirkung von selbst; wir
// re-requesten bei visibilitychange.
let wakeLockEnabled = $state(true);
let wakeLock: WakeLockSentinel | null = null;
async function acquireWakeLock() {
if (wakeLock || !wakeLockEnabled) return;
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
wakeLock = null;
});
}
} catch {
// 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
// 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">
@@ -161,23 +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 class="btn" onclick={() => (editMode = true)}>
<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>
@@ -222,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;
@@ -251,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;
@@ -258,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;
@@ -266,6 +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 {
color: #2b6a3d;
border-color: #b7d6c2;
background: #eaf4ed;
--pulse-color: rgba(43, 106, 61, 0.45);
}
/* Einmalige Bestätigung beim Aktivieren der Aktion — kurzer Scale-Bounce
plus ausklingender Ring in der Aktionsfarbe (siehe --pulse-color).
prefers-reduced-motion: Ring aus, kein Scale. */
.btn.pulse {
animation: btnPulse 0.5s ease-out;
}
@keyframes btnPulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 var(--pulse-color, rgba(43, 106, 61, 0.45));
}
55% {
transform: scale(1.07);
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.btn.pulse {
animation: none;
}
}
.btn.screen-on {
color: #b07e00;
border-color: #e6d48a;
background: #fff6d7;
} }
.btn.primary { .btn.primary {
background: #2b6a3d; background: #2b6a3d;

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,321 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Utensils, Trash2, CookingPot } from 'lucide-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';
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: 'popular', label: 'Meist gewünscht' },
{ value: 'newest', label: 'Neueste' },
{ value: 'oldest', label: 'Älteste' }
];
let entries = $state<WishlistEntry[]>([]);
let loading = $state(true);
let sort = $state<SortKey>('popular');
async function load() {
loading = true;
const params = new URLSearchParams({ sort });
if (profileStore.active) params.set('profile_id', String(profileStore.active.id));
const res = await fetch(`/api/wishlist?${params}`);
const body = await res.json();
entries = body.entries;
loading = false;
}
$effect(() => {
// Re-fetch when sort or active profile changes
sort;
profileStore.activeId;
void load();
});
async function toggleMine(entry: WishlistEntry) {
if (!profileStore.active) {
await alertAction({
title: 'Kein Profil gewählt',
message: 'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
});
return;
}
if (!requireOnline('Die Wunschlisten-Aktion')) return;
const profileId = profileStore.active.id;
if (entry.on_my_wishlist) {
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
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();
void wishlistStore.refresh();
}
onMount(() => {
void load();
void wishlistStore.refresh();
});
function resolveImage(p: string | null): string | null {
if (!p) return null;
return /^https?:\/\//i.test(p) ? p : `/images/${p}`;
}
</script>
<header class="head">
<h1>Wunschliste</h1>
<p class="sub">Das wollen wir bald mal essen.</p>
</header>
<div class="sort-chips" role="tablist" aria-label="Sortierung">
{#each SORT_OPTIONS as s (s.value)}
<button
type="button"
role="tab"
aria-selected={sort === s.value}
class="chip"
class:active={sort === s.value}
onclick={() => (sort = s.value)}
>
{s.label}
</button>
{/each}
</div>
{#if loading}
<p class="muted">Lädt …</p>
{:else if entries.length === 0}
<section class="empty">
<div class="big"><CookingPot size={48} strokeWidth={1.5} /></div>
<p>Noch nichts gewünscht.</p>
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
</section>
{:else}
<ul class="list">
{#each entries as e (e.recipe_id)}
<li class="card">
<a class="body" href={`/recipes/${e.recipe_id}`}>
{#if resolveImage(e.image_path)}
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={32} /></div>
{/if}
<div class="text">
<div class="title">{e.title}</div>
<div class="meta">
{#if e.wanted_by_names}
<span class="wanted-by">{e.wanted_by_names}</span>
{/if}
{#if e.source_domain}
<span class="src">· {e.source_domain}</span>
{/if}
{#if e.avg_stars !== null}
<span>· ★ {e.avg_stars.toFixed(1)}</span>
{/if}
</div>
</div>
</a>
<div class="actions">
<button
class="like"
class:active={e.on_my_wishlist}
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
onclick={() => toggleMine(e)}
>
<Utensils size={18} strokeWidth={2} />
{#if e.wanted_by_count > 0}
<span class="count">{e.wanted_by_count}</span>
{/if}
</button>
<button
class="del"
aria-label="Für alle entfernen"
onclick={() => removeForAll(e)}
>
<Trash2 size={18} strokeWidth={2} />
</button>
</div>
</li>
{/each}
</ul>
{/if}
<style>
.head {
padding: 1.25rem 0 0.5rem;
}
.head h1 {
margin: 0;
font-size: 1.6rem;
color: #2b6a3d;
}
.sub {
margin: 0.2rem 0 0;
color: #666;
}
.sort-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.5rem 0 1rem;
}
.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;
}
.muted {
color: #888;
text-align: center;
padding: 2rem 0;
}
.empty {
text-align: center;
padding: 3rem 1rem;
}
.big {
color: #8fb097;
display: inline-flex;
margin: 0 0 0.5rem;
}
.hint {
color: #888;
font-size: 0.9rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
display: flex;
align-items: stretch;
background: white;
border: 1px solid #e4eae7;
border-radius: 14px;
overflow: hidden;
min-height: 96px;
}
.body {
flex: 1;
display: flex;
gap: 0.75rem;
text-decoration: none;
color: inherit;
min-width: 0;
}
.body img,
.placeholder {
width: 96px;
object-fit: cover;
background: #eef3ef;
display: grid;
place-items: center;
color: #8fb097;
flex-shrink: 0;
}
.text {
flex: 1;
padding: 0.7rem 0.75rem;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.title {
font-weight: 600;
font-size: 1rem;
line-height: 1.3;
}
.meta {
display: flex;
gap: 0.3rem;
margin-top: 0.25rem;
color: #888;
font-size: 0.82rem;
flex-wrap: wrap;
}
.wanted-by {
color: #2b6a3d;
font-weight: 500;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: stretch;
justify-content: center;
padding: 0.5rem 0.6rem 0.5rem 0;
}
.like,
.del {
min-width: 48px;
min-height: 40px;
border-radius: 10px;
border: 1px solid #e4eae7;
background: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
font-size: 1.05rem;
color: #444;
}
.like.active {
color: #2b6a3d;
background: #eaf4ed;
border-color: #b7d6c2;
}
.del:hover {
color: #c53030;
border-color: #f1b4b4;
background: #fdf3f3;
}
.count {
font-size: 0.85rem;
font-weight: 600;
}
</style>

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -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
View 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
View 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');
});

View File

@@ -30,9 +30,14 @@ describe('db migrations', () => {
it('is idempotent', () => { it('is idempotent', () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
const countBefore = (
db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }
).c;
runMigrations(db); runMigrations(db);
const migs = db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }; const countAfter = (
expect(migs.c).toBe(1); db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }
).c;
expect(countAfter).toBe(countBefore);
}); });
it('cascades recipe delete to ingredients and steps', () => { it('cascades recipe delete to ingredients and steps', () => {

View 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');
});
});

View File

@@ -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', () => {

View File

@@ -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
); );
}); });

View File

@@ -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']);
});
}); });

View File

@@ -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');
});
});

View File

@@ -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');
}); });

View File

@@ -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);
});
}); });

Some files were not shown because too many files have changed in this diff Show More