Commit Graph

82 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
570a524d86 fix(search): unblock SearXNG 403 — config + headers
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s
SearXNG returned 403 for every query, logging
'X-Forwarded-For nor X-Real-IP header is set!'. Two fixes, both needed:

1. searxng/settings.yml was being overwritten by SearXNG's default
   config in fresh volumes. Explicitly set limiter: false,
   public_instance: false, and move secret_key to env lookup via
   ${SEARXNG_SECRET:-…}. Force a well-known JSON format list.

2. Even with the limiter off, SearXNG's bot detection still nags on
   missing forwarder headers. The Node client now sends
   X-Forwarded-For: 127.0.0.1, X-Real-IP: 127.0.0.1 and Accept: json
   deterministically. Done via a new extraHeaders option on the http
   wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 16:56:13 +02:00
041ef12582 fix(search): filter forum/magazin/listing URLs from web search results
Blocks common non-recipe paths like /forum/, /magazin/, /suche/, /themen/,
Chefkoch's /rs/s\d+/ search URLs and /Rezepte.html listings.

Before: 'ravioli' search returned forum threads and listing pages that
triggered 'No schema.org/Recipe JSON-LD' on preview.
After: only real recipe URLs pass through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:47:28 +02:00
ea9a79226a feat(db): bundle migration SQL via Vite glob + auto-create data dirs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:41:20 +02:00
0f9aabe76b refactor: move scaler out of $lib/server so it can run in browser
RecipeView needs scaleIngredients on the client for live portion scaling.
Moved scaler.ts from $lib/server/recipes/ to $lib/recipes/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:41:20 +02:00
2ca1bbaf07 feat(backup): add ZIP export endpoint (DB + images)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:35:19 +02:00
52c25fdd2c feat(search): add SearXNG client with whitelist-filtered web search
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:33:21 +02:00
7c62c977c4 feat(recipes): add local search (FTS5 bm25) and action handlers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:23:00 +02:00
86ff4c141a feat(api): expose preview/import/profile/domain endpoints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:12:59 +02:00
5693371673 feat(profiles): add profile repository
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:11:23 +02:00
99afc45c29 feat(recipes): add recipe importer (preview + persist)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:11:23 +02:00
aea07c5eb2 feat(recipes): add recipe repository (insert/get/delete with FTS refresh)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:11:23 +02:00
757b0f720e feat(images): add sha256-deduplicated image downloader
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:09:31 +02:00
4c8f4da46c feat(domains): add allowed-domain repository and whitelist check
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:09:31 +02:00
11b6b8fff1 feat(http): add fetchText/fetchBuffer with timeout and size limits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:09:31 +02:00
e90a37ff5e feat(db): add SQLite schema, FTS5, migration runner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:04:59 +02:00
2f3248c9a3 feat(parser): add JSON-LD schema.org/Recipe extractor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:04:05 +02:00
789af122f4 feat(scaler): add ingredient scaling with sensible rounding
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:02:09 +02:00
ae774e377d feat(parser): add ingredient parser
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:02:09 +02:00
c56201c5f3 feat(parser): add ISO8601 duration parser
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:02:08 +02:00