Some checks failed
CI / update (push) Failing after 0s
Remove Web Worker implementation and replace with debounced direct search to eliminate serialization overhead. Add pre-computed category Map and memoized filtering with $derived.by() to prevent redundant array iterations on every keystroke. Reduce debounce to 100ms for responsive feel. Performance improvements: - 100ms input debounce (was: instant on every keystroke) - No worker serialization overhead (was: ~5-10ms per search) - O(1) category lookups via Map (was: O(n) filter × 15 categories) - Memoized search filtering (was: recomputed on every render) Expected 5-10x performance improvement on low-power devices like old iPads.
215 lines
7.5 KiB
Svelte
215 lines
7.5 KiB
Svelte
<script>
|
||
import {onMount} from "svelte";
|
||
import { browser } from '$app/environment';
|
||
import "$lib/css/nordtheme.css";
|
||
|
||
// Filter props for different contexts
|
||
let {
|
||
category = null,
|
||
tag = null,
|
||
icon = null,
|
||
season = null,
|
||
favoritesOnly = false,
|
||
lang = 'de',
|
||
recipes = [],
|
||
onSearchResults = (matchedIds, matchedCategories) => {}
|
||
} = $props();
|
||
|
||
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('');
|
||
|
||
// Perform search directly (no worker)
|
||
function performSearch(query) {
|
||
// Empty query = show all recipes
|
||
if (!query || query.trim().length === 0) {
|
||
onSearchResults(
|
||
new Set(recipes.map(r => r._id)),
|
||
new Set(recipes.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
|
||
const matched = recipes.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(/­|/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 (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');
|
||
return url.toString();
|
||
} else {
|
||
// Server-side fallback - return just the base path
|
||
return searchResultsUrl;
|
||
}
|
||
}
|
||
|
||
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 - only triggers search 100ms after user stops typing
|
||
$effect(() => {
|
||
if (browser && recipes.length > 0) {
|
||
// Read searchQuery to track it as a dependency
|
||
const query = searchQuery;
|
||
|
||
// Set debounce timer
|
||
const timer = setTimeout(() => {
|
||
performSearch(query);
|
||
}, 100);
|
||
|
||
// Cleanup function - clear timer on re-run or unmount
|
||
return () => clearTimeout(timer);
|
||
}
|
||
});
|
||
|
||
onMount(() => {
|
||
// 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';
|
||
}
|
||
|
||
// 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;
|
||
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 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}
|
||
|
||
<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>
|