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
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user