feat(ui): wishlist page, recipe toggle button, header link
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s
- /wishlist renders cards with avatar-badge of who added it, like count, heart toggle for active profile, delete button. Sort dropdown switches between popular / newest / oldest. - /recipes/[id] gets 'Auf Wunschliste (setzen)' button alongside favorite. - Layout header shows 🍽️ link to /wishlist next to the admin ⚙️. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
<header class="bar">
|
||||
<a href="/" class="brand">Kochwas</a>
|
||||
<div class="bar-right">
|
||||
<a href="/admin" class="admin-link" aria-label="Einstellungen">⚙️</a>
|
||||
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽️</a>
|
||||
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
|
||||
<ProfileSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
@@ -59,7 +60,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.admin-link {
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -69,7 +70,7 @@
|
||||
text-decoration: none;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.admin-link:hover {
|
||||
.nav-link:hover {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
main {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
let comments = $state<CommentRow[]>([]);
|
||||
let cookingLog = $state<typeof data.cooking_log>([]);
|
||||
let isFav = $state(false);
|
||||
let onWishlist = $state(false);
|
||||
let newComment = $state('');
|
||||
|
||||
$effect(() => {
|
||||
@@ -124,6 +125,31 @@
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function toggleWishlist() {
|
||||
if (onWishlist) {
|
||||
await fetch(`/api/wishlist/${data.recipe.id}`, { method: 'DELETE' });
|
||||
onWishlist = false;
|
||||
} else {
|
||||
await fetch('/api/wishlist', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipe_id: data.recipe.id,
|
||||
profile_id: profileStore.active?.id ?? null
|
||||
})
|
||||
});
|
||||
onWishlist = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWishlistState() {
|
||||
// No dedicated GET for a single entry; scan the list and check.
|
||||
const res = await fetch('/api/wishlist?sort=newest');
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
onWishlist = body.entries.some((e: { recipe_id: number }) => e.recipe_id === data.recipe.id);
|
||||
}
|
||||
|
||||
// Wake-Lock
|
||||
let wakeLock: WakeLockSentinel | null = null;
|
||||
async function requestWakeLock() {
|
||||
@@ -139,6 +165,7 @@
|
||||
onMount(() => {
|
||||
void requestWakeLock();
|
||||
void checkFavorite();
|
||||
void refreshWishlistState();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible' && !wakeLock) void requestWakeLock();
|
||||
};
|
||||
@@ -171,6 +198,9 @@
|
||||
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
||||
{isFav ? '♥' : '♡'} Favorit
|
||||
</button>
|
||||
<button class="btn" class:wish={onWishlist} onclick={toggleWishlist}>
|
||||
{onWishlist ? '✓' : '🍽️'} {onWishlist ? 'Auf Wunschliste' : 'Auf Wunschliste setzen'}
|
||||
</button>
|
||||
<button class="btn" onclick={() => window.print()}>🖨 Drucken</button>
|
||||
<button class="btn" onclick={renameRecipe}>✎ Umbenennen</button>
|
||||
<button class="btn danger" onclick={deleteRecipe}>🗑 Löschen</button>
|
||||
@@ -267,6 +297,11 @@
|
||||
border-color: #f1b4b4;
|
||||
background: #fdf3f3;
|
||||
}
|
||||
.btn.wish {
|
||||
color: #2b6a3d;
|
||||
border-color: #b7d6c2;
|
||||
background: #eaf4ed;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
|
||||
259
src/routes/wishlist/+page.svelte
Normal file
259
src/routes/wishlist/+page.svelte
Normal file
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { profileStore } from '$lib/client/profile.svelte';
|
||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
||||
|
||||
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 toggleLike(entry: WishlistEntry) {
|
||||
if (!profileStore.active) {
|
||||
alert('Bitte Profil wählen, um zu liken.');
|
||||
return;
|
||||
}
|
||||
const method = entry.liked_by_me ? 'DELETE' : 'PUT';
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}/like`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileStore.active.id })
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(entry: WishlistEntry) {
|
||||
if (!confirm(`„${entry.title}" von der Wunschliste entfernen?`)) return;
|
||||
await fetch(`/api/wishlist/${entry.recipe_id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
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="controls">
|
||||
<label>
|
||||
Sortieren:
|
||||
<select bind:value={sort}>
|
||||
<option value="popular">Am meisten gewünscht</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Lädt …</p>
|
||||
{:else if entries.length === 0}
|
||||
<section class="empty">
|
||||
<p class="big">🥘</p>
|
||||
<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">🥘</div>
|
||||
{/if}
|
||||
<div class="text">
|
||||
<div class="title">{e.title}</div>
|
||||
<div class="meta">
|
||||
{#if e.added_by_name}
|
||||
<span>von {e.added_by_name}</span>
|
||||
{/if}
|
||||
{#if e.source_domain}
|
||||
<span>· {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.liked_by_me}
|
||||
aria-label={e.liked_by_me ? 'Unlike' : 'Like'}
|
||||
onclick={() => toggleLike(e)}
|
||||
>
|
||||
{e.liked_by_me ? '♥' : '♡'}
|
||||
{#if e.like_count > 0}
|
||||
<span class="count">{e.like_count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>🗑</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;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 0 1rem;
|
||||
}
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
.controls select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
min-height: 40px;
|
||||
background: white;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.big {
|
||||
font-size: 3rem;
|
||||
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;
|
||||
font-size: 2rem;
|
||||
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;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem 0.5rem 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.like,
|
||||
.del {
|
||||
min-width: 48px;
|
||||
min-height: 44px;
|
||||
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;
|
||||
}
|
||||
.like.active {
|
||||
color: #c53030;
|
||||
background: #fdf3f3;
|
||||
border-color: #f1b4b4;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user