Files
kochwas/src/lib/components/RecipeView.svelte
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

330 lines
7.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { scaleIngredients } from '$lib/recipes/scaler';
import type { Recipe } from '$lib/types';
type Props = {
recipe: Recipe;
showActions?: import('svelte').Snippet;
banner?: import('svelte').Snippet;
titleSlot?: import('svelte').Snippet;
};
let { recipe, showActions, banner, titleSlot }: Props = $props();
const defaultServings = $derived(recipe.servings_default ?? 4);
let servingsOverride = $state<number | null>(null);
const servings = $derived(servingsOverride ?? defaultServings);
const factor = $derived(servings / defaultServings);
const scaled = $derived(scaleIngredients(recipe.ingredients, factor));
let tab = $state<'ing' | 'prep'>('ing');
// In preview, image_path is still an external URL; after save, it's a local
// filename served under /images/. Detect absolute URLs and pass through.
const imageSrc = $derived(
recipe.image_path === null
? null
: /^https?:\/\//i.test(recipe.image_path)
? recipe.image_path
: `/images/${recipe.image_path}`
);
function decr() {
if (servings > 1) servingsOverride = servings - 1;
}
function incr() {
if (servings < 100) servingsOverride = servings + 1;
}
function formatQty(q: number | null): string {
if (q === null) return '';
if (Number.isInteger(q)) return String(q);
return q.toLocaleString('de-DE', { maximumFractionDigits: 2 });
}
function timeSummary(): string {
const parts: string[] = [];
if (recipe.prep_time_min) parts.push(`Vorb. ${recipe.prep_time_min} min`);
if (recipe.cook_time_min) parts.push(`Kochen ${recipe.cook_time_min} min`);
if (!recipe.prep_time_min && !recipe.cook_time_min && recipe.total_time_min)
parts.push(`Gesamt ${recipe.total_time_min} min`);
return parts.join(' · ');
}
</script>
{#if banner}
{@render banner()}
{/if}
<article class="recipe">
<header class="hdr">
{#if imageSrc}
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
{/if}
<div class="hdr-body">
{#if titleSlot}
{@render titleSlot()}
{:else}
<h1>{recipe.title}</h1>
{/if}
{#if recipe.description}
<p class="desc">{recipe.description}</p>
{/if}
<div class="meta">
{#if recipe.category}<span class="pill">{recipe.category}</span>{/if}
{#if recipe.cuisine}<span class="pill">{recipe.cuisine}</span>{/if}
{#if recipe.tags}
{#each recipe.tags.slice(0, 6) as t (t)}
<span class="tag">#{t}</span>
{/each}
{/if}
</div>
{#if timeSummary()}
<p class="times">{timeSummary()}</p>
{/if}
{#if recipe.source_url}
<p class="src">
Quelle: <a href={recipe.source_url} target="_blank" rel="noopener">{recipe.source_domain}</a>
</p>
{/if}
</div>
</header>
{#if showActions}
<div class="actions">
{@render showActions()}
</div>
{/if}
<div class="tabs" role="tablist">
<button
role="tab"
aria-selected={tab === 'ing'}
class="tab"
class:active={tab === 'ing'}
onclick={() => (tab = 'ing')}
>
Zutaten
</button>
<button
role="tab"
aria-selected={tab === 'prep'}
class="tab"
class:active={tab === 'prep'}
onclick={() => (tab = 'prep')}
>
Zubereitung
</button>
</div>
{#if tab === 'ing'}
<section class="ingredients" role="tabpanel">
<div class="servings">
<button class="srv-btn" aria-label="Weniger" onclick={decr}></button>
<div class="srv-value">
<strong>{servings}</strong>
<span>{recipe.servings_unit ?? 'Portionen'}</span>
</div>
<button class="srv-btn" aria-label="Mehr" onclick={incr}>+</button>
</div>
<ul class="ing-list">
{#each scaled as ing, i (i)}
<li>
{#if ing.quantity !== null || ing.unit}
<span class="qty">
{formatQty(ing.quantity)}
{#if ing.unit}
{' '}{ing.unit}
{/if}
</span>
{/if}
<span class="name">
{ing.name}
{#if ing.note}<span class="note"> ({ing.note})</span>{/if}
</span>
</li>
{/each}
</ul>
</section>
{:else}
<section class="steps" role="tabpanel">
<ol>
{#each recipe.steps as step (step.position)}
<li>{step.text}</li>
{/each}
</ol>
</section>
{/if}
</article>
<style>
.recipe {
padding-bottom: 2rem;
}
.hdr {
margin: 0 -1rem 1rem;
}
.cover {
display: block;
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
background: #eef3ef;
}
.hdr-body {
padding: 1rem 1rem 0.25rem;
}
h1 {
font-size: clamp(1.5rem, 5.5vw, 2rem);
line-height: 1.15;
margin: 0 0 0.4rem;
}
.desc {
color: #555;
margin: 0 0 0.5rem;
line-height: 1.4;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.pill {
padding: 0.15rem 0.55rem;
background: #eaf4ed;
border-radius: 999px;
font-size: 0.8rem;
color: #2b6a3d;
}
.tag {
font-size: 0.8rem;
color: #888;
}
.times {
margin: 0 0 0.25rem;
color: #666;
font-size: 0.9rem;
}
.src {
margin: 0;
font-size: 0.85rem;
color: #888;
}
.actions {
margin: 0 0 1rem;
}
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #e4eae7;
margin-bottom: 1rem;
position: sticky;
top: 57px;
background: #f8faf8;
z-index: 5;
}
.tab {
flex: 1;
background: none;
border: 0;
padding: 0.9rem 0;
font-size: 1rem;
cursor: pointer;
color: #666;
border-bottom: 3px solid transparent;
min-height: 48px;
}
.tab.active {
color: #2b6a3d;
border-bottom-color: #2b6a3d;
font-weight: 600;
}
.servings {
display: flex;
align-items: center;
gap: 1rem;
justify-content: center;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
padding: 0.6rem 1rem;
margin-bottom: 1rem;
}
.srv-btn {
width: 44px;
height: 44px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 10px;
font-size: 1.4rem;
cursor: pointer;
}
.srv-value {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
}
.srv-value strong {
font-size: 1.25rem;
}
.srv-value span {
color: #888;
font-size: 0.85rem;
}
.ing-list {
list-style: none;
padding: 0;
margin: 0;
}
.ing-list li {
display: flex;
gap: 0.75rem;
padding: 0.7rem 0.25rem;
border-bottom: 1px solid #edf1ee;
font-size: 1rem;
line-height: 1.4;
}
.qty {
min-width: 5rem;
font-weight: 600;
color: #2b6a3d;
}
.note {
color: #888;
font-size: 0.9em;
}
.steps ol {
padding-left: 0;
list-style: none;
counter-reset: step;
}
.steps li {
counter-increment: step;
position: relative;
padding: 0.8rem 0 0.8rem 3rem;
border-bottom: 1px solid #edf1ee;
line-height: 1.5;
}
.steps li::before {
content: counter(step);
position: absolute;
left: 0;
top: 0.8rem;
width: 2.1rem;
height: 2.1rem;
background: #2b6a3d;
color: white;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.95rem;
}
</style>