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

@@ -0,0 +1,203 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from './TagChip.svelte';
let {
categories = [],
selected = null,
onChange = () => {},
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Category' : 'Kategorie');
const selectLabel = $derived(isEnglish ? 'Select category...' : 'Kategorie auswählen...');
let inputValue = $state('');
let dropdownOpen = $state(false);
// Filter categories based on input
const filteredCategories = $derived(
inputValue.trim() === ''
? categories
: categories.filter(cat =>
cat.toLowerCase().includes(inputValue.toLowerCase())
)
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur() {
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleCategorySelect(category) {
onChange(category);
inputValue = '';
dropdownOpen = false;
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
const value = inputValue.trim();
const matchedCat = categories.find(c => c.toLowerCase() === value.toLowerCase())
|| filteredCategories[0];
if (matchedCat) {
onChange(matchedCat);
inputValue = '';
}
} else if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
function handleRemove() {
onChange(null);
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord6);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.input-wrapper {
position: relative;
}
input {
all: unset;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord3);
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.selected-category {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={selectLabel}
autocomplete="off"
/>
<!-- Custom dropdown with category chips -->
{#if dropdownOpen && filteredCategories.length > 0}
<div class="dropdown">
{#each filteredCategories as category}
<TagChip
tag={category}
selected={false}
removable={false}
onToggle={() => handleCategorySelect(category)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected category display below -->
{#if selected}
<div class="selected-category">
<TagChip
tag={selected}
selected={true}
removable={true}
onToggle={handleRemove}
/>
</div>
{/if}
</div>

View File

@@ -0,0 +1,80 @@
<script>
import "$lib/css/nordtheme.css";
import Toggle from './Toggle.svelte';
let {
enabled = false,
onToggle = () => {},
isLoggedIn = false,
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Favorites Only' : 'Nur Favoriten');
const loginRequiredLabel = $derived(isEnglish ? 'Login required' : 'Anmeldung erforderlich');
let checked = $state(enabled);
$effect(() => {
checked = enabled;
});
function handleChange() {
onToggle(checked);
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 100%;
align-items: center;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
align-items: flex-start;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord6);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.login-required {
font-size: 0.85rem;
color: var(--nord3);
font-style: italic;
padding: 0.5rem 0;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
{#if isLoggedIn}
<Toggle
bind:checked={checked}
label=""
on:change={handleChange}
/>
{:else}
<div class="login-required">{loginRequiredLabel}</div>
{/if}
</div>

View File

@@ -0,0 +1,164 @@
<script>
import "$lib/css/nordtheme.css";
import CategoryFilter from './CategoryFilter.svelte';
import TagFilter from './TagFilter.svelte';
import IconFilter from './IconFilter.svelte';
import SeasonFilter from './SeasonFilter.svelte';
import FavoritesFilter from './FavoritesFilter.svelte';
let {
availableCategories = [],
availableTags = [],
availableIcons = [],
selectedCategory = null,
selectedTags = [],
selectedIcon = null,
selectedSeasons = [],
selectedFavoritesOnly = false,
lang = 'de',
isLoggedIn = false,
onCategoryChange = () => {},
onTagToggle = () => {},
onIconChange = () => {},
onSeasonChange = () => {},
onFavoritesToggle = () => {}
} = $props();
const isEnglish = $derived(lang === 'en');
const months = $derived(isEnglish
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
);
let filtersOpen = $state(false);
function toggleFilters() {
filtersOpen = !filtersOpen;
}
</script>
<style>
.filter-wrapper {
width: 900px;
max-width: 95vw;
margin: 1rem auto 2rem;
}
.toggle-button {
display: none;
background: transparent;
color: var(--nord3);
padding: 0.5rem 0.8rem;
border: 1px solid var(--nord2);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 150ms ease;
margin: 0 auto 1rem;
max-width: 200px;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.toggle-button:hover {
background: var(--nord1);
color: var(--nord4);
border-color: var(--nord3);
}
.arrow {
transition: transform 150ms ease;
font-size: 1rem;
}
.arrow.open {
transform: rotate(180deg);
}
.filter-panel {
display: grid;
grid-template-columns: 120px 120px 1fr 160px 140px;
gap: 2rem;
align-items: start;
}
@media (max-width: 968px) {
.toggle-button {
display: flex;
}
.filter-panel {
grid-template-columns: 1fr;
gap: 1rem;
max-width: 600px;
margin: 0 auto;
transition: all 200ms ease;
}
.filter-panel:not(.open) {
display: none;
}
.filter-panel.open {
display: grid;
animation: slideDown 200ms ease;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
<div class="filter-wrapper">
<button class="toggle-button" onclick={toggleFilters} type="button">
<span>{filtersOpen ? (isEnglish ? 'Hide Filters' : 'Filter ausblenden') : (isEnglish ? 'Show Filters' : 'Filter einblenden')}</span>
<span class="arrow" class:open={filtersOpen}>▼</span>
</button>
<div class="filter-panel" class:open={filtersOpen}>
<CategoryFilter
categories={availableCategories}
selected={selectedCategory}
onChange={onCategoryChange}
{lang}
/>
<IconFilter
{availableIcons}
selected={selectedIcon}
onChange={onIconChange}
{lang}
/>
<TagFilter
{availableTags}
{selectedTags}
onToggle={onTagToggle}
{lang}
/>
<SeasonFilter
{selectedSeasons}
onChange={onSeasonChange}
{lang}
{months}
/>
<FavoritesFilter
enabled={selectedFavoritesOnly}
onToggle={onFavoritesToggle}
{isLoggedIn}
{lang}
/>
</div>
</div>

View File

@@ -0,0 +1,195 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from './TagChip.svelte';
let {
availableIcons = [],
selected = null,
onChange = () => {},
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = 'Icon';
const selectLabel = $derived(isEnglish ? 'Select icon...' : 'Icon auswählen...');
let inputValue = $state('');
let dropdownOpen = $state(false);
// Filter icons based on input (though input for emoji is uncommon)
const filteredIcons = $derived(
inputValue.trim() === ''
? availableIcons
: availableIcons.filter(icon =>
icon.includes(inputValue.trim())
)
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur() {
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleIconSelect(icon) {
onChange(icon);
inputValue = '';
dropdownOpen = false;
}
function handleKeyDown(event) {
if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
function handleRemove() {
onChange(null);
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord6);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.input-wrapper {
position: relative;
}
input {
all: unset;
font-family: "Noto Color Emoji", emoji, sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord3);
font-family: sans-serif;
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.selected-icon {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={selectLabel}
autocomplete="off"
/>
<!-- Custom dropdown with icon chips -->
{#if dropdownOpen && filteredIcons.length > 0}
<div class="dropdown">
{#each filteredIcons as icon}
<TagChip
tag={icon}
selected={false}
removable={false}
onToggle={() => handleIconSelect(icon)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected icon display below -->
{#if selected}
<div class="selected-icon">
<TagChip
tag={selected}
selected={true}
removable={true}
onToggle={handleRemove}
/>
</div>
{/if}
</div>

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}
/>

View File

@@ -0,0 +1,217 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from './TagChip.svelte';
let {
selectedSeasons = [],
onChange = () => {},
lang = 'de',
months = []
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Season' : 'Saison');
const selectLabel = $derived(isEnglish ? 'Select season...' : 'Saison auswählen...');
let inputValue = $state('');
let dropdownOpen = $state(false);
// Available month options (not yet selected)
const availableMonths = $derived(
months.map((month, i) => ({ name: month, number: i + 1 }))
.filter(m => !selectedSeasons.includes(m.number))
);
// Filter months based on input
const filteredMonths = $derived(
inputValue.trim() === ''
? availableMonths
: availableMonths.filter(m =>
m.name.toLowerCase().includes(inputValue.toLowerCase())
)
);
// Selected months for display
const selectedMonthNames = $derived(
selectedSeasons
.map(num => ({ name: months[num - 1], number: num }))
.filter(m => m.name) // Filter out invalid months
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur() {
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleMonthSelect(monthNumber) {
onChange([...selectedSeasons, monthNumber]);
inputValue = '';
}
function handleMonthRemove(monthNumber) {
onChange(selectedSeasons.filter(m => m !== monthNumber));
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
const value = inputValue.trim();
const matchedMonth = availableMonths.find(m =>
m.name.toLowerCase() === value.toLowerCase()
) || filteredMonths[0];
if (matchedMonth) {
handleMonthSelect(matchedMonth.number);
}
} else if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord6);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.input-wrapper {
position: relative;
}
input {
all: unset;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord3);
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.selected-seasons {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={selectLabel}
autocomplete="off"
/>
<!-- Custom dropdown with month chips -->
{#if dropdownOpen && filteredMonths.length > 0}
<div class="dropdown">
{#each filteredMonths as month}
<TagChip
tag={month.name}
selected={false}
removable={false}
onToggle={() => handleMonthSelect(month.number)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected seasons display below -->
{#if selectedMonthNames.length > 0}
<div class="selected-seasons">
{#each selectedMonthNames as month}
<TagChip
tag={month.name}
selected={true}
removable={true}
onToggle={() => handleMonthRemove(month.number)}
/>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,74 @@
<script>
import "$lib/css/nordtheme.css";
let {
tag = '',
selected = false,
onToggle = () => {},
removable = true
} = $props();
function handleClick() {
onToggle(tag);
}
</script>
<style>
.tag-chip {
all: unset;
padding: 0.4rem 0.8rem;
border-radius: 1000px;
font-size: 0.9rem;
cursor: pointer;
transition: all 100ms ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
user-select: none;
white-space: nowrap;
}
.tag-chip.available {
background: var(--nord2);
color: var(--nord6);
}
.tag-chip.available:hover {
background: var(--nord3);
transform: scale(1.05);
}
.tag-chip.selected {
background: var(--nord10);
color: white;
}
.tag-chip.selected:hover {
background: var(--nord9);
transform: scale(1.05);
}
.tag-chip:active {
transform: scale(0.95);
}
.remove-icon {
font-size: 0.8rem;
font-weight: bold;
margin-left: 0.2rem;
}
</style>
<button
class="tag-chip"
class:available={!selected}
class:selected={selected}
onclick={handleClick}
type="button"
aria-pressed={selected}
>
{tag}
{#if selected && removable}
<span class="remove-icon" aria-hidden="true">×</span>
{/if}
</button>

View File

@@ -0,0 +1,215 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from './TagChip.svelte';
let {
availableTags = [],
selectedTags = [],
onToggle = () => {},
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = 'Tags';
const addTagLabel = $derived(isEnglish ? 'Type or select tag...' : 'Tag eingeben oder auswählen...');
// Filter out already selected tags
const unselectedTags = $derived(availableTags.filter(t => !selectedTags.includes(t)));
let inputValue = $state('');
let dropdownOpen = $state(false);
let dropdownElement = null;
// Filter tags based on input
const filteredTags = $derived(
inputValue.trim() === ''
? unselectedTags
: unselectedTags.filter(tag =>
tag.toLowerCase().includes(inputValue.toLowerCase())
)
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur(event) {
// Delay to allow click events on dropdown items
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleTagSelect(tag) {
onToggle(tag);
inputValue = '';
dropdownOpen = false;
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
const value = inputValue.trim();
// Try to find exact match or first filtered result
const matchedTag = availableTags.find(t => t.toLowerCase() === value.toLowerCase())
|| filteredTags[0];
if (matchedTag && !selectedTags.includes(matchedTag)) {
onToggle(matchedTag);
inputValue = '';
}
} else if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord6);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
.input-wrapper {
position: relative;
}
input {
all: unset;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord3);
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.dropdown:empty::after {
content: 'No tags found';
color: var(--nord3);
font-size: 0.85rem;
font-style: italic;
padding: 0.5rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={addTagLabel}
autocomplete="off"
/>
<!-- Custom dropdown with tag chips -->
{#if dropdownOpen && filteredTags.length > 0}
<div class="dropdown" bind:this={dropdownElement}>
{#each filteredTags as tag}
<TagChip
{tag}
selected={false}
removable={false}
onToggle={() => handleTagSelect(tag)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected tags display below -->
{#if selectedTags.length > 0}
<div class="selected-tags">
{#each selectedTags as tag}
<TagChip
{tag}
selected={true}
removable={true}
onToggle={onToggle}
/>
{/each}
</div>
{/if}
</div>

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