Implement progressive enhancement for universal search with context-aware filtering
Some checks failed
CI / update (push) Failing after 5s

Add comprehensive search solution that works across all recipe pages with proper fallbacks. Features include universal API endpoint, context-aware filtering (category/tag/icon/season/favorites), and progressive enhancement with form submission fallback for no-JS users.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-04 17:53:59 +02:00
parent 88f9531a6f
commit 2dc871c50f
9 changed files with 348 additions and 84 deletions

View File

@@ -72,7 +72,7 @@
{/each} {/each}
</div> </div>
<section> <section>
<Search></Search> <Search icon={active_icon}></Search>
</section> </section>
<section> <section>
<slot name=recipes></slot> <slot name=recipes></slot>

View File

@@ -1,81 +1,128 @@
<script> <script>
import {onMount} from "svelte"; import {onMount} from "svelte";
import { browser } from '$app/environment';
import "$lib/css/nordtheme.css"; import "$lib/css/nordtheme.css";
// Filter props for different contexts
export let category = null;
export let tag = null;
export let icon = null;
export let season = null;
export let favoritesOnly = false;
export let searchResultsUrl = '/rezepte/search';
let searchQuery = '';
// 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
// This allows for future enhancements like instant search
const url = buildSearchUrl(searchQuery);
window.location.href = url;
}
// If no JS, form will submit normally
}
function clearSearch() {
searchQuery = '';
if (browser) {
// Reset any client-side filtering if present
const recipes = document.querySelectorAll(".search_me");
recipes.forEach(recipe => {
recipe.style.display = 'flex';
recipe.classList.remove("matched-recipe");
});
document.querySelectorAll(".media_scroller_wrapper").forEach( scroller => {
scroller.style.display= 'block'
});
}
}
onMount(() => { onMount(() => {
const recipes = document.querySelectorAll(".search_me"); // Swap buttons for JS-enabled experience
const search = document.getElementById("search"); const submitButton = document.getElementById('submit-search');
const clearSearch = document.getElementById("clear-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;
}
// Enhanced client-side filtering (existing functionality)
const recipes = document.querySelectorAll(".search_me");
const search = document.getElementById("search");
if (recipes.length > 0 && search) {
function do_search(click_only_result=false){
const searchText = search.value.toLowerCase().trim().normalize('NFD').replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ");
const hasFilter = searchText.length > 0;
function do_search(click_only_result=false){ let scrollers_with_results = [];
// grab search input value let scrollers = [];
const searchText = search.value.toLowerCase().trim().normalize('NFD').replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" "); recipes.forEach(recipe => {
const hasFilter = searchText.length > 0; const searchString = `${recipe.textContent} ${recipe.dataset.tags}`.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, '');
const isMatch = searchTerms.every(term => searchString.includes(term));
let scrollers_with_results = []; recipe.style.display = (isMatch ? 'flex' : 'none');
let scrollers = []; recipe.classList.toggle("matched-recipe", hasFilter && isMatch);
// for each recipe hide all but matched if(!scrollers.includes(recipe.parentNode)){
recipes.forEach(recipe => { scrollers.push(recipe.parentNode)
const searchString = `${recipe.textContent} ${recipe.dataset.tags}`.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, ''); }
const isMatch = searchTerms.every(term => searchString.includes(term)); if(!scrollers_with_results.includes(recipe.parentNode) && isMatch){
scrollers_with_results.push(recipe.parentNode)
}
})
scrollers_with_results.forEach( scroller => {
scroller.parentNode.style.display= 'block'
})
scrollers.filter(item => !scrollers_with_results.includes(item)).forEach( scroller => {
scroller.parentNode.style.display= 'none'
})
let items = document.querySelectorAll(".matched-recipe");
items = [...new Set(items)]
if(click_only_result && scrollers_with_results.length == 1 && items.length == 1){
items[0].click();
}
}
recipe.style.display = (isMatch ? 'flex' : 'none'); search.addEventListener("input", () => {
recipe.classList.toggle("matched-recipe", hasFilter && isMatch); searchQuery = search.value;
if(!scrollers.includes(recipe.parentNode)){ do_search();
scrollers.push(recipe.parentNode) })
}
if(!scrollers_with_results.includes(recipe.parentNode) && isMatch){ // Initial search if URL had query
scrollers_with_results.push(recipe.parentNode) if (urlQuery) {
} do_search(true);
}) }
scrollers_with_results.forEach( scroller => { }
scroller.parentNode.style.display= 'block' });
})
scrollers.filter(item => !scrollers_with_results.includes(item)).forEach( scroller => {
scroller.parentNode.style.display= 'none'
})
scroll
let items = document.querySelectorAll(".matched-recipe");
items = [...new Set(items)] // make unique as seasonal mediascroller can lead to duplicates
// if only one result and click_only_result is true, click it
if(click_only_result && scrollers_with_results.length == 1 && items.length == 1){
// add '/rezepte' to history to not force-redirect back to recipe if going back
items[0].click();
}
// if scrollers with results are presenet scroll first result into view
/*if(scrollers_with_results.length > 0){
scrollers_with_results[0].scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"});
}*/ // For now disabled because it is annoying on mobile
}
search.addEventListener("input", () => {
do_search();
})
clearSearch.addEventListener("click", () => {
search.value = "";
recipes.forEach(recipe => {
recipe.style.display = 'flex';
recipe.classList.remove("matched-recipe");
})
document.querySelectorAll(".media_scroller_wrapper").forEach( scroller => {
scroller.style.display= 'block'
})
})
let paramString = window.location.href.split('?')[1];
let queryString = new URLSearchParams(paramString);
for (let pair of queryString.entries()) {
if(pair[0] == 'q'){
const search = document.getElementById("search");
search.value=pair[1];
do_search(true);
}
}
});
</script> </script>
<style> <style>
@@ -110,7 +157,7 @@ input::placeholder{
scale: 1.02 1.02; scale: 1.02 1.02;
filter: drop-shadow(0.4em 0.5em 1em rgba(0,0,0,0.6)) filter: drop-shadow(0.4em 0.5em 1em rgba(0,0,0,0.6))
} }
button#clear-search { .search-button {
all: unset; all: unset;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -123,17 +170,35 @@ button#clear-search {
cursor: pointer; cursor: pointer;
transition: color 180ms ease-in-out; transition: color 180ms ease-in-out;
} }
button#clear-search:hover { .search-button:hover {
color: white; color: white;
scale: 1.1 1.1; scale: 1.1 1.1;
} }
button#clear-search:active{ .search-button:active{
transition: 50ms; transition: 50ms;
scale: 0.8 0.8; scale: 0.8 0.8;
} }
.search-button svg {
width: 100%;
height: 100%;
}
</style> </style>
<div class="search js-only"> <form class="search" method="get" action={buildSearchUrl('')} on:submit|preventDefault={handleSubmit}>
<input type="text" id="search" placeholder="Suche..."> {#if category}<input type="hidden" name="category" value={category} />{/if}
<button id="clear-search"> {#if tag}<input type="hidden" name="tag" value={tag} />{/if}
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>Sucheintrag löschen</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> {#if icon}<input type="hidden" name="icon" value={icon} />{/if}
</div> {#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="Suche..." 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>Suchen</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;" on:click={clearSearch}>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>Sucheintrag löschen</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>

View File

@@ -41,7 +41,7 @@ a.month:hover,
{/each} {/each}
</div> </div>
<section> <section>
<Search></Search> <Search season={active_index + 1}></Search>
</section> </section>
<section> <section>
<slot name=recipes></slot> <slot name=recipes></slot>

View File

@@ -0,0 +1,74 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import type { BriefRecipeType } from '../../../../types/types';
import { Recipe } from '../../../../models/Recipe';
import { dbConnect, dbDisconnect } 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');
const icon = url.searchParams.get('icon');
const season = url.searchParams.get('season');
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] };
}
if (icon) {
dbQuery.icon = icon;
}
if (season) {
const seasonNum = parseInt(season);
if (!isNaN(seasonNum)) {
dbQuery.season = { $in: [seasonNum] };
}
}
// Get all recipes matching base filters
let recipes = await Recipe.find(dbQuery, 'name short_name tags category icon description season dateModified').lean() as BriefRecipeType[];
// Handle favorites filter
if (favoritesOnly && locals.session?.user) {
const User = (await import('../../../../models/User')).User;
const user = await User.findById(locals.session.user.id);
if (user && user.favoriteRecipes) {
const favoriteShortNames = user.favoriteRecipes;
recipes = recipes.filter(recipe => favoriteShortNames.includes(recipe.short_name));
} else {
recipes = [];
}
}
// Apply text search if query provided
if (query) {
const searchTerms = query.normalize('NFD').replace(/\p{Diacritic}/gu, "").split(" ");
recipes = recipes.filter(recipe => {
const searchString = `${recipe.name} ${recipe.description || ''} ${recipe.tags?.join(' ') || ''}`.toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, '');
return searchTerms.every(term => searchString.includes(term));
});
}
await dbDisconnect();
return json(JSON.parse(JSON.stringify(recipes)));
} catch (error) {
await dbDisconnect();
return json({ error: 'Search failed' }, { status: 500 });
}
};

