Files
kochwas/src/lib/components/RecipeView.svelte
hsiegeln 6bde3909d8
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s
polish(sections): Muelltonne statt X + Ueberschrift groesser/fetter
- IngredientRow: Sektion-entfernen-Button nutzt Trash2 (konsistent
  mit dem Zutat-Entfernen-Button daneben)
- RecipeView: section-heading von 1rem/600 auf 1.2rem/700, mehr
  vertikaler Abstand fuer deutlichere optische Trennung
- E2E-Spec: type-inference-Trick durch APIRequestContext-Import
  ersetzt (svelte-check stolperte bei typeof test mit TestDetails-
  Overload)
- Plan-Datei der Feature-Session mitcommitet

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:26:39 +02:00

404 lines
9.1 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';
import TimeDisplay from '$lib/components/TimeDisplay.svelte';
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 });
}
</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>
<TimeDisplay
prepTimeMin={recipe.prep_time_min}
cookTimeMin={recipe.cook_time_min}
totalTimeMin={recipe.total_time_min}
/>
{#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>
<div class="panes">
<section
class="ingredients"
role="tabpanel"
class:hidden-mobile={tab !== 'ing'}
>
<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)}
{#if ing.section_heading && ing.section_heading.trim()}
<li class="section-heading">{ing.section_heading}</li>
{/if}
<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>
<section
class="steps"
role="tabpanel"
class:hidden-mobile={tab !== 'prep'}
>
<ol>
{#each recipe.steps as step (step.position)}
<li>{step.text}</li>
{/each}
</ol>
</section>
</div>
</article>
<style>
.recipe {
padding-bottom: 2rem;
}
.hdr {
margin: 0 -1rem 1rem;
}
.cover {
display: block;
width: 100%;
aspect-ratio: 16 / 10;
/* Nie mehr als 30% der Bildschirmhöhe — auf schmalen Screens würde das
Bild sonst alles Wichtige wegdrücken, auf breiten Desktops wäre es
unverhältnismäßig groß. */
max-height: 30vh;
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: var(--pill-radius);
font-size: 0.8rem;
color: #2b6a3d;
}
.tag {
font-size: 0.8rem;
color: #888;
}
.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 .section-heading {
list-style: none;
font-weight: 700;
color: #2b6a3d;
font-size: 1.2rem;
margin-top: 1.1rem;
margin-bottom: 0.3rem;
padding: 0.2rem 0;
border-bottom: 1px solid #e4eae7;
}
.ing-list .section-heading:first-child {
margin-top: 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;
}
.panes {
display: block;
}
.hidden-mobile {
display: none;
}
/* Querformat-Tablets und Desktop: Zutaten + Zubereitung nebeneinander,
Tabs ausgeblendet. Zutaten sticky, damit sie beim Scrollen der
Zubereitung oben bleiben.
Schriftgrößen hier bewusst größer — das Rezept wird auf einem 10"-
Tablet beim Kochen aus ~50 cm Abstand gelesen. */
@media (min-width: 820px) {
.tabs {
display: none;
}
.panes {
display: grid;
grid-template-columns: minmax(260px, 1fr) 1.6fr;
gap: 2rem;
align-items: start;
}
.hidden-mobile {
display: block;
}
.ingredients {
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.ing-list li {
font-size: 1.2rem;
line-height: 1.5;
padding: 0.85rem 0.25rem;
}
.qty {
min-width: 6rem;
}
.srv-value strong {
font-size: 1.5rem;
}
.srv-value span {
font-size: 1rem;
}
.steps li {
font-size: 1.2rem;
line-height: 1.55;
padding: 1rem 0 1rem 3.4rem;
}
.steps li::before {
width: 2.4rem;
height: 2.4rem;
font-size: 1.1rem;
top: 1rem;
}
}
</style>