Skip to content

Commit fc00f43

Browse files
JiroMusikclaude
andcommitted
feat: dedicated Favorites page with Heart icon on Dashboard header
- New /favorites route with full recipe view, cook, calendar add, and Bring! integration - Heart icon next to Settings gear in Dashboard header - Favorites list with delete confirmation, recipe detail view via RecipeCard Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7ccba13 commit fc00f43

3 files changed

Lines changed: 223 additions & 4 deletions

File tree

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Calendar from './pages/Calendar';
1414
import FreeCook from './pages/FreeCook';
1515
import Settings from './pages/Settings';
1616
import ShoppingList from './pages/ShoppingList';
17+
import Favorites from './pages/Favorites';
1718

1819
export default function App() {
1920
return (
@@ -30,6 +31,7 @@ export default function App() {
3031
<Route path="/free-cook" element={<FreeCook />} />
3132
<Route path="/settings" element={<Settings />} />
3233
<Route path="/shopping-list" element={<ShoppingList />} />
34+
<Route path="/favorites" element={<Favorites />} />
3335
</Routes>
3436
</main>
3537
<Navigation />

src/pages/Dashboard.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect } from 'react';
2-
import { AlertTriangle, PackageOpen, Calendar as CalendarIcon, Loader2, Settings, Sparkles, ExternalLink } from 'lucide-react';
2+
import { AlertTriangle, PackageOpen, Calendar as CalendarIcon, Loader2, Settings, Sparkles, ExternalLink, Heart } from 'lucide-react';
33
import { useNavigate, Link } from 'react-router-dom';
44
import RecipeCard from '../components/RecipeCard';
55
import { InventoryItem, PlannedRecipe } from '../types.ts';
@@ -97,9 +97,14 @@ export default function Dashboard() {
9797
</span>
9898
</div>
9999
</div>
100-
<button onClick={() => navigate('/settings')} className="p-2 text-gray-500 hover:text-gray-900 bg-gray-100 rounded-full">
101-
<Settings size={20} />
102-
</button>
100+
<div className="flex items-center gap-2">
101+
<button onClick={() => navigate('/favorites')} className="p-2 text-gray-500 hover:text-rose-500 bg-gray-100 rounded-full">
102+
<Heart size={20} />
103+
</button>
104+
<button onClick={() => navigate('/settings')} className="p-2 text-gray-500 hover:text-gray-900 bg-gray-100 rounded-full">
105+
<Settings size={20} />
106+
</button>
107+
</div>
103108
</header>
104109

105110
{/* 3-column layout: Feed | Main Content | empty or future widget */}

src/pages/Favorites.tsx

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { useState, useEffect } from 'react';
2+
import { Heart, Trash2, ChefHat, Calendar, Loader2, ArrowLeft } from 'lucide-react';
3+
import { toast } from 'react-hot-toast';
4+
import { useNavigate } from 'react-router-dom';
5+
import { Recipe } from '../types.ts';
6+
import RecipeCard from '../components/RecipeCard';
7+
import { useTranslation } from 'react-i18next';
8+
9+
export default function Favorites() {
10+
const { t } = useTranslation();
11+
const navigate = useNavigate();
12+
const [favorites, setFavorites] = useState<any[]>([]);
13+
const [loading, setLoading] = useState(true);
14+
const [selectedRecipe, setSelectedRecipe] = useState<any>(null);
15+
const [cookedResult, setCookedResult] = useState<any>(null);
16+
17+
useEffect(() => { fetchFavorites(); }, []);
18+
19+
const fetchFavorites = async () => {
20+
try {
21+
const res = await fetch('/api/recipes/favorites');
22+
if (res.ok) setFavorites(await res.json());
23+
} catch (e) {
24+
toast.error(t('common.errorLoading'));
25+
} finally {
26+
setLoading(false);
27+
}
28+
};
29+
30+
const deleteFavorite = async (id: number) => {
31+
if (!confirm(t('recipes.confirmDeleteFavorite') || 'Favorit wirklich löschen?')) return;
32+
try {
33+
await fetch(`/api/recipes/favorites/${id}`, { method: 'DELETE' });
34+
setFavorites(favorites.filter(f => f.id !== id));
35+
toast.success(t('recipes.favoriteRemoved'));
36+
} catch (e) {
37+
toast.error(t('common.errorDeleting'));
38+
}
39+
};
40+
41+
const handleCook = async (recipe: any) => {
42+
try {
43+
const res = await fetch('/api/recipes/cook', {
44+
method: 'POST',
45+
headers: { 'Content-Type': 'application/json' },
46+
body: JSON.stringify({ usedIngredients: recipe.ingredients })
47+
});
48+
if (!res.ok) throw new Error('Failed');
49+
const data = await res.json();
50+
setCookedResult(data);
51+
toast.success(t('recipes.enjoyMealInventoryUpdated'));
52+
} catch (e) {
53+
toast.error(t('recipes.errorUpdatingInventory'));
54+
}
55+
};
56+
57+
const addToCalendar = async (recipe: any) => {
58+
const date = new Date().toISOString().split('T')[0];
59+
try {
60+
const res = await fetch('/api/calendar', {
61+
method: 'POST',
62+
headers: { 'Content-Type': 'application/json' },
63+
body: JSON.stringify({
64+
date,
65+
title: recipe.title,
66+
description: recipe.description,
67+
ingredients: recipe.ingredients,
68+
instructions: recipe.instructions,
69+
portions: recipe.portions || 2
70+
})
71+
});
72+
if (res.ok) {
73+
toast.success(t('recipes.addedToCalendar'));
74+
}
75+
} catch (e) {
76+
toast.error(t('common.errorSaving'));
77+
}
78+
};
79+
80+
const addToBring = async (items: string[]) => {
81+
try {
82+
const res = await fetch('/api/bring/add', {
83+
method: 'POST',
84+
headers: { 'Content-Type': 'application/json' },
85+
body: JSON.stringify({ items })
86+
});
87+
if (!res.ok) throw new Error('Failed');
88+
toast.success(t('recipes.addedToBring'));
89+
} catch (e) {
90+
toast.error(t('recipes.errorAddingToBring'));
91+
}
92+
};
93+
94+
if (loading) {
95+
return (
96+
<div className="flex items-center justify-center h-64">
97+
<Loader2 className="animate-spin text-emerald-500" size={32} />
98+
</div>
99+
);
100+
}
101+
102+
// Show cooked result
103+
if (cookedResult) {
104+
return (
105+
<div className="p-4 space-y-4">
106+
<div className="bg-emerald-50 border border-emerald-200 rounded-2xl p-6 text-center">
107+
<ChefHat size={48} className="mx-auto text-emerald-600 mb-3" />
108+
<h2 className="text-xl font-bold text-emerald-800 mb-2">{t('recipes.mealCooked')}</h2>
109+
<p className="text-sm text-emerald-600">{t('recipes.inventoryUpdated')}</p>
110+
</div>
111+
{cookedResult.missing?.length > 0 && (
112+
<div className="bg-orange-50 border border-orange-200 rounded-2xl p-4">
113+
<h3 className="font-bold text-orange-800 mb-2">{t('recipes.missingIngredients')}</h3>
114+
<ul className="text-sm text-orange-700 space-y-1">
115+
{cookedResult.missing.map((item: string, i: number) => (
116+
<li key={i}>- {item}</li>
117+
))}
118+
</ul>
119+
<button
120+
onClick={() => addToBring(cookedResult.missing)}
121+
className="mt-3 w-full py-2 bg-orange-100 text-orange-700 rounded-xl text-sm font-bold hover:bg-orange-200"
122+
>
123+
{t('recipes.addMissingToBring')}
124+
</button>
125+
</div>
126+
)}
127+
<button
128+
onClick={() => setCookedResult(null)}
129+
className="w-full py-3 bg-gray-100 text-gray-600 rounded-xl font-bold hover:bg-gray-200"
130+
>
131+
{t('common.back')}
132+
</button>
133+
</div>
134+
);
135+
}
136+
137+
// Show selected recipe detail
138+
if (selectedRecipe) {
139+
const parsed: Recipe = {
140+
...selectedRecipe,
141+
ingredients: typeof selectedRecipe.ingredients === 'string' ? JSON.parse(selectedRecipe.ingredients) : selectedRecipe.ingredients,
142+
instructions: typeof selectedRecipe.instructions === 'string' ? JSON.parse(selectedRecipe.instructions) : selectedRecipe.instructions
143+
};
144+
return (
145+
<div className="p-4">
146+
<RecipeCard
147+
recipe={parsed}
148+
onCook={() => handleCook(parsed)}
149+
onBring={() => addToBring(parsed.ingredients.filter((i: any) => !i.in_inventory).map((i: any) => i.name))}
150+
onBack={() => setSelectedRecipe(null)}
151+
/>
152+
</div>
153+
);
154+
}
155+
156+
return (
157+
<div className="p-4 pb-24 space-y-4">
158+
<div className="flex items-center justify-between">
159+
<div className="flex items-center gap-3">
160+
<button onClick={() => navigate('/dashboard')} className="p-2 text-gray-400 hover:text-gray-600 rounded-xl">
161+
<ArrowLeft size={20} />
162+
</button>
163+
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
164+
<Heart size={24} className="text-rose-500" />
165+
{t('recipes.favorites')}
166+
</h1>
167+
<span className="bg-rose-100 text-rose-600 text-xs px-2 py-0.5 rounded-full font-bold">{favorites.length}</span>
168+
</div>
169+
</div>
170+
171+
{favorites.length === 0 ? (
172+
<div className="text-center py-16 text-gray-400">
173+
<Heart size={48} className="mx-auto mb-4 opacity-30" />
174+
<p className="font-medium">{t('recipes.noFavorites') || 'Noch keine Favoriten gespeichert'}</p>
175+
<p className="text-sm mt-1">{t('recipes.noFavoritesHint') || 'Speichere Rezepte als Favorit um sie hier zu sehen'}</p>
176+
</div>
177+
) : (
178+
<div className="space-y-3">
179+
{favorites.map((fav: any) => (
180+
<div key={fav.id} className="bg-white rounded-2xl border border-gray-100 p-4 shadow-sm">
181+
<div className="flex items-start justify-between mb-2">
182+
<div className="flex-1 cursor-pointer" onClick={() => setSelectedRecipe(fav)}>
183+
<h3 className="font-bold text-gray-900">{fav.title}</h3>
184+
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{fav.description}</p>
185+
</div>
186+
<button onClick={() => deleteFavorite(fav.id)} className="text-gray-300 hover:text-red-500 p-1 ml-2 shrink-0">
187+
<Trash2 size={16} />
188+
</button>
189+
</div>
190+
<div className="flex space-x-2 mt-3">
191+
<button
192+
onClick={() => setSelectedRecipe(fav)}
193+
className="flex-1 py-2.5 bg-emerald-50 text-emerald-600 rounded-xl text-sm font-bold hover:bg-emerald-100 flex items-center justify-center gap-1.5"
194+
>
195+
<ChefHat size={14} />
196+
{t('recipes.showFavorite')}
197+
</button>
198+
<button
199+
onClick={() => addToCalendar(fav)}
200+
className="flex-1 py-2.5 bg-gray-100 text-gray-600 rounded-xl text-sm font-bold hover:bg-gray-200 flex items-center justify-center gap-1.5"
201+
>
202+
<Calendar size={14} />
203+
{t('recipes.addToCalendar')}
204+
</button>
205+
</div>
206+
</div>
207+
))}
208+
</div>
209+
)}
210+
</div>
211+
);
212+
}

0 commit comments

Comments
 (0)