refactor: reorganize components into domain subfolders and replace relative imports

Move components from flat src/lib/components/ into recipes/, faith/, and
cospend/ subdirectories. Replace ~144 relative imports across API routes
and lib files with $models, $utils, $types, and $lib aliases. Add $types
alias to svelte.config.js. Remove unused EditRecipe.svelte.
This commit is contained in:
2026-02-10 21:46:16 +01:00
parent 896a99382d
commit 9e7ab0b16f
149 changed files with 286 additions and 611 deletions
+411
View File
@@ -0,0 +1,411 @@
<script>
import {onMount} from "svelte";
import { browser } from '$app/environment';
import "$lib/css/nordtheme.css";
import FilterPanel from './FilterPanel.svelte';
import { getCategories } from '$lib/js/categories';
// Filter props for different contexts
let {
category = null,
tag = null,
icon = null,
season = null,
favoritesOnly = false,
lang = 'de',
recipes = [],
onSearchResults = (matchedIds, matchedCategories) => {},
isLoggedIn = false
} = $props();
// Generate categories internally based on language
const categories = $derived(getCategories(lang));
const isEnglish = $derived(lang === 'en');
const searchResultsUrl = $derived(isEnglish ? '/recipes/search' : '/rezepte/search');
const labels = $derived({
placeholder: isEnglish ? 'Search...' : 'Suche...',
searchTitle: isEnglish ? 'Search' : 'Suchen',
clearTitle: isEnglish ? 'Clear search' : 'Sucheintrag löschen'
});
let searchQuery = $state('');
let showFilters = $state(false);
// 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);
let useAndLogic = $state(true);
// 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 => {
if (useAndLogic) {
// AND mode: recipe must satisfy ALL active filters
// Category filter (single value in AND mode)
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 (single value in AND mode)
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;
} else {
// OR mode: recipe must satisfy AT LEAST ONE active filter
const categoryArray = Array.isArray(selectedCategory) ? selectedCategory : (selectedCategory ? [selectedCategory] : []);
const iconArray = Array.isArray(selectedIcon) ? selectedIcon : (selectedIcon ? [selectedIcon] : []);
const hasActiveFilters = categoryArray.length > 0 || selectedTags.length > 0 || iconArray.length > 0 || selectedSeasons.length > 0 || selectedFavoritesOnly;
// If no filters active, return all
if (!hasActiveFilters) {
return true;
}
// Check if recipe matches any filter
const matchesCategory = categoryArray.length > 0 ? categoryArray.includes(recipe.category) : false;
const matchesTags = selectedTags.length > 0 ? selectedTags.some(tag => (recipe.tags || []).includes(tag)) : false;
const matchesIcon = iconArray.length > 0 ? iconArray.includes(recipe.icon) : false;
const matchesSeasons = selectedSeasons.length > 0 ? selectedSeasons.some(s => (recipe.season || []).includes(s)) : false;
const matchesFavorites = selectedFavoritesOnly ? recipe.isFavorite : false;
return matchesCategory || matchesTags || matchesIcon || matchesSeasons || matchesFavorites;
}
});
}
// Perform search directly (no worker)
function performSearch(query) {
// Apply non-text filters first
const filteredByNonText = applyNonTextFilters(recipes);
// Empty query = show all (filtered) recipes
if (!query || query.trim().length === 0) {
onSearchResults(
new Set(filteredByNonText.map(r => r._id)),
new Set(filteredByNonText.map(r => r.category))
);
return;
}
// Normalize and split search query
const searchText = query.toLowerCase().trim()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ").filter(term => term.length > 0);
// Filter recipes by text
const matched = filteredByNonText.filter(recipe => {
// Build searchable string from recipe data
const searchString = [
recipe.name || '',
recipe.description || '',
...(recipe.tags || [])
].join(' ')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "")
.replace(/&shy;|­/g, ''); // Remove soft hyphens
// All search terms must match
return searchTerms.every(term => searchString.includes(term));
});
// Return matched recipe IDs and categories
onSearchResults(
new Set(matched.map(r => r._id)),
new Set(matched.map(r => r.category))
);
}
// Build search URL with current filters
function buildSearchUrl(query) {
if (browser) {
const url = new URL(searchResultsUrl, window.location.origin);
if (query) url.searchParams.set('q', query);
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
return searchResultsUrl;
}
}
// Filter change handlers - the effect will automatically trigger search
function handleCategoryChange(newCategory) {
selectedCategory = newCategory;
}
function handleTagToggle(tag) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter(t => t !== tag);
} else {
selectedTags = [...selectedTags, tag];
}
}
function handleIconChange(newIcon) {
selectedIcon = newIcon;
}
function handleSeasonChange(newSeasons) {
selectedSeasons = newSeasons;
}
function handleFavoritesToggle(enabled) {
selectedFavoritesOnly = enabled;
}
function handleLogicModeToggle(useAnd) {
useAndLogic = useAnd;
// When switching to AND mode, convert arrays to single values
if (useAnd) {
if (Array.isArray(selectedCategory)) {
selectedCategory = selectedCategory.length > 0 ? selectedCategory[0] : null;
}
if (Array.isArray(selectedIcon)) {
selectedIcon = selectedIcon.length > 0 ? selectedIcon[0] : null;
}
}
}
function handleSubmit(event) {
if (browser) {
// For JS-enabled browsers, prevent default and navigate programmatically
const url = buildSearchUrl(searchQuery);
window.location.href = url;
}
// If no JS, form will submit normally
}
function clearSearch() {
searchQuery = '';
performSearch('');
}
// Debounced search effect - triggers search when query or filters change
$effect(() => {
if (browser && recipes.length > 0) {
// Read all dependencies to track them
const query = searchQuery;
const cat = selectedCategory;
const tags = selectedTags;
const icn = selectedIcon;
const seasons = selectedSeasons;
const favsOnly = selectedFavoritesOnly;
const andLogic = useAndLogic;
// Set debounce timer
const timer = setTimeout(() => {
performSearch(query);
}, 100);
// Cleanup function - clear timer on re-run or unmount
return () => clearTimeout(timer);
}
});
// 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');
if (submitButton && clearButton) {
submitButton.style.display = 'none';
clearButton.style.display = 'flex';
}
// Enable filter panel for JS-enabled browsers
showFilters = true;
// Get initial search value from URL if present
const urlParams = new URLSearchParams(window.location.search);
const urlQuery = urlParams.get('q');
if (urlQuery) {
searchQuery = urlQuery;
} else {
// Show all recipes initially
performSearch('');
}
});
</script>
<style>
input#search {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: #fff;
padding: 0.7rem 2rem;
border-radius: 1000px;
width: 100%;
}
input::placeholder{
color: var(--nord6);
}
.search {
width: 500px;
max-width: 85vw;
position: relative;
margin: 2.5rem auto 1.2rem;
font-size: 1.6rem;
display: flex;
align-items: center;
transition: 100ms;
filter: drop-shadow(0.4em 0.5em 0.4em rgba(0,0,0,0.4))
}
.search:hover,
.search:focus-within
{
scale: 1.02 1.02;
filter: drop-shadow(0.4em 0.5em 1em rgba(0,0,0,0.6))
}
.search-button {
all: unset;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: 0.5em;
width: 1.5em;
height: 1.5em;
color: var(--nord6);
cursor: pointer;
transition: color 180ms ease-in-out;
}
.search-button:hover {
color: white;
scale: 1.1 1.1;
}
.search-button:active{
transition: 50ms;
scale: 0.8 0.8;
}
.search-button svg {
width: 100%;
height: 100%;
}
</style>
<form class="search" method="get" action={buildSearchUrl('')} onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
{#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) -->
<button type="submit" id="submit-search" class="search-button" style="display: flex;">
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512" style="width: 100%; height: 100%;"><title>{labels.searchTitle}</title><path d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="m338.29 338.29 105.25 105.25"></path></svg>
</button>
<!-- Clear button (hidden by default, shown when JS loads) -->
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" onclick={clearSearch}>
<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>
{#if showFilters}
<FilterPanel
availableCategories={categories}
{availableTags}
{availableIcons}
{selectedCategory}
{selectedTags}
{selectedIcon}
{selectedSeasons}
{selectedFavoritesOnly}
{useAndLogic}
{lang}
{isLoggedIn}
hideFavoritesFilter={favoritesOnly}
onCategoryChange={handleCategoryChange}
onTagToggle={handleTagToggle}
onIconChange={handleIconChange}
onSeasonChange={handleSeasonChange}
onFavoritesToggle={handleFavoritesToggle}
onLogicModeToggle={handleLogicModeToggle}
/>
{/if}