refactor: rename zitare -> quotes (Zitate)

Zitare was opaque Latin/Italian-flavored branding. Renamed to clear
English "quotes" (DE: Zitate) matching short-concrete-noun cluster.

- Module, routes, API, i18n, standalone landing app, plans dirs
- Dexie tables: quotesFavorites, quotesLists, quotesListTags,
  customQuotes (dropped redundant "quotes" prefix on the last)
- Logo QuotesLogo, theme quotes.css, search provider, dashboard
  widget QuoteWidget
- German user-facing label "Zitate" (English brand stays Quotes)

Pre-launch, no data migration needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 20:59:16 +02:00
parent 7a1f11c971
commit 851a281e5a
196 changed files with 891 additions and 776 deletions

View file

@ -0,0 +1,24 @@
{
"name": "@quotes/content",
"version": "0.2.0",
"description": "Static quote content for Quotes",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,103 @@
/**
* Quote categories
*/
export const CATEGORIES = [
'motivation',
'weisheit',
'liebe',
'leben',
'erfolg',
'glueck',
'freundschaft',
'mut',
'hoffnung',
'natur',
'humor',
'wissenschaft',
'kunst',
] as const;
export type Category = (typeof CATEGORIES)[number];
/**
* German labels for categories
*/
export const CATEGORY_LABELS: Record<Category, string> = {
motivation: 'Motivation',
weisheit: 'Weisheit',
liebe: 'Liebe',
leben: 'Leben',
erfolg: 'Erfolg',
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
*/
export function getCategoryLabel(category: Category): string {
return CATEGORY_LABELS[category];
}
/**
* Check if a string is a valid category
*/
export function isValidCategory(value: string): value is Category {
return CATEGORIES.includes(value as Category);
}

View file

@ -0,0 +1,44 @@
// Types
export type {
Quote,
TranslatedText,
AuthorBio,
SupportedLanguage,
OriginalLanguage,
} from './types';
export { SUPPORTED_LANGUAGES, ORIGINAL_LANGUAGES } from './types';
export type { Category } from './categories';
// Data
export { QUOTES, QUOTE_COUNT } from './quotes';
export { CATEGORIES, CATEGORY_LABELS, THEME_DECKS } from './categories';
export type { ThemeDeckId } from './categories';
// Utilities
export {
getRandomQuote,
getDailyQuote,
getQuotesByCategory,
getRandomQuoteByCategory,
searchQuotes,
getQuoteById,
getQuoteByIndex,
getAllCategories,
getCategoryByName,
getQuoteText,
formatQuote,
formatQuoteWithNumber,
getTotalCount,
getQuotesByTag,
getAllTags,
getQuotesByAuthor,
getAllAuthors,
getQuotesByThemeDeck,
fuzzySearchQuotes,
getVerifiedQuotes,
getQuotesByYearRange,
getQuotesByOriginalLanguage,
} from './utils';
export type { AuthorInfo } from './utils';
export { getCategoryLabel, isValidCategory } from './categories';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
import type { Category } from './categories';
/**
* Supported languages for quote translations
*/
export const SUPPORTED_LANGUAGES = ['original', 'de', 'en', 'it', 'fr', 'es'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
/**
* Original language of a quote
*/
export const ORIGINAL_LANGUAGES = [
'de', // German
'en', // English
'fr', // French
'es', // Spanish
'it', // Italian
'la', // Latin
'el', // Greek (ancient & modern)
'zh', // Chinese
'sa', // Sanskrit
'ar', // Arabic
'fa', // Persian
'ja', // Japanese
'ru', // Russian
'pt', // Portuguese
'nl', // Dutch
'da', // Danish
'hi', // Hindi
'bn', // Bengali
] as const;
export type OriginalLanguage = (typeof ORIGINAL_LANGUAGES)[number];
/**
* Translated text object
*/
export interface TranslatedText {
/** Original language text */
original: string;
/** German translation */
de: string;
/** English translation */
en: string;
/** Italian translation */
it: string;
/** French translation */
fr: string;
/** Spanish translation */
es: string;
}
/**
* Author biography in multiple languages
*/
export interface AuthorBio {
de?: string;
en?: string;
it?: string;
fr?: string;
es?: string;
}
/**
* A quote with author, translations, and metadata
*/
export interface Quote {
/** Unique identifier (e.g., 'mot-1', 'weis-2') */
id: string;
/** Quote text in all supported languages */
text: TranslatedText;
/** Author name */
author: string;
/** Category for filtering */
category: Category;
/** Original language of the quote */
originalLanguage: OriginalLanguage;
/** Source: book, speech, interview, letter, etc. */
source?: string;
/** Year the quote was made/published */
year?: number;
/** Additional tags for search/filtering */
tags?: string[];
/** URL to author image */
imageUrl?: string;
/** Short author biography */
authorBio?: AuthorBio;
/** Whether the quote source has been verified */
verified?: boolean;
}
/**
* Helper type for creating quotes with partial translations
* (translations can be added incrementally)
*/
export type PartialQuote = Omit<Quote, 'text'> & {
text: Partial<TranslatedText> & { original: string; de: string };
};

View file

@ -0,0 +1,339 @@
import { QUOTES } from './quotes';
import {
CATEGORIES,
CATEGORY_LABELS,
THEME_DECKS,
type Category,
type ThemeDeckId,
} from './categories';
import type { Quote, SupportedLanguage } from './types';
/**
* Get a random quote
*/
export function getRandomQuote(): Quote {
const index = Math.floor(Math.random() * QUOTES.length);
return QUOTES[index];
}
/**
* Get deterministic daily quote based on date
*/
export function getDailyQuote(date: Date = new Date()): Quote {
const dateStr = date.toISOString().split('T')[0];
const hash = hashString(dateStr);
const index = Math.abs(hash) % QUOTES.length;
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 (uses pre-built index for O(1) lookups).
*/
export function getQuotesByCategory(category: Category): Quote[] {
return getCategoryIndex().get(category) ?? [];
}
/**
* Get a random quote from a specific category
*/
export function getRandomQuoteByCategory(category: Category): Quote | null {
const quotes = getQuotesByCategory(category);
if (quotes.length === 0) return null;
const index = Math.floor(Math.random() * quotes.length);
return quotes[index];
}
/**
* Search quotes by text or author (searches in specified language, defaults to German)
*/
export function searchQuotes(searchText: string, language: SupportedLanguage = 'de'): Quote[] {
const lowerSearch = searchText.toLowerCase();
return QUOTES.filter((q) => {
const text = language === 'original' ? q.text.original : q.text[language];
return text.toLowerCase().includes(lowerSearch) || q.author.toLowerCase().includes(lowerSearch);
});
}
/**
* Get a quote by ID
*/
export function getQuoteById(id: string): Quote | undefined {
return QUOTES.find((q) => q.id === id);
}
/**
* Get quote by index (1-based)
*/
export function getQuoteByIndex(index: number): Quote | null {
if (index < 1 || index > QUOTES.length) return null;
return QUOTES[index - 1];
}
/**
* Get all categories with counts
*/
export function getAllCategories(): { category: Category; label: string; count: number }[] {
return CATEGORIES.map((category) => ({
category,
label: CATEGORY_LABELS[category],
count: QUOTES.filter((q) => q.category === category).length,
}));
}
/**
* Find category by name (partial match)
*/
export function getCategoryByName(name: string): Category | null {
const lowerName = name.toLowerCase();
// Exact match first
if (CATEGORIES.includes(lowerName as Category)) {
return lowerName as Category;
}
// Partial match
for (const category of CATEGORIES) {
if (
category.startsWith(lowerName) ||
CATEGORY_LABELS[category].toLowerCase().startsWith(lowerName)
) {
return category;
}
}
return null;
}
/**
* Get quote text in a specific language
*/
export function getQuoteText(quote: Quote, language: SupportedLanguage = 'de'): string {
if (language === 'original') {
return quote.text.original;
}
return quote.text[language];
}
/**
* Format a quote for display
*/
export function formatQuote(quote: Quote, language: SupportedLanguage = 'de'): string {
const text = getQuoteText(quote, language);
const categoryLabel = CATEGORY_LABELS[quote.category];
return `"${text}"\n\n— *${quote.author}*\n\n[${categoryLabel}]`;
}
/**
* Format a quote with number
*/
export function formatQuoteWithNumber(
quote: Quote,
number: number,
language: SupportedLanguage = 'de'
): string {
const text = getQuoteText(quote, language);
const categoryLabel = CATEGORY_LABELS[quote.category];
return `**#${number}**\n"${text}"\n\n— *${quote.author}* [${categoryLabel}]`;
}
/**
* Get total quote count
*/
export function getTotalCount(): number {
return QUOTES.length;
}
/**
* Get quotes by tag
*/
export function getQuotesByTag(tag: string): Quote[] {
const lowerTag = tag.toLowerCase();
return QUOTES.filter((q) => q.tags?.some((t) => t.toLowerCase() === lowerTag));
}
/**
* Get all unique tags
*/
export function getAllTags(): string[] {
const tags = new Set<string>();
QUOTES.forEach((q) => q.tags?.forEach((t) => tags.add(t)));
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 (substring match on name).
*/
export function getQuotesByAuthor(author: string): Quote[] {
const lowerAuthor = author.toLowerCase();
// 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)
);
}
/**
* Get verified quotes only
*/
export function getVerifiedQuotes(): Quote[] {
return QUOTES.filter((q) => q.verified === true);
}
/**
* Get quotes by year range
*/
export function getQuotesByYearRange(startYear: number, endYear: number): Quote[] {
return QUOTES.filter((q) => q.year !== undefined && q.year >= startYear && q.year <= endYear);
}
/**
* Get quotes by original language
*/
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;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
});