feat: add comprehensive filter UI with chip-based dropdowns
Some checks failed
CI / update (push) Failing after 1m10s
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:
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user