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:
203
src/lib/components/CategoryFilter.svelte
Normal file
203
src/lib/components/CategoryFilter.svelte
Normal 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>
|
||||
80
src/lib/components/FavoritesFilter.svelte
Normal file
80
src/lib/components/FavoritesFilter.svelte
Normal 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>
|
||||
164
src/lib/components/FilterPanel.svelte
Normal file
164
src/lib/components/FilterPanel.svelte
Normal 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>
|
||||
195
src/lib/components/IconFilter.svelte
Normal file
195
src/lib/components/IconFilter.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
217
src/lib/components/SeasonFilter.svelte
Normal file
217
src/lib/components/SeasonFilter.svelte
Normal 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>
|
||||
74
src/lib/components/TagChip.svelte
Normal file
74
src/lib/components/TagChip.svelte
Normal 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>
|
||||
215
src/lib/components/TagFilter.svelte
Normal file
215
src/lib/components/TagFilter.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user