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:
Till JS 2026-04-10 18:01:15 +02:00
parent adb1649005
commit 28c10246e3
13 changed files with 855 additions and 44 deletions

View file

@ -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"
>
&laquo;{quotesStore.getText(quote)}&raquo;
</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"
>
&laquo;{quotesStore.getText(quote)}&raquo;
</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;

View file

@ -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 ────────────────────────────────────────────

View file

@ -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() {

View file

@ -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';

View file

@ -6,5 +6,6 @@ export const zitareModuleConfig: ModuleConfig = {
{ name: 'zitareFavorites', syncName: 'favorites' },
{ name: 'zitareLists', syncName: 'lists' },
{ name: 'zitareListTags' },
{ name: 'zitareCustomQuotes', syncName: 'custom-quotes' },
],
};

View file

@ -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. */

View file

@ -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(),
});
},
};

View file

@ -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(),
});
},
};

View file

@ -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;
}

View file

@ -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
*/

View file

@ -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';

View file

@ -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).',
},
},
];
/**

View file

@ -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;