feat: add comprehensive filter UI with chip-based dropdowns
Some checks failed
CI / update (push) Failing after 1m10s

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
This commit is contained in:
2026-01-02 21:30:04 +01:00
parent 903722b335
commit 2f71b13de6
13 changed files with 1407 additions and 60 deletions

View File

@@ -101,7 +101,7 @@ h1{
<h1>{labels.title}</h1>
<p class=subheading>{labels.subheading}</p>
<Search lang={data.lang} recipes={data.all_brief} onSearchResults={handleSearchResults}></Search>
<Search lang={data.lang} recipes={data.all_brief} categories={categories} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
{#if seasonRecipes.length > 0}
<LazyCategory title={labels.inSeason} eager={true}>

View File

@@ -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
}
};

View File

@@ -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 @@
<h1>{labels.title}</h1>
{#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}
<div class="filter-info">
{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}
</div>
{/if}
<Search
category={data.filters.category}
tag={data.filters.tag}
tag={data.filters.tags?.[0] || null}
icon={data.filters.icon}
season={data.filters.season}
season={data.filters.seasons?.[0] || null}
favoritesOnly={data.filters.favoritesOnly}
lang={data.lang}
recipes={data.allRecipes}
categories={categories}
isLoggedIn={!!data.session?.user}
onSearchResults={handleSearchResults}
/>

View File

@@ -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

View File

@@ -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