feat(zitare): add category filter chips to search results

Show clickable category pills below the search input when results
are available. Each chip shows the match count for that category.
Also respects display settings for category/source visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 20:30:04 +01:00
parent 6107d572a1
commit b7d1d2ec9a
3 changed files with 81 additions and 11 deletions

View file

@ -110,7 +110,9 @@
"createList": "als Liste erstellen",
"createListDescription": "Neue Liste mit diesem Namen erstellen",
"minChars": "Bitte gib mindestens 2 Zeichen ein",
"hint": "Suche nach Zitaten, Autoren oder Themen"
"hint": "Suche nach Zitaten, Autoren oder Themen",
"allCategories": "Alle",
"filterByCategory": "Nach Kategorie filtern"
},
"settings": {
"quoteLanguage": "Zitat-Sprache",

View file

@ -110,7 +110,9 @@
"createList": "create as list",
"createListDescription": "Create a new list with this name",
"minChars": "Please enter at least 2 characters",
"hint": "Search for quotes, authors, or topics"
"hint": "Search for quotes, authors, or topics",
"allCategories": "All",
"filterByCategory": "Filter by category"
},
"settings": {
"quoteLanguage": "Quote language",

View file

@ -1,25 +1,55 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { searchQuotes, type Quote } from '@zitare/content';
import { searchQuotes, getAllCategories, type Quote, type Category } from '@zitare/content';
import { ZitareEvents } from '@manacore/shared-utils/analytics';
import { quotesStore } from '$lib/stores/quotes.svelte';
import { zitareSettings } from '$lib/stores/settings.svelte';
import QuoteCard from '$lib/components/QuoteCard.svelte';
let searchTerm = $state('');
let lastTrackedTerm = $state('');
let selectedCategory = $state<Category | null>(null);
// Search results
let results = $derived<Quote[]>(
searchTerm.length >= 2 ? searchQuotes(searchTerm, quotesStore.language) : []
);
const categories = getAllCategories();
// Track search when results change (debounced by derived reactivity)
// Category i18n key mapping
const categoryKeys: Record<Category, string> = {
weisheit: 'categories.wisdom',
motivation: 'categories.motivation',
liebe: 'categories.love',
leben: 'categories.life',
erfolg: 'categories.success',
glueck: 'categories.happiness',
freundschaft: 'categories.friendship',
mut: 'categories.courage',
hoffnung: 'categories.hope',
natur: 'categories.nature',
};
// Search results with optional category filter
let results = $derived<Quote[]>(() => {
if (searchTerm.length < 2) return [];
const searchResults = searchQuotes(searchTerm, quotesStore.language);
if (!selectedCategory) return searchResults;
return searchResults.filter((q) => q.category === selectedCategory);
});
// Track search when results change
$effect(() => {
if (searchTerm.length >= 2 && searchTerm !== lastTrackedTerm) {
lastTrackedTerm = searchTerm;
ZitareEvents.searchPerformed(results.length);
}
});
function toggleCategory(cat: Category) {
selectedCategory = selectedCategory === cat ? null : cat;
}
function clearSearch() {
searchTerm = '';
selectedCategory = null;
}
</script>
<svelte:head>
@ -30,7 +60,7 @@
<h1 class="text-3xl font-bold text-foreground mb-6">{$_('search.title')}</h1>
<!-- Search Input -->
<div class="relative mb-8">
<div class="relative mb-4">
<svg
class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted"
fill="none"
@ -52,7 +82,7 @@
/>
{#if searchTerm}
<button
onclick={() => (searchTerm = '')}
onclick={clearSearch}
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-muted hover:text-foreground transition-colors"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -67,6 +97,38 @@
{/if}
</div>
<!-- Category Filter Chips -->
{#if searchTerm.length >= 2 && results.length > 0}
<div class="flex flex-wrap gap-2 mb-6">
<button
onclick={() => (selectedCategory = null)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors {selectedCategory ===
null
? 'bg-primary text-white'
: 'bg-surface-elevated text-foreground-secondary hover:text-foreground border border-border'}"
>
{$_('search.allCategories')}
</button>
{#each categories as cat}
{@const matchCount = searchQuotes(searchTerm, quotesStore.language).filter(
(q) => q.category === cat.name
).length}
{#if matchCount > 0}
<button
onclick={() => toggleCategory(cat.name)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors {selectedCategory ===
cat.name
? 'bg-primary text-white'
: 'bg-surface-elevated text-foreground-secondary hover:text-foreground border border-border'}"
>
{$_(categoryKeys[cat.name])}
<span class="ml-1 opacity-70">{matchCount}</span>
</button>
{/if}
{/each}
</div>
{/if}
<!-- Results -->
{#if searchTerm.length >= 2}
{#if results.length === 0}
@ -93,7 +155,11 @@
</p>
<div class="space-y-6">
{#each results as quote (quote.id)}
<QuoteCard {quote} showCategory showSource />
<QuoteCard
{quote}
showCategory={zitareSettings.showCategory}
showSource={zitareSettings.showSource}
/>
{/each}
</div>
{/if}