Compare commits
21 Commits
master
...
95b49ab6ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
95b49ab6ce
|
|||
|
06cd7e7677
|
|||
|
d3a291b9f1
|
|||
|
04b138eed1
|
|||
|
e01ff9eb59
|
|||
|
6a8478f8a6
|
|||
|
be26769efb
|
|||
|
7bc51e3a0e
|
|||
|
75142aa5ee
|
|||
|
2dc871c50f
|
|||
|
88f9531a6f
|
|||
|
aeec3b4865
|
|||
|
55a4e6a262
|
|||
|
48b94e3aef
|
|||
|
15a72e73ca
|
|||
|
bda30eb42d
|
|||
|
9f53e331a7
|
|||
|
b534cd1ddc
|
|||
|
6a64a7ddd6
|
|||
|
fe46ab194e
|
|||
|
1d78b5439e
|
@@ -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
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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sk-recipes-test",
|
||||
"version": "0.0.1",
|
||||
"name": "homepage",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -25,5 +25,6 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||
return session;
|
||||
},
|
||||
|
||||
}
|
||||
},
|
||||
trustHost: true // needed for reverse proxy setups
|
||||
})
|
||||
|
||||
33
src/lib/assets/icons/Heart.svelte
Normal file
33
src/lib/assets/icons/Heart.svelte
Normal 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>
|
||||
@@ -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){
|
||||
@@ -69,7 +71,7 @@ const img_name=recipe.short_name + ".webp?v=" + recipe.dateModified
|
||||
font-size: 1.5em;
|
||||
box-shadow: 0em 0em 1em 0.1em rgba(0, 0, 0, 0.6);
|
||||
transition: 100ms;
|
||||
z-index: 10;
|
||||
z-index: 5;
|
||||
}
|
||||
#image{
|
||||
width: 300px;
|
||||
@@ -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.1em;
|
||||
left: 0.1em;
|
||||
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}
|
||||
|
||||
@@ -378,7 +378,7 @@ input::placeholder{
|
||||
<div class=tags>
|
||||
{#each card_data.tags as tag}
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="tag" tabindex="0" on:keydown={remove_on_enter(event ,tag)} on:click='{remove_from_tags(tag)}'>{tag}</div>
|
||||
<div class="tag" role="button" tabindex="0" on:keydown={remove_on_enter(event ,tag)} on:click='{remove_from_tags(tag)}' aria-label="Tag {tag} entfernen">{tag}</div>
|
||||
{/each}
|
||||
<div class="tag input_wrapper"><span class=input>+</span><input class="tag_input" type="text" on:keydown={add_on_enter} on:focusout={add_to_tags} size="1" bind:value={new_tag} placeholder=Stichwort...></div>
|
||||
</div>
|
||||
|
||||
@@ -411,6 +411,25 @@ h3{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* Styling for converted div-to-button elements */
|
||||
.subheading-button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ingredient-amount-button, .ingredient-name-button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class=list_wrapper >
|
||||
@@ -422,49 +441,47 @@ h3{
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<h3>
|
||||
<div class=move_buttons_container>
|
||||
<button on:click="{() => update_list_position(list_index, 1)}">
|
||||
<button on:click="{() => update_list_position(list_index, 1)}" aria-label="Liste nach oben verschieben">
|
||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||
</button>
|
||||
<button on:click="{() => update_list_position(list_index, -1)}">
|
||||
<button on:click="{() => update_list_position(list_index, -1)}" aria-label="Liste nach unten verschieben">
|
||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div on:click="{() => show_modal_edit_subheading_ingredient(list_index)}">
|
||||
<button on:click="{() => show_modal_edit_subheading_ingredient(list_index)}" class="subheading-button">
|
||||
{#if list.name }
|
||||
{list.name}
|
||||
{:else}
|
||||
Leer
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div class=mod_icons>
|
||||
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_ingredient(list_index)}">
|
||||
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_ingredient(list_index)}" aria-label="Überschrift bearbeiten">
|
||||
<Pen fill=var(--nord1)></Pen> </button>
|
||||
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}">
|
||||
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}" aria-label="Liste entfernen">
|
||||
<Cross fill=var(--nord1)></Cross></button>
|
||||
</div>
|
||||
</h3>
|
||||
<div class=ingredients_grid>
|
||||
{#each list.list as ingredient, ingredient_index (ingredient_index)}
|
||||
<div class=move_buttons_container>
|
||||
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, 1)}">
|
||||
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, 1)}" aria-label="Zutat nach oben verschieben">
|
||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||
</button>
|
||||
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, -1)}">
|
||||
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, -1)}" aria-label="Zutat nach unten verschieben">
|
||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)} >
|
||||
<button on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)} class="ingredient-amount-button">
|
||||
{ingredient.amount} {ingredient.unit}
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class=force_wrap on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)} >
|
||||
</button>
|
||||
<button class="force_wrap ingredient-name-button" on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)}>
|
||||
{@html ingredient.name}
|
||||
</div>
|
||||
<div class=mod_icons><button class="action_button button_subtle" on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)}>
|
||||
</button>
|
||||
<div class=mod_icons><button class="action_button button_subtle" on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)} aria-label="Zutat bearbeiten">
|
||||
<Pen fill=var(--nord1) height=1em width=1em></Pen></button>
|
||||
<button class="action_button button_subtle" on:click="{() => remove_ingredient(list_index, ingredient_index)}"><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
|
||||
<button class="action_button button_subtle" on:click="{() => remove_ingredient(list_index, ingredient_index)}" aria-label="Zutat entfernen"><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -485,7 +502,7 @@ h3{
|
||||
<h2>Zutat verändern</h2>
|
||||
<div class=adder>
|
||||
<input class=category type="text" bind:value={edit_ingredient.sublist} placeholder="Kategorie (optional)">
|
||||
<div class=add_ingredient on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||
<div class=add_ingredient role="group" on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||
<input type="text" placeholder="250..." bind:value={edit_ingredient.amount} on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||
<input type="text" placeholder="mL..." bind:value={edit_ingredient.unit} on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||
<input type="text" placeholder="Milch..." bind:value={edit_ingredient.name} on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||
|
||||
@@ -441,6 +441,16 @@ h3{
|
||||
fill: var(--nord4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Styling for converted div-to-button elements */
|
||||
.subheading-button, .step-button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class=instructions>
|
||||
@@ -476,23 +486,23 @@ h3{
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<h3>
|
||||
<div class=move_buttons_container>
|
||||
<button on:click="{() => update_list_position(list_index, 1)}">
|
||||
<button on:click="{() => update_list_position(list_index, 1)}" aria-label="Liste nach oben verschieben">
|
||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||
</button>
|
||||
<button on:click="{() => update_list_position(list_index, -1)}">
|
||||
<button on:click="{() => update_list_position(list_index, -1)}" aria-label="Liste nach unten verschieben">
|
||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div on:click={() => show_modal_edit_subheading_step(list_index)}>
|
||||
<button on:click={() => show_modal_edit_subheading_step(list_index)} class="subheading-button">
|
||||
{#if list.name}
|
||||
{list.name}
|
||||
{:else}
|
||||
Leer
|
||||
{/if}
|
||||
</div>
|
||||
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_step(list_index)}">
|
||||
</button>
|
||||
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_step(list_index)}" aria-label="Überschrift bearbeiten">
|
||||
<Pen fill=var(--nord1)></Pen> </button>
|
||||
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}">
|
||||
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}" aria-label="Liste entfernen">
|
||||
<Cross fill=var(--nord1)></Cross>
|
||||
</button>
|
||||
</h3>
|
||||
@@ -501,17 +511,17 @@ h3{
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<li>
|
||||
<div class="move_buttons_container step_move_buttons">
|
||||
<button on:click="{() => update_step_position(list_index, step_index, 1)}">
|
||||
<button on:click="{() => update_step_position(list_index, step_index, 1)}" aria-label="Schritt nach oben verschieben">
|
||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||
</button>
|
||||
<button on:click="{() => update_step_position(list_index, step_index, -1)}">
|
||||
<button on:click="{() => update_step_position(list_index, step_index, -1)}" aria-label="Schritt nach unten verschieben">
|
||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div on:click={() => show_modal_edit_step(list_index, step_index)}>
|
||||
<button on:click={() => show_modal_edit_step(list_index, step_index)} class="step-button">
|
||||
{@html step}
|
||||
</div>
|
||||
</button>
|
||||
<div><button class="action_button button_subtle" on:click={() => show_modal_edit_step(list_index, step_index)}>
|
||||
<Pen fill=var(--nord1)></Pen>
|
||||
</button>
|
||||
|
||||
80
src/lib/components/FavoriteButton.svelte
Normal file
80
src/lib/components/FavoriteButton.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
export let recipeId: string;
|
||||
export let isFavorite: boolean = false;
|
||||
export let isLoggedIn: boolean = false;
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
async function toggleFavorite(event: Event) {
|
||||
// If JavaScript is available, prevent form submission and handle client-side
|
||||
if (browser) {
|
||||
event.preventDefault();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// If no JS, form will submit normally
|
||||
}
|
||||
</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}
|
||||
<form method="post" action="?/toggleFavorite" style="display: inline;" use:enhance>
|
||||
<input type="hidden" name="recipeId" value={recipeId} />
|
||||
<input type="hidden" name="isFavorite" value={isFavorite} />
|
||||
<button
|
||||
type="submit"
|
||||
class="favorite-button"
|
||||
disabled={isLoading}
|
||||
on:click={toggleFavorite}
|
||||
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit speichern'}
|
||||
>
|
||||
{isFavorite ? '❤️' : '🖤'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -1,19 +1,39 @@
|
||||
<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
|
||||
}
|
||||
else{
|
||||
item.name = "Frischhefe"
|
||||
item.amount = item.amount * 3
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let item;
|
||||
export let multiplier = 1;
|
||||
export let yeastId = 0;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Get all current URL parameters to preserve state
|
||||
$: currentParams = browser ? new URLSearchParams(window.location.search) : $page.url.searchParams;
|
||||
|
||||
function toggleHefe(event) {
|
||||
// If JavaScript is available, prevent form submission and handle client-side
|
||||
if (browser) {
|
||||
event.preventDefault();
|
||||
|
||||
// Simply toggle the yeast flag in the URL
|
||||
const url = new URL(window.location);
|
||||
const yeastParam = `y${yeastId}`;
|
||||
|
||||
if (url.searchParams.has(yeastParam)) {
|
||||
url.searchParams.delete(yeastParam);
|
||||
} else {
|
||||
url.searchParams.set(yeastParam, '1');
|
||||
}
|
||||
|
||||
window.history.replaceState({}, '', url);
|
||||
|
||||
// Trigger page reload to recalculate ingredients server-side
|
||||
window.location.reload();
|
||||
}
|
||||
// If no JS, form will submit normally
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@@ -28,7 +48,13 @@
|
||||
fill: var(--blue);
|
||||
}
|
||||
</style>
|
||||
<button onclick={toggleHefe}>
|
||||
{item.amount} {item.unit} {item.name}
|
||||
<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>
|
||||
<form method="post" action="?/swapYeast" style="display: inline;" use:enhance>
|
||||
<input type="hidden" name="yeastId" value={yeastId} />
|
||||
<!-- Include all current URL parameters to preserve state -->
|
||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||
<input type="hidden" name="currentParam_{key}" value={value} />
|
||||
{/each}
|
||||
<button type="submit" 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>
|
||||
</form>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<section>
|
||||
<Search></Search>
|
||||
<Search icon={active_icon}></Search>
|
||||
</section>
|
||||
<section>
|
||||
<slot name=recipes></slot>
|
||||
|
||||
@@ -1,20 +1,94 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onNavigate } from "$app/navigation";
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import HefeSwapper from './HefeSwapper.svelte';
|
||||
export let data
|
||||
let multiplier;
|
||||
let custom_mul = "…"
|
||||
let multiplier = data.multiplier || 1;
|
||||
|
||||
// Calculate yeast IDs for each yeast ingredient
|
||||
let yeastIds = {};
|
||||
$: {
|
||||
yeastIds = {};
|
||||
let yeastCounter = 0;
|
||||
if (data.ingredients) {
|
||||
for (let listIndex = 0; listIndex < data.ingredients.length; listIndex++) {
|
||||
const list = data.ingredients[listIndex];
|
||||
if (list.list) {
|
||||
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
|
||||
const ingredient = list.list[ingredientIndex];
|
||||
if (ingredient.name === "Frischhefe" || ingredient.name === "Trockenhefe") {
|
||||
yeastIds[`${listIndex}-${ingredientIndex}`] = yeastCounter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all current URL parameters to preserve state in multiplier forms
|
||||
$: currentParams = browser ? new URLSearchParams(window.location.search) : $page.url.searchParams;
|
||||
|
||||
// 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,22 +177,8 @@ 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 = "…"
|
||||
}
|
||||
}
|
||||
|
||||
// No need for complex yeast toggle handling - everything is calculated server-side now
|
||||
</script>
|
||||
<style>
|
||||
*{
|
||||
@@ -192,9 +252,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,33 +324,86 @@ span
|
||||
|
||||
<h3>Menge anpassen:</h3>
|
||||
<div class=multipliers>
|
||||
<button class:selected={multiplier==0.5} on:click={() => multiplier=0.5}><sup>1</sup>⁄<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>⁄<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" />
|
||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||
{#if key !== 'multiplier'}
|
||||
<input type="hidden" name={key} value={value} />
|
||||
{/if}
|
||||
{/each}
|
||||
<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" />
|
||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||
{#if key !== 'multiplier'}
|
||||
<input type="hidden" name={key} value={value} />
|
||||
{/if}
|
||||
{/each}
|
||||
<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" />
|
||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||
{#if key !== 'multiplier'}
|
||||
<input type="hidden" name={key} value={value} />
|
||||
{/if}
|
||||
{/each}
|
||||
<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" />
|
||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||
{#if key !== 'multiplier'}
|
||||
<input type="hidden" name={key} value={value} />
|
||||
{/if}
|
||||
{/each}
|
||||
<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" />
|
||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||
{#if key !== 'multiplier'}
|
||||
<input type="hidden" name={key} value={value} />
|
||||
{/if}
|
||||
{/each}
|
||||
<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}>
|
||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||
{#if key !== 'multiplier'}
|
||||
<input type="hidden" name={key} value={value} />
|
||||
{/if}
|
||||
{/each}
|
||||
<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>
|
||||
{#each data.ingredients as list}
|
||||
{#each data.ingredients as list, listIndex}
|
||||
{#if list.name}
|
||||
<h3>{list.name}</h3>
|
||||
{/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>
|
||||
{#each list.list as item, ingredientIndex}
|
||||
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div>
|
||||
<div class=name>
|
||||
{@html item.name.replace("{{multiplier}}", isNaN(parseFloat(item.amount)) ? multiplier : multiplier * parseFloat(item.amount))}
|
||||
{#if item.name === "Frischhefe" || item.name === "Trockenhefe"}
|
||||
{@const yeastId = yeastIds[`${listIndex}-${ingredientIndex}`] ?? 0}
|
||||
<HefeSwapper {item} {multiplier} {yeastId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,81 +1,128 @@
|
||||
<script>
|
||||
import {onMount} from "svelte";
|
||||
import { browser } from '$app/environment';
|
||||
import "$lib/css/nordtheme.css";
|
||||
|
||||
// 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';
|
||||
|
||||
let searchQuery = '';
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const recipes = document.querySelectorAll(".search_me");
|
||||
const search = document.getElementById("search");
|
||||
const clearSearch = document.getElementById("clear-search");
|
||||
onMount(() => {
|
||||
// Swap buttons for JS-enabled experience
|
||||
const submitButton = document.getElementById('submit-search');
|
||||
const clearButton = document.getElementById('clear-search');
|
||||
|
||||
if (submitButton && clearButton) {
|
||||
submitButton.style.display = 'none';
|
||||
clearButton.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Get initial search value from URL if present
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlQuery = urlParams.get('q');
|
||||
if (urlQuery) {
|
||||
searchQuery = urlQuery;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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 scrollers_with_results = [];
|
||||
let scrollers = [];
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
const searchString = `${recipe.textContent} ${recipe.dataset.tags}`.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/­|/g, '');
|
||||
const isMatch = searchTerms.every(term => searchString.includes(term));
|
||||
|
||||
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(/­|/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();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
do_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'
|
||||
})
|
||||
})
|
||||
|
||||
let paramString = window.location.href.split('?')[1];
|
||||
let queryString = new URLSearchParams(paramString);
|
||||
|
||||
for (let pair of queryString.entries()) {
|
||||
if(pair[0] == 'q'){
|
||||
const search = document.getElementById("search");
|
||||
search.value=pair[1];
|
||||
do_search(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -41,7 +41,7 @@ a.month:hover,
|
||||
{/each}
|
||||
</div>
|
||||
<section>
|
||||
<Search></Search>
|
||||
<Search season={active_index + 1}></Search>
|
||||
</section>
|
||||
<section>
|
||||
<slot name=recipes></slot>
|
||||
|
||||
@@ -173,6 +173,7 @@ dialog button{
|
||||
<section class="section">
|
||||
<figure class="image-container">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class:zoom-in={isloaded && !isredirected} on:click={show_dialog_img}>
|
||||
<div class=placeholder style="background-image:url({placeholder_src})" >
|
||||
<div class=placeholder_blur>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
126
src/lib/js/recipeJsonLd.ts
Normal file
126
src/lib/js/recipeJsonLd.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
function parseTimeToISO8601(timeString: string): string | undefined {
|
||||
if (!timeString) return undefined;
|
||||
|
||||
// Handle common German time formats
|
||||
const cleanTime = timeString.toLowerCase().trim();
|
||||
|
||||
// Match patterns like "30 min", "2 h", "1.5 h", "90 min"
|
||||
const minMatch = cleanTime.match(/(\d+(?:[.,]\d+)?)\s*(?:min|minuten?)/);
|
||||
const hourMatch = cleanTime.match(/(\d+(?:[.,]\d+)?)\s*(?:h|stunden?|std)/);
|
||||
|
||||
if (minMatch) {
|
||||
const minutes = Math.round(parseFloat(minMatch[1].replace(',', '.')));
|
||||
return `PT${minutes}M`;
|
||||
}
|
||||
|
||||
if (hourMatch) {
|
||||
const hours = parseFloat(hourMatch[1].replace(',', '.'));
|
||||
if (hours % 1 === 0) {
|
||||
return `PT${Math.round(hours)}H`;
|
||||
} else {
|
||||
const totalMinutes = Math.round(hours * 60);
|
||||
return `PT${totalMinutes}M`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function generateRecipeJsonLd(data: any) {
|
||||
const jsonLd: any = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": data.name?.replace(/<[^>]*>/g, ''), // Strip HTML tags
|
||||
"description": data.description,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Alexander Bocken"
|
||||
},
|
||||
"datePublished": data.dateCreated ? new Date(data.dateCreated).toISOString() : undefined,
|
||||
"dateModified": data.dateModified || data.updatedAt ? new Date(data.dateModified || data.updatedAt).toISOString() : undefined,
|
||||
"recipeCategory": data.category,
|
||||
"keywords": data.tags?.join(', '),
|
||||
"image": {
|
||||
"@type": "ImageObject",
|
||||
"url": `https://bocken.org/static/rezepte/full/${data.short_name}.webp`,
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
},
|
||||
"recipeIngredient": [] as string[],
|
||||
"recipeInstructions": [] as any[],
|
||||
"url": `https://bocken.org/rezepte/${data.short_name}`
|
||||
};
|
||||
|
||||
// Add optional fields if they exist
|
||||
if (data.portions) {
|
||||
jsonLd.recipeYield = data.portions;
|
||||
}
|
||||
|
||||
// Parse times properly for ISO 8601
|
||||
const prepTime = parseTimeToISO8601(data.preparation);
|
||||
if (prepTime) jsonLd.prepTime = prepTime;
|
||||
|
||||
const cookTime = parseTimeToISO8601(data.cooking);
|
||||
if (cookTime) jsonLd.cookTime = cookTime;
|
||||
|
||||
const totalTime = parseTimeToISO8601(data.total_time);
|
||||
if (totalTime) jsonLd.totalTime = totalTime;
|
||||
|
||||
// Extract ingredients
|
||||
if (data.ingredients) {
|
||||
for (const ingredientGroup of data.ingredients) {
|
||||
if (ingredientGroup.list) {
|
||||
for (const ingredient of ingredientGroup.list) {
|
||||
if (ingredient.name) {
|
||||
let ingredientText = ingredient.name;
|
||||
if (ingredient.amount) {
|
||||
ingredientText = `${ingredient.amount} ${ingredient.unit || ''} ${ingredient.name}`.trim();
|
||||
}
|
||||
jsonLd.recipeIngredient.push(ingredientText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract instructions
|
||||
if (data.instructions) {
|
||||
for (const instructionGroup of data.instructions) {
|
||||
if (instructionGroup.steps) {
|
||||
for (let i = 0; i < instructionGroup.steps.length; i++) {
|
||||
jsonLd.recipeInstructions.push({
|
||||
"@type": "HowToStep",
|
||||
"name": `Schritt ${i + 1}`,
|
||||
"text": instructionGroup.steps[i]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add baking instructions if available
|
||||
if (data.baking?.temperature || data.baking?.length) {
|
||||
const bakingText = [
|
||||
data.baking.temperature ? `bei ${data.baking.temperature}` : '',
|
||||
data.baking.length ? `für ${data.baking.length}` : '',
|
||||
data.baking.mode || ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (bakingText) {
|
||||
jsonLd.recipeInstructions.push({
|
||||
"@type": "HowToStep",
|
||||
"name": "Backen",
|
||||
"text": `Backen ${bakingText}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up undefined values
|
||||
Object.keys(jsonLd).forEach(key => {
|
||||
if (jsonLd[key] === undefined) {
|
||||
delete jsonLd[key];
|
||||
}
|
||||
});
|
||||
|
||||
return jsonLd;
|
||||
}
|
||||
48
src/lib/server/favorites.ts
Normal file
48
src/lib/server/favorites.ts
Normal 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
|
||||
};
|
||||
}
|
||||
11
src/models/UserFavorites.ts
Normal file
11
src/models/UserFavorites.ts
Normal 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);
|
||||
112
src/routes/api/rezepte/favorites/+server.ts
Normal file
112
src/routes/api/rezepte/favorites/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
40
src/routes/api/rezepte/favorites/recipes/+server.ts
Normal file
40
src/routes/api/rezepte/favorites/recipes/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
32
src/routes/api/rezepte/json-ld/[name]/+server.ts
Normal file
32
src/routes/api/rezepte/json-ld/[name]/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { Recipe } from '../../../../../models/Recipe';
|
||||
import { dbConnect, dbDisconnect } from '../../../../../utils/db';
|
||||
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
||||
import type { RecipeModelType } from '../../../../../types/types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
await dbConnect();
|
||||
let recipe = (await Recipe.findOne({ short_name: params.name }).lean()) as RecipeModelType;
|
||||
await dbDisconnect();
|
||||
|
||||
recipe = JSON.parse(JSON.stringify(recipe));
|
||||
if (recipe == null) {
|
||||
throw error(404, "Recipe not found");
|
||||
}
|
||||
|
||||
const jsonLd = generateRecipeJsonLd(recipe);
|
||||
|
||||
// Set appropriate headers for JSON-LD
|
||||
setHeaders({
|
||||
'Content-Type': 'application/ld+json',
|
||||
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(jsonLd, null, 2), {
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
});
|
||||
};
|
||||
74
src/routes/api/rezepte/search/+server.ts
Normal file
74
src/routes/api/rezepte/search/+server.ts
Normal 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 { UserFavorites } = await import('../../../../models/UserFavorites');
|
||||
const userFavorites = await UserFavorites.findOne({ username: locals.session.user.username });
|
||||
if (userFavorites && userFavorites.favorites) {
|
||||
const favoriteIds = userFavorites.favorites;
|
||||
recipes = recipes.filter(recipe => favoriteIds.some(id => id.toString() === recipe._id?.toString()));
|
||||
} 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(/­|/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 });
|
||||
}
|
||||
};
|
||||
@@ -463,19 +463,19 @@ Der unten abgebildete Rosenkranz zeigt die aktuellen Gehmeinisse des Tages nach
|
||||
</g>
|
||||
<g id=lbead5>
|
||||
<circle class=lbead />
|
||||
<circle class=hitbox onclick="{true}" />
|
||||
<circle class=hitbox onclick="{true}" role="button" tabindex="0" />
|
||||
</g>
|
||||
<g id=lbead4>
|
||||
<circle class=lbead />
|
||||
<circle class=hitbox onclick="{true}" />
|
||||
<circle class=hitbox onclick="{true}" role="button" tabindex="0" />
|
||||
</g>
|
||||
<g id=lbead3>
|
||||
<circle class=lbead />
|
||||
<circle class=hitbox onclick="{true}" />
|
||||
<circle class=hitbox onclick="{true}" role="button" tabindex="0" />
|
||||
</g>
|
||||
<g id=lbead6>
|
||||
<circle class=lbead />
|
||||
<circle class=hitbox onclick="{true}" />
|
||||
<circle class=hitbox onclick="{true}" role="button" tabindex="0" />
|
||||
</g>
|
||||
</g>
|
||||
<g class=beforedecades>
|
||||
@@ -490,11 +490,11 @@ Der unten abgebildete Rosenkranz zeigt die aktuellen Gehmeinisse des Tages nach
|
||||
</g>
|
||||
<g id=lbead1>
|
||||
<circle class=lbead />
|
||||
<circle class=hitbox onclick="{true}" />
|
||||
<circle class=hitbox onclick="{true}" role="button" tabindex="0" />
|
||||
</g>
|
||||
<g id=lbead2>
|
||||
<circle class=lbead />
|
||||
<circle class=hitbox onclick="{true}" />
|
||||
<circle class=hitbox onclick="{true}" role="button" tabindex="0" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
73
src/routes/rezepte/[name]/+page.server.ts
Normal file
73
src/routes/rezepte/[name]/+page.server.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
toggleFavorite: async ({ request, locals, url, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const recipeId = formData.get('recipeId') as string;
|
||||
const isFavorite = formData.get('isFavorite') === 'true';
|
||||
|
||||
if (!recipeId) {
|
||||
throw error(400, 'Recipe ID required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the existing API endpoint
|
||||
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) {
|
||||
const errorData = await response.text();
|
||||
console.error('API error:', response.status, errorData);
|
||||
throw error(response.status, `Failed to toggle favorite: ${errorData}`);
|
||||
}
|
||||
|
||||
// Redirect back to the same page to refresh the state
|
||||
throw redirect(303, url.pathname);
|
||||
} catch (e) {
|
||||
// If it's a redirect, let it through
|
||||
if (e && typeof e === 'object' && 'status' in e && e.status === 303) {
|
||||
throw e;
|
||||
}
|
||||
console.error('Favorite toggle error:', e);
|
||||
throw error(500, 'Failed to toggle favorite');
|
||||
}
|
||||
},
|
||||
|
||||
swapYeast: async ({ request, url }) => {
|
||||
const formData = await request.formData();
|
||||
const yeastId = parseInt(formData.get('yeastId') as string);
|
||||
|
||||
// Build new URL
|
||||
const newUrl = new URL(url.pathname, url.origin);
|
||||
|
||||
// Restore all parameters from the form data (they were submitted as currentParam_*)
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith('currentParam_')) {
|
||||
const paramName = key.substring('currentParam_'.length);
|
||||
newUrl.searchParams.set(paramName, value as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the yeast flag - if it exists, remove it; if not, add it
|
||||
const yeastParam = `y${yeastId}`;
|
||||
if (newUrl.searchParams.has(yeastParam)) {
|
||||
newUrl.searchParams.delete(yeastParam);
|
||||
} else {
|
||||
newUrl.searchParams.set(yeastParam, '1');
|
||||
}
|
||||
|
||||
throw redirect(303, newUrl.toString());
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -276,6 +277,7 @@ h4{
|
||||
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{data.short_name}.webp" />
|
||||
<meta property="og:image:type" content="image/webp" />
|
||||
<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(data.recipeJsonLd)}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<TitleImgParallax src={hero_img_src} {placeholder_src}>
|
||||
@@ -308,6 +310,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}
|
||||
|
||||
@@ -1,10 +1,114 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
||||
|
||||
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');
|
||||
|
||||
// Handle yeast swapping from URL parameters based on toggle flags
|
||||
// Look for parameters like y0=1, y1=1 (yeast #0 and #1 are toggled)
|
||||
if (item.ingredients) {
|
||||
let yeastCounter = 0;
|
||||
|
||||
// Iterate through all ingredients to find yeast and apply conversions
|
||||
for (let listIndex = 0; listIndex < item.ingredients.length; listIndex++) {
|
||||
const list = item.ingredients[listIndex];
|
||||
if (list.list) {
|
||||
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
|
||||
const ingredient = list.list[ingredientIndex];
|
||||
|
||||
// Check if this is a yeast ingredient
|
||||
if (ingredient.name === "Frischhefe" || ingredient.name === "Trockenhefe") {
|
||||
// Check if this yeast should be toggled
|
||||
const yeastParam = `y${yeastCounter}`;
|
||||
const isToggled = url.searchParams.has(yeastParam);
|
||||
|
||||
if (isToggled) {
|
||||
// Perform yeast conversion from original recipe data
|
||||
const originalName = ingredient.name;
|
||||
const originalAmount = parseFloat(ingredient.amount);
|
||||
const originalUnit = ingredient.unit;
|
||||
|
||||
let newName: string, newAmount: string, newUnit: string;
|
||||
|
||||
if (originalName === "Frischhefe") {
|
||||
// Convert fresh yeast to dry yeast
|
||||
newName = "Trockenhefe";
|
||||
|
||||
if (originalUnit === "Prise") {
|
||||
// "1 Prise Frischhefe" → "1 Prise Trockenhefe"
|
||||
newAmount = ingredient.amount;
|
||||
newUnit = "Prise";
|
||||
} else if (originalUnit === "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 (originalName === "Trockenhefe") {
|
||||
// Convert dry yeast to fresh yeast
|
||||
newName = "Frischhefe";
|
||||
|
||||
if (originalUnit === "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 {
|
||||
// Fallback
|
||||
newName = originalName;
|
||||
newAmount = ingredient.amount;
|
||||
newUnit = originalUnit;
|
||||
}
|
||||
|
||||
// Apply the conversion
|
||||
item.ingredients[listIndex].list[ingredientIndex] = {
|
||||
...item.ingredients[listIndex].list[ingredientIndex],
|
||||
name: newName,
|
||||
amount: newAmount,
|
||||
unit: newUnit
|
||||
};
|
||||
}
|
||||
|
||||
yeastCounter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JSON-LD server-side
|
||||
const recipeJsonLd = generateRecipeJsonLd(item);
|
||||
|
||||
return {
|
||||
...item,
|
||||
isFavorite,
|
||||
multiplier,
|
||||
recipeJsonLd
|
||||
};
|
||||
}
|
||||
|
||||
19
src/routes/rezepte/category/[category]/+page.server.ts
Normal file
19
src/routes/rezepte/category/[category]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
32
src/routes/rezepte/favorites/+page.server.ts
Normal file
32
src/routes/rezepte/favorites/+page.server.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
};
|
||||
58
src/routes/rezepte/favorites/+page.svelte
Normal file
58
src/routes/rezepte/favorites/+page.svelte
Normal 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}
|
||||
22
src/routes/rezepte/icon/[icon]/+page.server.ts
Normal file
22
src/routes/rezepte/icon/[icon]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
50
src/routes/rezepte/search/+page.server.ts
Normal file
50
src/routes/rezepte/search/+page.server.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
75
src/routes/rezepte/search/+page.svelte
Normal file
75
src/routes/rezepte/search/+page.svelte
Normal 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}
|
||||
19
src/routes/rezepte/season/+page.server.ts
Normal file
19
src/routes/rezepte/season/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
19
src/routes/rezepte/season/[month]/+page.server.ts
Normal file
19
src/routes/rezepte/season/[month]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
19
src/routes/rezepte/tag/[tag]/+page.server.ts
Normal file
19
src/routes/rezepte/tag/[tag]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -2,5 +2,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
allowedHosts: ["bocken.org"]
|
||||
},
|
||||
plugins: [sveltekit()],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user