Files
kochwas/src/routes/recipes/[id]/+page.svelte
hsiegeln 2b0bd4dc44
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 33s
fix(recipe): View-Beacon ueber \$effect statt onMount
Live-Test auf kochwas-dev: bei Hard-Reload/Cold-Start (nicht SPA-Click)
landete kein view-Eintrag in der DB. Ursache: Recipe-Page-onMount
feuert vor Layout-onMount, profileStore.load() laeuft aber im Layout-
onMount und macht erst danach einen async fetch — zum Zeitpunkt des
Beacons war profileStore.active noch null.

Loesung: Beacon im \$effect, das auf profileStore.active reagiert.
viewBeaconSent-Flag verhindert duplicate POSTs wenn der User waehrend
der Page-Lifetime das Profil wechselt — der ursprueglich getrackte
Profil-View bleibt der "richtige" fuer dieses Page-Open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:50:54 +02:00

797 lines
22 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick, untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import {
Heart,
Utensils,
Printer,
Pencil,
Trash2,
ChefHat,
Check,
X,
Lightbulb,
LightbulbOff
} from 'lucide-svelte';
import RecipeView from '$lib/components/RecipeView.svelte';
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
import StarRating from '$lib/components/StarRating.svelte';
import { profileStore, requireProfile } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online';
import type { CommentRow } from '$lib/server/recipes/actions';
let { data } = $props();
// local reactive copies we can mutate after actions
let ratings = $state<typeof data.ratings>([]);
let comments = $state<CommentRow[]>([]);
let cookingLog = $state<typeof data.cooking_log>([]);
let favoriteProfileIds = $state<number[]>([]);
let wishlistProfileIds = $state<number[]>([]);
let newComment = $state('');
let title = $state('');
let editingTitle = $state(false);
let titleDraft = $state('');
let titleInput: HTMLInputElement | null = $state(null);
let editMode = $state(false);
let saving = $state(false);
let recipeState = $state(untrack(() => data.recipe));
// Einmalige Pulse-Animation beim Aktivieren (nicht beim Wieder-Abwählen).
// Per tick()-Zwischenschritt "aus → an" erzwingen, damit die Animation
// auch bei mehrmaligem Klick innerhalb weniger hundert ms neu startet.
let pulseFav = $state(false);
let pulseWish = $state(false);
async function firePulse(which: 'fav' | 'wish') {
if (which === 'fav') {
pulseFav = false;
await tick();
pulseFav = true;
} else {
pulseWish = false;
await tick();
pulseWish = true;
}
}
async function saveRecipe(patch: {
title: string;
description: string | null;
servings_default: number | null;
prep_time_min: number | null;
cook_time_min: number | null;
total_time_min: number | null;
ingredients: typeof data.recipe.ingredients;
steps: typeof data.recipe.steps;
}) {
if (!requireOnline('Das Speichern')) return;
saving = true;
try {
const res = await asyncFetch(
`/api/recipes/${data.recipe.id}`,
{
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch)
},
'Speichern fehlgeschlagen'
);
if (!res) return;
const body = await res.json();
if (body.recipe) {
recipeState = body.recipe;
title = body.recipe.title;
}
editMode = false;
} finally {
saving = false;
}
}
$effect(() => {
ratings = [...data.ratings];
comments = [...data.comments];
cookingLog = [...data.cooking_log];
favoriteProfileIds = [...data.favorite_profile_ids];
wishlistProfileIds = [...data.wishlist_profile_ids];
title = data.recipe.title;
recipeState = data.recipe;
});
const myRating = $derived(
profileStore.active
? (ratings.find((r) => r.profile_id === profileStore.active!.id)?.stars ?? null)
: null
);
const isFav = $derived(
profileStore.active ? favoriteProfileIds.includes(profileStore.active.id) : false
);
const onMyWishlist = $derived(
profileStore.active ? wishlistProfileIds.includes(profileStore.active.id) : false
);
async function setRating(stars: number) {
const profile = await requireProfile();
if (!profile) return;
if (!requireOnline('Das Rating')) return;
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, stars })
});
const existing = ratings.find((r) => r.profile_id === profile.id);
if (existing) existing.stars = stars;
else ratings = [...ratings, { profile_id: profile.id, stars }];
}
async function toggleFavorite() {
const profile = await requireProfile();
if (!profile) return;
if (!requireOnline('Das Favorit-Setzen')) return;
const wasFav = isFav;
const method = wasFav ? 'DELETE' : 'PUT';
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id })
});
favoriteProfileIds = wasFav
? favoriteProfileIds.filter((id) => id !== profile.id)
: [...favoriteProfileIds, profile.id];
if (!wasFav) void firePulse('fav');
}
async function logCooked() {
const profile = await requireProfile();
if (!profile) return;
if (!requireOnline('Der Kochjournal-Eintrag')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id })
});
const entry = await res.json();
cookingLog = [entry, ...cookingLog];
if (entry.removed_from_wishlist) {
wishlistProfileIds = [];
void wishlistStore.refresh();
}
}
async function addComment() {
const profile = await requireProfile();
if (!profile) return;
if (!requireOnline('Das Speichern des Kommentars')) return;
const text = newComment.trim();
if (!text) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, text })
});
if (res.ok) {
const body = await res.json();
comments = [
...comments,
{
id: body.id,
profile_id: profile.id,
text,
created_at: new Date().toISOString(),
author: profile.name
}
];
newComment = '';
}
}
async function deleteComment(id: number) {
const ok = await confirmAction({
title: 'Kommentar löschen?',
message: 'Der Eintrag verschwindet ohne Umweg.',
confirmLabel: 'Löschen',
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Löschen')) return;
const res = await asyncFetch(
`/api/recipes/${data.recipe.id}/comments`,
{
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ comment_id: id })
},
'Löschen fehlgeschlagen'
);
if (!res) return;
comments = comments.filter((c) => c.id !== id);
}
async function deleteRecipe() {
const ok = await confirmAction({
title: 'Rezept löschen?',
message: `„${title}" wird endgültig entfernt — mit Bewertungen, Kommentaren und Kochjournal-Einträgen.`,
confirmLabel: 'Löschen',
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Löschen')) return;
await fetch(`/api/recipes/${data.recipe.id}`, { method: 'DELETE' });
goto('/');
}
async function startEditTitle() {
titleDraft = title;
editingTitle = true;
await tick();
titleInput?.focus();
titleInput?.select();
}
function cancelEditTitle() {
editingTitle = false;
titleDraft = '';
}
async function saveTitle() {
const next = titleDraft.trim();
if (!next || next === title) {
editingTitle = false;
return;
}
if (!requireOnline('Das Umbenennen')) return;
const res = await asyncFetch(
`/api/recipes/${data.recipe.id}`,
{
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: next })
},
'Umbenennen fehlgeschlagen'
);
if (!res) return;
title = next;
editingTitle = false;
}
function onTitleKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
void saveTitle();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditTitle();
}
}
async function toggleWishlist() {
const profile = await requireProfile();
if (!profile) return;
if (!requireOnline('Das Wunschlisten-Setzen')) return;
const wasOn = onMyWishlist;
if (wasOn) {
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profile.id}`, {
method: 'DELETE'
});
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profile.id);
} else {
await fetch('/api/wishlist', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profile.id })
});
wishlistProfileIds = [...wishlistProfileIds, profile.id];
}
void wishlistStore.refresh();
if (!wasOn) void firePulse('wish');
}
// Wake-Lock — Bildschirm beim Kochen nicht dimmen lassen.
// Browser-API navigator.wakeLock.request('screen') verhindert auto-lock
// und -dimmen, solange der Tab sichtbar ist. Sobald der Tab in den
// Hintergrund geht, verliert der Sentinel seine Wirkung von selbst; wir
// re-requesten bei visibilitychange.
let wakeLockEnabled = $state(true);
let wakeLock: WakeLockSentinel | null = null;
async function acquireWakeLock() {
if (wakeLock || !wakeLockEnabled) return;
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
wakeLock = null;
});
}
} catch {
// User hat es gecancelt oder Browser unterstützt es nicht — ignorieren
}
}
async function releaseWakeLock() {
if (!wakeLock) return;
try {
await wakeLock.release();
} catch {
// ignore
}
wakeLock = null;
}
function toggleWakeLock() {
wakeLockEnabled = !wakeLockEnabled;
if (typeof window !== 'undefined') {
localStorage.setItem('kochwas.wakeLock', wakeLockEnabled ? '1' : '0');
}
if (wakeLockEnabled) void acquireWakeLock();
else void releaseWakeLock();
}
onMount(() => {
// Wenn wir über "Manuell anlegen" hier landen, ist ?edit=1 gesetzt
// und wir starten direkt im Editor. Den Param danach aus der URL
// entfernen, damit Refresh nicht automatisch wieder edit-Mode ist.
if ($page.url.searchParams.get('edit') === '1') {
editMode = true;
const url = new URL(window.location.href);
url.searchParams.delete('edit');
history.replaceState(history.state, '', url.toString());
}
const stored = localStorage.getItem('kochwas.wakeLock');
if (stored !== null) wakeLockEnabled = stored === '1';
if (wakeLockEnabled) void acquireWakeLock();
const onVisibility = () => {
if (document.visibilityState === 'visible' && wakeLockEnabled && !wakeLock) {
void acquireWakeLock();
}
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
});
// Track view per active profile (fire-and-forget). Lives in $effect, not
// onMount, because profileStore.load() runs from layout's onMount and the
// child onMount fires first — at mount time profileStore.active is still
// null on cold loads. The effect re-runs once active populates, the
// viewBeaconSent flag prevents duplicate POSTs on subsequent profile
// switches within the same page instance.
let viewBeaconSent = $state(false);
$effect(() => {
if (viewBeaconSent) return;
if (!profileStore.active) return;
viewBeaconSent = true;
void fetch(`/api/recipes/${data.recipe.id}/view`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileStore.active.id })
});
});
onDestroy(() => {
void releaseWakeLock();
});
</script>
{#if editMode}
<RecipeEditor
recipe={recipeState}
{saving}
onsave={saveRecipe}
oncancel={() => (editMode = false)}
onimagechange={(path) => (recipeState = { ...recipeState, image_path: path })}
/>
{:else}
<RecipeView recipe={recipeState}>
{#snippet titleSlot()}
<div class="title-row">
{#if editingTitle}
<input
bind:this={titleInput}
bind:value={titleDraft}
class="title-input"
onkeydown={onTitleKey}
aria-label="Rezept-Titel"
maxlength="200"
/>
<button class="icon-btn save" aria-label="Titel speichern" onclick={saveTitle}>
<Check size={20} strokeWidth={2.5} />
</button>
<button class="icon-btn cancel" aria-label="Abbrechen" onclick={cancelEditTitle}>
<X size={20} strokeWidth={2.5} />
</button>
{:else}
<h1 class="title-heading">{title}</h1>
<button class="icon-btn edit" aria-label="Titel umbenennen" onclick={startEditTitle}>
<Pencil size={18} strokeWidth={2} />
</button>
{/if}
</div>
{/snippet}
{#snippet showActions()}
<div class="action-bar">
<div class="rating-row">
<span class="label">Deine Bewertung:</span>
<StarRating value={myRating} onChange={setRating} size="lg" />
{#if data.avg_stars !== null}
<span class="avg">{data.avg_stars.toFixed(1)} ({ratings.length})</span>
{/if}
</div>
<div class="btn-row">
<button
class="btn"
class:heart={isFav}
class:pulse={pulseFav}
onclick={toggleFavorite}
onanimationend={() => (pulseFav = false)}
>
<Heart size={18} strokeWidth={2} fill={isFav ? 'currentColor' : 'none'} />
<span>Favorit</span>
</button>
<button
class="btn"
class:wish={onMyWishlist}
class:pulse={pulseWish}
onclick={toggleWishlist}
onanimationend={() => (pulseWish = false)}
>
{#if onMyWishlist}
<Check size={18} strokeWidth={2.5} />
<span>Auf Wunschliste</span>
{:else}
<Utensils size={18} strokeWidth={2} />
<span>Auf Wunschliste setzen</span>
{/if}
</button>
<button class="btn" onclick={() => window.print()}>
<Printer size={18} strokeWidth={2} />
<span>Drucken</span>
</button>
</div>
<div class="btn-row">
<button class="btn" onclick={logCooked}>
<ChefHat size={18} strokeWidth={2} />
<span>Heute gekocht</span>
{#if cookingLog.length > 0}
<span class="count">({cookingLog.length})</span>
{/if}
</button>
<button
class="btn"
class:screen-on={wakeLockEnabled}
onclick={toggleWakeLock}
aria-label={wakeLockEnabled
? 'Bildschirm bleibt an — zum Deaktivieren klicken'
: 'Bildschirm darf dimmen — zum Aktivieren klicken'}
>
{#if wakeLockEnabled}
<Lightbulb size={18} strokeWidth={2} />
<span>Bildschirm an</span>
{:else}
<LightbulbOff size={18} strokeWidth={2} />
<span>Bildschirm aus</span>
{/if}
</button>
<button class="btn" onclick={() => (editMode = true)}>
<Pencil size={18} strokeWidth={2} />
<span>Bearbeiten</span>
</button>
<button class="btn danger" onclick={deleteRecipe}>
<Trash2 size={18} strokeWidth={2} />
<span>Löschen</span>
</button>
</div>
</div>
{/snippet}
</RecipeView>
{/if}
<section class="comments">
<h2>Kommentare</h2>
{#if comments.length === 0}
<p class="muted">Noch keine Kommentare.</p>
{/if}
<ul>
{#each comments as c (c.id)}
<li>
<div class="author">{c.author}</div>
<div class="text">{c.text}</div>
<div class="date">{new Date(c.created_at).toLocaleString('de-DE')}</div>
{#if profileStore.active?.id === c.profile_id}
<button
type="button"
class="comment-del"
aria-label="Kommentar löschen"
onclick={() => void deleteComment(c.id)}
>
<Trash2 size="14" />
</button>
{/if}
</li>
{/each}
</ul>
<div class="new-comment">
<textarea
bind:value={newComment}
placeholder={profileStore.active
? 'z.B. Salz durch Zucker ersetzen'
: 'Erst Profil wählen, um Kommentare zu schreiben.'}
rows="3"
disabled={!profileStore.active}
></textarea>
<button class="btn primary" onclick={addComment} disabled={!profileStore.active}>
Kommentar speichern
</button>
</div>
</section>
{#if cookingLog.length > 0}
<section class="cooking-log">
<h2>Kochjournal</h2>
<ul>
{#each cookingLog.slice(0, 20) as entry (entry.id)}
<li>
{new Date(entry.cooked_at).toLocaleString('de-DE')}
</li>
{/each}
</ul>
</section>
{/if}
<style>
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.4rem;
flex-wrap: wrap;
}
.title-heading {
font-size: clamp(1.5rem, 5.5vw, 2rem);
line-height: 1.15;
margin: 0;
flex: 1;
min-width: 0;
}
.title-input {
flex: 1;
min-width: 0;
font-size: clamp(1.3rem, 5vw, 1.8rem);
font-weight: 700;
padding: 0.25rem 0.5rem;
border: 2px solid #2b6a3d;
border-radius: 8px;
background: white;
font-family: inherit;
}
.title-input:focus {
outline: none;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid #cfd9d1;
background: white;
cursor: pointer;
color: #444;
flex-shrink: 0;
}
.icon-btn:hover {
background: #f4f8f5;
}
.icon-btn.save {
background: #2b6a3d;
color: white;
border-color: #2b6a3d;
}
.icon-btn.save:hover {
background: #235532;
}
.icon-btn.cancel {
color: #c53030;
border-color: #f1b4b4;
}
.action-bar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.9rem;
background: white;
border: 1px solid #e4eae7;
border-radius: 14px;
}
.rating-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.label {
font-size: 0.95rem;
color: #555;
}
.avg {
color: #888;
font-size: 0.9rem;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 0.85rem;
min-height: 44px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
color: #1a1a1a;
}
.btn:hover {
background: #f4f8f5;
}
.btn.heart {
color: #c53030;
border-color: #f1b4b4;
background: #fdf3f3;
--pulse-color: rgba(197, 48, 48, 0.45);
}
.btn.wish {
color: #2b6a3d;
border-color: #b7d6c2;
background: #eaf4ed;
--pulse-color: rgba(43, 106, 61, 0.45);
}
/* Einmalige Bestätigung beim Aktivieren der Aktion — kurzer Scale-Bounce
plus ausklingender Ring in der Aktionsfarbe (siehe --pulse-color).
prefers-reduced-motion: Ring aus, kein Scale. */
.btn.pulse {
animation: btnPulse 0.5s ease-out;
}
@keyframes btnPulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 var(--pulse-color, rgba(43, 106, 61, 0.45));
}
55% {
transform: scale(1.07);
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.btn.pulse {
animation: none;
}
}
.btn.screen-on {
color: #b07e00;
border-color: #e6d48a;
background: #fff6d7;
}
.btn.primary {
background: #2b6a3d;
color: white;
border: 0;
}
.btn.danger {
color: #c53030;
border-color: #f1b4b4;
}
.count {
color: #888;
font-size: 0.85em;
}
.comments {
margin-top: 2rem;
}
.comments h2 {
font-size: 1.15rem;
margin: 0 0 0.75rem;
}
.comments ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.comments li {
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
padding: 0.75rem 0.9rem;
position: relative;
}
.comment-del {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: #888;
border-radius: 8px;
cursor: pointer;
}
.comment-del:hover {
background: #f3f5f3;
color: #b42626;
}
.comments .author {
font-weight: 600;
font-size: 0.9rem;
}
.comments .text {
margin-top: 0.25rem;
line-height: 1.4;
}
.comments .date {
font-size: 0.8rem;
color: #888;
margin-top: 0.3rem;
}
.new-comment {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.new-comment textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #cfd9d1;
border-radius: 10px;
font: inherit;
resize: vertical;
}
.muted {
color: #888;
}
.cooking-log {
margin-top: 2rem;
}
.cooking-log h2 {
font-size: 1.1rem;
margin: 0 0 0.5rem;
}
.cooking-log ul {
list-style: none;
padding: 0;
margin: 0;
color: #555;
}
.cooking-log li {
padding: 0.35rem 0;
border-bottom: 1px solid #edf1ee;
font-size: 0.9rem;
}
</style>