All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Option B aus dem Roadmap-Plan. requireProfile bekommt einen optionalen message-Parameter mit dem bisherigen Text als Default — die 5 Bestands- Aufrufe aendern sich nicht, die Wunschliste nutzt die Custom-Message „um mitzuwünschen" sauber ueber den Helper statt mit dupliziertem alertAction-Block. Netto: -3 Zeilen in wishlist/+page.svelte, eine Duplikation weniger, Helper dokumentiert jetzt explizit den Message-Override-Use-Case. Gate: svelte-check 0 Warnings, 184/184 Tests, Wunschliste zeigt korrekte Message beim Klick ohne Profil. Refs docs/superpowers/plans/2026-04-19-post-review-roadmap.md Item G. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
319 lines
7.7 KiB
Svelte
319 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
|
|
import { profileStore, requireProfile } from '$lib/client/profile.svelte';
|
|
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
|
import { requireOnline } from '$lib/client/require-online';
|
|
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
|
|
|
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
|
|
{ value: 'popular', label: 'Meist gewünscht' },
|
|
{ value: 'newest', label: 'Neueste' },
|
|
{ value: 'oldest', label: 'Älteste' }
|
|
];
|
|
|
|
let entries = $state<WishlistEntry[]>([]);
|
|
let loading = $state(true);
|
|
let sort = $state<SortKey>('popular');
|
|
|
|
async function load() {
|
|
loading = true;
|
|
const params = new URLSearchParams({ sort });
|
|
if (profileStore.active) params.set('profile_id', String(profileStore.active.id));
|
|
const res = await fetch(`/api/wishlist?${params}`);
|
|
const body = await res.json();
|
|
entries = body.entries;
|
|
loading = false;
|
|
}
|
|
|
|
$effect(() => {
|
|
// Re-fetch when sort or active profile changes
|
|
sort;
|
|
profileStore.activeId;
|
|
void load();
|
|
});
|
|
|
|
async function toggleMine(entry: WishlistEntry) {
|
|
const profile = await requireProfile(
|
|
'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
|
);
|
|
if (!profile) return;
|
|
if (!requireOnline('Die Wunschlisten-Aktion')) return;
|
|
const profileId = profile.id;
|
|
if (entry.on_my_wishlist) {
|
|
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
} else {
|
|
await fetch('/api/wishlist', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ recipe_id: entry.recipe_id, profile_id: profileId })
|
|
});
|
|
}
|
|
await load();
|
|
void wishlistStore.refresh();
|
|
}
|
|
|
|
async function removeForAll(entry: WishlistEntry) {
|
|
const ok = await confirmAction({
|
|
title: 'Von der Wunschliste entfernen?',
|
|
message: `„${entry.title}" wird für alle Profile aus der Wunschliste gestrichen. Das Rezept selbst bleibt erhalten.`,
|
|
confirmLabel: 'Entfernen',
|
|
destructive: true
|
|
});
|
|
if (!ok) return;
|
|
if (!requireOnline('Das Entfernen')) return;
|
|
await fetch(`/api/wishlist/${entry.recipe_id}?all=true`, { method: 'DELETE' });
|
|
await load();
|
|
void wishlistStore.refresh();
|
|
}
|
|
|
|
onMount(() => {
|
|
void load();
|
|
void wishlistStore.refresh();
|
|
});
|
|
|
|
function resolveImage(p: string | null): string | null {
|
|
if (!p) return null;
|
|
return /^https?:\/\//i.test(p) ? p : `/images/${p}`;
|
|
}
|
|
</script>
|
|
|
|
<header class="head">
|
|
<h1>Wunschliste</h1>
|
|
<p class="sub">Das wollen wir bald mal essen.</p>
|
|
</header>
|
|
|
|
<div class="sort-chips" role="tablist" aria-label="Sortierung">
|
|
{#each SORT_OPTIONS as s (s.value)}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={sort === s.value}
|
|
class="chip"
|
|
class:active={sort === s.value}
|
|
onclick={() => (sort = s.value)}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if loading}
|
|
<p class="muted">Lädt …</p>
|
|
{:else if entries.length === 0}
|
|
<section class="empty">
|
|
<div class="big"><CookingPot size={48} strokeWidth={1.5} /></div>
|
|
<p>Noch nichts gewünscht.</p>
|
|
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
|
|
</section>
|
|
{:else}
|
|
<ul class="list">
|
|
{#each entries as e (e.recipe_id)}
|
|
<li class="card">
|
|
<a class="body" href={`/recipes/${e.recipe_id}`}>
|
|
{#if resolveImage(e.image_path)}
|
|
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
|
|
{:else}
|
|
<div class="placeholder"><CookingPot size={32} /></div>
|
|
{/if}
|
|
<div class="text">
|
|
<div class="title">{e.title}</div>
|
|
<div class="meta">
|
|
{#if e.wanted_by_names}
|
|
<span class="wanted-by">{e.wanted_by_names}</span>
|
|
{/if}
|
|
{#if e.source_domain}
|
|
<span class="src">· {e.source_domain}</span>
|
|
{/if}
|
|
{#if e.avg_stars !== null}
|
|
<span>· ★ {e.avg_stars.toFixed(1)}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
<div class="actions">
|
|
<button
|
|
class="like"
|
|
class:active={e.on_my_wishlist}
|
|
aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
|
|
onclick={() => toggleMine(e)}
|
|
>
|
|
<Utensils size={18} strokeWidth={2} />
|
|
{#if e.wanted_by_count > 0}
|
|
<span class="count">{e.wanted_by_count}</span>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
class="del"
|
|
aria-label="Für alle entfernen"
|
|
onclick={() => removeForAll(e)}
|
|
>
|
|
<Trash2 size={18} strokeWidth={2} />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
<style>
|
|
.head {
|
|
padding: 1.25rem 0 0.5rem;
|
|
}
|
|
.head h1 {
|
|
margin: 0;
|
|
font-size: 1.6rem;
|
|
color: #2b6a3d;
|
|
}
|
|
.sub {
|
|
margin: 0.2rem 0 0;
|
|
color: #666;
|
|
}
|
|
.sort-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.35rem;
|
|
margin: 0.5rem 0 1rem;
|
|
}
|
|
.chip {
|
|
padding: 0.4rem 0.85rem;
|
|
background: white;
|
|
border: 1px solid #cfd9d1;
|
|
border-radius: var(--pill-radius);
|
|
color: #2b6a3d;
|
|
font-size: 0.88rem;
|
|
cursor: pointer;
|
|
min-height: 36px;
|
|
font-family: inherit;
|
|
white-space: nowrap;
|
|
}
|
|
.chip:hover {
|
|
background: #f4f8f5;
|
|
}
|
|
.chip.active {
|
|
background: #2b6a3d;
|
|
color: white;
|
|
border-color: #2b6a3d;
|
|
font-weight: 600;
|
|
}
|
|
.muted {
|
|
color: #888;
|
|
text-align: center;
|
|
padding: 2rem 0;
|
|
}
|
|
.empty {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
}
|
|
.big {
|
|
color: #8fb097;
|
|
display: inline-flex;
|
|
margin: 0 0 0.5rem;
|
|
}
|
|
.hint {
|
|
color: #888;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
.card {
|
|
display: flex;
|
|
align-items: stretch;
|
|
background: white;
|
|
border: 1px solid #e4eae7;
|
|
border-radius: 14px;
|
|
overflow: hidden;
|
|
min-height: 96px;
|
|
}
|
|
.body {
|
|
flex: 1;
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
min-width: 0;
|
|
}
|
|
.body img,
|
|
.placeholder {
|
|
width: 96px;
|
|
object-fit: cover;
|
|
background: #eef3ef;
|
|
display: grid;
|
|
place-items: center;
|
|
color: #8fb097;
|
|
flex-shrink: 0;
|
|
}
|
|
.text {
|
|
flex: 1;
|
|
padding: 0.7rem 0.75rem;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
}
|
|
.title {
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
line-height: 1.3;
|
|
}
|
|
.meta {
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
margin-top: 0.25rem;
|
|
color: #888;
|
|
font-size: 0.82rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.wanted-by {
|
|
color: #2b6a3d;
|
|
font-weight: 500;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
align-items: stretch;
|
|
justify-content: center;
|
|
padding: 0.5rem 0.6rem 0.5rem 0;
|
|
}
|
|
.like,
|
|
.del {
|
|
min-width: 48px;
|
|
min-height: 40px;
|
|
border-radius: 10px;
|
|
border: 1px solid #e4eae7;
|
|
background: white;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.3rem;
|
|
font-size: 1.05rem;
|
|
color: #444;
|
|
}
|
|
.like.active {
|
|
color: #2b6a3d;
|
|
background: #eaf4ed;
|
|
border-color: #b7d6c2;
|
|
}
|
|
.del:hover {
|
|
color: #c53030;
|
|
border-color: #f1b4b4;
|
|
background: #fdf3f3;
|
|
}
|
|
.count {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|