View File

@@ -14,7 +14,7 @@
} }
</style> </style>
<h1>Rezepte in Kategorie <q>{data.category}</q>:</h1> <h1>Rezepte in Kategorie <q>{data.category}</q>:</h1>
<Search></Search> <Search category={data.category}></Search>
<section> <section>
<Recipes> <Recipes>
{#each rand_array(data.recipes) as recipe} {#each rand_array(data.recipes) as recipe}

View File

@@ -40,7 +40,7 @@ h1{
{/if} {/if}
</p> </p>
<Search></Search> <Search favoritesOnly={true}></Search>
{#if data.error} {#if data.error}
<p class="empty-state">Fehler beim Laden der Favoriten: {data.error}</p> <p class="empty-state">Fehler beim Laden der Favoriten: {data.error}</p>

View File

@@ -0,0 +1,50 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, fetch }) => {
const query = url.searchParams.get('q') || '';
const category = url.searchParams.get('category');
const tag = url.searchParams.get('tag');
const icon = url.searchParams.get('icon');
const season = url.searchParams.get('season');
const favoritesOnly = url.searchParams.get('favorites') === 'true';
// Build API URL with filters
const apiUrl = new URL('/api/rezepte/search', url.origin);
if (query) apiUrl.searchParams.set('q', query);
if (category) apiUrl.searchParams.set('category', category);
if (tag) apiUrl.searchParams.set('tag', tag);
if (icon) apiUrl.searchParams.set('icon', icon);
if (season) apiUrl.searchParams.set('season', season);
if (favoritesOnly) apiUrl.searchParams.set('favorites', 'true');
try {
const response = await fetch(apiUrl.toString());
const results = await response.json();
return {
query,
results: response.ok ? results : [],
error: response.ok ? null : results.error || 'Search failed',
filters: {
category,
tag,
icon,
season,
favoritesOnly
}
};
} catch (error) {
return {
query,
results: [],
error: 'Search failed',
filters: {
category,
tag,
icon,
season,
favoritesOnly
}
};
}
};

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/Recipes.svelte';
import Search from '$lib/components/Search.svelte';
import Card from '$lib/components/Card.svelte';
export let data: PageData;
export let current_month = new Date().getMonth() + 1;
</script>
<style>
h1 {
text-align: center;
font-size: 3em;
}
.search-info {
text-align: center;
margin-bottom: 2rem;
color: var(--nord3);
}
.filter-info {
text-align: center;
margin-bottom: 1rem;
font-size: 0.9em;
color: var(--nord2);
}
</style>
<svelte:head>
<title>Suchergebnisse{data.query ? ` für "${data.query}"` : ''} - Bocken Rezepte</title>
<meta name="description" content="Suchergebnisse in den Bockenschen Rezepten." />
</svelte:head>
<h1>Suchergebnisse</h1>
{#if data.filters.category || data.filters.tag || data.filters.icon || data.filters.season || data.filters.favoritesOnly}
<div class="filter-info">
Gefiltert nach:
{#if data.filters.category}Kategorie "{data.filters.category}"{/if}
{#if data.filters.tag}Stichwort "{data.filters.tag}"{/if}
{#if data.filters.icon}Icon "{data.filters.icon}"{/if}
{#if data.filters.season}Saison "{data.filters.season}"{/if}
{#if data.filters.favoritesOnly}Nur Favoriten{/if}
</div>
{/if}
<Search
category={data.filters.category}
tag={data.filters.tag}
icon={data.filters.icon}
season={data.filters.season}
favoritesOnly={data.filters.favoritesOnly}
/>
{#if data.error}
<div class="search-info">
<p>Fehler bei der Suche: {data.error}</p>
</div>
{:else if data.query}
<div class="search-info">
<p>{data.results.length} Ergebnisse für "{data.query}"</p>
</div>
{/if}
{#if data.results.length > 0}
<Recipes>
{#each data.results as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={true}></Card>
{/each}
</Recipes>
{:else if data.query && !data.error}
<div class="search-info">
<p>Keine Rezepte gefunden.</p>
<p>Versuche es mit anderen Suchbegriffen.</p>
</div>
{/if}

View File

@@ -14,7 +14,7 @@
} }
</style> </style>
<h1>Rezepte mit Stichwort <q>{data.tag}</q>:</h1> <h1>Rezepte mit Stichwort <q>{data.tag}</q>:</h1>
<Search></Search> <Search tag={data.tag}></Search>
<section> <section>
<Recipes> <Recipes>
{#each rand_array(data.recipes) as recipe} {#each rand_array(data.recipes) as recipe}