12 Commits

Author SHA1 Message Date
2dc871c50f Implement progressive enhancement for universal search with context-aware filtering
Some checks failed
CI / update (push) Failing after 5s
Add comprehensive search solution that works across all recipe pages with proper fallbacks. Features include universal API endpoint, context-aware filtering (category/tag/icon/season/favorites), and progressive enhancement with form submission fallback for no-JS users.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 14:53:59 +02:00
88f9531a6f Implement progressive enhancement for recipe multiplier with form fallbacks
Add form-based multiplier controls that work without JavaScript while providing enhanced UX when JS is available. Fixed fraction display and NaN flash issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 14:34:43 +02:00
aeec3b4865 Add yeast type swapper with intelligent unit conversion
All checks were successful
CI / update (push) Successful in 16s
- Implements swap button for Frischhefe/Trockenhefe ingredients
- Supports 3:1 fresh-to-dry yeast conversion ratio
- Handles special Prise unit conversions (1 Prise = 1 Prise or 1g)
- Accounts for recipe multipliers (0.5x, 1x, 1.5x, 2x, 3x, custom)
- Automatic unit switching between grams and Prise for practical cooking

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 12:57:28 +02:00
55a4e6a262 Revert "Implement secure client-side favorites loading to fix nginx 502 issues"
All checks were successful
CI / update (push) Successful in 16s
This reverts commit 48b94e3aef.
2025-09-04 12:26:27 +02:00
48b94e3aef Implement secure client-side favorites loading to fix nginx 502 issues
All checks were successful
CI / update (push) Successful in 16s
- Create client-side favorites store with secure authentication
- Remove server-side favorites fetching that caused nginx routing issues
- Update FavoriteButton to properly handle short_name/ObjectId relationship
- Use existing /api/rezepte/favorites/check endpoint for status checking
- Maintain security by requiring authentication for all favorites operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 12:20:08 +02:00
15a72e73ca Revert "Fix server-side favorites fetching for production nginx setup"
This reverts commit bda30eb42d.
2025-09-04 12:13:08 +02:00
bda30eb42d Fix server-side favorites fetching for production nginx setup
All checks were successful
CI / update (push) Successful in 17s
- Use absolute URLs for internal server-side fetch calls to bypass nginx routing issues
- Add debugging logs to favorites loading process
- Temporarily disable CSRF protection for local testing
- Clean up page server load function

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 12:09:28 +02:00
9f53e331a7 update allowed hosts 2025-09-04 11:52:28 +02:00
b534cd1ddc Add favorite indicators to recipe cards and improve favorites UI
All checks were successful
CI / update (push) Successful in 17s
- Add heart emoji indicators to recipe cards (top-left positioning)
- Show favorites across all recipe list pages (season, category, icon, tag)
- Create favorites utility functions for server-side data merging
- Convert client-side load files to server-side for session access
- Redesign favorite button with emoji hearts (🖤/❤️) and bottom-right positioning
- Fix randomizer array mutation issue causing card display glitches
- Implement consistent favorite indicators with drop shadows for visibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 20:45:28 +02:00
6a64a7ddd6 fix comma typo 2025-09-01 20:19:24 +02:00
fe46ab194e 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>
2025-09-01 20:18:57 +02:00
1d78b5439e trust host for reverse proxy in prod, general cleanup
Some checks failed
CI / update (push) Failing after 1m27s
2025-08-31 22:42:52 +02:00
45 changed files with 1170 additions and 6934 deletions

View File

@@ -1,88 +0,0 @@
# Development Authentication Bypass
This document explains how to safely disable authentication during development.
## 🔐 Security Overview
The authentication bypass is designed with multiple layers of security:
1. **Development Mode Only**: Only works when `vite dev` is running
2. **Explicit Opt-in**: Requires setting `DEV_DISABLE_AUTH=true`
3. **Production Protection**: Build fails if enabled in production mode
4. **Environment Isolation**: Uses local environment files (gitignored)
## 🚀 Usage
### 1. Create Local Environment File
Create `.env.local` (this file is gitignored):
```bash
# Copy from example
cp .env.local.example .env.local
```
### 2. Enable Development Bypass
Edit `.env.local` and set:
```env
DEV_DISABLE_AUTH=true
```
### 3. Start Development Server
```bash
pnpm run dev
```
You'll see a warning in the console:
```
🚨 AUTH DISABLED: Development mode with DEV_DISABLE_AUTH=true
```
### 4. Access Protected Routes
Protected routes (`/rezepte/edit/*`, `/rezepte/add`) will now be accessible without authentication.
## 🛡️ Security Guarantees
### Production Safety
- **Build-time Check**: Production builds fail if `DEV_DISABLE_AUTH=true`
- **Runtime Check**: Double verification using `dev` flag from `$app/environment`
- **No Environment Leakage**: Uses `process.env` (server-only) not client environment
### Development Isolation
- **Gitignored Files**: `.env.local` is never committed
- **Example Template**: `.env.local.example` shows safe defaults
- **Clear Warnings**: Console warns when auth is disabled
## 🧪 Testing the Security
### Test Production Build Safety
```bash
# This should FAIL with security error
DEV_DISABLE_AUTH=true pnpm run build
```
### Test Normal Production Build
```bash
# This should succeed
pnpm run build
```
## 🔄 Re-enabling Authentication
Set in `.env.local`:
```env
DEV_DISABLE_AUTH=false
```
Or simply delete/rename the `.env.local` file.
## ⚠️ Important Notes
- **Never** commit `.env.local` to git
- **Never** set `DEV_DISABLE_AUTH=true` in production environment
- The bypass provides a mock session with `rezepte_users` group access
- All other authentication flows (signin pages, etc.) remain unchanged

