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

30
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"archiver": "^7.0.1",
"better-sqlite3": "^11.5.0",
"linkedom": "^0.18.5",
"lucide-svelte": "^1.0.1",
"yauzl": "^3.3.0",
"zod": "^3.23.8"
},
@@ -443,7 +444,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -454,7 +454,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -465,7 +464,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -475,14 +473,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -963,7 +959,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1097,7 +1092,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -1129,7 +1123,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yauzl": {
@@ -1280,7 +1273,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1445,7 +1437,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1471,7 +1462,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1750,7 +1740,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2041,7 +2030,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-serializer": {
@@ -2192,14 +2180,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -2619,7 +2605,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
@@ -2641,11 +2626,19 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/lucide-svelte": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -3434,7 +3427,6 @@
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -3486,7 +3478,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -3960,7 +3951,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/zip-stream": {

View File

@@ -33,6 +33,7 @@
"archiver": "^7.0.1",
"better-sqlite3": "^11.5.0",
"linkedom": "^0.18.5",
"lucide-svelte": "^1.0.1",
"yauzl": "^3.3.0",
"zod": "^3.23.8"
}

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">
{#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,17 +195,18 @@
{/if}
{/if}
</section>
{:else if recent.length > 0}
<section class="recent">
<h2>Zuletzt hinzugefügt</h2>
{:else}
{#if profileStore.active && favorites.length > 0}
<section class="listing">
<h2>Deine Favoriten</h2>
<ul class="cards">
{#each recent as r (r.id)}
<li>
{#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">🥘</div>
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
@@ -180,6 +220,38 @@
</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>
.hero {
@@ -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}