mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
feat(zitare): smooth transitions, custom quotes, notes, new categories, fuzzy search
- Smooth fade/slide animation on quote cycling in workbench - Custom quotes: new table (DB v5), store, queries for user-created quotes - Notes field on favorites for personal thoughts - 3 new categories: humor, wissenschaft, kunst - 23 new quotes incl. contemporary authors (Angelou, Mandela, Winfrey, etc.) - 5 curated theme decks (Stoizismus, Feminismus, Philosophie, etc.) - getAllAuthors() for author browse pages with bio + quote counts - fuzzySearchQuotes() with bigram similarity for typo-tolerant search - Pre-built category + author indexes for O(1) lookups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
adb1649005
commit
28c10246e3
13 changed files with 855 additions and 44 deletions
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
let favorites = $state<LocalFavorite[]>([]);
|
||||
let quote = $state<Quote | null>(null);
|
||||
let transitioning = $state(false);
|
||||
|
||||
// Initialize once on mount (writes to store state — keep out of $effect
|
||||
// to avoid the read/write loop where reading currentQuote retriggers
|
||||
|
|
@ -59,8 +60,14 @@
|
|||
let currentTags = $derived(getTagsByIds(allTags, currentTagIds));
|
||||
|
||||
function nextQuote() {
|
||||
quotesStore.loadRandomQuote();
|
||||
quote = quotesStore.currentQuote;
|
||||
if (transitioning) return;
|
||||
transitioning = true;
|
||||
// After fade-out completes, swap quote and fade back in
|
||||
setTimeout(() => {
|
||||
quotesStore.loadRandomQuote();
|
||||
quote = quotesStore.currentQuote;
|
||||
transitioning = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function toggleFav(e: Event) {
|
||||
|
|
@ -102,42 +109,60 @@
|
|||
}}
|
||||
>
|
||||
{#if quote}
|
||||
<blockquote
|
||||
class="max-w-[280px] text-center text-base font-light italic leading-relaxed text-white/80"
|
||||
>
|
||||
«{quotesStore.getText(quote)}»
|
||||
</blockquote>
|
||||
<p class="mt-3 text-xs text-white/40">— {quote.author}</p>
|
||||
<div class="quote-transition" class:fade-out={transitioning}>
|
||||
<blockquote
|
||||
class="max-w-[280px] text-center text-base font-light italic leading-relaxed text-white/80"
|
||||
>
|
||||
«{quotesStore.getText(quote)}»
|
||||
</blockquote>
|
||||
<p class="mt-3 text-xs text-white/40">— {quote.author}</p>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if currentTags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{#each currentTags as tag (tag.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] text-white/50"
|
||||
style="background: {tag.color}20; border: 1px solid {tag.color}30"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" style="background: {tag.color}"></span>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Tags -->
|
||||
{#if currentTags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{#each currentTags as tag (tag.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] text-white/50"
|
||||
style="background: {tag.color}20; border: 1px solid {tag.color}30"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" style="background: {tag.color}"></span>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={toggleFav}
|
||||
class="mt-3 min-h-[44px] rounded-full p-1.5 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<Heart
|
||||
size={16}
|
||||
weight={isFav ? 'fill' : 'regular'}
|
||||
class="transition-colors {isFav ? 'text-red-400' : 'text-white/20 hover:text-white/40'}"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleFav}
|
||||
class="mt-3 min-h-[44px] rounded-full p-1.5 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<Heart
|
||||
size={16}
|
||||
weight={isFav ? 'fill' : 'regular'}
|
||||
class="transition-colors {isFav ? 'text-red-400' : 'text-white/20 hover:text-white/40'}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.quote-transition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition:
|
||||
opacity 0.2s ease-out,
|
||||
transform 0.2s ease-out;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.quote-transition.fade-out {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
:global(.mana-drop-target-hover) {
|
||||
outline: 2px solid rgba(139, 92, 246, 0.4);
|
||||
outline-offset: -2px;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalFavorite, LocalQuoteList } from './types';
|
||||
import type { LocalFavorite, LocalQuoteList, LocalCustomQuote } from './types';
|
||||
|
||||
// ─── Collection Accessors ──────────────────────────────────
|
||||
|
||||
export const favoriteTable = db.table<LocalFavorite>('zitareFavorites');
|
||||
export const listTable = db.table<LocalQuoteList>('zitareLists');
|
||||
export const customQuoteTable = db.table<LocalCustomQuote>('zitareCustomQuotes');
|
||||
|
||||
// ─── Guest Seed ────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@
|
|||
mut: 'quote-gradient-courage',
|
||||
hoffnung: 'quote-gradient-hope',
|
||||
natur: 'quote-gradient-nature',
|
||||
humor: 'quote-gradient-humor',
|
||||
wissenschaft: 'quote-gradient-science',
|
||||
kunst: 'quote-gradient-art',
|
||||
};
|
||||
|
||||
// Category labels
|
||||
|
|
@ -61,6 +64,9 @@
|
|||
mut: 'categories.courage',
|
||||
hoffnung: 'categories.hope',
|
||||
natur: 'categories.nature',
|
||||
humor: 'categories.humor',
|
||||
wissenschaft: 'categories.science',
|
||||
kunst: 'categories.art',
|
||||
};
|
||||
|
||||
async function toggleFavorite() {
|
||||
|
|
|
|||
|
|
@ -5,17 +5,20 @@
|
|||
export { favoritesStore } from './stores/favorites.svelte';
|
||||
export { listsStore } from './stores/lists.svelte';
|
||||
export { quotesStore } from './stores/quotes.svelte';
|
||||
export { customQuotesStore } from './stores/custom-quotes.svelte';
|
||||
export { zitareSettings } from './stores/settings.svelte';
|
||||
export { spiralStore } from './stores/spiral.svelte';
|
||||
export {
|
||||
useAllFavorites,
|
||||
useAllLists,
|
||||
useAllCustomQuotes,
|
||||
toFavorite,
|
||||
toQuoteList,
|
||||
toCustomQuote,
|
||||
isFavorite,
|
||||
findFavoriteByQuoteId,
|
||||
findListById,
|
||||
} from './queries';
|
||||
export type { Favorite, QuoteList } from './queries';
|
||||
export { favoriteTable, listTable, ZITARE_GUEST_SEED } from './collections';
|
||||
export type { LocalFavorite, LocalQuoteList } from './types';
|
||||
export type { Favorite, QuoteList, CustomQuote } from './queries';
|
||||
export { favoriteTable, listTable, customQuoteTable, ZITARE_GUEST_SEED } from './collections';
|
||||
export type { LocalFavorite, LocalQuoteList, LocalCustomQuote } from './types';
|
||||
|
|
|
|||
|
|
@ -6,5 +6,6 @@ export const zitareModuleConfig: ModuleConfig = {
|
|||
{ name: 'zitareFavorites', syncName: 'favorites' },
|
||||
{ name: 'zitareLists', syncName: 'lists' },
|
||||
{ name: 'zitareListTags' },
|
||||
{ name: 'zitareCustomQuotes', syncName: 'custom-quotes' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,13 +4,24 @@
|
|||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalFavorite, LocalQuoteList } from './types';
|
||||
import type { LocalFavorite, LocalQuoteList, LocalCustomQuote } from './types';
|
||||
|
||||
// ─── Domain Types ─────────────────────────────────────────
|
||||
|
||||
export interface Favorite {
|
||||
id: string;
|
||||
quoteId: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CustomQuote {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
year?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +40,19 @@ export function toFavorite(local: LocalFavorite): Favorite {
|
|||
return {
|
||||
id: local.id,
|
||||
quoteId: local.quoteId,
|
||||
notes: local.notes ?? undefined,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toCustomQuote(local: LocalCustomQuote): CustomQuote {
|
||||
return {
|
||||
id: local.id,
|
||||
text: local.text,
|
||||
author: local.author,
|
||||
category: local.category ?? undefined,
|
||||
source: local.source ?? undefined,
|
||||
year: local.year ?? undefined,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -62,6 +86,14 @@ export function useAllLists() {
|
|||
});
|
||||
}
|
||||
|
||||
/** All custom quotes. Auto-updates on any change. */
|
||||
export function useAllCustomQuotes() {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db.table<LocalCustomQuote>('zitareCustomQuotes').toArray();
|
||||
return locals.filter((q) => !q.deletedAt).map(toCustomQuote);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Pure Helper Functions (for $derived) ─────────────────
|
||||
|
||||
/** Check if a quote is in the favorites list. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Custom Quotes Store — Mutation-only
|
||||
* Handles CRUD for user-created quotes.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalCustomQuote } from '../types';
|
||||
|
||||
export interface CustomQuoteInput {
|
||||
text: string;
|
||||
author: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export const customQuotesStore = {
|
||||
async create(input: CustomQuoteInput): Promise<string> {
|
||||
const now = new Date().toISOString();
|
||||
const id = `custom-${crypto.randomUUID()}`;
|
||||
await db.table<LocalCustomQuote>('zitareCustomQuotes').add({
|
||||
id,
|
||||
text: input.text,
|
||||
author: input.author,
|
||||
category: input.category ?? null,
|
||||
source: input.source ?? null,
|
||||
year: input.year ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
||||
async update(id: string, updates: Partial<CustomQuoteInput>): Promise<void> {
|
||||
await db.table('zitareCustomQuotes').update(id, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.table('zitareCustomQuotes').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -37,4 +37,11 @@ export const favoritesStore = {
|
|||
await this.add(quoteId);
|
||||
}
|
||||
},
|
||||
|
||||
async setNotes(favoriteId: string, notes: string) {
|
||||
await db.table('zitareFavorites').update(favoriteId, {
|
||||
notes: notes || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import type { BaseRecord } from '@mana/local-store';
|
|||
export interface LocalFavorite extends BaseRecord {
|
||||
quoteId: string;
|
||||
tagIds?: string[] | null;
|
||||
/** Personal notes / thoughts about this quote. */
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalQuoteList extends BaseRecord {
|
||||
|
|
@ -14,3 +16,12 @@ export interface LocalQuoteList extends BaseRecord {
|
|||
description?: string | null;
|
||||
quoteIds: string[];
|
||||
}
|
||||
|
||||
/** A user-created custom quote stored locally. */
|
||||
export interface LocalCustomQuote extends BaseRecord {
|
||||
text: string;
|
||||
author: string;
|
||||
category?: string | null;
|
||||
source?: string | null;
|
||||
year?: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export const CATEGORIES = [
|
|||
'mut',
|
||||
'hoffnung',
|
||||
'natur',
|
||||
'humor',
|
||||
'wissenschaft',
|
||||
'kunst',
|
||||
] as const;
|
||||
|
||||
export type Category = (typeof CATEGORIES)[number];
|
||||
|
|
@ -25,13 +28,66 @@ export const CATEGORY_LABELS: Record<Category, string> = {
|
|||
liebe: 'Liebe',
|
||||
leben: 'Leben',
|
||||
erfolg: 'Erfolg',
|
||||
glueck: 'Glueck',
|
||||
glueck: 'Glück',
|
||||
freundschaft: 'Freundschaft',
|
||||
mut: 'Mut',
|
||||
hoffnung: 'Hoffnung',
|
||||
natur: 'Natur',
|
||||
humor: 'Humor',
|
||||
wissenschaft: 'Wissenschaft',
|
||||
kunst: 'Kunst',
|
||||
};
|
||||
|
||||
/** Curated theme decks — cross-category collections around a topic. */
|
||||
export const THEME_DECKS = [
|
||||
{
|
||||
id: 'stoizismus',
|
||||
label: 'Stoizismus',
|
||||
description: 'Gelassenheit und innere Stärke',
|
||||
authors: ['Marcus Aurelius', 'Seneca', 'Epiktet'],
|
||||
},
|
||||
{
|
||||
id: 'feminismus',
|
||||
label: 'Feminismus',
|
||||
description: 'Gleichberechtigung und Selbstbestimmung',
|
||||
authors: ['Simone de Beauvoir', 'Virginia Woolf', 'Maya Angelou', 'Marie Curie', 'Frida Kahlo'],
|
||||
},
|
||||
{
|
||||
id: 'unternehmertum',
|
||||
label: 'Unternehmertum',
|
||||
description: 'Innovation und Durchhaltevermögen',
|
||||
authors: ['Steve Jobs', 'Henry Ford', 'Thomas Edison', 'Walt Disney'],
|
||||
},
|
||||
{
|
||||
id: 'philosophie',
|
||||
label: 'Philosophie',
|
||||
description: 'Die großen Fragen des Lebens',
|
||||
authors: [
|
||||
'Sokrates',
|
||||
'Platon',
|
||||
'Aristoteles',
|
||||
'Immanuel Kant',
|
||||
'Friedrich Nietzsche',
|
||||
'Konfuzius',
|
||||
'Laozi',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'literatur',
|
||||
label: 'Literatur',
|
||||
description: 'Worte der großen Dichter und Schriftsteller',
|
||||
authors: [
|
||||
'Johann Wolfgang von Goethe',
|
||||
'Oscar Wilde',
|
||||
'Mark Twain',
|
||||
'William Shakespeare',
|
||||
'Rainer Maria Rilke',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ThemeDeckId = (typeof THEME_DECKS)[number]['id'];
|
||||
|
||||
/**
|
||||
* Get label for a category
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export type { Category } from './categories';
|
|||
|
||||
// Data
|
||||
export { QUOTES, QUOTE_COUNT } from './quotes';
|
||||
export { CATEGORIES, CATEGORY_LABELS } from './categories';
|
||||
export { CATEGORIES, CATEGORY_LABELS, THEME_DECKS } from './categories';
|
||||
export type { ThemeDeckId } from './categories';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
|
|
@ -31,9 +32,13 @@ export {
|
|||
getQuotesByTag,
|
||||
getAllTags,
|
||||
getQuotesByAuthor,
|
||||
getAllAuthors,
|
||||
getQuotesByThemeDeck,
|
||||
fuzzySearchQuotes,
|
||||
getVerifiedQuotes,
|
||||
getQuotesByYearRange,
|
||||
getQuotesByOriginalLanguage,
|
||||
} from './utils';
|
||||
export type { AuthorInfo } from './utils';
|
||||
|
||||
export { getCategoryLabel, isValidCategory } from './categories';
|
||||
|
|
|
|||
|
|
@ -1417,6 +1417,479 @@ export const QUOTES: Quote[] = [
|
|||
en: 'Greek philosopher from Ephesus (c. 535-475 BC).',
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// HUMOR
|
||||
// ============================================
|
||||
{
|
||||
id: 'humor-1',
|
||||
text: {
|
||||
original: 'Be yourself; everyone else is already taken.',
|
||||
de: 'Sei du selbst; alle anderen sind bereits vergeben.',
|
||||
en: 'Be yourself; everyone else is already taken.',
|
||||
it: 'Sii te stesso; tutti gli altri sono già occupati.',
|
||||
fr: 'Sois toi-même ; tous les autres sont déjà pris.',
|
||||
es: 'Sé tú mismo; todos los demás ya están ocupados.',
|
||||
},
|
||||
author: 'Oscar Wilde',
|
||||
category: 'humor',
|
||||
originalLanguage: 'en',
|
||||
tags: ['humor', 'identität', 'selbst'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Irischer Schriftsteller und Dramatiker (1854-1900).',
|
||||
en: 'Irish writer and playwright (1854-1900).',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'humor-2',
|
||||
text: {
|
||||
original: "I have not failed. I've just found 10,000 ways that won't work.",
|
||||
de: 'Ich habe nicht versagt. Ich habe nur 10.000 Wege gefunden, die nicht funktionieren.',
|
||||
en: "I have not failed. I've just found 10,000 ways that won't work.",
|
||||
it: 'Non ho fallito. Ho solo trovato 10.000 modi che non funzionano.',
|
||||
fr: "Je n'ai pas échoué. J'ai juste trouvé 10 000 façons qui ne fonctionnent pas.",
|
||||
es: 'No he fracasado. Solo he encontrado 10.000 formas que no funcionan.',
|
||||
},
|
||||
author: 'Thomas Edison',
|
||||
category: 'humor',
|
||||
originalLanguage: 'en',
|
||||
tags: ['humor', 'ausdauer', 'erfindung'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'US-amerikanischer Erfinder und Unternehmer (1847-1931).',
|
||||
en: 'American inventor and businessman (1847-1931).',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'humor-3',
|
||||
text: {
|
||||
original: 'I can resist everything except temptation.',
|
||||
de: 'Ich kann allem widerstehen, nur nicht der Versuchung.',
|
||||
en: 'I can resist everything except temptation.',
|
||||
it: 'Posso resistere a tutto tranne che alla tentazione.',
|
||||
fr: 'Je peux résister à tout, sauf à la tentation.',
|
||||
es: 'Puedo resistir todo, excepto la tentación.',
|
||||
},
|
||||
author: 'Oscar Wilde',
|
||||
category: 'humor',
|
||||
originalLanguage: 'en',
|
||||
source: "Lady Windermere's Fan",
|
||||
year: 1892,
|
||||
tags: ['humor', 'versuchung', 'ironie'],
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 'humor-4',
|
||||
text: {
|
||||
original:
|
||||
'Zwei Dinge sind unendlich, das Universum und die menschliche Dummheit, aber bei dem Universum bin ich mir noch nicht ganz sicher.',
|
||||
de: 'Zwei Dinge sind unendlich, das Universum und die menschliche Dummheit, aber bei dem Universum bin ich mir noch nicht ganz sicher.',
|
||||
en: "Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.",
|
||||
it: "Due cose sono infinite: l'universo e la stupidità umana; e non sono sicuro dell'universo.",
|
||||
fr: "Deux choses sont infinies : l'univers et la bêtise humaine ; et je ne suis pas sûr de l'univers.",
|
||||
es: 'Dos cosas son infinitas: el universo y la estupidez humana; y no estoy seguro del universo.',
|
||||
},
|
||||
author: 'Albert Einstein',
|
||||
category: 'humor',
|
||||
originalLanguage: 'de',
|
||||
tags: ['humor', 'intelligenz', 'universum'],
|
||||
verified: false,
|
||||
authorBio: {
|
||||
de: 'Deutsch-schweizerisch-amerikanischer Physiker (1879-1955), Nobelpreisträger.',
|
||||
en: 'German-Swiss-American physicist (1879-1955), Nobel laureate.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'humor-5',
|
||||
text: {
|
||||
original: 'In der Kürze liegt die Würze.',
|
||||
de: 'In der Kürze liegt die Würze.',
|
||||
en: 'Brevity is the soul of wit.',
|
||||
it: "La brevità è l'anima dell'arguzia.",
|
||||
fr: "La brièveté est l'âme de l'esprit.",
|
||||
es: 'La brevedad es el alma del ingenio.',
|
||||
},
|
||||
author: 'William Shakespeare',
|
||||
category: 'humor',
|
||||
originalLanguage: 'en',
|
||||
source: 'Hamlet',
|
||||
year: 1601,
|
||||
tags: ['humor', 'kürze', 'sprache'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Englischer Dramatiker und Dichter (1564-1616).',
|
||||
en: 'English playwright and poet (1564-1616).',
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// WISSENSCHAFT
|
||||
// ============================================
|
||||
{
|
||||
id: 'wiss-1',
|
||||
text: {
|
||||
original: 'Imagination is more important than knowledge.',
|
||||
de: 'Vorstellungskraft ist wichtiger als Wissen.',
|
||||
en: 'Imagination is more important than knowledge.',
|
||||
it: "L'immaginazione è più importante della conoscenza.",
|
||||
fr: "L'imagination est plus importante que le savoir.",
|
||||
es: 'La imaginación es más importante que el conocimiento.',
|
||||
},
|
||||
author: 'Albert Einstein',
|
||||
category: 'wissenschaft',
|
||||
originalLanguage: 'en',
|
||||
source: 'Interview, Saturday Evening Post',
|
||||
year: 1929,
|
||||
tags: ['wissenschaft', 'phantasie', 'wissen'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Deutsch-schweizerisch-amerikanischer Physiker (1879-1955), Nobelpreisträger.',
|
||||
en: 'German-Swiss-American physicist (1879-1955), Nobel laureate.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wiss-2',
|
||||
text: {
|
||||
original: 'Nothing in life is to be feared, it is only to be understood.',
|
||||
de: 'Nichts im Leben muss gefürchtet werden, es muss nur verstanden werden.',
|
||||
en: 'Nothing in life is to be feared, it is only to be understood.',
|
||||
it: "Nulla nella vita va temuto, dev'essere soltanto compreso.",
|
||||
fr: "Dans la vie, rien n'est à craindre, tout est à comprendre.",
|
||||
es: 'Nada en la vida debe ser temido, solamente comprendido.',
|
||||
},
|
||||
author: 'Marie Curie',
|
||||
category: 'wissenschaft',
|
||||
originalLanguage: 'fr',
|
||||
tags: ['wissenschaft', 'mut', 'verständnis'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Polnisch-französische Physikerin und Chemikerin (1867-1934), zweifache Nobelpreisträgerin.',
|
||||
en: 'Polish-French physicist and chemist (1867-1934), two-time Nobel laureate.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wiss-3',
|
||||
text: {
|
||||
original:
|
||||
'The important thing is not to stop questioning. Curiosity has its own reason for existing.',
|
||||
de: 'Das Wichtigste ist, nicht aufzuhören zu fragen. Neugier hat ihre eigene Daseinsberechtigung.',
|
||||
en: 'The important thing is not to stop questioning. Curiosity has its own reason for existing.',
|
||||
it: 'La cosa importante è non smettere di fare domande. La curiosità ha la sua ragione di esistere.',
|
||||
fr: "L'important est de ne pas cesser de questionner. La curiosité a sa propre raison d'exister.",
|
||||
es: 'Lo importante es no dejar de hacerse preguntas. La curiosidad tiene su propia razón de existir.',
|
||||
},
|
||||
author: 'Albert Einstein',
|
||||
category: 'wissenschaft',
|
||||
originalLanguage: 'en',
|
||||
source: 'LIFE Magazine',
|
||||
year: 1955,
|
||||
tags: ['wissenschaft', 'neugier', 'fragen'],
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 'wiss-4',
|
||||
text: {
|
||||
original: 'Somewhere, something incredible is waiting to be known.',
|
||||
de: 'Irgendwo wartet etwas Unglaubliches darauf, entdeckt zu werden.',
|
||||
en: 'Somewhere, something incredible is waiting to be known.',
|
||||
it: 'Da qualche parte, qualcosa di incredibile attende di essere scoperto.',
|
||||
fr: "Quelque part, quelque chose d'incroyable attend d'être découvert.",
|
||||
es: 'En algún lugar, algo increíble está esperando ser descubierto.',
|
||||
},
|
||||
author: 'Carl Sagan',
|
||||
category: 'wissenschaft',
|
||||
originalLanguage: 'en',
|
||||
tags: ['wissenschaft', 'entdeckung', 'kosmos'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'US-amerikanischer Astrophysiker und Wissenschaftskommunikator (1934-1996).',
|
||||
en: 'American astrophysicist and science communicator (1934-1996).',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wiss-5',
|
||||
text: {
|
||||
original: 'One, remember to look up at the stars and not down at your feet.',
|
||||
de: 'Erstens: Denkt daran, zu den Sternen aufzublicken und nicht auf eure Füße.',
|
||||
en: 'One, remember to look up at the stars and not down at your feet.',
|
||||
it: 'Primo: ricordatevi di guardare le stelle e non i vostri piedi.',
|
||||
fr: "Premièrement, n'oubliez pas de regarder les étoiles et non vos pieds.",
|
||||
es: 'Primero: recuerden mirar las estrellas y no sus pies.',
|
||||
},
|
||||
author: 'Stephen Hawking',
|
||||
category: 'wissenschaft',
|
||||
originalLanguage: 'en',
|
||||
tags: ['wissenschaft', 'sterne', 'perspektive'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Britischer Physiker und Kosmologe (1942-2018).',
|
||||
en: 'British physicist and cosmologist (1942-2018).',
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// KUNST
|
||||
// ============================================
|
||||
{
|
||||
id: 'kunst-1',
|
||||
text: {
|
||||
original: 'Every child is an artist. The problem is how to remain an artist once we grow up.',
|
||||
de: 'Jedes Kind ist ein Künstler. Das Problem ist, ein Künstler zu bleiben, wenn man erwachsen wird.',
|
||||
en: 'Every child is an artist. The problem is how to remain an artist once we grow up.',
|
||||
it: 'Ogni bambino è un artista. Il problema è come rimanere un artista quando si cresce.',
|
||||
fr: 'Chaque enfant est un artiste. Le problème est de rester un artiste en grandissant.',
|
||||
es: 'Todo niño es un artista. El problema es cómo seguir siendo artista cuando se crece.',
|
||||
},
|
||||
author: 'Pablo Picasso',
|
||||
category: 'kunst',
|
||||
originalLanguage: 'es',
|
||||
tags: ['kunst', 'kreativität', 'kindheit'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Spanischer Maler und Bildhauer (1881-1973), Mitbegründer des Kubismus.',
|
||||
en: 'Spanish painter and sculptor (1881-1973), co-founder of Cubism.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kunst-2',
|
||||
text: {
|
||||
original: 'Art is not what you see, but what you make others see.',
|
||||
de: 'Kunst ist nicht das, was du siehst, sondern das, was du andere sehen lässt.',
|
||||
en: 'Art is not what you see, but what you make others see.',
|
||||
it: "L'arte non è ciò che vedi, ma ciò che fai vedere agli altri.",
|
||||
fr: "L'art, ce n'est pas ce que vous voyez, mais ce que vous faites voir aux autres.",
|
||||
es: 'El arte no es lo que ves, sino lo que haces ver a los demás.',
|
||||
},
|
||||
author: 'Edgar Degas',
|
||||
category: 'kunst',
|
||||
originalLanguage: 'fr',
|
||||
tags: ['kunst', 'wahrnehmung', 'perspektive'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Französischer Maler und Bildhauer (1834-1917), Impressionist.',
|
||||
en: 'French painter and sculptor (1834-1917), Impressionist.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kunst-3',
|
||||
text: {
|
||||
original: 'Die Kunst ist eine Vermittlerin des Unaussprechlichen.',
|
||||
de: 'Die Kunst ist eine Vermittlerin des Unaussprechlichen.',
|
||||
en: 'Art is a mediator of the inexpressible.',
|
||||
it: "L'arte è una mediatrice dell'inesprimibile.",
|
||||
fr: "L'art est un médiateur de l'inexprimable.",
|
||||
es: 'El arte es un mediador de lo inexpresable.',
|
||||
},
|
||||
author: 'Johann Wolfgang von Goethe',
|
||||
category: 'kunst',
|
||||
originalLanguage: 'de',
|
||||
source: 'Maximen und Reflexionen',
|
||||
tags: ['kunst', 'sprache', 'ausdruck'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Deutscher Dichter und Naturforscher (1749-1832).',
|
||||
en: 'German poet and naturalist (1749-1832).',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kunst-4',
|
||||
text: {
|
||||
original: 'I dream my painting and I paint my dream.',
|
||||
de: 'Ich träume mein Gemälde und male meinen Traum.',
|
||||
en: 'I dream my painting and I paint my dream.',
|
||||
it: 'Sogno il mio dipinto e dipingo il mio sogno.',
|
||||
fr: 'Je rêve ma peinture et je peins mon rêve.',
|
||||
es: 'Sueño mi pintura y pinto mi sueño.',
|
||||
},
|
||||
author: 'Vincent van Gogh',
|
||||
category: 'kunst',
|
||||
originalLanguage: 'nl',
|
||||
tags: ['kunst', 'traum', 'malerei'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Niederländischer Maler (1853-1890), Wegbereiter der modernen Kunst.',
|
||||
en: 'Dutch painter (1853-1890), pioneer of modern art.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kunst-5',
|
||||
text: {
|
||||
original: 'Creativity takes courage.',
|
||||
de: 'Kreativität erfordert Mut.',
|
||||
en: 'Creativity takes courage.',
|
||||
it: 'La creatività richiede coraggio.',
|
||||
fr: 'La créativité demande du courage.',
|
||||
es: 'La creatividad requiere coraje.',
|
||||
},
|
||||
author: 'Henri Matisse',
|
||||
category: 'kunst',
|
||||
originalLanguage: 'fr',
|
||||
tags: ['kunst', 'mut', 'kreativität'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Französischer Maler (1869-1954), Wegbereiter des Fauvismus.',
|
||||
en: 'French painter (1869-1954), pioneer of Fauvism.',
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// ZEITGENÖSSISCH (across categories)
|
||||
// ============================================
|
||||
{
|
||||
id: 'zeit-1',
|
||||
text: {
|
||||
original: "Your time is limited, so don't waste it living someone else's life.",
|
||||
de: 'Deine Zeit ist begrenzt, also verschwende sie nicht damit, das Leben eines anderen zu leben.',
|
||||
en: "Your time is limited, so don't waste it living someone else's life.",
|
||||
it: 'Il tuo tempo è limitato, quindi non sprecarlo vivendo la vita di qualcun altro.',
|
||||
fr: "Votre temps est limité, ne le gaspillez pas en vivant la vie de quelqu'un d'autre.",
|
||||
es: 'Tu tiempo es limitado, no lo desperdicies viviendo la vida de otra persona.',
|
||||
},
|
||||
author: 'Steve Jobs',
|
||||
category: 'leben',
|
||||
originalLanguage: 'en',
|
||||
source: 'Stanford Commencement Speech',
|
||||
year: 2005,
|
||||
tags: ['leben', 'zeit', 'authentizität'],
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 'zeit-2',
|
||||
text: {
|
||||
original:
|
||||
"I've learned that people will forget what you said, people will forget what you did, but people will never forget how you made them feel.",
|
||||
de: 'Ich habe gelernt, dass Menschen vergessen, was du gesagt hast, vergessen, was du getan hast, aber nie vergessen, wie du sie fühlen ließest.',
|
||||
en: "I've learned that people will forget what you said, people will forget what you did, but people will never forget how you made them feel.",
|
||||
it: 'Ho imparato che le persone dimenticheranno ciò che hai detto, ciò che hai fatto, ma non dimenticheranno mai come le hai fatte sentire.',
|
||||
fr: "J'ai appris que les gens oublieront ce que vous avez dit, ce que vous avez fait, mais jamais ce que vous leur avez fait ressentir.",
|
||||
es: 'He aprendido que la gente olvidará lo que dijiste, lo que hiciste, pero nunca olvidará cómo les hiciste sentir.',
|
||||
},
|
||||
author: 'Maya Angelou',
|
||||
category: 'weisheit',
|
||||
originalLanguage: 'en',
|
||||
tags: ['weisheit', 'empathie', 'gefühl'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'US-amerikanische Schriftstellerin und Bürgerrechtsaktivistin (1928-2014).',
|
||||
en: 'American writer and civil rights activist (1928-2014).',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zeit-3',
|
||||
text: {
|
||||
original: 'Education is the most powerful weapon which you can use to change the world.',
|
||||
de: 'Bildung ist die mächtigste Waffe, die du nutzen kannst, um die Welt zu verändern.',
|
||||
en: 'Education is the most powerful weapon which you can use to change the world.',
|
||||
it: "L'istruzione è l'arma più potente che puoi usare per cambiare il mondo.",
|
||||
fr: "L'éducation est l'arme la plus puissante qu'on puisse utiliser pour changer le monde.",
|
||||
es: 'La educación es el arma más poderosa que puedes usar para cambiar el mundo.',
|
||||
},
|
||||
author: 'Nelson Mandela',
|
||||
category: 'weisheit',
|
||||
originalLanguage: 'en',
|
||||
year: 2003,
|
||||
tags: ['bildung', 'veränderung', 'welt'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'Südafrikanischer Freiheitskämpfer und Staatspräsident (1918-2013), Nobelpreisträger.',
|
||||
en: 'South African freedom fighter and president (1918-2013), Nobel laureate.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zeit-4',
|
||||
text: {
|
||||
original: 'Stay hungry, stay foolish.',
|
||||
de: 'Bleibt hungrig, bleibt verrückt.',
|
||||
en: 'Stay hungry, stay foolish.',
|
||||
it: 'Siate affamati, siate folli.',
|
||||
fr: 'Restez affamés, restez fous.',
|
||||
es: 'Sigan hambrientos, sigan alocados.',
|
||||
},
|
||||
author: 'Steve Jobs',
|
||||
category: 'motivation',
|
||||
originalLanguage: 'en',
|
||||
source: 'Stanford Commencement Speech',
|
||||
year: 2005,
|
||||
tags: ['motivation', 'neugier', 'leidenschaft'],
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 'zeit-5',
|
||||
text: {
|
||||
original: 'No one can make you feel inferior without your consent.',
|
||||
de: 'Niemand kann dir ohne dein Einverständnis das Gefühl geben, minderwertig zu sein.',
|
||||
en: 'No one can make you feel inferior without your consent.',
|
||||
it: 'Nessuno può farti sentire inferiore senza il tuo consenso.',
|
||||
fr: 'Personne ne peut vous faire sentir inférieur sans votre consentement.',
|
||||
es: 'Nadie puede hacerte sentir inferior sin tu consentimiento.',
|
||||
},
|
||||
author: 'Eleanor Roosevelt',
|
||||
category: 'mut',
|
||||
originalLanguage: 'en',
|
||||
tags: ['mut', 'selbstwert', 'stärke'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'US-amerikanische Menschenrechtsaktivistin und First Lady (1884-1962).',
|
||||
en: 'American human rights activist and First Lady (1884-1962).',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zeit-6',
|
||||
text: {
|
||||
original: 'It is during our darkest moments that we must focus to see the light.',
|
||||
de: 'In unseren dunkelsten Momenten müssen wir uns darauf konzentrieren, das Licht zu sehen.',
|
||||
en: 'It is during our darkest moments that we must focus to see the light.',
|
||||
it: 'È nei nostri momenti più bui che dobbiamo concentrarci per vedere la luce.',
|
||||
fr: "C'est dans nos moments les plus sombres que nous devons nous concentrer pour voir la lumière.",
|
||||
es: 'Es en nuestros momentos más oscuros que debemos concentrarnos para ver la luz.',
|
||||
},
|
||||
author: 'Aristoteles',
|
||||
category: 'hoffnung',
|
||||
originalLanguage: 'el',
|
||||
tags: ['hoffnung', 'stärke', 'licht'],
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
id: 'zeit-7',
|
||||
text: {
|
||||
original: 'The only impossible journey is the one you never begin.',
|
||||
de: 'Die einzige unmögliche Reise ist die, die du nie beginnst.',
|
||||
en: 'The only impossible journey is the one you never begin.',
|
||||
it: "L'unico viaggio impossibile è quello che non inizi mai.",
|
||||
fr: 'Le seul voyage impossible est celui que vous ne commencez jamais.',
|
||||
es: 'El único viaje imposible es el que nunca comienzas.',
|
||||
},
|
||||
author: 'Tony Robbins',
|
||||
category: 'motivation',
|
||||
originalLanguage: 'en',
|
||||
tags: ['motivation', 'anfang', 'reise'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'US-amerikanischer Motivationsredner und Autor (*1960).',
|
||||
en: 'American motivational speaker and author (b. 1960).',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zeit-8',
|
||||
text: {
|
||||
original: 'Think like a queen. A queen is not afraid to fail.',
|
||||
de: 'Denke wie eine Königin. Eine Königin hat keine Angst zu scheitern.',
|
||||
en: 'Think like a queen. A queen is not afraid to fail.',
|
||||
it: 'Pensa come una regina. Una regina non ha paura di fallire.',
|
||||
fr: "Pense comme une reine. Une reine n'a pas peur d'échouer.",
|
||||
es: 'Piensa como una reina. Una reina no tiene miedo de fracasar.',
|
||||
},
|
||||
author: 'Oprah Winfrey',
|
||||
category: 'mut',
|
||||
originalLanguage: 'en',
|
||||
tags: ['mut', 'selbstvertrauen', 'frauen'],
|
||||
verified: true,
|
||||
authorBio: {
|
||||
de: 'US-amerikanische Talkshow-Moderatorin und Unternehmerin (*1954).',
|
||||
en: 'American talk show host and entrepreneur (b. 1954).',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { QUOTES } from './quotes';
|
||||
import { CATEGORIES, CATEGORY_LABELS, type Category } from './categories';
|
||||
import {
|
||||
CATEGORIES,
|
||||
CATEGORY_LABELS,
|
||||
THEME_DECKS,
|
||||
type Category,
|
||||
type ThemeDeckId,
|
||||
} from './categories';
|
||||
import type { Quote, SupportedLanguage } from './types';
|
||||
|
||||
/**
|
||||
|
|
@ -20,11 +26,24 @@ export function getDailyQuote(date: Date = new Date()): Quote {
|
|||
return QUOTES[index];
|
||||
}
|
||||
|
||||
// ─── Pre-built category index (built once, O(1) per lookup) ──
|
||||
|
||||
let _categoryIndex: Map<Category, Quote[]> | null = null;
|
||||
|
||||
function getCategoryIndex(): Map<Category, Quote[]> {
|
||||
if (!_categoryIndex) {
|
||||
_categoryIndex = new Map();
|
||||
for (const cat of CATEGORIES) _categoryIndex.set(cat, []);
|
||||
for (const q of QUOTES) _categoryIndex.get(q.category)?.push(q);
|
||||
}
|
||||
return _categoryIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quotes by category
|
||||
* Get quotes by category (uses pre-built index for O(1) lookups).
|
||||
*/
|
||||
export function getQuotesByCategory(category: Category): Quote[] {
|
||||
return QUOTES.filter((q) => q.category === category);
|
||||
return getCategoryIndex().get(category) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -154,12 +173,72 @@ export function getAllTags(): string[] {
|
|||
return Array.from(tags).sort();
|
||||
}
|
||||
|
||||
// ─── Pre-built author index ──────────────────────────────────
|
||||
|
||||
let _authorIndex: Map<string, Quote[]> | null = null;
|
||||
|
||||
function getAuthorIndex(): Map<string, Quote[]> {
|
||||
if (!_authorIndex) {
|
||||
_authorIndex = new Map();
|
||||
for (const q of QUOTES) {
|
||||
const key = q.author.toLowerCase();
|
||||
let arr = _authorIndex.get(key);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
_authorIndex.set(key, arr);
|
||||
}
|
||||
arr.push(q);
|
||||
}
|
||||
}
|
||||
return _authorIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quotes by author
|
||||
* Get quotes by author (substring match on name).
|
||||
*/
|
||||
export function getQuotesByAuthor(author: string): Quote[] {
|
||||
const lowerAuthor = author.toLowerCase();
|
||||
return QUOTES.filter((q) => q.author.toLowerCase().includes(lowerAuthor));
|
||||
// Exact match via index first
|
||||
const exact = getAuthorIndex().get(lowerAuthor);
|
||||
if (exact) return exact;
|
||||
// Fall back to substring match across all authors
|
||||
const results: Quote[] = [];
|
||||
for (const [key, quotes] of getAuthorIndex()) {
|
||||
if (key.includes(lowerAuthor)) results.push(...quotes);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Author summary for browse pages. */
|
||||
export interface AuthorInfo {
|
||||
name: string;
|
||||
quoteCount: number;
|
||||
categories: string[];
|
||||
bio?: { de?: string; en?: string; it?: string; fr?: string; es?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique authors with their quote counts, categories, and bios.
|
||||
* Sorted by quote count descending, then name ascending.
|
||||
*/
|
||||
export function getAllAuthors(): AuthorInfo[] {
|
||||
const map = new Map<string, AuthorInfo>();
|
||||
for (const q of QUOTES) {
|
||||
let info = map.get(q.author);
|
||||
if (!info) {
|
||||
info = { name: q.author, quoteCount: 0, categories: [], bio: q.authorBio };
|
||||
map.set(q.author, info);
|
||||
}
|
||||
info.quoteCount++;
|
||||
if (!info.categories.includes(q.category)) {
|
||||
info.categories.push(q.category);
|
||||
}
|
||||
// Prefer the bio entry that has content
|
||||
if (!info.bio && q.authorBio) info.bio = q.authorBio;
|
||||
}
|
||||
return Array.from(map.values()).sort(
|
||||
(a, b) => b.quoteCount - a.quoteCount || a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -183,6 +262,71 @@ export function getQuotesByOriginalLanguage(language: string): Quote[] {
|
|||
return QUOTES.filter((q) => q.originalLanguage === language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quotes for a curated theme deck.
|
||||
*/
|
||||
export function getQuotesByThemeDeck(deckId: ThemeDeckId): Quote[] {
|
||||
const deck = THEME_DECKS.find((d) => d.id === deckId);
|
||||
if (!deck) return [];
|
||||
const authorSet = new Set(deck.authors.map((a) => a.toLowerCase()));
|
||||
return QUOTES.filter((q) => authorSet.has(q.author.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy search — matches even with typos using bigram similarity.
|
||||
* Falls back to simple substring match for short queries.
|
||||
*/
|
||||
export function fuzzySearchQuotes(
|
||||
query: string,
|
||||
language: SupportedLanguage = 'de',
|
||||
threshold = 0.3
|
||||
): Quote[] {
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
if (!normalizedQuery) return [];
|
||||
|
||||
// For very short queries (1-2 chars), use exact substring
|
||||
if (normalizedQuery.length <= 2) return searchQuotes(query, language);
|
||||
|
||||
const queryBigrams = toBigrams(normalizedQuery);
|
||||
|
||||
return QUOTES.filter((q) => {
|
||||
const text = language === 'original' ? q.text.original : q.text[language];
|
||||
const haystack = `${text} ${q.author}`.toLowerCase();
|
||||
|
||||
// Fast path: exact substring match
|
||||
if (haystack.includes(normalizedQuery)) return true;
|
||||
|
||||
// Check individual words for fuzzy match
|
||||
const queryWords = normalizedQuery.split(/\s+/);
|
||||
return queryWords.every((word) => {
|
||||
if (haystack.includes(word)) return true;
|
||||
if (word.length <= 2) return false;
|
||||
const wordBigrams = toBigrams(word);
|
||||
// Check if any word in the haystack has high bigram similarity
|
||||
return haystack.split(/\s+/).some((hw) => {
|
||||
if (hw.length <= 2) return false;
|
||||
return bigramSimilarity(wordBigrams, toBigrams(hw)) >= threshold;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toBigrams(s: string): Set<string> {
|
||||
const bigrams = new Set<string>();
|
||||
for (let i = 0; i < s.length - 1; i++) {
|
||||
bigrams.add(s.slice(i, i + 2));
|
||||
}
|
||||
return bigrams;
|
||||
}
|
||||
|
||||
function bigramSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
let intersection = 0;
|
||||
for (const bigram of a) {
|
||||
if (b.has(bigram)) intersection++;
|
||||
}
|
||||
return (2 * intersection) / (a.size + b.size);
|
||||
}
|
||||
|
||||
// Helper function
|
||||
function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue