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
This commit is contained in:
2026-01-02 21:30:04 +01:00
parent 402cb015df
commit b98d4b7007
13 changed files with 1407 additions and 60 deletions
+22 -9
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
+28 -15
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