Files
kochwas/src/lib/server/recipes/actions.ts
hsiegeln 7cac02de5a
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m31s
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

164 lines
4.1 KiB
TypeScript

import type Database from 'better-sqlite3';
export type RatingRow = { profile_id: number; stars: number };
export type CommentRow = {
id: number;
profile_id: number;
text: string;
created_at: string;
author: string;
};
export type CookedRow = { id: number; profile_id: number; cooked_at: string };
export function setRating(
db: Database.Database,
recipeId: number,
profileId: number,
stars: number
): void {
if (stars < 1 || stars > 5) throw new Error('stars must be 1..5');
db.prepare(
`INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, ?, ?)
ON CONFLICT(recipe_id, profile_id) DO UPDATE SET stars = excluded.stars, updated_at = CURRENT_TIMESTAMP`
).run(recipeId, profileId, stars);
}
export function clearRating(
db: Database.Database,
recipeId: number,
profileId: number
): void {
db.prepare('DELETE FROM rating WHERE recipe_id = ? AND profile_id = ?').run(
recipeId,
profileId
);
}
export function listRatings(db: Database.Database, recipeId: number): RatingRow[] {
return db
.prepare('SELECT profile_id, stars FROM rating WHERE recipe_id = ?')
.all(recipeId) as RatingRow[];
}
export function addFavorite(
db: Database.Database,
recipeId: number,
profileId: number
): void {
db.prepare(
'INSERT OR IGNORE INTO favorite(recipe_id, profile_id) VALUES (?, ?)'
).run(recipeId, profileId);
}
export function removeFavorite(
db: Database.Database,
recipeId: number,
profileId: number
): void {
db.prepare('DELETE FROM favorite WHERE recipe_id = ? AND profile_id = ?').run(
recipeId,
profileId
);
}
export function isFavorite(
db: Database.Database,
recipeId: number,
profileId: number
): boolean {
return (
db
.prepare('SELECT 1 AS ok FROM favorite WHERE recipe_id = ? AND profile_id = ?')
.get(recipeId, profileId) !== undefined
);
}
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(
db: Database.Database,
recipeId: number,
profileId: number
): CookedRow {
return db
.prepare(
`INSERT INTO cooking_log(recipe_id, profile_id) VALUES (?, ?)
RETURNING id, profile_id, cooked_at`
)
.get(recipeId, profileId) as CookedRow;
}
export function listCookingLog(
db: Database.Database,
recipeId: number
): CookedRow[] {
return db
.prepare(
'SELECT id, profile_id, cooked_at FROM cooking_log WHERE recipe_id = ? ORDER BY cooked_at DESC'
)
.all(recipeId) as CookedRow[];
}
export function addComment(
db: Database.Database,
recipeId: number,
profileId: number,
text: string
): number {
const trimmed = text.trim();
if (!trimmed) throw new Error('comment text cannot be empty');
const info = db
.prepare('INSERT INTO comment(recipe_id, profile_id, text) VALUES (?, ?, ?)')
.run(recipeId, profileId, trimmed);
return Number(info.lastInsertRowid);
}
export function deleteComment(db: Database.Database, id: number): void {
db.prepare('DELETE FROM comment WHERE id = ?').run(id);
}
export function listComments(db: Database.Database, recipeId: number): CommentRow[] {
return db
.prepare(
`SELECT c.id, c.profile_id, c.text, c.created_at, p.name AS author
FROM comment c
JOIN profile p ON p.id = c.profile_id
WHERE c.recipe_id = ?
ORDER BY c.created_at ASC`
)
.all(recipeId) as CommentRow[];
}
export function renameRecipe(
db: Database.Database,
recipeId: number,
newTitle: string
): void {
const trimmed = newTitle.trim();
if (!trimmed) throw new Error('title cannot be empty');
db.prepare('UPDATE recipe SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
trimmed,
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
);
}