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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user