Implement user favorites feature for recipes

- Add UserFavorites MongoDB model with ObjectId references
- Create authenticated API endpoints for favorites management
- Add Heart icon and FavoriteButton components with toggle functionality
- Display favorite button below recipe tags for logged-in users
- Add Favoriten navigation link (visible only when authenticated)
- Create favorites page with grid layout and search functionality
- Store favorites by MongoDB ObjectId for data integrity

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-01 20:18:57 +02:00
parent 1d78b5439e
commit fe46ab194e
11 changed files with 460 additions and 1 deletions

View File

@@ -0,0 +1,33 @@
<script>
</script>
<style>
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(-30deg)
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1,1);
}
}
</style>
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512" {...$$restProps}><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="m47.6 300.4 180.7 168.7c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z"/></svg>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import Heart from '$lib/assets/icons/Heart.svelte';
export let recipeId: string;
export let isFavorite: boolean = false;
export let isLoggedIn: boolean = false;
let isLoading = false;
async function toggleFavorite() {
if (!isLoggedIn || isLoading) return;
isLoading = true;
try {
const method = isFavorite ? 'DELETE' : 'POST';
const response = await fetch('/api/rezepte/favorites', {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipeId }),
});
if (response.ok) {
isFavorite = !isFavorite;
}
} catch (error) {
console.error('Failed to toggle favorite:', error);
} finally {
isLoading = false;
}
}
</script>
<style>
.favorite-button {
all: unset;
color: var(--nord0);
font-size: 1.1rem;
background-color: var(--nord5);
border-radius: 10000px;
padding: 0.5em 1em;
transition: 100ms;
box-shadow: 0em 0em 0.5em 0.05em rgba(0,0,0,0.3);
display: flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
}
.favorite-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.favorite-button.favorited {
background-color: var(--nord11);
color: white;
}
.favorite-button:not(.favorited):hover,
.favorite-button:not(.favorited):focus-visible {
transform: scale(1.1);
background-color: var(--nord11);
color: white;
box-shadow: 0.1em 0.1em 0.5em 0.1em rgba(0,0,0,0.3);
}
.favorite-button.favorited:hover,
.favorite-button.favorited:focus-visible {
transform: scale(1.1);
background-color: var(--nord5);
color: var(--nord0);
box-shadow: 0.1em 0.1em 0.5em 0.1em rgba(0,0,0,0.3);
}
@media (prefers-color-scheme: dark) {
.favorite-button {
background-color: var(--nord0);
color: white;
}
.favorite-button.favorited:hover,
.favorite-button.favorited:focus-visible {
background-color: var(--nord6);
color: var(--nord0);
}
}
</style>
{#if isLoggedIn}
<button
class="favorite-button"
class:favorited={isFavorite}
disabled={isLoading}
on:click={toggleFavorite}
>
<Heart />
{isFavorite ? 'Favorit entfernen' : 'Als Favorit speichern'}
</button>
{/if}

View File

@@ -0,0 +1,11 @@
import mongoose from 'mongoose';
const UserFavoritesSchema = new mongoose.Schema(
{
username: { type: String, required: true, unique: true },
favorites: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Recipe' }] // Recipe MongoDB ObjectIds
},
{ timestamps: true }
);
export const UserFavorites = mongoose.model("UserFavorites", UserFavoritesSchema);

View File

@@ -0,0 +1,112 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { UserFavorites } from '../../../../models/UserFavorites';
import { Recipe } from '../../../../models/Recipe';
import { dbConnect, dbDisconnect } from '../../../../utils/db';
import { error } from '@sveltejs/kit';
import mongoose from 'mongoose';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
await dbConnect();
try {
const userFavorites = await UserFavorites.findOne({
username: session.user.nickname
}).lean();
await dbDisconnect();
return json({
favorites: userFavorites?.favorites || []
});
} catch (e) {
await dbDisconnect();
throw error(500, 'Failed to fetch favorites');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const { recipeId } = await request.json();
if (!recipeId) {
throw error(400, 'Recipe ID required');
}
await dbConnect();
try {
// Validate that the recipe exists and get its ObjectId
const recipe = await Recipe.findOne({ short_name: recipeId });
if (!recipe) {
await dbDisconnect();
throw error(404, 'Recipe not found');
}
await UserFavorites.findOneAndUpdate(
{ username: session.user.nickname },
{ $addToSet: { favorites: recipe._id } },
{ upsert: true, new: true }
);
await dbDisconnect();
return json({ success: true });
} catch (e) {
await dbDisconnect();
if (e instanceof Error && e.message.includes('404')) {
throw e;
}
throw error(500, 'Failed to add favorite');
}
};
export const DELETE: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const { recipeId } = await request.json();
if (!recipeId) {
throw error(400, 'Recipe ID required');
}
await dbConnect();
try {
// Find the recipe's ObjectId
const recipe = await Recipe.findOne({ short_name: recipeId });
if (!recipe) {
await dbDisconnect();
throw error(404, 'Recipe not found');
}
await UserFavorites.findOneAndUpdate(
{ username: session.user.nickname },
{ $pull: { favorites: recipe._id } }
);
await dbDisconnect();
return json({ success: true });
} catch (e) {
await dbDisconnect();
if (e instanceof Error && e.message.includes('404')) {
throw e;
}
throw error(500, 'Failed to remove favorite');
}
};

View File

