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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
- "← 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.
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>
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>
- /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>
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>
In the preview flow, recipe.image_path is still the external recipe-page
URL (we only download the image when persisting). The RecipeView
component always prefixed with /images/ and produced a 404.
Now RecipeView detects http(s):// prefix and uses it directly, else
treats the value as a local filename under /images/.
Also adds referrerpolicy=no-referrer on the preview image so remote
CDNs don't block us based on Origin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
In the alpine runtime 'localhost' resolves to ::1 (IPv6) first. The
SvelteKit Node adapter binds to 0.0.0.0 which is IPv4-only, so wget to
'localhost:3000' fails with 'Connection refused'. Traefik then filters
the container as unhealthy and no router is registered.
Using 127.0.0.1 makes the probe deterministically hit the bound IPv4
socket.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Traefik filters containers that are 'unhealthy' or still 'starting'
(no health result yet). The old 30s interval meant kochwas stayed in
'starting' for 30+ seconds after boot, blocking Traefik from routing to it.
New timing:
--start-period=20s grace window — failures don't mark unhealthy yet
--start-interval=2s fast probes during start-period
--interval=15s steady-state cadence after first success
--timeout=5s, retries=3 unchanged
Container becomes 'healthy' within ~2-5s of the app coming up, so Traefik
picks it up almost immediately after 'docker compose up'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Gitea Actions cache HTTP backend times out on export
(dial tcp 172.20.8.x:xxxxx: i/o timeout), failing the job even though
the image push itself succeeds.
Registry cache stores layer cache as a dedicated tag ':buildcache' in
the same container registry that already holds the images — reliable
and self-contained.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The auto-issued GITEA_TOKEN in Actions does not carry write:package scope,
so the docker login step failed with 'unauthorized'. Switching to a user-
supplied secret REGISTRY_TOKEN (PAT with write:package + read:package).
Setup on Gitea side:
1. Profile → Settings → Applications → Generate New Token
with scopes write:package + read:package.
2. Repo → Settings → Actions → Secrets → add REGISTRY_TOKEN = <that PAT>.
Optional: REGISTRY_USER if the owning account differs from gitea.actor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- kochwas service gets Traefik v2 labels matching the project's conventions:
websecure entrypoint, cloudflareResolver, Host(`kochwas.siegeln.net`).
- Service port 3000 exposed to Traefik only; the external port binding is gone.
- Dual network: external 'proxy' (for Traefik ingress) and internal 'internal'
(for kochwas ↔ searxng). traefik.docker.network hint is set.
- SearXNG has no Traefik labels — intentionally only reachable from kochwas.
Note: the 'proxy' network name must match the existing external Traefik network
(change via 'name:' field if your homelab uses a different one like 'traefik').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runner is arm64, so native build is much faster than amd64-via-QEMU.
Dev/test amd64 images can still be built locally with 'docker build'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On push to main (or version tag), the workflow logs into the Gitea container
registry, builds a multi-tag image (sha-<short>, branch name, 'latest' on main,
version on tag) and pushes to gitea.siegeln.net/<owner>/<repo>.
docker-compose.prod.yml now pulls from the registry by default, with
KOCHWAS_TAG env var to pin a specific build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spares the user a click: empty local result set triggers goto('/search/web?q=...')
with replaceState so the back button returns to the previous page, not the empty
local results list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Explains that the page was likely a forum/listing, not a recipe, and
offers 'Zurück zur Trefferliste' plus 'Seite im Browser öffnen' as escape hatches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>