diff --git a/src/lib/components/CategoryFilter.svelte b/src/lib/components/CategoryFilter.svelte
new file mode 100644
index 00000000..135a0b38
--- /dev/null
+++ b/src/lib/components/CategoryFilter.svelte
@@ -0,0 +1,203 @@
+
+
+
+
+
+
{label}
+
+
+
+
+
+ {#if selected}
+
+
+
+ {/if}
+
diff --git a/src/lib/components/FavoritesFilter.svelte b/src/lib/components/FavoritesFilter.svelte
new file mode 100644
index 00000000..12e40ab3
--- /dev/null
+++ b/src/lib/components/FavoritesFilter.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+
+
{label}
+ {#if isLoggedIn}
+
+ {:else}
+
{loginRequiredLabel}
+ {/if}
+
diff --git a/src/lib/components/FilterPanel.svelte b/src/lib/components/FilterPanel.svelte
new file mode 100644
index 00000000..8e06fb1d
--- /dev/null
+++ b/src/lib/components/FilterPanel.svelte
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/IconFilter.svelte b/src/lib/components/IconFilter.svelte
new file mode 100644
index 00000000..ac40244e
--- /dev/null
+++ b/src/lib/components/IconFilter.svelte
@@ -0,0 +1,195 @@
+
+
+
+
+
+
{label}
+
+
+
+
+
+ {#if selected}
+
+
+
+ {/if}
+
diff --git a/src/lib/components/Search.svelte b/src/lib/components/Search.svelte
index 74e42f9b..10ce3045 100644
--- a/src/lib/components/Search.svelte
+++ b/src/lib/components/Search.svelte
@@ -2,6 +2,7 @@
import {onMount} from "svelte";
import { browser } from '$app/environment';
import "$lib/css/nordtheme.css";
+ import FilterPanel from './FilterPanel.svelte';
// Filter props for different contexts
let {
@@ -12,7 +13,9 @@
favoritesOnly = false,
lang = 'de',
recipes = [],
- onSearchResults = (matchedIds, matchedCategories) => {}
+ categories = [],
+ onSearchResults = (matchedIds, matchedCategories) => {},
+ isLoggedIn = false
} = $props();
const isEnglish = $derived(lang === 'en');
@@ -25,13 +28,74 @@
let searchQuery = $state('');
+ // Filter data loaded from APIs
+ let availableTags = $state([]);
+ let availableIcons = $state([]);
+
+ // Selected filters (internal state)
+ let selectedCategory = $state(null);
+ let selectedTags = $state([]);
+ let selectedIcon = $state(null);
+ let selectedSeasons = $state([]);
+ let selectedFavoritesOnly = $state(false);
+
+ // Initialize from props (for backwards compatibility)
+ $effect(() => {
+ selectedCategory = category || null;
+ selectedTags = tag ? [tag] : [];
+ selectedIcon = icon || null;
+ selectedSeasons = season ? [parseInt(season)] : [];
+ selectedFavoritesOnly = favoritesOnly;
+ });
+
+ // Apply non-text filters (category, tags, icon, season)
+ function applyNonTextFilters(recipeList) {
+ return recipeList.filter(recipe => {
+ // Category filter
+ if (selectedCategory && recipe.category !== selectedCategory) {
+ return false;
+ }
+
+ // Multi-tag AND logic: recipe must have ALL selected tags
+ if (selectedTags.length > 0) {
+ const recipeTags = recipe.tags || [];
+ if (!selectedTags.every(tag => recipeTags.includes(tag))) {
+ return false;
+ }
+ }
+
+ // Icon filter
+ if (selectedIcon && recipe.icon !== selectedIcon) {
+ return false;
+ }
+
+ // Season filter: recipe in any selected season
+ if (selectedSeasons.length > 0) {
+ const recipeSeasons = recipe.season || [];
+ if (!selectedSeasons.some(s => recipeSeasons.includes(s))) {
+ return false;
+ }
+ }
+
+ // Favorites filter
+ if (selectedFavoritesOnly && !recipe.isFavorite) {
+ return false;
+ }
+
+ return true;
+ });
+ }
+
// Perform search directly (no worker)
function performSearch(query) {
- // Empty query = show all recipes
+ // Apply non-text filters first
+ const filteredByNonText = applyNonTextFilters(recipes);
+
+ // Empty query = show all (filtered) recipes
if (!query || query.trim().length === 0) {
onSearchResults(
- new Set(recipes.map(r => r._id)),
- new Set(recipes.map(r => r.category))
+ new Set(filteredByNonText.map(r => r._id)),
+ new Set(filteredByNonText.map(r => r.category))
);
return;
}
@@ -42,8 +106,8 @@
.replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ").filter(term => term.length > 0);
- // Filter recipes
- const matched = recipes.filter(recipe => {
+ // Filter recipes by text
+ const matched = filteredByNonText.filter(recipe => {
// Build searchable string from recipe data
const searchString = [
recipe.name || '',
@@ -71,11 +135,21 @@
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');
+ if (selectedCategory) url.searchParams.set('category', selectedCategory);
+
+ // Multiple tags: use comma-separated format
+ if (selectedTags.length > 0) {
+ url.searchParams.set('tags', selectedTags.join(','));
+ }
+
+ if (selectedIcon) url.searchParams.set('icon', selectedIcon);
+
+ // Multiple seasons: use comma-separated format
+ if (selectedSeasons.length > 0) {
+ url.searchParams.set('seasons', selectedSeasons.join(','));
+ }
+
+ if (selectedFavoritesOnly) url.searchParams.set('favorites', 'true');
return url.toString();
} else {
// Server-side fallback - return just the base path
@@ -83,6 +157,36 @@
}
}
+ // Filter change handlers
+ function handleCategoryChange(newCategory) {
+ selectedCategory = newCategory;
+ performSearch(searchQuery);
+ }
+
+ function handleTagToggle(tag) {
+ if (selectedTags.includes(tag)) {
+ selectedTags = selectedTags.filter(t => t !== tag);
+ } else {
+ selectedTags = [...selectedTags, tag];
+ }
+ performSearch(searchQuery);
+ }
+
+ function handleIconChange(newIcon) {
+ selectedIcon = newIcon;
+ performSearch(searchQuery);
+ }
+
+ function handleSeasonChange(newSeasons) {
+ selectedSeasons = newSeasons;
+ performSearch(searchQuery);
+ }
+
+ function handleFavoritesToggle(enabled) {
+ selectedFavoritesOnly = enabled;
+ performSearch(searchQuery);
+ }
+
function handleSubmit(event) {
if (browser) {
// For JS-enabled browsers, prevent default and navigate programmatically
@@ -113,7 +217,26 @@
}
});
- onMount(() => {
+ // Load filter data reactively when language changes
+ $effect(() => {
+ const loadFilterData = async () => {
+ try {
+ const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
+ const [tagsRes, iconsRes] = await Promise.all([
+ fetch(`${apiBase}/items/tag`),
+ fetch('/api/rezepte/items/icon')
+ ]);
+ availableTags = await tagsRes.json();
+ availableIcons = await iconsRes.json();
+ } catch (error) {
+ console.error('Failed to load filter data:', error);
+ }
+ };
+
+ loadFilterData();
+ });
+
+ onMount(async () => {
// Swap buttons for JS-enabled experience
const submitButton = document.getElementById('submit-search');
const clearButton = document.getElementById('clear-search');
@@ -194,12 +317,16 @@ scale: 0.8 0.8;
}
+
+
diff --git a/src/lib/components/SeasonFilter.svelte b/src/lib/components/SeasonFilter.svelte
new file mode 100644
index 00000000..e8520e7e
--- /dev/null
+++ b/src/lib/components/SeasonFilter.svelte
@@ -0,0 +1,217 @@
+
+
+
+
+
+
{label}
+
+
+
+
+
+ {#if selectedMonthNames.length > 0}
+
+ {#each selectedMonthNames as month}
+ handleMonthRemove(month.number)}
+ />
+ {/each}
+
+ {/if}
+
diff --git a/src/lib/components/TagChip.svelte b/src/lib/components/TagChip.svelte
new file mode 100644
index 00000000..37f142e9
--- /dev/null
+++ b/src/lib/components/TagChip.svelte
@@ -0,0 +1,74 @@
+
+
+
+
+
diff --git a/src/lib/components/TagFilter.svelte b/src/lib/components/TagFilter.svelte
new file mode 100644
index 00000000..268ebd7b
--- /dev/null
+++ b/src/lib/components/TagFilter.svelte
@@ -0,0 +1,215 @@
+
+
+
+
+
+
{label}
+
+
+
+
+
+ {#if selectedTags.length > 0}
+
+ {#each selectedTags as tag}
+
+ {/each}
+
+ {/if}
+
diff --git a/src/routes/[recipeLang=recipeLang]/+page.svelte b/src/routes/[recipeLang=recipeLang]/+page.svelte
index e1fd93ec..97cf26dd 100644
--- a/src/routes/[recipeLang=recipeLang]/+page.svelte
+++ b/src/routes/[recipeLang=recipeLang]/+page.svelte
@@ -101,7 +101,7 @@ h1{
{labels.title}
{labels.subheading}
-
+
{#if seasonRecipes.length > 0}
diff --git a/src/routes/[recipeLang=recipeLang]/search/+page.server.ts b/src/routes/[recipeLang=recipeLang]/search/+page.server.ts
index 85283bfd..46de24ae 100644
--- a/src/routes/[recipeLang=recipeLang]/search/+page.server.ts
+++ b/src/routes/[recipeLang=recipeLang]/search/+page.server.ts
@@ -7,18 +7,41 @@ export const load: PageServerLoad = async ({ url, fetch, params, locals }) => {
const query = url.searchParams.get('q') || '';
const category = url.searchParams.get('category');
- const tag = url.searchParams.get('tag');
+
+ // Handle both old and new tag params
+ const singleTag = url.searchParams.get('tag');
+ const multipleTags = url.searchParams.get('tags');
+ const tags = multipleTags
+ ? multipleTags.split(',').map(t => t.trim()).filter(Boolean)
+ : (singleTag ? [singleTag] : []);
+
const icon = url.searchParams.get('icon');
- const season = url.searchParams.get('season');
+
+ // Handle multiple seasons
+ const singleSeason = url.searchParams.get('season');
+ const multipleSeasons = url.searchParams.get('seasons');
+ const seasons = multipleSeasons
+ ? multipleSeasons.split(',').map(s => s.trim()).filter(Boolean)
+ : (singleSeason ? [singleSeason] : []);
+
const favoritesOnly = url.searchParams.get('favorites') === 'true';
// Build API URL with filters
const apiUrl = new URL(`${apiBase}/search`, url.origin);
if (query) apiUrl.searchParams.set('q', query);
if (category) apiUrl.searchParams.set('category', category);
- if (tag) apiUrl.searchParams.set('tag', tag);
+
+ // Pass as comma-separated to API
+ if (tags.length > 0) {
+ apiUrl.searchParams.set('tags', tags.join(','));
+ }
+
if (icon) apiUrl.searchParams.set('icon', icon);
- if (season) apiUrl.searchParams.set('season', season);
+
+ if (seasons.length > 0) {
+ apiUrl.searchParams.set('seasons', seasons.join(','));
+ }
+
if (favoritesOnly) apiUrl.searchParams.set('favorites', 'true');
try {
@@ -39,9 +62,9 @@ export const load: PageServerLoad = async ({ url, fetch, params, locals }) => {
error: searchResponse.ok ? null : results.error || 'Search failed',
filters: {
category,
- tag,
+ tags, // Now an array
icon,
- season,
+ seasons, // Now an array
favoritesOnly
}
};
@@ -53,9 +76,9 @@ export const load: PageServerLoad = async ({ url, fetch, params, locals }) => {
error: 'Search failed',
filters: {
category,
- tag,
+ tags: [],
icon,
- season,
+ seasons: [],
favoritesOnly
}
};
diff --git a/src/routes/[recipeLang=recipeLang]/search/+page.svelte b/src/routes/[recipeLang=recipeLang]/search/+page.svelte
index a7216365..fb69277b 100644
--- a/src/routes/[recipeLang=recipeLang]/search/+page.svelte
+++ b/src/routes/[recipeLang=recipeLang]/search/+page.svelte
@@ -7,6 +7,9 @@
let current_month = new Date().getMonth() + 1;
const isEnglish = $derived(data.lang === 'en');
+ const categories = $derived(isEnglish
+ ? ["Main course", "Noodle", "Bread", "Dessert", "Soup", "Side dish", "Salad", "Cake", "Breakfast", "Sauce", "Ingredient", "Drink", "Spread", "Biscuits", "Snack"]
+ : ["Hauptspeise", "Nudel", "Brot", "Dessert", "Suppe", "Beilage", "Salat", "Kuchen", "Frühstück", "Sauce", "Zutat", "Getränk", "Aufstrich", "Guetzli", "Snack"]);
const labels = $derived({
title: isEnglish ? 'Search Results' : 'Suchergebnisse',
pageTitle: isEnglish
@@ -17,9 +20,9 @@
: 'Suchergebnisse in den Bockenschen Rezepten.',
filteredBy: isEnglish ? 'Filtered by:' : 'Gefiltert nach:',
category: isEnglish ? 'Category' : 'Kategorie',
- keyword: isEnglish ? 'Keyword' : 'Stichwort',
+ keywords: isEnglish ? 'Keywords' : 'Stichwörter',
icon: 'Icon',
- season: isEnglish ? 'Season' : 'Saison',
+ seasons: isEnglish ? 'Seasons' : 'Monate',
favoritesOnly: isEnglish ? 'Favorites only' : 'Nur Favoriten',
searchError: isEnglish ? 'Search error:' : 'Fehler bei der Suche:',
resultsFor: isEnglish ? 'results for' : 'Ergebnisse für',
@@ -73,25 +76,27 @@
{labels.title}
-{#if data.filters.category || data.filters.tag || data.filters.icon || data.filters.season || data.filters.favoritesOnly}
+{#if data.filters.category || data.filters.tags?.length > 0 || data.filters.icon || data.filters.seasons?.length > 0 || data.filters.favoritesOnly}
{labels.filteredBy}
- {#if data.filters.category}{labels.category} "{data.filters.category}"{/if}
- {#if data.filters.tag}{labels.keyword} "{data.filters.tag}"{/if}
- {#if data.filters.icon}{labels.icon} "{data.filters.icon}"{/if}
- {#if data.filters.season}{labels.season} "{data.filters.season}"{/if}
+ {#if data.filters.category}{labels.category}: "{data.filters.category}"{/if}
+ {#if data.filters.tags?.length > 0}{labels.keywords}: {data.filters.tags.join(', ')}{/if}
+ {#if data.filters.icon}{labels.icon}: "{data.filters.icon}"{/if}
+ {#if data.filters.seasons?.length > 0}{labels.seasons}: {data.filters.seasons.join(', ')}{/if}
{#if data.filters.favoritesOnly}{labels.favoritesOnly}{/if}
{/if}
diff --git a/src/routes/api/recipes/search/+server.ts b/src/routes/api/recipes/search/+server.ts
index c602d77b..97f87051 100644
--- a/src/routes/api/recipes/search/+server.ts
+++ b/src/routes/api/recipes/search/+server.ts
@@ -8,9 +8,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
const category = url.searchParams.get('category');
- const tag = url.searchParams.get('tag');
+
+ // Support both single tag (backwards compat) and multiple tags
+ const singleTag = url.searchParams.get('tag');
+ const multipleTags = url.searchParams.get('tags');
+ const tags = multipleTags
+ ? multipleTags.split(',').map(t => t.trim()).filter(Boolean)
+ : (singleTag ? [singleTag] : []);
+
const icon = url.searchParams.get('icon');
- const season = url.searchParams.get('season');
+
+ // Support both single season (backwards compat) and multiple seasons
+ const singleSeason = url.searchParams.get('season');
+ const multipleSeasons = url.searchParams.get('seasons');
+ const seasons = multipleSeasons
+ ? multipleSeasons.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
+ : (singleSeason ? [parseInt(singleSeason)].filter(n => !isNaN(n)) : []);
+
const favoritesOnly = url.searchParams.get('favorites') === 'true';
try {
@@ -24,19 +38,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
dbQuery['translations.en.category'] = category;
}
- if (tag) {
- dbQuery['translations.en.tags'] = { $in: [tag] };
+ // Multi-tag AND logic: recipe must have ALL selected tags
+ if (tags.length > 0) {
+ dbQuery['translations.en.tags'] = { $all: tags };
}
if (icon) {
dbQuery.icon = icon; // Icon is the same for both languages
}
- if (season) {
- const seasonNum = parseInt(season);
- if (!isNaN(seasonNum)) {
- dbQuery.season = { $in: [seasonNum] }; // Season is the same for both languages
- }
+ // Multi-season OR logic: recipe in any selected season
+ if (seasons.length > 0) {
+ dbQuery.season = { $in: seasons }; // Season is the same for both languages
}
// Get all recipes matching base filters
diff --git a/src/routes/api/rezepte/search/+server.ts b/src/routes/api/rezepte/search/+server.ts
index f5a1429e..282526ef 100644
--- a/src/routes/api/rezepte/search/+server.ts
+++ b/src/routes/api/rezepte/search/+server.ts
@@ -5,36 +5,49 @@ import { dbConnect } 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');
+
+ // Support both single tag (backwards compat) and multiple tags
+ const singleTag = url.searchParams.get('tag');
+ const multipleTags = url.searchParams.get('tags');
+ const tags = multipleTags
+ ? multipleTags.split(',').map(t => t.trim()).filter(Boolean)
+ : (singleTag ? [singleTag] : []);
+
const icon = url.searchParams.get('icon');
- const season = url.searchParams.get('season');
+
+ // Support both single season (backwards compat) and multiple seasons
+ const singleSeason = url.searchParams.get('season');
+ const multipleSeasons = url.searchParams.get('seasons');
+ const seasons = multipleSeasons
+ ? multipleSeasons.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
+ : (singleSeason ? [parseInt(singleSeason)].filter(n => !isNaN(n)) : []);
+
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] };
+
+ // Multi-tag AND logic: recipe must have ALL selected tags
+ if (tags.length > 0) {
+ dbQuery.tags = { $all: tags };
}
-
+
if (icon) {
dbQuery.icon = icon;
}
-
- if (season) {
- const seasonNum = parseInt(season);
- if (!isNaN(seasonNum)) {
- dbQuery.season = { $in: [seasonNum] };
- }
+
+ // Multi-season OR logic: recipe in any selected season
+ if (seasons.length > 0) {
+ dbQuery.season = { $in: seasons };
}
// Get all recipes matching base filters