From b98d4b7007f3c87354035d6e4a29c195b3dc6671 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 2 Jan 2026 21:30:04 +0100 Subject: [PATCH] feat: add comprehensive filter UI with chip-based dropdowns Add advanced filtering with category, tags (multi-select), icon, season, and favorites filters. All filters use consistent chip-based dropdown UI with type-to-search functionality. New Components: - TagChip.svelte: Reusable chip component with selected/removable states - CategoryFilter.svelte: Single-select category with chip dropdown - TagFilter.svelte: Multi-select tags with AND logic and chip dropdown - IconFilter.svelte: Single-select emoji icon with chip dropdown - SeasonFilter.svelte: Multi-select months with chip dropdown - FavoritesFilter.svelte: Toggle for favorites-only filtering - FilterPanel.svelte: Container with responsive layout and mobile toggle Search Component: - Integrated FilterPanel with all filter types - Added applyNonTextFilters() for category/tags/icon/season/favorites - Implemented favorites filter logic (recipe.isFavorite check) - Made tags/icons reload reactively when language changes with $effect - Updated buildSearchUrl() for comma-separated array parameters - Passed categories and isLoggedIn props to enable all filters Server API: - Both /api/rezepte/search and /api/recipes/search support: - Multi-tag AND logic using MongoDB $all operator - Multi-season filtering using MongoDB $in operator - Backwards compatible with single tag/season parameters - Updated search page server load to parse tag/season arrays UI/UX: - Filters display inline on wide screens with 2rem gap - Mobile: collapsible with subtle toggle button and slide-down animation - Chip-based dropdowns appear on focus with filtering as you type - Selected items display as removable chips below inputs (no background) - Centered labels on desktop, left-aligned on mobile - Reduced vertical spacing on mobile (0.3rem gap) - Max-width constraints: 500px for filters, 600px for panel on mobile - Consistent naming: "Tags" and "Icon" instead of German translations --- src/lib/components/CategoryFilter.svelte | 203 ++++++++++++++++ src/lib/components/FavoritesFilter.svelte | 80 +++++++ src/lib/components/FilterPanel.svelte | 164 +++++++++++++ src/lib/components/IconFilter.svelte | 195 ++++++++++++++++ src/lib/components/Search.svelte | 181 +++++++++++++-- src/lib/components/SeasonFilter.svelte | 217 ++++++++++++++++++ src/lib/components/TagChip.svelte | 74 ++++++ src/lib/components/TagFilter.svelte | 215 +++++++++++++++++ .../[recipeLang=recipeLang]/+page.svelte | 2 +- .../search/+page.server.ts | 39 +++- .../search/+page.svelte | 23 +- src/routes/api/recipes/search/+server.ts | 31 ++- src/routes/api/rezepte/search/+server.ts | 43 ++-- 13 files changed, 1407 insertions(+), 60 deletions(-) create mode 100644 src/lib/components/CategoryFilter.svelte create mode 100644 src/lib/components/FavoritesFilter.svelte create mode 100644 src/lib/components/FilterPanel.svelte create mode 100644 src/lib/components/IconFilter.svelte create mode 100644 src/lib/components/SeasonFilter.svelte create mode 100644 src/lib/components/TagChip.svelte create mode 100644 src/lib/components/TagFilter.svelte 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 dropdownOpen && filteredCategories.length > 0} + + {/if} +
+ + + {#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} + + {/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 dropdownOpen && filteredIcons.length > 0} + + {/if} +
+ + + {#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 dropdownOpen && filteredMonths.length > 0} + + {/if} +
+ + + {#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 dropdownOpen && filteredTags.length > 0} + + {/if} +
+ + + {#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