2026-04-17 15:23:00 +02:00
|
|
|
import type Database from 'better-sqlite3';
|
|
|
|
|
|
|
|
|
|
export type SearchHit = {
|
|
|
|
|
id: number;
|
|
|
|
|
title: string;
|
|
|
|
|
description: string | null;
|
|
|
|
|
image_path: string | null;
|
|
|
|
|
source_domain: string | null;
|
|
|
|
|
avg_stars: number | null;
|
|
|
|
|
last_cooked_at: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function escapeFtsTerm(term: string): string {
|
|
|
|
|
// FTS5 requires double-quoting to treat as a literal phrase; escape inner quotes by doubling
|
|
|
|
|
return `"${term.replace(/"/g, '""')}"`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildFtsQuery(q: string): string | null {
|
|
|
|
|
const tokens = q
|
|
|
|
|
.trim()
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map(escapeFtsTerm);
|
|
|
|
|
if (tokens.length === 0) return null;
|
|
|
|
|
// prefix-match each token
|
|
|
|
|
return tokens.map((t) => `${t}*`).join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function searchLocal(
|
|
|
|
|
db: Database.Database,
|
|
|
|
|
query: string,
|
2026-04-17 21:58:47 +02:00
|
|
|
limit = 30,
|
2026-04-21 21:59:48 +02:00
|
|
|
offset = 0
|
2026-04-17 15:23:00 +02:00
|
|
|
): SearchHit[] {
|
|
|
|
|
const fts = buildFtsQuery(query);
|
|
|
|
|
if (!fts) return [];
|
|
|
|
|
|
|
|
|
|
// bm25: lower is better. Use weights: title > tags > ingredients > description
|
2026-04-18 08:13:33 +02:00
|
|
|
const sql = `SELECT r.id,
|
2026-04-17 15:23:00 +02:00
|
|
|
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 recipe_fts f ON f.rowid = r.id
|
|
|
|
|
WHERE recipe_fts MATCH ?
|
|
|
|
|
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
2026-04-18 08:13:33 +02:00
|
|
|
LIMIT ? OFFSET ?`;
|
2026-04-21 21:59:48 +02:00
|
|
|
return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
|
2026-04-17 15:23:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listRecentRecipes(
|
|
|
|
|
db: Database.Database,
|
|
|
|
|
limit = 12
|
|
|
|
|
): 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
|
feat(ui): Favoriten-Liste, Dismiss-from-Recent, Inline-Rename, Lucide-Icons
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
|
|
|
WHERE r.hidden_from_recent = 0
|
2026-04-17 15:23:00 +02:00
|
|
|
ORDER BY r.created_at DESC
|
|
|
|
|
LIMIT ?`
|
|
|
|
|
)
|
|
|
|
|
.all(limit) as SearchHit[];
|
|
|
|
|
}
|
feat(ui): Favoriten-Liste, Dismiss-from-Recent, Inline-Rename, Lucide-Icons
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
|
|
|
|
2026-04-17 21:54:04 +02:00
|
|
|
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[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 11:14:44 +02:00
|
|
|
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[];
|
|
|
|
|
}
|
|
|
|
|
|
feat(ui): Favoriten-Liste, Dismiss-from-Recent, Inline-Rename, Lucide-Icons
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
|
|
|
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[];
|
|
|
|
|
}
|