feat(ui): Favoriten-Liste, Dismiss-from-Recent, Inline-Rename, Lucide-Icons
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m31s

Homepage:
- Neue Sektion "Deine Favoriten" über "Zuletzt hinzugefügt" (alphabetisch
  sortiert, lädt wenn Profil aktiv ist; versteckt sonst)
- Jede Karte in "Zuletzt hinzugefügt" hat jetzt oben-rechts ein X-Icon
  zum Ausblenden. Das Rezept selbst bleibt in der DB — nur die
  Anzeige in der Recent-Liste wird per recipe.hidden_from_recent = 1
  unterdrückt. Section versteckt sich, wenn die Liste leer wird.

DB:
- Neue Migration 004_recipe_hidden_from_recent.sql (+Index)
- listFavoritesForProfile in search-local.ts (ORDER BY title NOCASE)
- setRecipeHiddenFromRecent in actions.ts

API:
- GET /api/recipes/favorites?profile_id=X
- PATCH /api/recipes/[id] akzeptiert jetzt title und/oder
  hidden_from_recent (Zod-Schema mit refine)

Rezept-Detail:
- Titel ist jetzt inline editierbar: kleines Stift-Icon rechts neben
  H1. Click öffnet Input, Enter speichert (PATCH), Escape bricht ab.
  Kein location.reload() mehr.
- RecipeView bekommt neuen Snippet-Prop titleSlot für Title-Override.
- Neue Aktionsreihenfolge:
  Zeile 1: Favorit | Wunschliste | Drucken
  Zeile 2: Heute gekocht | Löschen
  (Umbenennen ist jetzt am Titel statt in der Leiste.)

Icons (lucide-svelte, neues Dep):
- Emoji-Icons durch Lucide-SVGs ersetzt auf Startseite, Header,
  Rezept-Detail, Wunschliste, Header-Dropdown:
    🍽️→Heart/Utensils, ⚙️→Settings, 🥘→CookingPot, 🌐→Globe,
    ♥/♡→Heart(filled), 🖨→Printer, ✎→Pencil, 🗑→Trash2, ✓→Check,
    🍳→ChefHat, X→X
- Header-Brand-Badge auf Mobile behält sein 🍳 (ist im ::after-Pseudo,
  Lucide käme da nicht sauber rein).
- SearchLoader-Emojis bleiben — die sind Teil der Animations-Charme.

Tests: 99/99 grün (bestehend), Typecheck 0 Fehler.
This commit is contained in:
hsiegeln
2026-04-17 18:57:17 +02:00
parent 657d006441
commit 7cac02de5a
12 changed files with 420 additions and 87 deletions

View File

