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

@@ -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;
}
</style>
<form class="search" method="get" action={buildSearchUrl('')} onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
{#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}
{#if selectedCategory}<input type="hidden" name="category" value={selectedCategory} />{/if}
{#each selectedTags as tag}
<input type="hidden" name="tag" value={tag} />
{/each}
{#if selectedIcon}<input type="hidden" name="icon" value={selectedIcon} />{/if}
{#each selectedSeasons as season}
<input type="hidden" name="season" value={season} />
{/each}
{#if selectedFavoritesOnly}<input type="hidden" name="favorites" value="true" />{/if}
<input type="text" id="search" name="q" placeholder={labels.placeholder} bind:value={searchQuery}>
<!-- Submit button (visible by default, hidden when JS loads) -->
@@ -212,3 +339,21 @@ scale: 0.8 0.8;
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>{labels.clearTitle}</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>
<FilterPanel
availableCategories={categories}
{availableTags}
{availableIcons}
{selectedCategory}
{selectedTags}
{selectedIcon}
{selectedSeasons}
{selectedFavoritesOnly}
{lang}
{isLoggedIn}
onCategoryChange={handleCategoryChange}
onTagToggle={handleTagToggle}
onIconChange={handleIconChange}
onSeasonChange={handleSeasonChange}
onFavoritesToggle={handleFavoritesToggle}
/>