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 {onMount} from "svelte";
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import "$lib/css/nordtheme.css";
|
import "$lib/css/nordtheme.css";
|
||||||
|
import FilterPanel from './FilterPanel.svelte';
|
||||||
|
|
||||||
// Filter props for different contexts
|
// Filter props for different contexts
|
||||||
let {
|
let {
|
||||||
@@ -12,7 +13,9 @@
|
|||||||
favoritesOnly = false,
|
favoritesOnly = false,
|
||||||
lang = 'de',
|
lang = 'de',
|
||||||
recipes = [],
|
recipes = [],
|
||||||
onSearchResults = (matchedIds, matchedCategories) => {}
|
categories = [],
|
||||||
|
onSearchResults = (matchedIds, matchedCategories) => {},
|
||||||
|
isLoggedIn = false
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const isEnglish = $derived(lang === 'en');
|
const isEnglish = $derived(lang === 'en');
|
||||||
@@ -25,13 +28,74 @@
|
|||||||
|
|
||||||
let searchQuery = $state('');
|
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)
|
// Perform search directly (no worker)
|
||||||
function performSearch(query) {
|
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) {
|
if (!query || query.trim().length === 0) {
|
||||||
onSearchResults(
|
onSearchResults(
|
||||||
new Set(recipes.map(r => r._id)),
|
new Set(filteredByNonText.map(r => r._id)),
|
||||||
new Set(recipes.map(r => r.category))
|
new Set(filteredByNonText.map(r => r.category))
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,8 +106,8 @@
|
|||||||
.replace(/\p{Diacritic}/gu, "");
|
.replace(/\p{Diacritic}/gu, "");
|
||||||
const searchTerms = searchText.split(" ").filter(term => term.length > 0);
|
const searchTerms = searchText.split(" ").filter(term => term.length > 0);
|
||||||
|
|
||||||
// Filter recipes
|
// Filter recipes by text
|
||||||
const matched = recipes.filter(recipe => {
|
const matched = filteredByNonText.filter(recipe => {
|
||||||
// Build searchable string from recipe data
|
// Build searchable string from recipe data
|
||||||
const searchString = [
|
const searchString = [
|
||||||
recipe.name || '',
|
recipe.name || '',
|
||||||
@@ -71,11 +135,21 @@
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
const url = new URL(searchResultsUrl, window.location.origin);
|
const url = new URL(searchResultsUrl, window.location.origin);
|
||||||
if (query) url.searchParams.set('q', query);
|
if (query) url.searchParams.set('q', query);
|
||||||
if (category) url.searchParams.set('category', category);
|
if (selectedCategory) url.searchParams.set('category', selectedCategory);
|
||||||
if (tag) url.searchParams.set('tag', tag);
|
|
||||||
if (icon) url.searchParams.set('icon', icon);
|
// Multiple tags: use comma-separated format
|
||||||
if (season) url.searchParams.set('season', season);
|
if (selectedTags.length > 0) {
|
||||||
if (favoritesOnly) url.searchParams.set('favorites', 'true');
|
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();
|
return url.toString();
|
||||||
} else {
|
} else {
|
||||||
// Server-side fallback - return just the base path
|
// 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) {
|
function handleSubmit(event) {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// For JS-enabled browsers, prevent default and navigate programmatically
|
// 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
|
// Swap buttons for JS-enabled experience
|
||||||
const submitButton = document.getElementById('submit-search');
|
const submitButton = document.getElementById('submit-search');
|
||||||
const clearButton = document.getElementById('clear-search');
|
const clearButton = document.getElementById('clear-search');
|
||||||
@@ -194,11 +317,15 @@ scale: 0.8 0.8;
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<form class="search" method="get" action={buildSearchUrl('')} onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
<form class="search" method="get" action={buildSearchUrl('')} onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
||||||
{#if category}<input type="hidden" name="category" value={category} />{/if}
|
{#if selectedCategory}<input type="hidden" name="category" value={selectedCategory} />{/if}
|
||||||
{#if tag}<input type="hidden" name="tag" value={tag} />{/if}
|
{#each selectedTags as tag}
|
||||||
{#if icon}<input type="hidden" name="icon" value={icon} />{/if}
|
<input type="hidden" name="tag" value={tag} />
|
||||||
{#if season}<input type="hidden" name="season" value={season} />{/if}
|
{/each}
|
||||||
{#if favoritesOnly}<input type="hidden" name="favorites" value="true" />{/if}
|
{#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}>
|
<input type="text" id="search" name="q" placeholder={labels.placeholder} bind:value={searchQuery}>
|
||||||
|
|
||||||
@@ -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>
|
<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>
|
</button>
|
||||||
</form>
|
</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>
|
<h1>{labels.title}</h1>
|
||||||
<p class=subheading>{labels.subheading}</p>
|
<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}
|
{#if seasonRecipes.length > 0}
|
||||||
<LazyCategory title={labels.inSeason} eager={true}>
|
<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 query = url.searchParams.get('q') || '';
|
||||||
const category = url.searchParams.get('category');
|
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 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';
|
const favoritesOnly = url.searchParams.get('favorites') === 'true';
|
||||||
|
|
||||||
// Build API URL with filters
|
// Build API URL with filters
|
||||||
const apiUrl = new URL(`${apiBase}/search`, url.origin);
|
const apiUrl = new URL(`${apiBase}/search`, url.origin);
|
||||||
if (query) apiUrl.searchParams.set('q', query);
|
if (query) apiUrl.searchParams.set('q', query);
|
||||||
if (category) apiUrl.searchParams.set('category', category);
|
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 (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');
|
if (favoritesOnly) apiUrl.searchParams.set('favorites', 'true');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -39,9 +62,9 @@ export const load: PageServerLoad = async ({ url, fetch, params, locals }) => {
|
|||||||
error: searchResponse.ok ? null : results.error || 'Search failed',
|
error: searchResponse.ok ? null : results.error || 'Search failed',
|
||||||
filters: {
|
filters: {
|
||||||
category,
|
category,
|
||||||
tag,
|
tags, // Now an array
|
||||||
icon,
|
icon,
|
||||||
season,
|
seasons, // Now an array
|
||||||
favoritesOnly
|
favoritesOnly
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -53,9 +76,9 @@ export const load: PageServerLoad = async ({ url, fetch, params, locals }) => {
|
|||||||
error: 'Search failed',
|
error: 'Search failed',
|
||||||
filters: {
|
filters: {
|
||||||
category,
|
category,
|
||||||
tag,
|
tags: [],
|
||||||
icon,
|
icon,
|
||||||
season,
|
seasons: [],
|
||||||
favoritesOnly
|
favoritesOnly
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
let current_month = new Date().getMonth() + 1;
|
let current_month = new Date().getMonth() + 1;
|
||||||
|
|
||||||
const isEnglish = $derived(data.lang === 'en');
|
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({
|
const labels = $derived({
|
||||||
title: isEnglish ? 'Search Results' : 'Suchergebnisse',
|
title: isEnglish ? 'Search Results' : 'Suchergebnisse',
|
||||||
pageTitle: isEnglish
|
pageTitle: isEnglish
|
||||||
@@ -17,9 +20,9 @@
|
|||||||
: 'Suchergebnisse in den Bockenschen Rezepten.',
|
: 'Suchergebnisse in den Bockenschen Rezepten.',
|
||||||
filteredBy: isEnglish ? 'Filtered by:' : 'Gefiltert nach:',
|
filteredBy: isEnglish ? 'Filtered by:' : 'Gefiltert nach:',
|
||||||
category: isEnglish ? 'Category' : 'Kategorie',
|
category: isEnglish ? 'Category' : 'Kategorie',
|
||||||
keyword: isEnglish ? 'Keyword' : 'Stichwort',
|
keywords: isEnglish ? 'Keywords' : 'Stichwörter',
|
||||||
icon: 'Icon',
|
icon: 'Icon',
|
||||||
season: isEnglish ? 'Season' : 'Saison',
|
seasons: isEnglish ? 'Seasons' : 'Monate',
|
||||||
favoritesOnly: isEnglish ? 'Favorites only' : 'Nur Favoriten',
|
favoritesOnly: isEnglish ? 'Favorites only' : 'Nur Favoriten',
|
||||||
searchError: isEnglish ? 'Search error:' : 'Fehler bei der Suche:',
|
searchError: isEnglish ? 'Search error:' : 'Fehler bei der Suche:',
|
||||||
resultsFor: isEnglish ? 'results for' : 'Ergebnisse für',
|
resultsFor: isEnglish ? 'results for' : 'Ergebnisse für',
|
||||||
@@ -73,25 +76,27 @@
|
|||||||
|
|
||||||
<h1>{labels.title}</h1>
|
<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">
|
<div class="filter-info">
|
||||||
{labels.filteredBy}
|
{labels.filteredBy}
|
||||||
{#if data.filters.category}{labels.category} "{data.filters.category}"{/if}
|
{#if data.filters.category}{labels.category}: "{data.filters.category}"{/if}
|
||||||
{#if data.filters.tag}{labels.keyword} "{data.filters.tag}"{/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.icon}{labels.icon}: "{data.filters.icon}"{/if}
|
||||||
{#if data.filters.season}{labels.season} "{data.filters.season}"{/if}
|
{#if data.filters.seasons?.length > 0}{labels.seasons}: {data.filters.seasons.join(', ')}{/if}
|
||||||
{#if data.filters.favoritesOnly}{labels.favoritesOnly}{/if}
|
{#if data.filters.favoritesOnly}{labels.favoritesOnly}{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Search
|
<Search
|
||||||
category={data.filters.category}
|
category={data.filters.category}
|
||||||
tag={data.filters.tag}
|
tag={data.filters.tags?.[0] || null}
|
||||||
icon={data.filters.icon}
|
icon={data.filters.icon}
|
||||||
season={data.filters.season}
|
season={data.filters.seasons?.[0] || null}
|
||||||
favoritesOnly={data.filters.favoritesOnly}
|
favoritesOnly={data.filters.favoritesOnly}
|
||||||
lang={data.lang}
|
lang={data.lang}
|
||||||
recipes={data.allRecipes}
|
recipes={data.allRecipes}
|
||||||
|
categories={categories}
|
||||||
|
isLoggedIn={!!data.session?.user}
|
||||||
onSearchResults={handleSearchResults}
|
onSearchResults={handleSearchResults}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
|
|
||||||
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
|
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
|
||||||
const category = url.searchParams.get('category');
|
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 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';
|
const favoritesOnly = url.searchParams.get('favorites') === 'true';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,19 +38,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
dbQuery['translations.en.category'] = category;
|
dbQuery['translations.en.category'] = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tag) {
|
// Multi-tag AND logic: recipe must have ALL selected tags
|
||||||
dbQuery['translations.en.tags'] = { $in: [tag] };
|
if (tags.length > 0) {
|
||||||
|
dbQuery['translations.en.tags'] = { $all: tags };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (icon) {
|
if (icon) {
|
||||||
dbQuery.icon = icon; // Icon is the same for both languages
|
dbQuery.icon = icon; // Icon is the same for both languages
|
||||||
}
|
}
|
||||||
|
|
||||||
if (season) {
|
// Multi-season OR logic: recipe in any selected season
|
||||||
const seasonNum = parseInt(season);
|
if (seasons.length > 0) {
|
||||||
if (!isNaN(seasonNum)) {
|
dbQuery.season = { $in: seasons }; // Season is the same for both languages
|
||||||
dbQuery.season = { $in: [seasonNum] }; // Season is the same for both languages
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all recipes matching base filters
|
// Get all recipes matching base filters
|
||||||
|
|||||||
@@ -8,9 +8,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
|
|
||||||
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
|
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
|
||||||
const category = url.searchParams.get('category');
|
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 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';
|
const favoritesOnly = url.searchParams.get('favorites') === 'true';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -22,19 +36,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
dbQuery.category = category;
|
dbQuery.category = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tag) {
|
// Multi-tag AND logic: recipe must have ALL selected tags
|
||||||
dbQuery.tags = { $in: [tag] };
|
if (tags.length > 0) {
|
||||||
|
dbQuery.tags = { $all: tags };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (icon) {
|
if (icon) {
|
||||||
dbQuery.icon = icon;
|
dbQuery.icon = icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (season) {
|
// Multi-season OR logic: recipe in any selected season
|
||||||
const seasonNum = parseInt(season);
|
if (seasons.length > 0) {
|
||||||
if (!isNaN(seasonNum)) {
|
dbQuery.season = { $in: seasons };
|
||||||
dbQuery.season = { $in: [seasonNum] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all recipes matching base filters
|
// Get all recipes matching base filters
|
||||||
|
|||||||
Reference in New Issue
Block a user