@@ -0,0 +1,42 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { UserFavorites } from '../../../../../../models/UserFavorites';
import { Recipe } from '../../../../../../models/Recipe';
import { dbConnect, dbDisconnect } from '../../../../../../utils/db';
import { error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals, params }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
return json({ isFavorite: false });
}
await dbConnect();
try {
// Find the recipe by short_name to get its ObjectId
const recipe = await Recipe.findOne({ short_name: params.shortName });
if (!recipe) {
await dbDisconnect();
throw error(404, 'Recipe not found');
}
// Check if this recipe is in the user's favorites
const userFavorites = await UserFavorites.findOne({
username: session.user.nickname,
favorites: recipe._id
}).lean();
await dbDisconnect();
return json({
isFavorite: !!userFavorites
});
} catch (e) {
await dbDisconnect();
if (e instanceof Error && e.message.includes('404')) {
throw e;
}
throw error(500, 'Failed to check favorite status');
}
};

View File

@@ -0,0 +1,40 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { UserFavorites } from '../../../../../models/UserFavorites';
import { Recipe } from '../../../../../models/Recipe';
import { dbConnect, dbDisconnect } from '../../../../../utils/db';
import type { RecipeModelType } from '../../../../../types/types';
import { error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
await dbConnect();
try {
const userFavorites = await UserFavorites.findOne({
username: session.user.nickname
}).lean();
if (!userFavorites?.favorites?.length) {
await dbDisconnect();
return json([]);
}
let recipes = await Recipe.find({
_id: { $in: userFavorites.favorites }
}).lean() as RecipeModelType[];
await dbDisconnect();
recipes = JSON.parse(JSON.stringify(recipes));
return json(recipes);
} catch (e) {
await dbDisconnect();
throw error(500, 'Failed to fetch favorite recipes');
}
};

View File

@@ -11,6 +11,9 @@ if(data.session){
<Header>
<ul class=site_header slot=links>
<li><a href="/rezepte">Alle Rezepte</a></li>
{#if user}
<li><a href="/rezepte/favorites">Favoriten</a></li>
{/if}
<li><a href="/rezepte/season">In Saison</a></li>
<li><a href="/rezepte/category">Kategorie</a></li>
<li><a href="/rezepte/icon">Icon</a></li>

View File

@@ -12,6 +12,7 @@
import {season} from '$lib/js/season_store';
import RecipeNote from '$lib/components/RecipeNote.svelte';
import {stripHtmlTags} from '$lib/js/stripHtmlTags';
import FavoriteButton from '$lib/components/FavoriteButton.svelte';
export let data: PageData;
@@ -308,6 +309,15 @@ h4{
<a class=tag href="/rezepte/tag/{tag}">{tag}</a>
{/each}
</div>
<div class="tags center">
<FavoriteButton
recipeId={data.short_name}
isFavorite={data.isFavorite || false}
isLoggedIn={!!data.session?.user}
/>
</div>
{#if data.note}
<RecipeNote note={data.note}></RecipeNote>
{/if}

View File

@@ -6,5 +6,21 @@ export async function load({ fetch, params}) {
if(!res.ok){
throw error(res.status, item.message)
}
return item;
// Check if this recipe is favorited by the user
let isFavorite = false;
try {
const favRes = await fetch(`/api/rezepte/favorites/check/${params.name}`);
if (favRes.ok) {
const favData = await favRes.json();
isFavorite = favData.isFavorite;
}
} catch (e) {
// Silently fail if not authenticated or other error
}
return {
...item,
isFavorite
};
}

View File

@@ -0,0 +1,32 @@
import type { PageServerLoad } from "./$types";
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw redirect(302, '/rezepte');
}
try {
const res = await fetch('/api/rezepte/favorites/recipes');
if (!res.ok) {
return {
favorites: [],
error: 'Failed to load favorites'
};
}
const favorites = await res.json();
return {
favorites,
session
};
} catch (e) {
return {
favorites: [],
error: 'Failed to load favorites'
};
}
};

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { PageData } from './$types';
import '$lib/css/nordtheme.css';
import Recipes from '$lib/components/Recipes.svelte';
import Card from '$lib/components/Card.svelte';
import Search from '$lib/components/Search.svelte';
export let data: PageData;
export let current_month = new Date().getMonth() + 1;
</script>
<style>
h1{
text-align: center;
margin-bottom: 0;
font-size: 4rem;
}
.subheading{
text-align: center;
margin-top: 0;
font-size: 1.5rem;
}
.empty-state{
text-align: center;
margin-top: 3rem;
color: var(--nord3);
}
</style>
<svelte:head>
<title>Meine Favoriten - Bocken Rezepte</title>
<meta name="description" content="Meine favorisierten Rezepte aus der Bockenschen Küche." />
</svelte:head>
<h1>Favoriten</h1>
<p class=subheading>
{#if data.favorites.length > 0}
{data.favorites.length} favorisierte Rezepte
{:else}
Noch keine Favoriten gespeichert
{/if}
</p>
<Search></Search>
{#if data.error}
<p class="empty-state">Fehler beim Laden der Favoriten: {data.error}</p>
{:else if data.favorites.length > 0}
<Recipes>
{#each data.favorites as recipe}
<Card {recipe} {current_month}></Card>
{/each}
</Recipes>
{:else}
<div class="empty-state">
<p>Du hast noch keine Rezepte als Favoriten gespeichert.</p>
<p>Besuche ein <a href="/rezepte">Rezept</a> und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.</p>
</div>
{/if}