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">
|
<header class="bar">
|
||||||
<a href="/" class="brand">Kochwas</a>
|
<a href="/" class="brand">Kochwas</a>
|
||||||
<div class="bar-right">
|
<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 />
|
<ProfileSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.admin-link {
|
.nav-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
.admin-link:hover {
|
.nav-link:hover {
|
||||||
background: #f4f8f5;
|
background: #f4f8f5;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
let comments = $state<CommentRow[]>([]);
|
let comments = $state<CommentRow[]>([]);
|
||||||
let cookingLog = $state<typeof data.cooking_log>([]);
|
let cookingLog = $state<typeof data.cooking_log>([]);
|
||||||
let isFav = $state(false);
|
let isFav = $state(false);
|
||||||
|
let onWishlist = $state(false);
|
||||||
let newComment = $state('');
|
let newComment = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -124,6 +125,31 @@
|
|||||||
location.reload();
|
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
|
// Wake-Lock
|
||||||
let wakeLock: WakeLockSentinel | null = null;
|
let wakeLock: WakeLockSentinel | null = null;
|
||||||
async function requestWakeLock() {
|
async function requestWakeLock() {
|
||||||
@@ -139,6 +165,7 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
void requestWakeLock();
|
void requestWakeLock();
|
||||||
void checkFavorite();
|
void checkFavorite();
|
||||||
|
void refreshWishlistState();
|
||||||
const onVisibility = () => {
|
const onVisibility = () => {
|
||||||
if (document.visibilityState === 'visible' && !wakeLock) void requestWakeLock();
|
if (document.visibilityState === 'visible' && !wakeLock) void requestWakeLock();
|
||||||
};
|
};
|
||||||
@@ -171,6 +198,9 @@
|
|||||||
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
|
||||||
{isFav ? '♥' : '♡'} Favorit
|
{isFav ? '♥' : '♡'} Favorit
|
||||||
</button>
|
</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={() => window.print()}>🖨 Drucken</button>
|
||||||
<button class="btn" onclick={renameRecipe}>✎ Umbenennen</button>
|
<button class="btn" onclick={renameRecipe}>✎ Umbenennen</button>
|
||||||
<button class="btn danger" onclick={deleteRecipe}>🗑 Löschen</button>
|
<button class="btn danger" onclick={deleteRecipe}>🗑 Löschen</button>
|
||||||
@@ -267,6 +297,11 @@
|
|||||||
border-color: #f1b4b4;
|
border-color: #f1b4b4;
|
||||||
background: #fdf3f3;
|
background: #fdf3f3;
|
||||||
}
|
}
|
||||||
|
.btn.wish {
|
||||||
|
color: #2b6a3d;
|
||||||
|
border-color: #b7d6c2;
|
||||||
|
background: #eaf4ed;
|
||||||
|
}
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: #2b6a3d;
|
background: #2b6a3d;
|
||||||
color: white;
|
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