3318
package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sk-recipes-test",
"version": "0.0.1",
"name": "homepage",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -25,5 +25,6 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
return session;
},
}
},
trustHost: true // needed for reverse proxy setups
})

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

@@ -7,6 +7,8 @@ import "$lib/css/nordtheme.css";
import "$lib/css/shake.css";
import "$lib/css/icon.css";
export let do_margin_right = false;
export let isFavorite = false;
export let showFavoriteIndicator = false;
// to manually override lazy loading for top cards
export let loading_strat : "lazy" | "eager" | undefined;
if(loading_strat === undefined){
@@ -192,6 +194,14 @@ const img_name=recipe.short_name + ".webp?v=" + recipe.dateModified
scale: 0.9 0.9;
}
.favorite-indicator{
position: absolute;
font-size: 2rem;
top: -0.5em;
left: -0.5em;
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8));
}
.icon:hover,
.icon:focus-visible
{
@@ -224,6 +234,9 @@ const img_name=recipe.short_name + ".webp?v=" + recipe.dateModified
<img class:blur={!isloaded} id=image class="backdrop_blur" src={'https://bocken.org/static/rezepte/thumb/' + recipe.short_name + '.webp'} loading={loading_strat} alt="{recipe.alt}" on:load={() => isloaded=true}/>
</div>
</div>
{#if showFavoriteIndicator && isFavorite}
<div class="favorite-indicator">❤️</div>
{/if}
{#if icon_override || recipe.season.includes(current_month)}
<button class=icon on:click={(e) => {e.stopPropagation(); window.location.href = `/rezepte/icon/${recipe.icon}`}}>{recipe.icon}</button>
{/if}

View File

@@ -0,0 +1,66 @@
<script lang="ts">
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;
font-size: 1.5rem;
cursor: pointer;
transition: 100ms;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
position: absolute;
bottom: 0.5em;
right: 0.5em;
}
.favorite-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.favorite-button:hover,
.favorite-button:focus-visible {
transform: scale(1.2);
}
</style>
{#if isLoggedIn}
<button
class="favorite-button"
disabled={isLoading}
on:click={toggleFavorite}
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit speichern'}
>
{isFavorite ? '❤️' : '🖤'}
</button>
{/if}

View File

@@ -1,18 +1,50 @@
<script>
// get ingredients_store from IngredientsPage.svelte
import ingredients_store from './IngredientsPage.svelte';
let ingredients = [];
ingredients_store.subscribe(value => {
ingredients = value;
});
function toggleHefe(){
if(data.ingredients[i].list[j].name == "Frischhefe"){
data.ingredients[i].list[j].name = "Trockenhefe"
data.ingredients[i].list[j].amount = item.amount / 3
import { createEventDispatcher } from 'svelte';
export let item;
export let multiplier = 1;
const dispatch = createEventDispatcher();
function toggleHefe() {
let newName, newAmount, newUnit = item.unit;
if (item.name === "Frischhefe") {
// Convert fresh yeast to dry yeast
const originalAmount = parseFloat(item.amount) / multiplier;
newName = "Trockenhefe";
if (item.unit === "Prise") {
// "1 Prise Frischhefe" → "1 Prise Trockenhefe"
newAmount = item.amount;
newUnit = "Prise";
} else if (item.unit === "g" && originalAmount === 1) {
// "1 g Frischhefe" → "1 Prise Trockenhefe"
newAmount = "1";
newUnit = "Prise";
} else {
// Normal conversion: "9 g Frischhefe" → "3 g Trockenhefe" (divide by 3)
newAmount = (originalAmount / 3).toString();
newUnit = "g";
}
} else if (item.name === "Trockenhefe") {
// Convert dry yeast to fresh yeast
const originalAmount = parseFloat(item.amount) / multiplier;
newName = "Frischhefe";
if (item.unit === "Prise") {
// "1 Prise Trockenhefe" → "1 g Frischhefe"
newAmount = "1";
newUnit = "g";
} else {
// Normal conversion: "1 g Trockenhefe" → "3 g Frischhefe" (multiply by 3)
newAmount = (originalAmount * 3).toString();
newUnit = "g";
}
}
else{
item.name = "Frischhefe"
item.amount = item.amount * 3
if (newName && newAmount) {
dispatch('toggle', { name: newName, amount: newAmount, unit: newUnit });
}
}
</script>
@@ -28,7 +60,6 @@
fill: var(--blue);
}
</style>
<button onclick={toggleHefe}>
{item.amount} {item.unit} {item.name}
<button on:click={toggleHefe} title="Zwischen Frischhefe und Trockenhefe wechseln">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M105.1 202.6c7.7-21.8 20.2-42.3 37.8-59.8c62.5-62.5 163.8-62.5 226.3 0L386.3 160 352 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l111.5 0c0 0 0 0 0 0l.4 0c17.7 0 32-14.3 32-32l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 35.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0C73.2 122 55.6 150.7 44.8 181.4c-5.9 16.7 2.9 34.9 19.5 40.8s34.9-2.9 40.8-19.5zM39 289.3c-5 1.5-9.8 4.2-13.7 8.2c-4 4-6.7 8.8-8.1 14c-.3 1.2-.6 2.5-.8 3.8c-.3 1.7-.4 3.4-.4 5.1L16 432c0 17.7 14.3 32 32 32s32-14.3 32-32l0-35.1 17.6 17.5c0 0 0 0 0 0c87.5 87.4 229.3 87.4 316.7 0c24.4-24.4 42.1-53.1 52.9-83.8c5.9-16.7-2.9-34.9-19.5-40.8s-34.9 2.9-40.8 19.5c-7.7 21.8-20.2 42.3-37.8 59.8c-62.5 62.5-163.8 62.5-226.3 0l-.1-.1L125.6 352l34.4 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L48.4 288c-1.6 0-3.2 .1-4.8 .3s-3.1 .5-4.6 1z"/></svg>
</button>

View File

@@ -72,7 +72,7 @@
{/each}
</div>
<section>
<Search></Search>
<Search icon={active_icon}></Search>
</section>
<section>
<slot name=recipes></slot>

View File

@@ -1,20 +1,70 @@
<script>
import { onMount } from 'svelte';
import { onNavigate } from "$app/navigation";
import { browser } from '$app/environment';
import HefeSwapper from './HefeSwapper.svelte';
export let data
let multiplier;
let custom_mul = "…"
let multiplier = data.multiplier || 1;
// Progressive enhancement - use JS if available
onMount(() => {
// Apply multiplier from URL
const urlParams = new URLSearchParams(window.location.search);
multiplier = urlParams.get('multiplier') || 1;
if (browser) {
const urlParams = new URLSearchParams(window.location.search);
multiplier = parseFloat(urlParams.get('multiplier')) || 1;
}
})
onNavigate(() => {
const urlParams = new URLSearchParams(window.location.search);
multiplier = urlParams.get('multiplier') || 1;
if (browser) {
const urlParams = new URLSearchParams(window.location.search);
multiplier = parseFloat(urlParams.get('multiplier')) || 1;
}
})
function handleMultiplierClick(event, value) {
if (browser) {
event.preventDefault();
multiplier = value;
// Update URL without reloading
const url = new URL(window.location);
if (value === 1) {
url.searchParams.delete('multiplier');
} else {
url.searchParams.set('multiplier', value);
}
window.history.replaceState({}, '', url);
}
// If no JS, form will submit normally
}
function handleCustomInput(event) {
if (browser) {
const value = parseFloat(event.target.value);
if (!isNaN(value) && value > 0) {
multiplier = value;
// Update URL without reloading
const url = new URL(window.location);
if (value === 1) {
url.searchParams.delete('multiplier');
} else {
url.searchParams.set('multiplier', value);
}
window.history.replaceState({}, '', url);
}
}
}
function handleCustomSubmit(event) {
if (browser) {
event.preventDefault();
// Value already updated by handleCustomInput
}
// If no JS, form will submit normally
}
function convertFloatsToFractions(inputString) {
// Split the input string into individual words
const words = inputString.split(' ');
@@ -103,21 +153,14 @@ function adjust_amount(string, multiplier){
return temp
}
function apply_if_not_NaN(custom){
const multipliers = [0.5, 1, 1.5, 2, 3]
if((!isNaN(custom * 1)) && custom != ""){
if(multipliers.includes(parseFloat(custom))){
multiplier = custom
custom_mul = "…"
}
else{
custom_mul = convertFloatsToFractions(custom)
multiplier = custom
}
}
else{
custom_mul = "…"
function handleHefeToggle(event, item) {
item.name = event.detail.name;
item.amount = event.detail.amount;
if (event.detail.unit) {
item.unit = event.detail.unit;
}
data = data; // Trigger reactivity
}
</script>
<style>
@@ -192,9 +235,67 @@ span
padding: 0;
margin: 0;
}
.multipliers button:last-child{
.custom-multiplier {
display: flex;
align-items: center;
min-width: 2em;
font-size: 1.1rem;
border-radius: 0.3rem;
border: none;
cursor: pointer;
transition: 100ms;
color: var(--nord0);
background-color: var(--nord5);
box-shadow: 0px 0px 0.4em 0.05em rgba(0,0,0, 0.2);
}
.custom-input {
width: 3em;
padding: 0;
margin: 0;
border: none;
background: transparent;
text-align: center;
color: inherit;
font-size: inherit;
outline: none;
box-shadow: none;
}
/* Remove number input arrows */
.custom-input::-webkit-outer-spin-button,
.custom-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.custom-input[type=number] {
-moz-appearance: textfield;
}
.custom-button {
padding: 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
font-size: inherit;
cursor: pointer;
box-shadow: none;
}
@media (prefers-color-scheme: dark){
.custom-multiplier {
color: var(--tag-font);
background-color: var(--nord6-dark);
}
}
.custom-multiplier:hover,
.custom-multiplier:focus-within {
scale: 1.2;
background-color: var(--orange);
box-shadow: 0px 0px 0.5em 0.1em rgba(0,0,0, 0.3);
}
</style>
{#if data.ingredients}
@@ -206,23 +307,39 @@ span
<h3>Menge anpassen:</h3>
<div class=multipliers>
<button class:selected={multiplier==0.5} on:click={() => multiplier=0.5}><sup>1</sup>&frasl;<sub>2</sub>x</button>
<button class:selected={multiplier==1} on:click={() => {multiplier=1; custom_mul="…"}}>1x</button>
<button class:selected={multiplier==1.5} on:click={() => {multiplier=1.5; custom_mul="…"}}><sup>3</sup>&frasl;<sub>2</sub>x</button>
<button class:selected={multiplier==2} on:click="{() => {multiplier=2; custom_mul="…"}}">2x</button>
<button class:selected={multiplier==3} on:click="{() => {multiplier=3; custom_mul="…"}}">3x</button>
<button class:selected={multiplier==custom_mul} on:click={(e) => { const el = e.composedPath()[0].children[0]; if(el){ el.focus()}}}>
<span class:selected={multiplier==custom_mul}
on:focus={() => { custom_mul="" }
}
on:blur="{() => { apply_if_not_NaN(custom_mul);
if(custom_mul == "")
{custom_mul = "…"}
}}"
bind:innerHTML={custom_mul}
contenteditable > </span>
x
</button>
<form method="get" style="display: inline;">
<input type="hidden" name="multiplier" value="0.5" />
<button type="submit" class:selected={multiplier==0.5} on:click={(e) => handleMultiplierClick(e, 0.5)}>{@html "<sup>1</sup>/<sub>2</sub>x"}</button>
</form>
<form method="get" style="display: inline;">
<input type="hidden" name="multiplier" value="1" />
<button type="submit" class:selected={multiplier==1} on:click={(e) => handleMultiplierClick(e, 1)}>1x</button>
</form>
<form method="get" style="display: inline;">
<input type="hidden" name="multiplier" value="1.5" />
<button type="submit" class:selected={multiplier==1.5} on:click={(e) => handleMultiplierClick(e, 1.5)}>{@html "<sup>3</sup>/<sub>2</sub>x"}</button>
</form>
<form method="get" style="display: inline;">
<input type="hidden" name="multiplier" value="2" />
<button type="submit" class:selected={multiplier==2} on:click={(e) => handleMultiplierClick(e, 2)}>2x</button>
</form>
<form method="get" style="display: inline;">
<input type="hidden" name="multiplier" value="3" />
<button type="submit" class:selected={multiplier==3} on:click={(e) => handleMultiplierClick(e, 3)}>3x</button>
</form>
<form method="get" style="display: inline;" class="custom-multiplier" on:submit={handleCustomSubmit}>
<input
type="text"
name="multiplier"
pattern="[0-9]+(\.[0-9]*)?"
title="Enter a positive number (e.g., 2.5, 0.75, 3.14)"
placeholder="…"
class="custom-input"
value={multiplier != 0.5 && multiplier != 1 && multiplier != 1.5 && multiplier != 2 && multiplier != 3 ? multiplier : ''}
on:input={handleCustomInput}
/>
<button type="submit" class="custom-button">x</button>
</form>
</div>
<h2>Zutaten</h2>
@@ -232,7 +349,13 @@ span
{/if}
<div class=ingredients_grid>
{#each list.list as item}
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div><div class=name>{@html item.name.replace("{{multiplier}}", multiplier * item.amount)}</div>
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div>
<div class=name>
{@html item.name.replace("{{multiplier}}", multiplier * item.amount)}
{#if item.name === "Frischhefe" || item.name === "Trockenhefe"}
<HefeSwapper {item} {multiplier} on:toggle={(event) => handleHefeToggle(event, item)} />
{/if}
</div>
{/each}
</div>
{/each}

View File

@@ -1,81 +1,128 @@
<script>
import {onMount} from "svelte";
import { browser } from '$app/environment';
import "$lib/css/nordtheme.css";
onMount(() => {
const recipes = document.querySelectorAll(".search_me");
const search = document.getElementById("search");
const clearSearch = document.getElementById("clear-search");
// Filter props for different contexts
export let category = null;
export let tag = null;
export let icon = null;
export let season = null;
export let favoritesOnly = false;
export let searchResultsUrl = '/rezepte/search';
function do_search(click_only_result=false){
// grab search input value
const searchText = search.value.toLowerCase().trim().normalize('NFD').replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ");
const hasFilter = searchText.length > 0;
let searchQuery = '';
let scrollers_with_results = [];
let scrollers = [];
// for each recipe hide all but matched
recipes.forEach(recipe => {
const searchString = `${recipe.textContent} ${recipe.dataset.tags}`.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, '');
const isMatch = searchTerms.every(term => searchString.includes(term));
// Build search URL with current filters
function buildSearchUrl(query) {
if (browser) {
const url = new URL(searchResultsUrl, window.location.origin);
if (query) url.searchParams.set('q', query);
if (category) url.searchParams.set('category', category);
if (tag) url.searchParams.set('tag', tag);
if (icon) url.searchParams.set('icon', icon);
if (season) url.searchParams.set('season', season);
if (favoritesOnly) url.searchParams.set('favorites', 'true');
return url.toString();
} else {
// Server-side fallback - return just the base path
return searchResultsUrl;
}
}
recipe.style.display = (isMatch ? 'flex' : 'none');
recipe.classList.toggle("matched-recipe", hasFilter && isMatch);
if(!scrollers.includes(recipe.parentNode)){
scrollers.push(recipe.parentNode)
}
if(!scrollers_with_results.includes(recipe.parentNode) && isMatch){
scrollers_with_results.push(recipe.parentNode)
}
})
scrollers_with_results.forEach( scroller => {
scroller.parentNode.style.display= 'block'
})
scrollers.filter(item => !scrollers_with_results.includes(item)).forEach( scroller => {
scroller.parentNode.style.display= 'none'
})
scroll
let items = document.querySelectorAll(".matched-recipe");
items = [...new Set(items)] // make unique as seasonal mediascroller can lead to duplicates
// if only one result and click_only_result is true, click it
if(click_only_result && scrollers_with_results.length == 1 && items.length == 1){
// add '/rezepte' to history to not force-redirect back to recipe if going back
items[0].click();
}
// if scrollers with results are presenet scroll first result into view
/*if(scrollers_with_results.length > 0){
scrollers_with_results[0].scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"});
}*/ // For now disabled because it is annoying on mobile
function handleSubmit(event) {
if (browser) {
// For JS-enabled browsers, prevent default and navigate programmatically
// This allows for future enhancements like instant search
const url = buildSearchUrl(searchQuery);
window.location.href = url;
}
// If no JS, form will submit normally
}
}
function clearSearch() {
searchQuery = '';
if (browser) {
// Reset any client-side filtering if present
const recipes = document.querySelectorAll(".search_me");
recipes.forEach(recipe => {
recipe.style.display = 'flex';
recipe.classList.remove("matched-recipe");
});
document.querySelectorAll(".media_scroller_wrapper").forEach( scroller => {
scroller.style.display= 'block'
});
}
}
search.addEventListener("input", () => {
do_search();
})
onMount(() => {
// Swap buttons for JS-enabled experience
const submitButton = document.getElementById('submit-search');
const clearButton = document.getElementById('clear-search');
clearSearch.addEventListener("click", () => {
search.value = "";
recipes.forEach(recipe => {
recipe.style.display = 'flex';
recipe.classList.remove("matched-recipe");
})
document.querySelectorAll(".media_scroller_wrapper").forEach( scroller => {
scroller.style.display= 'block'
})
})
if (submitButton && clearButton) {
submitButton.style.display = 'none';
clearButton.style.display = 'flex';
}
let paramString = window.location.href.split('?')[1];
let queryString = new URLSearchParams(paramString);
// Get initial search value from URL if present
const urlParams = new URLSearchParams(window.location.search);
const urlQuery = urlParams.get('q');
if (urlQuery) {
searchQuery = urlQuery;
}
for (let pair of queryString.entries()) {
if(pair[0] == 'q'){
const search = document.getElementById("search");
search.value=pair[1];
do_search(true);
}
}
});
// Enhanced client-side filtering (existing functionality)
const recipes = document.querySelectorAll(".search_me");
const search = document.getElementById("search");
if (recipes.length > 0 && search) {
function do_search(click_only_result=false){
const searchText = search.value.toLowerCase().trim().normalize('NFD').replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ");
const hasFilter = searchText.length > 0;
let scrollers_with_results = [];
let scrollers = [];
recipes.forEach(recipe => {
const searchString = `${recipe.textContent} ${recipe.dataset.tags}`.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, '');
const isMatch = searchTerms.every(term => searchString.includes(term));
recipe.style.display = (isMatch ? 'flex' : 'none');
recipe.classList.toggle("matched-recipe", hasFilter && isMatch);
if(!scrollers.includes(recipe.parentNode)){
scrollers.push(recipe.parentNode)
}
if(!scrollers_with_results.includes(recipe.parentNode) && isMatch){
scrollers_with_results.push(recipe.parentNode)
}
})
scrollers_with_results.forEach( scroller => {
scroller.parentNode.style.display= 'block'
})
scrollers.filter(item => !scrollers_with_results.includes(item)).forEach( scroller => {
scroller.parentNode.style.display= 'none'
})
let items = document.querySelectorAll(".matched-recipe");
items = [...new Set(items)]
if(click_only_result && scrollers_with_results.length == 1 && items.length == 1){
items[0].click();
}
}
search.addEventListener("input", () => {
searchQuery = search.value;
do_search();
})
// Initial search if URL had query
if (urlQuery) {
do_search(true);
}
}
});
</script>
<style>
@@ -110,7 +157,7 @@ input::placeholder{
scale: 1.02 1.02;
filter: drop-shadow(0.4em 0.5em 1em rgba(0,0,0,0.6))
}
button#clear-search {
.search-button {
all: unset;
display: flex;
justify-content: center;
@@ -123,17 +170,35 @@ button#clear-search {
cursor: pointer;
transition: color 180ms ease-in-out;
}
button#clear-search:hover {
.search-button:hover {
color: white;
scale: 1.1 1.1;
}
button#clear-search:active{
.search-button:active{
transition: 50ms;
scale: 0.8 0.8;
}
.search-button svg {
width: 100%;
height: 100%;
}
</style>
<div class="search js-only">
<input type="text" id="search" placeholder="Suche...">
<button id="clear-search">
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>Sucheintrag löschen</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg></button>
</div>
<form class="search" method="get" action={buildSearchUrl('')} on:submit|preventDefault={handleSubmit}>
{#if category}<input type="hidden" name="category" value={category} />{/if}
{#if tag}<input type="hidden" name="tag" value={tag} />{/if}
{#if icon}<input type="hidden" name="icon" value={icon} />{/if}
{#if season}<input type="hidden" name="season" value={season} />{/if}
{#if favoritesOnly}<input type="hidden" name="favorites" value="true" />{/if}
<input type="text" id="search" name="q" placeholder="Suche..." bind:value={searchQuery}>
<!-- Submit button (visible by default, hidden when JS loads) -->
<button type="submit" id="submit-search" class="search-button" style="display: flex;">
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512" style="width: 100%; height: 100%;"><title>Suchen</title><path d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="m338.29 338.29 105.25 105.25"></path></svg>
</button>
<!-- Clear button (hidden by default, shown when JS loads) -->
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" on:click={clearSearch}>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>Sucheintrag löschen</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg>
</button>
</form>

View File

@@ -41,7 +41,7 @@ a.month:hover,
{/each}
</div>
<section>
<Search></Search>
<Search season={active_index + 1}></Search>
</section>
<section>
<slot name=recipes></slot>

View File

@@ -12,6 +12,6 @@ export function rand_array(array){
let time = new Date()
const seed = Math.floor(time.getTime()/MS_PER_DAY)
let rand = mulberry32(seed)
array.sort((a,b) => 0.5 - rand())
return array
// Create a copy to avoid mutating the original array
return [...array].sort((a,b) => 0.5 - rand())
}

View File

@@ -0,0 +1,48 @@
/**
* Utility functions for handling user favorites on the server side
*/
export async function getUserFavorites(fetch: any, locals: any): Promise<string[]> {
const session = await locals.auth();
if (!session?.user?.nickname) {
return [];
}
try {
const favRes = await fetch('/api/rezepte/favorites');
if (favRes.ok) {
const favData = await favRes.json();
return favData.favorites || [];
}
} catch (e) {
// Silently fail if favorites can't be loaded
console.error('Error loading user favorites:', e);
}
return [];
}
export function addFavoriteStatusToRecipes(recipes: any[], userFavorites: string[]): any[] {
return recipes.map(recipe => ({
...recipe,
isFavorite: userFavorites.some(favId => favId.toString() === recipe._id.toString())
}));
}
export async function loadRecipesWithFavorites(
fetch: any,
locals: any,
recipeLoader: () => Promise<any>
): Promise<{ recipes: any[], session: any }> {
const [recipes, userFavorites, session] = await Promise.all([
recipeLoader(),
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
recipes: addFavoriteStatusToRecipes(recipes, userFavorites),
session
};
}

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

@@ -0,0 +1,74 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import type { BriefRecipeType } from '../../../../types/types';
import { Recipe } from '../../../../models/Recipe';
import { dbConnect, dbDisconnect } from '../../../../utils/db';
export const GET: RequestHandler = async ({ url, locals }) => {
await dbConnect();
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
const category = url.searchParams.get('category');
const tag = url.searchParams.get('tag');
const icon = url.searchParams.get('icon');
const season = url.searchParams.get('season');
const favoritesOnly = url.searchParams.get('favorites') === 'true';
try {
// Build base query
let dbQuery: any = {};
// Apply filters based on context
if (category) {
dbQuery.category = category;
}
if (tag) {
dbQuery.tags = { $in: [tag] };
}
if (icon) {
dbQuery.icon = icon;
}
if (season) {
const seasonNum = parseInt(season);
if (!isNaN(seasonNum)) {
dbQuery.season = { $in: [seasonNum] };
}
}
// Get all recipes matching base filters
let recipes = await Recipe.find(dbQuery, 'name short_name tags category icon description season dateModified').lean() as BriefRecipeType[];
// Handle favorites filter
if (favoritesOnly && locals.session?.user) {
const User = (await import('../../../../models/User')).User;
const user = await User.findById(locals.session.user.id);
if (user && user.favoriteRecipes) {
const favoriteShortNames = user.favoriteRecipes;
recipes = recipes.filter(recipe => favoriteShortNames.includes(recipe.short_name));
} else {
recipes = [];
}
}
// Apply text search if query provided
if (query) {
const searchTerms = query.normalize('NFD').replace(/\p{Diacritic}/gu, "").split(" ");
recipes = recipes.filter(recipe => {
const searchString = `${recipe.name} ${recipe.description || ''} ${recipe.tags?.join(' ') || ''}`.toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, '');
return searchTerms.every(term => searchString.includes(term));
});
}
await dbDisconnect();
return json(JSON.parse(JSON.stringify(recipes)));
} catch (error) {
await dbDisconnect();
return json({ error: 'Search failed' }, { status: 500 });
}
};

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

@@ -1,13 +1,22 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export async function load({ fetch }) {
export async function load({ fetch, locals }) {
let current_month = new Date().getMonth() + 1
const res_season = await fetch(`/api/rezepte/items/in_season/` + current_month);
const res_all_brief = await fetch(`/api/rezepte/items/all_brief`);
const item_season = await res_season.json();
const item_all_brief = await res_all_brief.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
season: item_season,
all_brief: item_all_brief,
season: addFavoriteStatusToRecipes(item_season, userFavorites),
all_brief: addFavoriteStatusToRecipes(item_all_brief, userFavorites),
session
};
};

View File

@@ -36,14 +36,14 @@ h1{
<MediaScroller title="In Saison">
{#each data.season as recipe}
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true}></Card>
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
{/each}
</MediaScroller>
{#each categories as category}
<MediaScroller title={category}>
{#each data.all_brief.filter(recipe => recipe.category == category) as recipe}
<Card {recipe} {current_month} do_margin_right={true}></Card>
<Card {recipe} {current_month} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
{/each}
</MediaScroller>
{/each}

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,13 @@ h4{
<a class=tag href="/rezepte/tag/{tag}">{tag}</a>
{/each}
</div>
<FavoriteButton
recipeId={data.short_name}
isFavorite={data.isFavorite || false}
isLoggedIn={!!data.session?.user}
/>
{#if data.note}
<RecipeNote note={data.note}></RecipeNote>
{/if}

View File

@@ -1,10 +1,30 @@
import { error } from "@sveltejs/kit";
export async function load({ fetch, params}) {
export async function load({ fetch, params, url}) {
const res = await fetch(`/api/rezepte/items/${params.name}`);
let item = await res.json();
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
}
// Get multiplier from URL parameters
const multiplier = parseFloat(url.searchParams.get('multiplier') || '1');
return {
...item,
isFavorite,
multiplier
};
}

View File

@@ -0,0 +1,19 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const res = await fetch(`/api/rezepte/items/category/${params.category}`);
const items = await res.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
category: params.category,
recipes: addFavoriteStatusToRecipes(items, userFavorites),
session
};
};

View File

@@ -14,11 +14,11 @@
}
</style>
<h1>Rezepte in Kategorie <q>{data.category}</q>:</h1>
<Search></Search>
<Search category={data.category}></Search>
<section>
<Recipes>
{#each rand_array(data.recipes) as recipe}
<Card {recipe} {current_month}></Card>
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
{/each}
</Recipes>
</section>

View File

@@ -1,10 +0,0 @@
import type { PageLoad } from "./$types";
export async function load({ fetch, params }) {
const res = await fetch(`/api/rezepte/items/category/${params.category}`);
const items = await res.json();
return {
category: params.category,
recipes: items
}
};

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 favoritesOnly={true}></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} isFavorite={true} showFavoriteIndicator={true}></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}

View File

@@ -0,0 +1,22 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const res_season = await fetch(`/api/rezepte/items/icon/` + params.icon);
const res_icons = await fetch(`/api/rezepte/items/icon`);
const icons = await res_icons.json();
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
icons: icons,
icon: params.icon,
season: addFavoriteStatusToRecipes(item_season, userFavorites),
session
};
};

View File

@@ -11,7 +11,7 @@
<IconLayout icons={data.icons} active_icon={data.icon} >
<Recipes slot=recipes>
{#each rand_array(data.season) as recipe}
<Card {recipe} icon_override=true></Card>
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
{/each}
</Recipes>
</IconLayout>

View File

@@ -1,13 +0,0 @@
import type { PageLoad } from "./$types";
export async function load({ fetch, params }) {
const res_season = await fetch(`/api/rezepte/items/icon/` + params.icon);
const res_icons = await fetch(`/api/rezepte/items/icon`);
const icons = await res_icons.json();
const item_season = await res_season.json();
return {
icons: icons,
icon: params.icon,
season: item_season,
};
};

View File

@@ -0,0 +1,50 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, fetch }) => {
const query = url.searchParams.get('q') || '';
const category = url.searchParams.get('category');
const tag = url.searchParams.get('tag');
const icon = url.searchParams.get('icon');
const season = url.searchParams.get('season');
const favoritesOnly = url.searchParams.get('favorites') === 'true';
// Build API URL with filters
const apiUrl = new URL('/api/rezepte/search', url.origin);
if (query) apiUrl.searchParams.set('q', query);
if (category) apiUrl.searchParams.set('category', category);
if (tag) apiUrl.searchParams.set('tag', tag);
if (icon) apiUrl.searchParams.set('icon', icon);
if (season) apiUrl.searchParams.set('season', season);
if (favoritesOnly) apiUrl.searchParams.set('favorites', 'true');
try {
const response = await fetch(apiUrl.toString());
const results = await response.json();
return {
query,
results: response.ok ? results : [],
error: response.ok ? null : results.error || 'Search failed',
filters: {
category,
tag,
icon,
season,
favoritesOnly
}
};
} catch (error) {
return {
query,
results: [],
error: 'Search failed',
filters: {
category,
tag,
icon,
season,
favoritesOnly
}
};
}
};

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/Recipes.svelte';
import Search from '$lib/components/Search.svelte';
import Card from '$lib/components/Card.svelte';
export let data: PageData;
export let current_month = new Date().getMonth() + 1;
</script>
<style>
h1 {
text-align: center;
font-size: 3em;
}
.search-info {
text-align: center;
margin-bottom: 2rem;
color: var(--nord3);
}
.filter-info {
text-align: center;
margin-bottom: 1rem;
font-size: 0.9em;
color: var(--nord2);
}
</style>
<svelte:head>
<title>Suchergebnisse{data.query ? ` für "${data.query}"` : ''} - Bocken Rezepte</title>
<meta name="description" content="Suchergebnisse in den Bockenschen Rezepten." />
</svelte:head>
<h1>Suchergebnisse</h1>
{#if data.filters.category || data.filters.tag || data.filters.icon || data.filters.season || data.filters.favoritesOnly}
<div class="filter-info">
Gefiltert nach:
{#if data.filters.category}Kategorie "{data.filters.category}"{/if}
{#if data.filters.tag}Stichwort "{data.filters.tag}"{/if}
{#if data.filters.icon}Icon "{data.filters.icon}"{/if}
{#if data.filters.season}Saison "{data.filters.season}"{/if}
{#if data.filters.favoritesOnly}Nur Favoriten{/if}
</div>
{/if}
<Search
category={data.filters.category}
tag={data.filters.tag}
icon={data.filters.icon}
season={data.filters.season}
favoritesOnly={data.filters.favoritesOnly}
/>
{#if data.error}
<div class="search-info">
<p>Fehler bei der Suche: {data.error}</p>
</div>
{:else if data.query}
<div class="search-info">
<p>{data.results.length} Ergebnisse für "{data.query}"</p>
</div>
{/if}
{#if data.results.length > 0}
<Recipes>
{#each data.results as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={true}></Card>
{/each}
</Recipes>
{:else if data.query && !data.error}
<div class="search-info">
<p>Keine Rezepte gefunden.</p>
<p>Versuche es mit anderen Suchbegriffen.</p>
</div>
{/if}

View File

@@ -0,0 +1,19 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals }) => {
let current_month = new Date().getMonth() + 1
const res_season = await fetch(`/api/rezepte/items/in_season/` + current_month);
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
season: addFavoriteStatusToRecipes(item_season, userFavorites),
session
};
};

View File

@@ -14,7 +14,7 @@
<SeasonLayout active_index={current_month-1}>
<Recipes slot=recipes>
{#each rand_array(data.season) as recipe}
<Card {recipe} {current_month}></Card>
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
{/each}
</Recipes>
</SeasonLayout>

View File

@@ -1,10 +0,0 @@
import type { PageLoad } from "./$types";
export async function load({ fetch }) {
let current_month = new Date().getMonth() + 1
const res_season = await fetch(`/api/rezepte/items/in_season/` + current_month);
const item_season = await res_season.json();
return {
season: item_season,
};
};

View File

@@ -0,0 +1,19 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const res_season = await fetch(`/api/rezepte/items/in_season/` + params.month);
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
month: params.month,
season: addFavoriteStatusToRecipes(item_season, userFavorites),
session
};
};

View File

@@ -12,7 +12,7 @@
<SeasonLayout active_index={data.month -1}>
<Recipes slot=recipes>
{#each rand_array(data.season) as recipe}
<Card {recipe} icon_override=true></Card>
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
{/each}
</Recipes>
</SeasonLayout>

View File

@@ -1,12 +0,0 @@
import type { PageLoad } from "./$types";
export async function load({ fetch, params }) {
const res_season = await fetch(`/api/rezepte/items/in_season/` + params.month);
const res_all_brief = await fetch(`/api/rezepte/items/all_brief`);
const item_season = await res_season.json();
const item_all_brief = await res_all_brief.json();
return {
month: params.month,
season: item_season,
};
};

View File

@@ -0,0 +1,19 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const res_tag = await fetch(`/api/rezepte/items/tag/${params.tag}`);
const items_tag = await res_tag.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
tag: params.tag,
recipes: addFavoriteStatusToRecipes(items_tag, userFavorites),
session
};
};

View File

@@ -14,11 +14,11 @@
}
</style>
<h1>Rezepte mit Stichwort <q>{data.tag}</q>:</h1>
<Search></Search>
<Search tag={data.tag}></Search>
<section>
<Recipes>
{#each rand_array(data.recipes) as recipe}
<Card {recipe} {current_month}></Card>
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
{/each}
</Recipes>
</section>

View File

@@ -1,10 +0,0 @@
import type { PageLoad } from "./$types";
export async function load({ fetch, params }) {
const res_tag = await fetch(`/api/rezepte/items/tag/${params.tag}`);
const items_tag = await res_tag.json();
return {
tag: params.tag,
recipes: items_tag
}
};

View File

@@ -2,5 +2,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
server: {
allowedHosts: ["bocken.org"]
},
plugins: [sveltekit()],
});