@@ -6,9 +6,10 @@
recipe: Recipe;
showActions?: import('svelte').Snippet;
banner?: import('svelte').Snippet;
titleSlot?: import('svelte').Snippet;
};
let { recipe, showActions, banner }: Props = $props();
let { recipe, showActions, banner, titleSlot }: Props = $props();
const defaultServings = $derived(recipe.servings_default ?? 4);
let servingsOverride = $state<number | null>(null);
@@ -61,7 +62,11 @@
<img src={imageSrc} alt="" class="cover" loading="eager" referrerpolicy="no-referrer" />
{/if}
<div class="hdr-body">
<h1>{recipe.title}</h1>
{#if titleSlot}
{@render titleSlot()}
{:else}
<h1>{recipe.title}</h1>
{/if}
{#if recipe.description}
<p class="desc">{recipe.description}</p>
{/if}

View File

@@ -0,0 +1,6 @@
-- Let the user dismiss individual recipes from the "Zuletzt hinzugefügt"
-- list on the homepage. The recipe itself stays searchable and fully
-- functional — only its appearance in the "recent" list is suppressed.
ALTER TABLE recipe ADD COLUMN hidden_from_recent INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_recipe_hidden_from_recent ON recipe(hidden_from_recent, created_at);

View File

@@ -150,3 +150,14 @@ export function renameRecipe(
recipeId
);
}
export function setRecipeHiddenFromRecent(
db: Database.Database,
recipeId: number,
hidden: boolean
): void {
db.prepare('UPDATE recipe SET hidden_from_recent = ? WHERE id = ?').run(
hidden ? 1 : 0,
recipeId
);
}

View File

@@ -67,8 +67,30 @@ export function listRecentRecipes(
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
WHERE r.hidden_from_recent = 0
ORDER BY r.created_at DESC
LIMIT ?`
)
.all(limit) as SearchHit[];
}
export function listFavoritesForProfile(
db: Database.Database,
profileId: number
): SearchHit[] {
return db
.prepare(
`SELECT r.id,
r.title,
r.description,
r.image_path,
r.source_domain,
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
JOIN favorite f ON f.recipe_id = r.id
WHERE f.profile_id = ?
ORDER BY r.title COLLATE NOCASE`
)
.all(profileId) as SearchHit[];
}

View File

@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto, afterNavigate } from '$app/navigation';
import { Heart, Settings, CookingPot, Globe, Utensils } from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte';
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -151,7 +152,7 @@
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="dd-placeholder">🥘</div>
<div class="dd-placeholder"><CookingPot size={22} /></div>
{/if}
<div class="dd-body">
<div class="dd-title">{r.title}</div>
@@ -168,7 +169,8 @@
href={`/search/web?q=${encodeURIComponent(navQuery.trim())}`}
onclick={pickHit}
>
🌐 Im Internet weitersuchen
<Globe size={16} strokeWidth={2} />
<span>Im Internet weitersuchen</span>
</a>
{:else}
<p class="dd-section">Keine lokalen Rezepte aus dem Internet:</p>
@@ -190,7 +192,7 @@
{#if w.thumbnail}
<img src={w.thumbnail} alt="" loading="lazy" />
{:else}
<div class="dd-placeholder">🍽️</div>
<div class="dd-placeholder"><Utensils size={22} /></div>
{/if}
<div class="dd-body">
<div class="dd-title">{w.title}</div>
@@ -209,8 +211,12 @@
</div>
{/if}
<div class="bar-right">
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽️</a>
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">
<Heart size={20} strokeWidth={2} />
</a>
<a href="/admin" class="nav-link" aria-label="Einstellungen">
<Settings size={20} strokeWidth={2} />
</a>
<ProfileSwitcher />
</div>
</div>
@@ -363,9 +369,11 @@
letter-spacing: 0.03em;
}
.dd-web {
display: block;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.75rem 0.85rem;
text-align: center;
border-top: 1px solid #e4eae7;
text-decoration: none;
color: #2b6a3d;

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { CookingPot, Globe, X } from 'lucide-svelte';
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
import { randomQuote } from '$lib/quotes';
import SearchLoader from '$lib/components/SearchLoader.svelte';
import { profileStore } from '$lib/client/profile.svelte';
let query = $state('');
let quote = $state('');
let recent = $state<SearchHit[]>([]);
let favorites = $state<SearchHit[]>([]);
let hits = $state<SearchHit[]>([]);
let webHits = $state<WebHit[]>([]);
let searching = $state(false);
@@ -15,11 +18,34 @@
let webError = $state<string | null>(null);
let searchedFor = $state<string | null>(null);
onMount(async () => {
quote = randomQuote();
async function loadRecent() {
const res = await fetch('/api/recipes/search');
const body = await res.json();
recent = body.hits;
}
async function loadFavorites(profileId: number) {
const res = await fetch(`/api/recipes/favorites?profile_id=${profileId}`);
if (!res.ok) {
favorites = [];
return;
}
const body = await res.json();
favorites = body.hits;
}
onMount(() => {
quote = randomQuote();
void loadRecent();
});
$effect(() => {
const active = profileStore.active;
if (!active) {
favorites = [];
return;
}
void loadFavorites(active.id);
});
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -82,6 +108,17 @@
void runSearch(q);
}
async function dismissFromRecent(recipeId: number, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
recent = recent.filter((r) => r.id !== recipeId);
await fetch(`/api/recipes/${recipeId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ hidden_from_recent: true })
});
}
const activeSearch = $derived(query.trim().length > 3);
</script>
@@ -112,7 +149,7 @@
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder">🥘</div>
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
@@ -125,10 +162,12 @@
{/each}
</ul>
<a class="web-more" href={`/search/web?q=${encodeURIComponent(query.trim())}`}>
🌐 Im Internet weitersuchen
<Globe size={18} strokeWidth={2} /> Im Internet weitersuchen
</a>
{:else if searchedFor === query.trim()}
<p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:</p>
<p class="muted no-local-msg">
Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:
</p>
{#if webSearching}
<SearchLoader scope="web" />
{:else if webError}
@@ -141,7 +180,7 @@
{#if w.thumbnail}
<img src={w.thumbnail} alt="" loading="lazy" />
{:else}
<div class="placeholder">🍽️</div>
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
<div class="card-body">
<div class="title">{w.title}</div>
@@ -156,29 +195,62 @@
{/if}
{/if}
</section>
{:else if recent.length > 0}
<section class="recent">
<h2>Zuletzt hinzugefügt</h2>
<ul class="cards">
{#each recent as r (r.id)}
<li>
<a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder">🥘</div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{:else}
{#if profileStore.active && favorites.length > 0}
<section class="listing">
<h2>Deine Favoriten</h2>
<ul class="cards">
{#each favorites as r (r.id)}
<li class="card-wrap">
<a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</section>
<div class="card-body">
<div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</section>
{/if}
{#if recent.length > 0}
<section class="listing">
<h2>Zuletzt hinzugefügt</h2>
<ul class="cards">
{#each recent as r (r.id)}
<li class="card-wrap">
<a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
<button
class="dismiss"
aria-label="Aus Zuletzt-hinzugefügt entfernen"
onclick={(e) => dismissFromRecent(r.id, e)}
>
<X size={16} strokeWidth={2.5} />
</button>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<style>
@@ -219,10 +291,10 @@
outline-offset: 1px;
}
.results,
.recent {
.listing {
margin-top: 1.5rem;
}
.recent h2 {
.listing h2 {
font-size: 1.05rem;
color: #444;
margin: 0 0 0.75rem;
@@ -249,6 +321,9 @@
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.card-wrap {
position: relative;
}
.card {
display: block;
background: white;
@@ -270,7 +345,7 @@
background: #eef3ef;
display: grid;
place-items: center;
font-size: 2rem;
color: #8fb097;
}
.card-body {
padding: 0.6rem 0.75rem 0.75rem;
@@ -285,8 +360,41 @@
color: #888;
margin-top: 0.25rem;
}
.dismiss {
position: absolute;
top: 0.4rem;
right: 0.4rem;
width: 28px;
height: 28px;
border-radius: 999px;
border: 0;
background: rgba(255, 255, 255, 0.9);
color: #444;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
opacity: 0;
transition: opacity 0.1s;
}
.card-wrap:hover .dismiss,
.dismiss:focus-visible {
opacity: 1;
}
@media (max-width: 640px) {
.dismiss {
opacity: 1; /* always visible on touch devices */
}
}
.dismiss:hover {
background: #fff;
color: #c53030;
}
.web-more {
display: inline-block;
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 1rem;
padding: 0.7rem 1.1rem;
border: 1px solid #b7d6c2;

View File

@@ -7,10 +7,18 @@ import {
listComments,
listCookingLog,
listRatings,
renameRecipe
renameRecipe,
setRecipeHiddenFromRecent
} from '$lib/server/recipes/actions';
const RenameSchema = z.object({ title: z.string().min(1).max(200) });
const PatchSchema = z
.object({
title: z.string().min(1).max(200).optional(),
hidden_from_recent: z.boolean().optional()
})
.refine((v) => v.title !== undefined || v.hidden_from_recent !== undefined, {
message: 'Need title or hidden_from_recent'
});
function parseId(raw: string): number {
const id = Number(raw);
@@ -34,9 +42,15 @@ export const GET: RequestHandler = async ({ params }) => {
export const PATCH: RequestHandler = async ({ params, request }) => {
const id = parseId(params.id!);
const body = await request.json().catch(() => null);
const parsed = RenameSchema.safeParse(body);
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) error(400, { message: 'Invalid body' });
renameRecipe(getDb(), id, parsed.data.title);
const db = getDb();
if (parsed.data.title !== undefined) {
renameRecipe(db, id, parsed.data.title);
}
if (parsed.data.hidden_from_recent !== undefined) {
setRecipeHiddenFromRecent(db, id, parsed.data.hidden_from_recent);
}
return json({ ok: true });
};

View File

@@ -0,0 +1,14 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { listFavoritesForProfile } from '$lib/server/recipes/search-local';
export const GET: RequestHandler = async ({ url }) => {
const raw = url.searchParams.get('profile_id');
const profileId = raw === null ? NaN : Number(raw);
if (!Number.isInteger(profileId) || profileId <= 0) {
error(400, { message: 'profile_id required' });
}
const hits = listFavoritesForProfile(getDb(), profileId);
return json({ hits });
};

View File

@@ -1,6 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import {
Heart,
Utensils,
Printer,
Pencil,
Trash2,
ChefHat,
Check,
X
} from 'lucide-svelte';
import RecipeView from '$lib/components/RecipeView.svelte';
import StarRating from '$lib/components/StarRating.svelte';
import { profileStore } from '$lib/client/profile.svelte';
@@ -17,11 +27,17 @@
let onWishlist = $state(false);
let newComment = $state('');
let title = $state('');
let editingTitle = $state(false);
let titleDraft = $state('');
let titleInput: HTMLInputElement | null = $state(null);
$effect(() => {
ratings = [...data.ratings];
comments = [...data.comments];
cookingLog = [...data.cooking_log];
favoriteProfileIds = [...data.favorite_profile_ids];
title = data.recipe.title;
});
const myRating = $derived(
@@ -123,7 +139,7 @@
async function deleteRecipe() {
const ok = await confirmAction({
title: 'Rezept löschen?',
message: `„${data.recipe.title}" wird endgültig entfernt — mit Bewertungen, Kommentaren und Kochjournal-Einträgen.`,
message: `„${title}" wird endgültig entfernt — mit Bewertungen, Kommentaren und Kochjournal-Einträgen.`,
confirmLabel: 'Löschen',
destructive: true
});
@@ -132,15 +148,50 @@
goto('/');
}
async function renameRecipe() {
const newTitle = prompt('Neuer Titel:', data.recipe.title);
if (!newTitle || newTitle === data.recipe.title) return;
await fetch(`/api/recipes/${data.recipe.id}`, {
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;
}
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: newTitle })
body: JSON.stringify({ title: next })
});
location.reload();
if (!res.ok) {
const body = await res.json().catch(() => ({}));
await alertAction({
title: 'Umbenennen fehlgeschlagen',
message: body.message ?? `HTTP ${res.status}`
});
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() {
@@ -161,7 +212,6 @@
}
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();
@@ -196,6 +246,32 @@
</script>
<RecipeView recipe={data.recipe}>
{#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">
@@ -205,22 +281,37 @@
<span class="avg">{data.avg_stars.toFixed(1)} ({ratings.length})</span>
{/if}
</div>
<div class="btn-row">
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
<Heart size={18} strokeWidth={2} fill={isFav ? 'currentColor' : 'none'} />
<span>Favorit</span>
</button>
<button class="btn" class:wish={onWishlist} onclick={toggleWishlist}>
{#if onWishlist}
<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}>
🍳 Heute gekocht
<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:heart={isFav} onclick={toggleFavorite}>
{isFav ? '♥' : '♡'} Favorit
<button class="btn danger" onclick={deleteRecipe}>
<Trash2 size={18} strokeWidth={2} />
<span>Löschen</span>
</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>
</div>
</div>
{/snippet}
@@ -269,6 +360,62 @@
{/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;
@@ -298,6 +445,9 @@
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;
@@ -305,6 +455,7 @@
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
color: #1a1a1a;
}
.btn:hover {
background: #f4f8f5;

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Heart, Trash2, CookingPot } from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
@@ -82,7 +83,7 @@
<p class="muted">Lädt …</p>
{:else if entries.length === 0}
<section class="empty">
<p class="big">🥘</p>
<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>
@@ -94,7 +95,7 @@
{#if resolveImage(e.image_path)}
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
{:else}
<div class="placeholder">🥘</div>
<div class="placeholder"><CookingPot size={32} /></div>
{/if}
<div class="text">
<div class="title">{e.title}</div>
@@ -118,12 +119,14 @@
aria-label={e.liked_by_me ? 'Unlike' : 'Like'}
onclick={() => toggleLike(e)}
>
{e.liked_by_me ? '' : ''}
<Heart size={18} strokeWidth={2} fill={e.liked_by_me ? 'currentColor' : 'none'} />
{#if e.like_count > 0}
<span class="count">{e.like_count}</span>
{/if}
</button>
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>🗑</button>
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>
<Trash2 size={18} strokeWidth={2} />
</button>
</div>
</li>
{/each}