chore(mana): quotes + apps/quotes aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run

Zitare ist als Standalone-App live (zitare.mana.how) und im Wesentlichen
feature-complete für das Public-Korpus + Curator-Workflow. Keine Daten
im managarten/quotes-Modul vorhanden (Till bestätigt), kein
Migrations-Aufwand.

Lücken in zitare (Favoriten/Lists/Custom-private-Quotes) bewusst
nicht jetzt geschlossen — DB-Schema in zitare für User-Collections
ist da (collections.curatorId + visibility='private'), API/UI
können später nachgezogen werden wenn gebraucht.

Entfernt:
- apps/mana/apps/web/src/routes/(app)/quotes/ (6 Routes inkl.
  category, lists, favorites, categories)
- apps/mana/apps/web/src/lib/modules/quotes/ (6 Stores, Queries,
  Collections, Tools, Types, SpiralCanvas-Component)
- apps/mana/apps/web/src/lib/i18n/locales/quotes/ (DE/EN/ES/FR/IT)
- apps/mana/apps/web/src/lib/search/providers/quotes.ts
- apps/mana/apps/web/src/lib/components/dashboard/widgets/QuoteWidget.svelte
- apps/mana/apps/web/src/lib/modules/core/widgets/QuoteOfTheDayWidget.svelte
- apps/quotes/ (komplettes Top-Level inkl. @quotes/content Workspace-
  Package mit 87 Zitaten in 13 Kategorien)

Aktualisiert (Quotes-Refs raus):
- module-registry.ts (quotesModuleConfig)
- module-registry.test.ts (quotes-Tabellen + sync-name-Mappings)
- cross-app-queries.ts (useRandomFavorite + LocalFavorite-Import)
- search/providers/index.ts (registerLazy 'quotes')
- app-registry/apps.ts (registerApp 'quotes' + Quotes-Icon-Import)
- packages/shared-branding/src/mana-apps.ts (quotes-Eintrag)
- hooks.server.ts (Allowlist)
- types/dashboard.ts (WidgetType 'quotes-quote' + 'quotes')
- types/dashboard.test.ts
- stores/dashboard.svelte.ts (Widget-Default-Liste)
- splitscreen/registry.ts
- components/dashboard/widget-registry.ts
- modules/core/widgets/{WidgetGrid.svelte,index.ts}
- modules/spiral/collect.ts (Quotes/Music/Cards-Snapshots raus —
  collect dient den Spiral-DB-Engagement-Snapshot, alle 3 Apps
  sind dekommissioniert)
- crypto/plaintext-allowlist.ts (quotesFavorites/Lists/ListTags +
  customQuotes raus; bei der Gelegenheit auch music-Reste:
  mukkeProjects/playlistSongs/songTags)
- apps/mana/apps/web/package.json ('@quotes/content' Workspace-Dep)
- package.json (6 Quotes-Scripts: quotes:dev, dev:quotes:*,
  deploy:landing:quotes, cf:projects:create-Eintrag, dev:quotes:local)

NICHT angefasst (mit Absicht):
- data/database.ts db.version(1).stores — Schema-Snapshot ist frozen
  (gleiche Konvention wie für cards/music). Tabellen quotesFavorites,
  quotesLists, quotesListTags, customQuotes bleiben im IndexedDB-
  Schema, werden aber nicht mehr beschrieben.
- packages/spiral-db — bleibt, wird vom verbleibenden modules/spiral
  noch konsumiert (Mana-Activity-Spiral).
- packages/shared-branding/src/app-icons.ts APP_ICONS.quotes (für
  Native-PNG-Generator, harmlos).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-19 16:15:22 +02:00
parent 1b637b9aa7
commit 001548c74d
63 changed files with 5 additions and 11938 deletions

View file

@ -81,7 +81,6 @@
"@mana/spiral-db": "workspace:*",
"@mana/wallpaper-generator": "workspace:*",
"@mana/website-blocks": "workspace:*",
"@quotes/content": "workspace:*",
"@tiptap/core": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",

View file

@ -138,7 +138,6 @@ const APP_SUBDOMAINS = new Set([
'chat',
'calendar',
'contacts',
'quotes',
'skilltree',
'cards',
'storage',

View file

@ -21,7 +21,6 @@ import {
MapPin,
ChatCircle,
Clock,
Quotes,
Image,
Camera,
HardDrives,
@ -576,19 +575,10 @@ registerApp({
},
});
registerApp({
id: 'quotes',
name: 'Quotes',
color: '#EC4899',
icon: Quotes,
views: {
list: { load: () => import('$lib/modules/quotes/ListView.svelte') },
detail: { load: () => import('$lib/modules/quotes/views/DetailView.svelte') },
},
});
// Cards-Modul: dekommissioniert 2026-05-08, Cards lebt jetzt als
// standalone-App auf cardecky.mana.how (git.mana.how/till/cards).
// Quotes-Modul: dekommissioniert 2026-05-19, lebt als zitare standalone
// auf zitare.mana.how (Code/zitare).
registerApp({
id: 'picture',

View file

@ -16,7 +16,6 @@ import TasksUpcomingWidget from './widgets/TasksUpcomingWidget.svelte';
import CalendarEventsWidget from './widgets/CalendarEventsWidget.svelte';
import ChatRecentWidget from './widgets/ChatRecentWidget.svelte';
import ContactsFavoritesWidget from './widgets/ContactsFavoritesWidget.svelte';
import QuoteWidget from './widgets/QuoteWidget.svelte';
import PictureRecentWidget from './widgets/PictureRecentWidget.svelte';
import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
@ -44,7 +43,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
'chat-recent': ChatRecentWidget,
'contacts-favorites': ContactsFavoritesWidget,
'contacts-recent': RecentContactsWidget,
'quotes-quote': QuoteWidget,
'picture-recent': PictureRecentWidget,
'clock-timers': ClockTimersWidget,
'storage-usage': StorageUsageWidget,

View file

@ -1,40 +0,0 @@
<script lang="ts">
/**
* QuotesQuoteWidget - Random favorite quote (local-first)
*/
import { _ } from 'svelte-i18n';
import { useRandomFavorite } from '$lib/data/cross-app-queries';
const favorite = useRandomFavorite();
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>💡</span>
{$_('dashboard.widgets.quotes.title')}
</h3>
</div>
{#if favorite.loading}
<div class="h-16 animate-pulse rounded bg-surface-hover"></div>
{:else if !favorite.value}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">💡</div>
<p class="text-sm text-muted-foreground">{$_('dashboard.widgets.quotes.empty')}</p>
<a
href="/quotes"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Zitate entdecken
</a>
</div>
{:else}
<a href="/quotes" class="block rounded-lg p-3 transition-colors hover:bg-surface-hover">
<p class="text-sm italic text-muted-foreground">
Favorit #{favorite.value.quoteId}
</p>
</a>
{/if}
</div>

View file

@ -13,7 +13,6 @@ import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalContact } from '$lib/modules/contacts/types';
import type { LocalConversation } from '$lib/modules/chat/types';
import type { LocalFavorite } from '$lib/modules/quotes/types';
import type { LocalImage } from '$lib/modules/picture/types';
import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
import type { LocalFile } from '$lib/modules/storage/types';
@ -139,21 +138,6 @@ export function useRecentConversations(limit = 5) {
}, [] as LocalConversation[]);
}
// ─── Quotes Queries ─────────────────────────────────────────
/** A random favorite quote. */
export function useRandomFavorite() {
return useLiveQueryWithDefault(
async () => {
const all = await db.table<LocalFavorite>('quotesFavorites').toArray();
const active = all.filter((f) => !f.deletedAt);
if (active.length === 0) return null;
return active[Math.floor(Math.random() * active.length)];
},
null as LocalFavorite | null
);
}
// ─── Picture Queries ────────────────────────────────────────
/** Recent generated images. */

View file

@ -41,7 +41,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'companionMessages', // TODO: audit
'contactTags', // TODO: audit
'conversationTags', // TODO: audit
'customQuotes', // TODO: audit
'dashboardConfigs', // TODO: audit
'deckTags', // TODO: audit
'dreamTags', // TODO: audit
@ -68,21 +67,16 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'mealTags', // TODO: audit
'moodTags', // TODO: audit
'moods', // TODO: audit
'mukkeProjects', // TODO: audit
'newsCachedFeed', // TODO: audit
'noteTags', // TODO: audit
'periodSymptoms', // TODO: audit
'photoFavorites', // TODO: audit
'photoMediaTags', // TODO: audit
'placeTags', // TODO: audit
'playlistSongs', // TODO: audit
'presiDeckTags', // TODO: audit
'qCollections', // TODO: audit
'questionTags', // TODO: audit
'quizAttempts', // TODO: audit
'quotesFavorites', // TODO: audit
'quotesListTags', // TODO: audit
'quotesLists', // TODO: audit
'reminders', // TODO: audit
'ritualLogs', // TODO: audit
'ritualSteps', // TODO: audit
@ -92,7 +86,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'sequences', // TODO: audit
'skillTags', // TODO: audit
'skills', // TODO: audit
'songTags', // TODO: audit
'storageFolders', // TODO: audit
'taskLabels', // TODO: audit
'timeAlarms', // TODO: audit

View file

@ -199,7 +199,6 @@ describe('module-registry — snapshot', () => {
contacts: ['contacts', 'contactTags'],
chat: ['conversations', 'messages', 'chatTemplates', 'conversationTags'],
picture: ['images', 'boards', 'boardItems', 'imageTags'],
quotes: ['quotesFavorites', 'quotesLists', 'quotesListTags', 'customQuotes'],
storage: ['files', 'storageFolders', 'fileTags'],
presi: ['presiDecks', 'slides', 'presiDeckTags'],
inventory: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'],
@ -280,11 +279,6 @@ describe('module-registry — snapshot', () => {
manaLinks: 'links',
todoProjects: 'projects',
chatTemplates: 'templates',
quotesFavorites: 'favorites',
quotesLists: 'lists',
customQuotes: 'custom-quotes',
mukkePlaylists: 'playlists',
mukkeProjects: 'projects',
storageFolders: 'folders',
presiDecks: 'decks',
invCollections: 'collections',

View file

@ -56,7 +56,6 @@ import { calendarModuleConfig } from '$lib/modules/calendar/module.config';
import { contactsModuleConfig } from '$lib/modules/contacts/module.config';
import { chatModuleConfig } from '$lib/modules/chat/module.config';
import { pictureModuleConfig } from '$lib/modules/picture/module.config';
import { quotesModuleConfig } from '$lib/modules/quotes/module.config';
import { storageModuleConfig } from '$lib/modules/storage/module.config';
import { presiModuleConfig } from '$lib/modules/presi/module.config';
import { inventoryModuleConfig } from '$lib/modules/inventory/module.config';
@ -108,7 +107,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
contactsModuleConfig,
chatModuleConfig,
pictureModuleConfig,
quotesModuleConfig,
storageModuleConfig,
presiModuleConfig,
inventoryModuleConfig,

View file

@ -1,160 +0,0 @@
{
"app": {
"name": "Quotes",
"tagline": "Inspirierende Zitate jeden Tag"
},
"nav": {
"home": "Heute",
"today": "Heute",
"categories": "Kategorien",
"favorites": "Favoriten",
"lists": "Listen",
"search": "Suche",
"settings": "Einstellungen",
"feedback": "Feedback",
"menu": "Menü",
"allThemes": "Alle Themes",
"showNav": "Navigation anzeigen",
"hideNav": "Navigation ausblenden"
},
"home": {
"dailyQuote": "Zitat des Tages",
"newQuote": "Neues Zitat",
"share": "Teilen",
"favorite": "Favorit",
"unfavorite": "Entfernen",
"source": "Quelle",
"year": "Jahr"
},
"categories": {
"title": "Kategorien",
"wisdom": "Weisheit",
"motivation": "Motivation",
"love": "Liebe",
"life": "Leben",
"success": "Erfolg",
"happiness": "Glück",
"friendship": "Freundschaft",
"courage": "Mut",
"hope": "Hoffnung",
"nature": "Natur",
"quotes": "{count} Zitate",
"notFound": "Kategorie nicht gefunden",
"backToCategories": "Zurück zu Kategorien",
"searchInCategory": "In dieser Kategorie suchen...",
"sortByAuthor": "Nach Autor",
"sortByDefault": "Standard"
},
"favorites": {
"title": "Favoriten",
"empty": "Noch keine Favoriten",
"emptyDescription": "Tippe auf das Herz-Symbol, um Zitate zu speichern",
"loginPrompt": "Melde dich an, um Favoriten zu speichern",
"removeFromFavorites": "Aus Favoriten entfernen",
"copyQuote": "Zitat kopieren",
"share": "Teilen"
},
"lists": {
"title": "Meine Listen",
"create": "Neue Liste",
"empty": "Noch keine Listen",
"emptyDescription": "Erstelle Listen, um Zitate zu organisieren",
"loginPrompt": "Melde dich an, um Listen zu erstellen",
"quoteCount": "{count} Zitate",
"createModal": {
"title": "Neue Liste erstellen",
"namePlaceholder": "z.B. Motivierende Zitate",
"descriptionPlaceholder": "Was macht diese Liste besonders?",
"submit": "Erstellen"
},
"nameLabel": "Name",
"descriptionLabel": "Beschreibung (optional)",
"confirmDelete": "Möchtest du diese Liste wirklich löschen?",
"detail": {
"notFound": "Liste nicht gefunden",
"notFoundDescription": "Diese Liste existiert nicht oder wurde gelöscht.",
"backToLists": "Zurück zu Listen",
"breadcrumb": "Listen",
"lastEdited": "Zuletzt bearbeitet: {date}",
"searchPlaceholder": "Zitate durchsuchen...",
"emptyTitle": "Keine Zitate in dieser Liste",
"emptyDescription": "Füge Zitate hinzu, um deine Sammlung zu starten",
"addQuotes": "Zitate hinzufügen",
"remove": "Entfernen",
"removeConfirm": "Zitat aus dieser Liste entfernen?",
"noSearchResults": "Keine Ergebnisse",
"noSearchResultsDescription": "Versuche es mit anderen Suchbegriffen",
"floatingResults": "{filtered} von {total} Zitaten",
"editModal": {
"title": "Liste bearbeiten",
"deleteList": "Liste löschen"
},
"addModal": {
"title": "Zitate hinzufügen",
"selected": "{count} ausgewählt",
"submit": "Hinzufügen ({count})"
},
"toast": {
"updated": "Liste aktualisiert!",
"updateError": "Fehler beim Aktualisieren",
"deleted": "Liste gelöscht",
"deleteError": "Fehler beim Löschen",
"quotesAdded": "{count} {count, plural, one {Zitat} other {Zitate}} hinzugefügt!",
"quoteRemoved": "Zitat entfernt",
"removeError": "Fehler beim Entfernen"
}
}
},
"search": {
"title": "Suche",
"placeholder": "Zitat oder Autor suchen...",
"noResults": "Keine Ergebnisse",
"results": "{count} Ergebnisse",
"searching": "Suche...",
"create": "Erstellen",
"createList": "als Liste erstellen",
"createListDescription": "Neue Liste mit diesem Namen erstellen",
"minChars": "Bitte gib mindestens 2 Zeichen ein",
"hint": "Suche nach Zitaten, Autoren oder Themen",
"allCategories": "Alle",
"filterByCategory": "Nach Kategorie filtern"
},
"settings": {
"quoteLanguage": "Zitat-Sprache",
"quoteLanguageDescription": "Wähle die Sprache, in der die Zitate angezeigt werden sollen.",
"display": "Anzeige",
"showCategory": "Kategorie anzeigen",
"showCategoryDescription": "Zeigt die Kategorie-Badge auf Zitat-Karten",
"showSource": "Quelle anzeigen",
"showSourceDescription": "Zeigt Quelle und Jahr unter dem Zitat",
"fontSize": "Schriftgröße",
"fontSizeSmall": "Klein",
"fontSizeNormal": "Normal",
"fontSizeLarge": "Groß",
"fontSizeXLarge": "Sehr groß",
"about": "Über Quotes",
"aboutDescription": "Quotes bietet dir täglich inspirierende Zitate von den größten Denkern der Geschichte. Speichere deine Favoriten und erstelle eigene Listen.",
"stats": "{quotes} Zitate · {categories} Kategorien · {languages} Sprachen"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",
"register": "Registrieren"
},
"feedback": {
"title": "Feedback & Vorschläge",
"subtitle": "Teile deine Ideen und stimme für Feature-Wünsche ab"
},
"common": {
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten",
"retry": "Erneut versuchen",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"close": "Schließen",
"search": "Suchen",
"list": "Liste"
}
}

View file

@ -1,160 +0,0 @@
{
"app": {
"name": "Quotes",
"tagline": "Inspiring quotes every day"
},
"nav": {
"home": "Today",
"today": "Today",
"categories": "Categories",
"favorites": "Favorites",
"lists": "Lists",
"search": "Search",
"settings": "Settings",
"feedback": "Feedback",
"menu": "Menu",
"allThemes": "All Themes",
"showNav": "Show navigation",
"hideNav": "Hide navigation"
},
"home": {
"dailyQuote": "Quote of the Day",
"newQuote": "New Quote",
"share": "Share",
"favorite": "Favorite",
"unfavorite": "Remove",
"source": "Source",
"year": "Year"
},
"categories": {
"title": "Categories",
"wisdom": "Wisdom",
"motivation": "Motivation",
"love": "Love",
"life": "Life",
"success": "Success",
"happiness": "Happiness",
"friendship": "Friendship",
"courage": "Courage",
"hope": "Hope",
"nature": "Nature",
"quotes": "{count} quotes",
"notFound": "Category not found",
"backToCategories": "Back to categories",
"searchInCategory": "Search in this category...",
"sortByAuthor": "By author",
"sortByDefault": "Default"
},
"favorites": {
"title": "Favorites",
"empty": "No favorites yet",
"emptyDescription": "Tap the heart icon to save quotes",
"loginPrompt": "Sign in to save favorites",
"removeFromFavorites": "Remove from favorites",
"copyQuote": "Copy quote",
"share": "Share"
},
"lists": {
"title": "My Lists",
"create": "New List",
"empty": "No lists yet",
"emptyDescription": "Create lists to organize quotes",
"loginPrompt": "Sign in to create lists",
"quoteCount": "{count} quotes",
"createModal": {
"title": "Create new list",
"namePlaceholder": "e.g. Motivational Quotes",
"descriptionPlaceholder": "What makes this list special?",
"submit": "Create"
},
"nameLabel": "Name",
"descriptionLabel": "Description (optional)",
"confirmDelete": "Do you really want to delete this list?",
"detail": {
"notFound": "List not found",
"notFoundDescription": "This list does not exist or has been deleted.",
"backToLists": "Back to lists",
"breadcrumb": "Lists",
"lastEdited": "Last edited: {date}",
"searchPlaceholder": "Search quotes...",
"emptyTitle": "No quotes in this list",
"emptyDescription": "Add quotes to start your collection",
"addQuotes": "Add quotes",
"remove": "Remove",
"removeConfirm": "Remove quote from this list?",
"noSearchResults": "No results",
"noSearchResultsDescription": "Try different search terms",
"floatingResults": "{filtered} of {total} quotes",
"editModal": {
"title": "Edit list",
"deleteList": "Delete list"
},
"addModal": {
"title": "Add quotes",
"selected": "{count} selected",
"submit": "Add ({count})"
},
"toast": {
"updated": "List updated!",
"updateError": "Error updating list",
"deleted": "List deleted",
"deleteError": "Error deleting list",
"quotesAdded": "{count} {count, plural, one {quote} other {quotes}} added!",
"quoteRemoved": "Quote removed",
"removeError": "Error removing quote"
}
}
},
"search": {
"title": "Search",
"placeholder": "Search quotes or authors...",
"noResults": "No results",
"results": "{count} results",
"searching": "Searching...",
"create": "Create",
"createList": "create as list",
"createListDescription": "Create a new list with this name",
"minChars": "Please enter at least 2 characters",
"hint": "Search for quotes, authors, or topics",
"allCategories": "All",
"filterByCategory": "Filter by category"
},
"settings": {
"quoteLanguage": "Quote language",
"quoteLanguageDescription": "Choose the language in which quotes are displayed.",
"display": "Display",
"showCategory": "Show category",
"showCategoryDescription": "Shows the category badge on quote cards",
"showSource": "Show source",
"showSourceDescription": "Shows source and year below the quote",
"fontSize": "Font size",
"fontSizeSmall": "Small",
"fontSizeNormal": "Normal",
"fontSizeLarge": "Large",
"fontSizeXLarge": "Extra large",
"about": "About Quotes",
"aboutDescription": "Quotes offers you daily inspiring quotes from the greatest thinkers in history. Save your favorites and create your own lists.",
"stats": "{quotes} quotes · {categories} categories · {languages} languages"
},
"auth": {
"login": "Sign In",
"logout": "Sign Out",
"register": "Sign Up"
},
"feedback": {
"title": "Feedback & Suggestions",
"subtitle": "Share your ideas and vote for feature requests"
},
"common": {
"loading": "Loading...",
"error": "An error occurred",
"retry": "Try again",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"search": "Search",
"list": "List"
}
}

View file

@ -1,160 +0,0 @@
{
"app": {
"name": "Quotes",
"tagline": "Citas inspiradoras cada día"
},
"nav": {
"home": "Hoy",
"today": "Hoy",
"categories": "Categorías",
"favorites": "Favoritos",
"lists": "Listas",
"search": "Buscar",
"settings": "Ajustes",
"feedback": "Feedback",
"menu": "Menú",
"allThemes": "Todos los temas",
"showNav": "Mostrar navegación",
"hideNav": "Ocultar navegación"
},
"home": {
"dailyQuote": "Cita del día",
"newQuote": "Nueva cita",
"share": "Compartir",
"favorite": "Favorito",
"unfavorite": "Quitar",
"source": "Fuente",
"year": "Año"
},
"categories": {
"title": "Categorías",
"wisdom": "Sabiduría",
"motivation": "Motivación",
"love": "Amor",
"life": "Vida",
"success": "Éxito",
"happiness": "Felicidad",
"friendship": "Amistad",
"courage": "Valentía",
"hope": "Esperanza",
"nature": "Naturaleza",
"quotes": "{count} citas",
"notFound": "Categoría no encontrada",
"backToCategories": "Volver a categorías",
"searchInCategory": "Buscar en esta categoría...",
"sortByAuthor": "Por autor",
"sortByDefault": "Por defecto"
},
"favorites": {
"title": "Favoritos",
"empty": "Aún no hay favoritos",
"emptyDescription": "Toca el corazón para guardar citas",
"loginPrompt": "Inicia sesión para guardar favoritos",
"removeFromFavorites": "Quitar de favoritos",
"copyQuote": "Copiar cita",
"share": "Compartir"
},
"lists": {
"title": "Mis listas",
"create": "Nueva lista",
"empty": "Aún no hay listas",
"emptyDescription": "Crea listas para organizar citas",
"loginPrompt": "Inicia sesión para crear listas",
"quoteCount": "{count} citas",
"createModal": {
"title": "Crear nueva lista",
"namePlaceholder": "ej. Citas motivacionales",
"descriptionPlaceholder": "¿Qué hace especial esta lista?",
"submit": "Crear"
},
"nameLabel": "Nombre",
"descriptionLabel": "Descripción (opcional)",
"confirmDelete": "¿Realmente quieres eliminar esta lista?",
"detail": {
"notFound": "Lista no encontrada",
"notFoundDescription": "Esta lista no existe o ha sido eliminada.",
"backToLists": "Volver a listas",
"breadcrumb": "Listas",
"lastEdited": "Última edición: {date}",
"searchPlaceholder": "Buscar citas...",
"emptyTitle": "No hay citas en esta lista",
"emptyDescription": "Agrega citas para empezar tu colección",
"addQuotes": "Agregar citas",
"remove": "Quitar",
"removeConfirm": "¿Quitar cita de esta lista?",
"noSearchResults": "Sin resultados",
"noSearchResultsDescription": "Prueba con otros términos",
"floatingResults": "{filtered} de {total} citas",
"editModal": {
"title": "Editar lista",
"deleteList": "Eliminar lista"
},
"addModal": {
"title": "Agregar citas",
"selected": "{count} seleccionadas",
"submit": "Agregar ({count})"
},
"toast": {
"updated": "¡Lista actualizada!",
"updateError": "Error al actualizar la lista",
"deleted": "Lista eliminada",
"deleteError": "Error al eliminar la lista",
"quotesAdded": "¡{count} {count, plural, one {cita} other {citas}} agregadas!",
"quoteRemoved": "Cita eliminada",
"removeError": "Error al eliminar la cita"
}
}
},
"search": {
"title": "Buscar",
"placeholder": "Buscar citas o autores...",
"noResults": "Sin resultados",
"results": "{count} resultados",
"searching": "Buscando...",
"create": "Crear",
"createList": "crear como lista",
"createListDescription": "Crea una nueva lista con este nombre",
"minChars": "Ingresa al menos 2 caracteres",
"hint": "Busca citas, autores o temas",
"allCategories": "Todas",
"filterByCategory": "Filtrar por categoría"
},
"settings": {
"quoteLanguage": "Idioma de citas",
"quoteLanguageDescription": "Elige el idioma en que se muestran las citas.",
"display": "Visualización",
"showCategory": "Mostrar categoría",
"showCategoryDescription": "Muestra la etiqueta de categoría en las tarjetas",
"showSource": "Mostrar fuente",
"showSourceDescription": "Muestra la fuente y año debajo de la cita",
"fontSize": "Tamaño de fuente",
"fontSizeSmall": "Pequeño",
"fontSizeNormal": "Normal",
"fontSizeLarge": "Grande",
"fontSizeXLarge": "Muy grande",
"about": "Sobre Quotes",
"aboutDescription": "Quotes te ofrece citas inspiradoras diarias de los más grandes pensadores de la historia. Guarda tus favoritas y crea tus propias listas.",
"stats": "{quotes} citas · {categories} categorías · {languages} idiomas"
},
"auth": {
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"register": "Registrarse"
},
"feedback": {
"title": "Feedback y sugerencias",
"subtitle": "Comparte tus ideas y vota por nuevas funciones"
},
"common": {
"loading": "Cargando...",
"error": "Ha ocurrido un error",
"retry": "Reintentar",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"close": "Cerrar",
"search": "Buscar",
"list": "Lista"
}
}

View file

@ -1,160 +0,0 @@
{
"app": {
"name": "Quotes",
"tagline": "Des citations inspirantes chaque jour"
},
"nav": {
"home": "Aujourd'hui",
"today": "Aujourd'hui",
"categories": "Catégories",
"favorites": "Favoris",
"lists": "Listes",
"search": "Rechercher",
"settings": "Paramètres",
"feedback": "Feedback",
"menu": "Menu",
"allThemes": "Tous les thèmes",
"showNav": "Afficher la navigation",
"hideNav": "Masquer la navigation"
},
"home": {
"dailyQuote": "Citation du jour",
"newQuote": "Nouvelle citation",
"share": "Partager",
"favorite": "Favori",
"unfavorite": "Retirer",
"source": "Source",
"year": "Année"
},
"categories": {
"title": "Catégories",
"wisdom": "Sagesse",
"motivation": "Motivation",
"love": "Amour",
"life": "Vie",
"success": "Succès",
"happiness": "Bonheur",
"friendship": "Amitié",
"courage": "Courage",
"hope": "Espoir",
"nature": "Nature",
"quotes": "{count} citations",
"notFound": "Catégorie introuvable",
"backToCategories": "Retour aux catégories",
"searchInCategory": "Rechercher dans cette catégorie...",
"sortByAuthor": "Par auteur",
"sortByDefault": "Par défaut"
},
"favorites": {
"title": "Favoris",
"empty": "Pas encore de favoris",
"emptyDescription": "Appuyez sur le cœur pour sauvegarder des citations",
"loginPrompt": "Connectez-vous pour sauvegarder vos favoris",
"removeFromFavorites": "Retirer des favoris",
"copyQuote": "Copier la citation",
"share": "Partager"
},
"lists": {
"title": "Mes listes",
"create": "Nouvelle liste",
"empty": "Pas encore de listes",
"emptyDescription": "Créez des listes pour organiser vos citations",
"loginPrompt": "Connectez-vous pour créer des listes",
"quoteCount": "{count} citations",
"createModal": {
"title": "Créer une nouvelle liste",
"namePlaceholder": "ex. Citations motivantes",
"descriptionPlaceholder": "Qu'est-ce qui rend cette liste spéciale ?",
"submit": "Créer"
},
"nameLabel": "Nom",
"descriptionLabel": "Description (optionnel)",
"confirmDelete": "Voulez-vous vraiment supprimer cette liste ?",
"detail": {
"notFound": "Liste introuvable",
"notFoundDescription": "Cette liste n'existe pas ou a été supprimée.",
"backToLists": "Retour aux listes",
"breadcrumb": "Listes",
"lastEdited": "Dernière modification : {date}",
"searchPlaceholder": "Rechercher des citations...",
"emptyTitle": "Aucune citation dans cette liste",
"emptyDescription": "Ajoutez des citations pour commencer votre collection",
"addQuotes": "Ajouter des citations",
"remove": "Retirer",
"removeConfirm": "Retirer la citation de cette liste ?",
"noSearchResults": "Aucun résultat",
"noSearchResultsDescription": "Essayez d'autres termes",
"floatingResults": "{filtered} sur {total} citations",
"editModal": {
"title": "Modifier la liste",
"deleteList": "Supprimer la liste"
},
"addModal": {
"title": "Ajouter des citations",
"selected": "{count} sélectionnées",
"submit": "Ajouter ({count})"
},
"toast": {
"updated": "Liste mise à jour !",
"updateError": "Erreur lors de la mise à jour",
"deleted": "Liste supprimée",
"deleteError": "Erreur lors de la suppression",
"quotesAdded": "{count} {count, plural, one {citation ajoutée} other {citations ajoutées}} !",
"quoteRemoved": "Citation retirée",
"removeError": "Erreur lors du retrait"
}
}
},
"search": {
"title": "Rechercher",
"placeholder": "Rechercher des citations ou auteurs...",
"noResults": "Aucun résultat",
"results": "{count} résultats",
"searching": "Recherche...",
"create": "Créer",
"createList": "créer comme liste",
"createListDescription": "Créer une nouvelle liste avec ce nom",
"minChars": "Saisissez au moins 2 caractères",
"hint": "Cherchez des citations, auteurs ou thèmes",
"allCategories": "Toutes",
"filterByCategory": "Filtrer par catégorie"
},
"settings": {
"quoteLanguage": "Langue des citations",
"quoteLanguageDescription": "Choisissez la langue d'affichage des citations.",
"display": "Affichage",
"showCategory": "Afficher la catégorie",
"showCategoryDescription": "Affiche le badge de catégorie sur les cartes",
"showSource": "Afficher la source",
"showSourceDescription": "Affiche la source et l'année sous la citation",
"fontSize": "Taille de police",
"fontSizeSmall": "Petite",
"fontSizeNormal": "Normale",
"fontSizeLarge": "Grande",
"fontSizeXLarge": "Très grande",
"about": "À propos de Quotes",
"aboutDescription": "Quotes vous propose chaque jour des citations inspirantes des plus grands penseurs de l'histoire. Sauvegardez vos favoris et créez vos propres listes.",
"stats": "{quotes} citations · {categories} catégories · {languages} langues"
},
"auth": {
"login": "Connexion",
"logout": "Déconnexion",
"register": "Inscription"
},
"feedback": {
"title": "Feedback et suggestions",
"subtitle": "Partagez vos idées et votez pour de nouvelles fonctionnalités"
},
"common": {
"loading": "Chargement...",
"error": "Une erreur est survenue",
"retry": "Réessayer",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"close": "Fermer",
"search": "Rechercher",
"list": "Liste"
}
}

View file

@ -1,160 +0,0 @@
{
"app": {
"name": "Quotes",
"tagline": "Citazioni ispiratrici ogni giorno"
},
"nav": {
"home": "Oggi",
"today": "Oggi",
"categories": "Categorie",
"favorites": "Preferiti",
"lists": "Liste",
"search": "Cerca",
"settings": "Impostazioni",
"feedback": "Feedback",
"menu": "Menu",
"allThemes": "Tutti i temi",
"showNav": "Mostra navigazione",
"hideNav": "Nascondi navigazione"
},
"home": {
"dailyQuote": "Citazione del giorno",
"newQuote": "Nuova citazione",
"share": "Condividi",
"favorite": "Preferito",
"unfavorite": "Rimuovi",
"source": "Fonte",
"year": "Anno"
},
"categories": {
"title": "Categorie",
"wisdom": "Saggezza",
"motivation": "Motivazione",
"love": "Amore",
"life": "Vita",
"success": "Successo",
"happiness": "Felicità",
"friendship": "Amicizia",
"courage": "Coraggio",
"hope": "Speranza",
"nature": "Natura",
"quotes": "{count} citazioni",
"notFound": "Categoria non trovata",
"backToCategories": "Torna alle categorie",
"searchInCategory": "Cerca in questa categoria...",
"sortByAuthor": "Per autore",
"sortByDefault": "Predefinito"
},
"favorites": {
"title": "Preferiti",
"empty": "Ancora nessun preferito",
"emptyDescription": "Tocca il cuore per salvare le citazioni",
"loginPrompt": "Accedi per salvare i preferiti",
"removeFromFavorites": "Rimuovi dai preferiti",
"copyQuote": "Copia citazione",
"share": "Condividi"
},
"lists": {
"title": "Le mie liste",
"create": "Nuova lista",
"empty": "Ancora nessuna lista",
"emptyDescription": "Crea liste per organizzare le citazioni",
"loginPrompt": "Accedi per creare liste",
"quoteCount": "{count} citazioni",
"createModal": {
"title": "Crea nuova lista",
"namePlaceholder": "es. Citazioni motivazionali",
"descriptionPlaceholder": "Cosa rende speciale questa lista?",
"submit": "Crea"
},
"nameLabel": "Nome",
"descriptionLabel": "Descrizione (opzionale)",
"confirmDelete": "Vuoi davvero eliminare questa lista?",
"detail": {
"notFound": "Lista non trovata",
"notFoundDescription": "Questa lista non esiste o è stata eliminata.",
"backToLists": "Torna alle liste",
"breadcrumb": "Liste",
"lastEdited": "Ultima modifica: {date}",
"searchPlaceholder": "Cerca citazioni...",
"emptyTitle": "Nessuna citazione in questa lista",
"emptyDescription": "Aggiungi citazioni per iniziare la tua collezione",
"addQuotes": "Aggiungi citazioni",
"remove": "Rimuovi",
"removeConfirm": "Rimuovere la citazione da questa lista?",
"noSearchResults": "Nessun risultato",
"noSearchResultsDescription": "Prova con altri termini",
"floatingResults": "{filtered} di {total} citazioni",
"editModal": {
"title": "Modifica lista",
"deleteList": "Elimina lista"
},
"addModal": {
"title": "Aggiungi citazioni",
"selected": "{count} selezionate",
"submit": "Aggiungi ({count})"
},
"toast": {
"updated": "Lista aggiornata!",
"updateError": "Errore durante l'aggiornamento",
"deleted": "Lista eliminata",
"deleteError": "Errore durante l'eliminazione",
"quotesAdded": "{count} {count, plural, one {citazione aggiunta} other {citazioni aggiunte}}!",
"quoteRemoved": "Citazione rimossa",
"removeError": "Errore durante la rimozione"
}
}
},
"search": {
"title": "Cerca",
"placeholder": "Cerca citazioni o autori...",
"noResults": "Nessun risultato",
"results": "{count} risultati",
"searching": "Ricerca...",
"create": "Crea",
"createList": "crea come lista",
"createListDescription": "Crea una nuova lista con questo nome",
"minChars": "Inserisci almeno 2 caratteri",
"hint": "Cerca citazioni, autori o argomenti",
"allCategories": "Tutte",
"filterByCategory": "Filtra per categoria"
},
"settings": {
"quoteLanguage": "Lingua delle citazioni",
"quoteLanguageDescription": "Scegli la lingua in cui vengono mostrate le citazioni.",
"display": "Visualizzazione",
"showCategory": "Mostra categoria",
"showCategoryDescription": "Mostra il badge categoria sulle schede",
"showSource": "Mostra fonte",
"showSourceDescription": "Mostra fonte e anno sotto la citazione",
"fontSize": "Dimensione carattere",
"fontSizeSmall": "Piccolo",
"fontSizeNormal": "Normale",
"fontSizeLarge": "Grande",
"fontSizeXLarge": "Molto grande",
"about": "Informazioni su Quotes",
"aboutDescription": "Quotes ti offre ogni giorno citazioni ispiratrici dei più grandi pensatori della storia. Salva i tuoi preferiti e crea le tue liste.",
"stats": "{quotes} citazioni · {categories} categorie · {languages} lingue"
},
"auth": {
"login": "Accedi",
"logout": "Esci",
"register": "Registrati"
},
"feedback": {
"title": "Feedback e suggerimenti",
"subtitle": "Condividi le tue idee e vota per nuove funzionalità"
},
"common": {
"loading": "Caricamento...",
"error": "Si è verificato un errore",
"retry": "Riprova",
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"close": "Chiudi",
"search": "Cerca",
"list": "Lista"
}
}

View file

@ -1,85 +0,0 @@
<script lang="ts">
/**
* QuoteOfTheDayWidget — Zufälliges Tageszitat aus Quotes-Favoriten.
*
* Liest direkt aus der unified IndexedDB (quotesFavorites table).
* Zeigt eine zufällige Favoriten-ID — das vollständige Zitat stammt
* aus dem eingebetteten Zitate-Katalog von Quotes.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@mana/local-store';
interface QuotesFavorite extends BaseRecord {
quoteId: string;
}
let favorite: QuotesFavorite | null = $state(null);
let totalFavorites = $state(0);
let loading = $state(true);
// Use date as seed for consistent "daily" pick
const today = new Date().toISOString().slice(0, 10);
function hashStr(s: string): number {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (hash << 5) - hash + s.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<QuotesFavorite>('quotesFavorites').toArray();
return all.filter((f) => !f.deletedAt);
}).subscribe({
next: (val) => {
totalFavorites = val.length;
if (val.length > 0) {
const idx = hashStr(today) % val.length;
favorite = val[idx];
} else {
favorite = null;
}
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">Zitat des Tages</h3>
</div>
{#if loading}
<div class="h-16 animate-pulse rounded bg-surface-hover"></div>
{:else if !favorite}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#128161;</div>
<p class="text-sm text-muted-foreground">Noch keine Lieblingszitate gespeichert.</p>
<a
href="/quotes"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Zitate entdecken
</a>
</div>
{:else}
<a href="/quotes" class="block rounded-lg p-3 transition-colors hover:bg-surface-hover">
<p class="mb-2 text-sm italic text-foreground/80">
Favorit #{favorite.quoteId}
</p>
<p class="text-xs text-muted-foreground">
{totalFavorites} Lieblingszitate gespeichert
</p>
</a>
{/if}
</div>

View file

@ -9,7 +9,6 @@
import TasksTodayWidget from './TasksTodayWidget.svelte';
import UpcomingEventsWidget from './UpcomingEventsWidget.svelte';
import RecentContactsWidget from './RecentContactsWidget.svelte';
import QuoteOfTheDayWidget from './QuoteOfTheDayWidget.svelte';
import ActiveTimerWidget from './ActiveTimerWidget.svelte';
import RecentChatsWidget from './RecentChatsWidget.svelte';
import QuickActionsWidget from './QuickActionsWidget.svelte';
@ -21,7 +20,6 @@
{ id: 'quick-actions', component: QuickActionsWidget },
{ id: 'recent-chats', component: RecentChatsWidget },
{ id: 'recent-contacts', component: RecentContactsWidget },
{ id: 'quote-of-the-day', component: QuoteOfTheDayWidget },
];
</script>

View file

@ -9,7 +9,6 @@
export { default as TasksTodayWidget } from './TasksTodayWidget.svelte';
export { default as UpcomingEventsWidget } from './UpcomingEventsWidget.svelte';
export { default as RecentContactsWidget } from './RecentContactsWidget.svelte';
export { default as QuoteOfTheDayWidget } from './QuoteOfTheDayWidget.svelte';
export { default as ActiveTimerWidget } from './ActiveTimerWidget.svelte';
export { default as RecentChatsWidget } from './RecentChatsWidget.svelte';
export { default as QuickActionsWidget } from './QuickActionsWidget.svelte';

View file

@ -1,173 +0,0 @@
<!--
Quotes — Workbench ListView
Shows one quote at a time. Tap to cycle. Fav button inline.
Supports tag drag-and-drop onto the current quote.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { quotesStore } from '$lib/modules/quotes/stores/quotes.svelte';
import { favoritesStore } from '$lib/modules/quotes/stores/favorites.svelte';
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/quotes/queries';
import { Heart } from '@mana/shared-icons';
import { dropTarget } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import type { ViewProps } from '$lib/app-registry';
import type { LocalFavorite } from './types';
import type { Quote } from '@quotes/content';
let { navigate, goBack, params }: ViewProps = $props();
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
// the effect after initialize() updates it).
onMount(() => {
quotesStore.initialize();
quote = quotesStore.currentQuote;
});
$effect(() => {
quote = quotesStore.currentQuote;
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFavorite>('quotesFavorites')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
favorites = val ?? [];
});
return () => sub.unsubscribe();
});
let favoritesAsDomain = $derived<Favorite[]>(
favorites.map((f) => ({ id: f.id, quoteId: f.quoteId, createdAt: f.createdAt ?? '' }))
);
let currentFav = $derived(quote ? favorites.find((f) => f.quoteId === quote!.id) : undefined);
let isFav = $derived(!!currentFav);
const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []);
let currentTagIds = $derived(currentFav?.tagIds ?? []);
let currentTags = $derived(getTagsByIds(allTags, currentTagIds));
function nextQuote() {
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) {
e.stopPropagation();
if (!quote) return;
await favoritesStore.toggle(quote.id, favoritesAsDomain);
}
async function handleTagDrop(tagData: TagDragData) {
if (!quote) return;
// Ensure quote is favorited first
let fav = favorites.find((f) => f.quoteId === quote!.id);
if (!fav) {
await favoritesStore.add(quote.id);
// Re-fetch to get the new favorite
const all = await db.table<LocalFavorite>('quotesFavorites').toArray();
fav = all.find((f) => f.quoteId === quote!.id && !f.deletedAt);
if (!fav) return;
}
const current = fav.tagIds ?? [];
if (!current.includes(tagData.id)) {
await db.table('quotesFavorites').update(fav.id, {
tagIds: [...current, tagData.id],
});
}
}
</script>
<div
class="flex h-full cursor-pointer flex-col items-center justify-center p-4 sm:p-6"
onclick={nextQuote}
onkeydown={(e) => e.key === 'Enter' && nextQuote()}
role="button"
tabindex="0"
use:dropTarget={{
accepts: ['tag'],
onDrop: (p) => handleTagDrop(p.data as unknown as TagDragData),
canDrop: (p) => !currentTagIds.includes((p.data as unknown as TagDragData).id),
}}
>
{#if quote}
<div class="quote-transition" class:fade-out={transitioning}>
<blockquote
class="max-w-[280px] text-center text-base font-light italic leading-relaxed text-[hsl(var(--color-foreground)/0.8)]"
>
&laquo;{quotesStore.getText(quote)}&raquo;
</blockquote>
<p class="mt-3 text-xs text-[hsl(var(--color-muted-foreground))]">{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-[hsl(var(--color-muted-foreground))]"
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-[hsl(var(--color-foreground)/0.05)]"
>
<Heart
size={16}
weight={isFav ? 'fill' : 'regular'}
class="transition-colors {isFav
? 'text-red-400'
: 'text-[hsl(var(--color-muted-foreground)/0.5)] hover:text-[hsl(var(--color-muted-foreground))]'}"
/>
</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;
background: rgba(139, 92, 246, 0.06) !important;
}
</style>

View file

@ -1,38 +0,0 @@
/**
* Quotes module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalFavorite, LocalQuoteList, LocalCustomQuote } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const favoriteTable = db.table<LocalFavorite>('quotesFavorites');
export const listTable = db.table<LocalQuoteList>('quotesLists');
export const customQuoteTable = db.table<LocalCustomQuote>('customQuotes');
// ─── Guest Seed ────────────────────────────────────────────
export const QUOTES_GUEST_SEED = {
quotesFavorites: [
{ id: 'fav-1', quoteId: 'mot-1' },
{ id: 'fav-2', quoteId: 'weis-3' },
{ id: 'fav-3', quoteId: 'mot-7' },
{ id: 'fav-4', quoteId: 'weis-1' },
{ id: 'fav-5', quoteId: 'liebe-1' },
],
quotesLists: [
{
id: 'list-motivation',
name: 'Motivation & Antrieb',
description: 'Zitate die dich voranbringen',
quoteIds: ['mot-1', 'mot-7', 'mot-3'],
},
{
id: 'list-weisheit',
name: 'Zeitlose Weisheiten',
description: 'Die großen Denker und Dichter',
quoteIds: ['weis-1', 'weis-3', 'weis-5'],
},
],
};

View file

@ -1,186 +0,0 @@
<script lang="ts">
import type { Quote, Category } from '@quotes/content';
import { quotesStore } from '$lib/modules/quotes/stores/quotes.svelte';
import { favoritesStore } from '$lib/modules/quotes/stores/favorites.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { QuotesEvents } from '@mana/shared-utils/analytics';
import { toast } from '$lib/stores/toast.svelte';
import { quotesSettings } from '$lib/modules/quotes/stores/settings.svelte';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/quotes/queries';
import { Info, ShareNetwork, Heart } from '@mana/shared-icons';
interface Props {
quote: Quote;
showCategory?: boolean;
showSource?: boolean;
size?: 'small' | 'medium' | 'large';
}
let { quote, showCategory = false, showSource = true, size = 'medium' }: Props = $props();
const allFavorites: { readonly value: Favorite[] } = getContext('favorites');
let isFavorite = $derived(checkIsFavorite(allFavorites.value, quote.id));
let quoteText = $derived(quotesStore.getText(quote));
let showBio = $state(false);
// Get author bio in current language. `$derived.by` is the variant
// that takes a thunk; plain `$derived(expr)` would have stored the
// arrow function itself, making `authorBioText` always truthy and
// the {#if} below dead.
let authorBioText = $derived.by(() => {
if (!quote.authorBio) return '';
const lang = quotesStore.language === 'original' ? 'de' : quotesStore.language;
return quote.authorBio[lang] || quote.authorBio.de || '';
});
// Category gradient classes
const categoryGradients: Record<Category, string> = {
weisheit: 'quote-gradient-wisdom',
motivation: 'quote-gradient-motivation',
liebe: 'quote-gradient-love',
leben: 'quote-gradient-life',
erfolg: 'quote-gradient-success',
glueck: 'quote-gradient-happiness',
freundschaft: 'quote-gradient-friendship',
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
const categoryLabels: Record<Category, string> = {
weisheit: 'categories.wisdom',
motivation: 'categories.motivation',
liebe: 'categories.love',
leben: 'categories.life',
erfolg: 'categories.success',
glueck: 'categories.happiness',
freundschaft: 'categories.friendship',
mut: 'categories.courage',
hoffnung: 'categories.hope',
natur: 'categories.nature',
humor: 'categories.humor',
wissenschaft: 'categories.science',
kunst: 'categories.art',
};
async function toggleFavorite() {
if (!authStore.isAuthenticated) return;
const wasFavorite = isFavorite;
try {
await favoritesStore.toggle(quote.id, allFavorites.value);
if (wasFavorite) {
QuotesEvents.quoteUnfavorited();
} else {
QuotesEvents.quoteFavorited(quote.category);
}
} catch {
toast.error($_('common.error'));
}
}
async function shareQuote() {
const text = `"${quoteText}" — ${quote.author}`;
if (navigator.share) {
await navigator.share({
text,
title: 'Quotes',
});
} else {
await navigator.clipboard.writeText(text);
}
QuotesEvents.quoteShared(quote.category);
}
const sizeClasses = {
small: 'p-4 text-base',
medium: 'p-6 text-lg',
large: 'p-8 text-xl md:text-2xl',
};
</script>
<div
class="quote-card rounded-2xl bg-surface-elevated shadow-lg overflow-hidden {sizeClasses[size]}"
style="font-size: {quotesSettings.fontSizeMultiplier !== 1
? `${quotesSettings.fontSizeMultiplier}em`
: ''}"
>
{#if showCategory}
<div class="mb-4">
<span
class="inline-block px-3 py-1 rounded-full text-sm font-medium text-white {categoryGradients[
quote.category
]}"
>
{$_(categoryLabels[quote.category])}
</span>
</div>
{/if}
<blockquote class="quote-text text-foreground mb-4">
"{quoteText}"
</blockquote>
<div class="flex items-center justify-between">
<div>
<p class="quote-author text-foreground-secondary">
{quote.author}
{#if authorBioText}
<button
onclick={() => (showBio = !showBio)}
class="inline-flex ml-1 text-foreground-muted hover:text-primary transition-colors align-middle"
aria-label="Info"
>
<Info size={16} />
</button>
{/if}
</p>
{#if showBio && authorBioText}
<p class="text-sm text-foreground-muted mt-1 italic">{authorBioText}</p>
{/if}
{#if showSource && (quote.source || quote.year)}
<p class="text-sm text-foreground-muted mt-1">
{#if quote.source}
{quote.source}
{/if}
{#if quote.source && quote.year}
·
{/if}
{#if quote.year}
{quote.year}
{/if}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<button
onclick={shareQuote}
class="p-2 rounded-full hover:bg-surface-hover transition-colors text-foreground-secondary"
aria-label={$_('home.share')}
>
<ShareNetwork size={20} />
</button>
{#if authStore.isAuthenticated}
<button
onclick={toggleFavorite}
class="p-2 rounded-full hover:bg-surface-hover transition-colors"
aria-label={isFavorite ? $_('home.unfavorite') : $_('home.favorite')}
>
<Heart
size={20}
class="transition-colors {isFavorite
? 'text-red-500 fill-red-500'
: 'text-foreground-secondary'}"
/>
</button>
{/if}
</div>
</div>
</div>

View file

@ -1,165 +0,0 @@
<script lang="ts">
import type { SpiralImage } from '@mana/spiral-db';
import { spiralToXY, xyToSpiral } from '@mana/spiral-db';
interface Props {
image: SpiralImage;
scale?: number;
showGrid?: boolean;
highlightIndex?: number | null;
onPixelClick?: (index: number, x: number, y: number) => void;
}
let {
image,
scale = 10,
showGrid = false,
highlightIndex = null,
onPixelClick,
}: Props = $props();
let canvas: HTMLCanvasElement;
let hoveredIndex = $state<number | null>(null);
$effect(() => {
if (!canvas || !image) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width, height, pixels } = image;
canvas.width = width * scale;
canvas.height = height * scale;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 3;
const r = pixels[offset];
const g = pixels[offset + 1];
const b = pixels[offset + 2];
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x * scale, y * scale, scale, scale);
}
}
if (showGrid && scale >= 8) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
for (let x = 0; x <= width; x++) {
ctx.beginPath();
ctx.moveTo(x * scale, 0);
ctx.lineTo(x * scale, height * scale);
ctx.stroke();
}
for (let y = 0; y <= height; y++) {
ctx.beginPath();
ctx.moveTo(0, y * scale);
ctx.lineTo(width * scale, y * scale);
ctx.stroke();
}
}
const center = Math.floor(width / 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(center * scale, center * scale, scale, scale);
if (highlightIndex !== null && highlightIndex >= 0) {
const point = spiralToXY(highlightIndex, width);
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
if (hoveredIndex !== null) {
const point = spiralToXY(hoveredIndex, width);
ctx.strokeStyle = '#8b5cf6';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
});
function handleMouseMove(e: MouseEvent) {
if (!canvas || !image) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
hoveredIndex = xyToSpiral(x, y, image.width);
} else {
hoveredIndex = null;
}
}
function handleMouseLeave() {
hoveredIndex = null;
}
function handleClick(e: MouseEvent) {
if (!canvas || !image || !onPixelClick) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
const index = xyToSpiral(x, y, image.width);
onPixelClick(index, x, y);
}
}
</script>
<div class="spiral-canvas-container">
<canvas
bind:this={canvas}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
onclick={handleClick}
class="spiral-canvas"
class:clickable={!!onPixelClick}
></canvas>
{#if hoveredIndex !== null}
<div class="pixel-info">
Pixel #{hoveredIndex}
</div>
{/if}
</div>
<style>
.spiral-canvas-container {
position: relative;
display: inline-block;
}
.spiral-canvas {
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.spiral-canvas.clickable {
cursor: pointer;
}
.pixel-info {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
white-space: nowrap;
}
</style>

View file

@ -1,24 +0,0 @@
/**
* Quotes module barrel exports.
*/
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 { quotesSettings } 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, CustomQuote } from './queries';
export { favoriteTable, listTable, customQuoteTable, QUOTES_GUEST_SEED } from './collections';
export type { LocalFavorite, LocalQuoteList, LocalCustomQuote } from './types';

View file

@ -1,11 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const quotesModuleConfig: ModuleConfig = {
appId: 'quotes',
tables: [
{ name: 'quotesFavorites', syncName: 'favorites' },
{ name: 'quotesLists', syncName: 'lists' },
{ name: 'quotesListTags' },
{ name: 'customQuotes', syncName: 'custom-quotes' },
],
};

View file

@ -1,123 +0,0 @@
/**
* Reactive queries for Quotes uses Dexie liveQuery on the unified DB.
*/
import { liveQuery } from 'dexie';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
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;
}
export interface QuoteList {
id: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
// ─── Type Converters ──────────────────────────────────────
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(),
};
}
export function toQuoteList(local: LocalQuoteList): QuoteList {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
quoteIds: local.quoteIds,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
// ─── Live Queries ─────────────────────────────────────────
/** All favorites. Auto-updates on any change. */
export function useAllFavorites() {
return liveQuery(async () => {
const locals = await scopedForModule<LocalFavorite, string>(
'quotes',
'quotesFavorites'
).toArray();
return locals.filter((f) => !f.deletedAt).map(toFavorite);
});
}
/** All lists. Auto-updates on any change. */
export function useAllLists() {
return liveQuery(async () => {
const locals = await scopedForModule<LocalQuoteList, string>('quotes', 'quotesLists').toArray();
return locals.filter((l) => !l.deletedAt).map(toQuoteList);
});
}
/** All custom quotes. Auto-updates on any change. */
export function useAllCustomQuotes() {
return liveQuery(async () => {
const locals = await scopedForModule<LocalCustomQuote, string>(
'quotes',
'customQuotes'
).toArray();
return locals.filter((q) => !q.deletedAt).map(toCustomQuote);
});
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Check if a quote is in the favorites list. */
export function isFavorite(favorites: Favorite[], quoteId: string): boolean {
return favorites.some((f) => f.quoteId === quoteId);
}
/** Find a favorite by quote ID. */
export function findFavoriteByQuoteId(
favorites: Favorite[],
quoteId: string
): Favorite | undefined {
return favorites.find((f) => f.quoteId === quoteId);
}
/** Find a list by ID. */
export function findListById(lists: QuoteList[], listId: string): QuoteList | undefined {
return lists.find((l) => l.id === listId);
}

View file

@ -1,44 +0,0 @@
/**
* 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>('customQuotes').add({
id,
text: input.text,
author: input.author,
category: input.category ?? null,
source: input.source ?? null,
year: input.year ?? null,
createdAt: now,
});
return id;
},
async update(id: string, updates: Partial<CustomQuoteInput>): Promise<void> {
await db.table('customQuotes').update(id, {
...updates,
});
},
async remove(id: string): Promise<void> {
await db.table('customQuotes').update(id, {
deletedAt: new Date().toISOString(),
});
},
};

View file

@ -1,44 +0,0 @@
/**
* Favorites Store Mutation-only
* Reads come from liveQuery via queries.ts (reactive, auto-updating).
* This store only handles write operations.
*/
import { db } from '$lib/data/database';
import type { LocalFavorite } from '../types';
import type { Favorite } from '../queries';
export const favoritesStore = {
async add(quoteId: string) {
const now = new Date().toISOString();
await db.table<LocalFavorite>('quotesFavorites').add({
id: crypto.randomUUID(),
quoteId,
createdAt: now,
});
},
async remove(quoteId: string, favorites: Favorite[]) {
const fav = favorites.find((f) => f.quoteId === quoteId);
if (fav) {
await db.table('quotesFavorites').update(fav.id, {
deletedAt: new Date().toISOString(),
});
}
},
async toggle(quoteId: string, favorites: Favorite[]) {
const exists = favorites.some((f) => f.quoteId === quoteId);
if (exists) {
await this.remove(quoteId, favorites);
} else {
await this.add(quoteId);
}
},
async setNotes(favoriteId: string, notes: string) {
await db.table('quotesFavorites').update(favoriteId, {
notes: notes || null,
});
},
};

View file

@ -1,99 +0,0 @@
/**
* Lists Store Mutation-only
* Reads come from liveQuery via queries.ts (reactive, auto-updating).
* This store only handles write operations.
*/
import { db } from '$lib/data/database';
import { QuotesEvents } from '@mana/shared-utils/analytics';
import type { LocalQuoteList } from '../types';
import { toQuoteList, type QuoteList } from '../queries';
export type { QuoteList } from '../queries';
export const listsStore = {
async getList(id: string): Promise<QuoteList | null> {
const local = await db.table<LocalQuoteList>('quotesLists').get(id);
return local ? toQuoteList(local) : null;
},
async createList(name: string, description?: string): Promise<QuoteList | null> {
try {
const now = new Date().toISOString();
const newLocal: LocalQuoteList = {
id: crypto.randomUUID(),
name,
description: description ?? null,
quoteIds: [],
createdAt: now,
};
await db.table<LocalQuoteList>('quotesLists').add(newLocal);
QuotesEvents.listCreated();
return toQuoteList(newLocal);
} catch {
return null;
}
},
async updateList(
id: string,
updates: { name?: string; description?: string }
): Promise<QuoteList | null> {
try {
await db.table('quotesLists').update(id, {
...updates,
});
const updated = await db.table<LocalQuoteList>('quotesLists').get(id);
return updated ? toQuoteList(updated) : null;
} catch {
return null;
}
},
async deleteList(id: string): Promise<boolean> {
try {
await db.table('quotesLists').update(id, {
deletedAt: new Date().toISOString(),
});
QuotesEvents.listDeleted();
return true;
} catch {
return false;
}
},
async addQuoteToList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await db.table<LocalQuoteList>('quotesLists').get(listId);
if (!existing) return false;
const quoteIds = [...(existing.quoteIds || [])];
if (!quoteIds.includes(quoteId)) {
quoteIds.push(quoteId);
}
await db.table('quotesLists').update(listId, {
quoteIds,
});
return true;
} catch {
return false;
}
},
async removeQuoteFromList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await db.table<LocalQuoteList>('quotesLists').get(listId);
if (!existing) return false;
const quoteIds = (existing.quoteIds || []).filter((qid) => qid !== quoteId);
await db.table('quotesLists').update(listId, {
quoteIds,
});
return true;
} catch {
return false;
}
},
};

View file

@ -1,117 +0,0 @@
/**
* Quotes Store - Manages quote display state
*/
import { browser } from '$app/environment';
import {
QUOTES,
getDailyQuote,
getRandomQuote,
getQuotesByCategory,
searchQuotes,
getQuoteText,
type Quote,
type Category,
type SupportedLanguage,
} from '@quotes/content';
// State
let currentQuote = $state<Quote | null>(null);
let language = $state<SupportedLanguage>('de');
// Get stored language or detect from browser
function getInitialLanguage(): SupportedLanguage {
if (browser) {
const stored = localStorage.getItem('quotes_quote_language');
if (stored && ['de', 'en', 'it', 'fr', 'es', 'original'].includes(stored)) {
return stored as SupportedLanguage;
}
// Map browser language to supported language
const browserLang = navigator.language.split('-')[0];
const langMap: Record<string, SupportedLanguage> = {
de: 'de',
en: 'en',
it: 'it',
fr: 'fr',
es: 'es',
};
return langMap[browserLang] || 'de';
}
return 'de';
}
export const quotesStore = {
get currentQuote() {
return currentQuote;
},
get language() {
return language;
},
get allQuotes() {
return QUOTES;
},
get totalCount() {
return QUOTES.length;
},
/**
* Initialize the store
*/
initialize() {
language = getInitialLanguage();
currentQuote = getDailyQuote();
},
/**
* Set the display language
*/
setLanguage(lang: SupportedLanguage) {
language = lang;
if (browser) {
localStorage.setItem('quotes_quote_language', lang);
}
},
/**
* Get quote text in current language
*/
getText(quote: Quote): string {
return getQuoteText(quote, language);
},
/**
* Load the daily quote
*/
loadDailyQuote() {
currentQuote = getDailyQuote();
},
/**
* Load a random quote
*/
loadRandomQuote() {
currentQuote = getRandomQuote();
},
/**
* Get quotes by category
*/
getByCategory(category: Category): Quote[] {
return getQuotesByCategory(category);
},
/**
* Search quotes
*/
search(query: string): Quote[] {
return searchQuotes(query, language);
},
/**
* Set current quote
*/
setCurrentQuote(quote: Quote) {
currentQuote = quote;
},
};

View file

@ -1,96 +0,0 @@
/**
* Settings Store - Manages user preferences for the Quotes module
* Uses @mana/shared-stores createAppSettingsStore factory
*/
import { createAppSettingsStore } from '@mana/shared-stores';
export interface QuotesAppSettings extends Record<string, unknown> {
// View & Display
showQuoteOfTheDay: boolean;
autoRefreshDaily: boolean;
compactMode: boolean;
// Quote Display
showCategory: boolean;
showSource: boolean;
fontSizeMultiplier: number;
// Immersive Mode
immersiveModeEnabled: boolean;
// Navigation UI
pillNavCollapsed: boolean;
}
const DEFAULT_SETTINGS: QuotesAppSettings = {
// View & Display
showQuoteOfTheDay: true,
autoRefreshDaily: true,
compactMode: false,
// Quote Display
showCategory: true,
showSource: true,
fontSizeMultiplier: 1,
// Immersive Mode
immersiveModeEnabled: false,
// Navigation UI
pillNavCollapsed: true,
};
// Create base store using factory
const baseStore = createAppSettingsStore<QuotesAppSettings>('quotes-settings', DEFAULT_SETTINGS);
// Export with convenience getters
export const quotesSettings = {
// Base store methods
get settings() {
return baseStore.settings;
},
initialize: baseStore.initialize,
set: baseStore.set,
update: baseStore.update,
reset: baseStore.reset,
getDefaults: baseStore.getDefaults,
toggleImmersiveMode: baseStore.toggleImmersiveMode,
// Convenience getters
get showQuoteOfTheDay() {
return baseStore.settings.showQuoteOfTheDay;
},
get autoRefreshDaily() {
return baseStore.settings.autoRefreshDaily;
},
get compactMode() {
return baseStore.settings.compactMode;
},
get showCategory() {
return baseStore.settings.showCategory;
},
get showSource() {
return baseStore.settings.showSource;
},
get fontSizeMultiplier() {
return baseStore.settings.fontSizeMultiplier;
},
get immersiveModeEnabled() {
return baseStore.settings.immersiveModeEnabled;
},
get pillNavCollapsed() {
return baseStore.settings.pillNavCollapsed;
},
// Toggle methods
togglePillNav() {
baseStore.update({ pillNavCollapsed: !baseStore.settings.pillNavCollapsed });
},
showPillNav() {
baseStore.update({ pillNavCollapsed: false });
},
hidePillNav() {
baseStore.update({ pillNavCollapsed: true });
},
};

View file

@ -1,233 +0,0 @@
/**
* Spiral DB Store for Quotes
* Manages SpiralDB state for visual quote storage
*/
import {
SpiralDB,
createQuoteSchema,
type SpiralImage,
type SpiralRecord,
exportToPngBytes,
importFromPngBytes,
downloadPng,
} from '@mana/spiral-db';
interface QuoteData extends Record<string, unknown> {
id: number;
status: number;
category: number;
language: number;
createdAt: Date;
quoteId: string;
author: string;
text: string;
}
interface SpiralStats {
imageSize: number;
totalPixels: number;
usedPixels: number;
totalRecords: number;
activeRecords: number;
deletedRecords: number;
currentRing: number;
compressionRatio: number;
}
const CATEGORY_MAP: Record<string, number> = {
motivation: 0,
weisheit: 1,
liebe: 2,
leben: 3,
erfolg: 4,
glueck: 5,
freundschaft: 6,
mut: 7,
hoffnung: 8,
natur: 9,
};
const CATEGORY_NAMES: Record<number, string> = Object.fromEntries(
Object.entries(CATEGORY_MAP).map(([k, v]) => [v, k])
);
const LANGUAGE_MAP: Record<string, number> = {
original: 0,
de: 1,
en: 2,
it: 3,
fr: 4,
es: 5,
};
class SpiralStore {
private db: SpiralDB<QuoteData>;
image = $state<SpiralImage | null>(null);
stats = $state<SpiralStats | null>(null);
records = $state<SpiralRecord<QuoteData>[]>([]);
isLoading = $state(false);
error = $state<string | null>(null);
constructor() {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
this.updateState();
}
private updateState() {
this.image = this.db.getImage();
this.records = this.db.getAll();
const dbStats = this.db.getStats();
const jsonSize = JSON.stringify(this.records.map((r) => r.data)).length || 1;
const pixelBytes = Math.ceil((dbStats.usedPixels * 3) / 8);
this.stats = {
...dbStats,
compressionRatio: Math.round((1 - pixelBytes / jsonSize) * 100),
};
}
/**
* Import favorites from the favorites store, merged with quote data
*/
importFavorites(
favorites: Array<{
quoteId: string;
createdAt?: string | Date;
}>,
getQuote: (quoteId: string) => {
author: string;
text: string;
category: string;
language?: string;
} | null
) {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
for (const fav of favorites) {
const quote = getQuote(fav.quoteId);
if (!quote) continue;
const result = this.db.insert({
id: 0,
status: 2, // favorited
category: CATEGORY_MAP[quote.category] ?? 0,
language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1,
createdAt: fav.createdAt ? new Date(fav.createdAt) : new Date(),
quoteId: fav.quoteId.slice(0, 100),
author: quote.author.slice(0, 100),
text: quote.text.slice(0, 255),
});
if (result.success) {
this.db.complete(result.recordId!);
}
}
this.updateState();
}
/**
* Add a single quote to the spiral
*/
addQuote(quote: {
quoteId: string;
author: string;
text: string;
category: string;
language?: string;
}) {
const result = this.db.insert({
id: 0,
status: 0,
category: CATEGORY_MAP[quote.category] ?? 0,
language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1,
createdAt: new Date(),
quoteId: quote.quoteId.slice(0, 100),
author: quote.author.slice(0, 100),
text: quote.text.slice(0, 255),
});
if (result.success) {
this.updateState();
}
return result;
}
/**
* Remove a quote (soft delete)
*/
removeQuote(id: number) {
const result = this.db.delete(id);
if (result.success) {
this.updateState();
}
return result;
}
/**
* Mark a quote as favorited
*/
favoriteQuote(id: number) {
const result = this.db.complete(id);
if (result.success) {
this.updateState();
}
return result;
}
downloadPng(filename = 'spiral-quotes.png') {
if (this.image) {
downloadPng(this.image, filename);
}
}
getPngBytes(): Uint8Array | null {
if (!this.image) return null;
return exportToPngBytes(this.image);
}
clear() {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
this.updateState();
}
async importFromPng(file: File): Promise<{ success: boolean; error?: string }> {
try {
this.isLoading = true;
this.error = null;
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const image = await importFromPngBytes(bytes);
this.db = SpiralDB.fromImage<QuoteData>(image, createQuoteSchema());
this.updateState();
return { success: true };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
this.error = errorMessage;
return { success: false, error: errorMessage };
} finally {
this.isLoading = false;
}
}
getCategoryName(index: number): string {
return CATEGORY_NAMES[index] ?? 'unknown';
}
}
export const spiralStore = new SpiralStore();

View file

@ -1,19 +0,0 @@
/**
* Uquotes Tags Uses shared global tags + module-specific junction table.
*/
import { db } from '$lib/data/database';
import { createTagLinkOps } from '@mana/shared-stores';
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
} from '@mana/shared-stores';
export const listTagOps = createTagLinkOps({
table: () => db.table('quotesListTags'),
entityIdField: 'listId',
});

View file

@ -1,27 +0,0 @@
/**
* Quotes module types for the unified app.
*/
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 {
name: string;
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

@ -1,248 +0,0 @@
<!--
Quotes — DetailView
Full quote detail with category, source, author bio, share, favorite.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { quotesStore } from '$lib/modules/quotes/stores/quotes.svelte';
import { favoritesStore } from '$lib/modules/quotes/stores/favorites.svelte';
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/modules/quotes/queries';
import { Heart, ShareNetwork, Info } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalFavorite } from '../types';
import { QUOTES, type Quote, type Category } from '@quotes/content';
let { navigate, goBack, params }: ViewProps = $props();
let quoteId = $derived(params.quoteId as string);
let favorites = $state<Favorite[]>([]);
let showBio = $state(false);
let quote = $derived(QUOTES.find((q) => q.id === quoteId) ?? null);
let isFav = $derived(quote ? checkIsFavorite(favorites, quote.id) : false);
let quoteText = $derived(quote ? quotesStore.getText(quote) : '');
const categoryLabels: Record<Category, string> = {
weisheit: 'Weisheit',
motivation: 'Motivation',
liebe: 'Liebe',
leben: 'Leben',
erfolg: 'Erfolg',
glueck: 'Glück',
freundschaft: 'Freundschaft',
mut: 'Mut',
hoffnung: 'Hoffnung',
natur: 'Natur',
humor: 'Humor',
wissenschaft: 'Wissenschaft',
kunst: 'Kunst',
};
$effect(() => {
quotesStore.initialize();
});
$effect(() => {
const sub = liveQuery(async () => {
const locals = await db.table<LocalFavorite>('quotesFavorites').toArray();
return locals
.filter((f) => !f.deletedAt)
.map((f) => ({ id: f.id, quoteId: f.quoteId, createdAt: f.createdAt ?? '' }));
}).subscribe((val) => {
favorites = val ?? [];
});
return () => sub.unsubscribe();
});
let authorBio = $derived(() => {
if (!quote?.authorBio) return '';
const lang = quotesStore.language === 'original' ? 'de' : quotesStore.language;
return quote.authorBio[lang] || quote.authorBio.de || '';
});
async function toggleFav() {
if (!quote) return;
await favoritesStore.toggle(quote.id, favorites);
}
async function shareQuote() {
if (!quote) return;
const text = `"${quoteText}" — ${quote.author}`;
if (navigator.share) {
await navigator.share({ text, title: 'Quotes' });
} else {
await navigator.clipboard.writeText(text);
}
}
</script>
<div class="detail-view">
{#if !quote}
<p class="empty">Zitat nicht gefunden</p>
{:else}
<!-- Quote -->
<blockquote class="quote-text">
&ldquo;{quoteText}&rdquo;
</blockquote>
<!-- Author -->
<div class="author-row">
<span class="author-name">{quote.author}</span>
{#if authorBio()}
<button class="bio-btn" onclick={() => (showBio = !showBio)}>
<Info size={14} />
</button>
{/if}
</div>
{#if showBio && authorBio()}
<p class="author-bio">{authorBio()}</p>
{/if}
<!-- Meta -->
<div class="meta-row">
<span class="category-badge">{categoryLabels[quote.category]}</span>
{#if quote.source || quote.year}
<span class="source-text">
{#if quote.source}{quote.source}{/if}
{#if quote.source && quote.year}
&middot;
{/if}
{#if quote.year}{quote.year}{/if}
</span>
{/if}
</div>
<!-- Actions -->
<div class="actions">
<button class="action-btn" class:fav-active={isFav} onclick={toggleFav}>
<Heart size={18} weight={isFav ? 'fill' : 'regular'} />
<span>{isFav ? 'Gespeichert' : 'Speichern'}</span>
</button>
<button class="action-btn" onclick={shareQuote}>
<ShareNetwork size={18} />
<span>Teilen</span>
</button>
</div>
{/if}
</div>
<style>
/* P5: theme-token migration. All :global(.dark) paired rules and the
hand-rolled #374151/#9ca3af palette removed — hsl(var(--color-X))
from @mana/shared-tailwind/themes.css handles light + dark + variants
automatically. */
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.quote-text {
font-size: 1.25rem;
font-weight: 300;
font-style: italic;
line-height: 1.7;
color: hsl(var(--color-foreground));
}
.author-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.author-name {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.bio-btn {
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.125rem;
border-radius: 0.25rem;
display: flex;
transition: color 0.15s;
}
.bio-btn:hover {
color: hsl(var(--color-foreground));
}
.author-bio {
font-size: 0.8125rem;
font-style: italic;
color: hsl(var(--color-muted-foreground));
line-height: 1.5;
}
.meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Category badge uses brand violet (deliberate accent, not theme primary) */
.category-badge {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: color-mix(in srgb, #8b5cf6 12%, transparent);
color: #8b5cf6;
}
.source-text {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.actions {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--color-border));
}
.action-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.action-btn.fav-active {
color: hsl(var(--color-error));
border-color: hsl(var(--color-error) / 0.25);
}
@media (max-width: 640px) {
.detail-view {
padding: 1rem;
}
.action-btn,
.bio-btn {
min-height: 44px;
}
}
</style>

View file

@ -28,32 +28,15 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
const snapshots: AppSnapshot[] = [];
// Run all reads in parallel
const [
tasks,
events,
contacts,
conversations,
favorites,
images,
alarms,
files,
songs,
decks,
cardDecks,
cards,
] = await Promise.all([
const [tasks, events, contacts, conversations, images, alarms, files, decks] = await Promise.all([
safeGetAll('tasks'),
safeGetAll('events'),
safeGetAll('contacts'),
safeGetAll('conversations'),
safeGetAll('quotesFavorites'),
safeGetAll('images'),
safeGetAll('alarms'),
safeGetAll('files'),
safeGetAll('songs'),
safeGetAll('presiDecks'),
safeGetAll('cardDecks'),
safeGetAll('cards'),
]);
// Todo
@ -106,18 +89,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
});
}
// Quotes
if (favorites.length > 0) {
snapshots.push({
app: 'Quotes',
appIndex: MANA_APP_INDEX.quotes,
totalItems: favorites.length,
completedItems: 0,
favoriteItems: favorites.length,
label: `${favorites.length} Favoriten`,
});
}
// Picture
if (images.length > 0) {
const favs = images.filter((i: any) => i.isFavorite).length;
@ -155,18 +126,6 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
});
}
// Music
if (songs.length > 0) {
snapshots.push({
app: 'Music',
appIndex: MANA_APP_INDEX.music,
totalItems: songs.length,
completedItems: 0,
favoriteItems: 0,
label: `${songs.length} Songs`,
});
}
// Presi
if (decks.length > 0) {
snapshots.push({
@ -179,17 +138,5 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
});
}
// Cards
if (cardDecks.length > 0 || cards.length > 0) {
snapshots.push({
app: 'Cards',
appIndex: MANA_APP_INDEX.cards,
totalItems: cards.length,
completedItems: 0,
favoriteItems: 0,
label: `${cardDecks.length} Decks, ${cards.length} Karten`,
});
}
return snapshots;
}

View file

@ -23,6 +23,5 @@ export function registerAllProviders(registry: SearchRegistry): void {
// 'cards': dekommissioniert 2026-05-08 — Cards eigenständig auf cardecky.mana.how.
registry.registerLazy('picture', () => import('./picture').then((m) => m.pictureSearchProvider));
registry.registerLazy('presi', () => import('./presi').then((m) => m.presiSearchProvider));
registry.registerLazy('quotes', () => import('./quotes').then((m) => m.quotesSearchProvider));
registry.registerLazy('clock', () => import('./clock').then((m) => m.clockSearchProvider));
}

View file

@ -1,48 +0,0 @@
import { db } from '$lib/data/database';
import { getManaApp } from '@mana/shared-branding';
import { scoreRecord, truncateSubtitle } from '../scoring';
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
const app = getManaApp('quotes');
export const quotesSearchProvider: SearchProvider = {
appId: 'quotes',
appName: 'Quotes',
appIcon: app?.icon,
appColor: app?.color,
searchableTypes: ['list'],
async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
// Search quote lists
const lists = await db.table('quotesLists').toArray();
for (const list of lists) {
if (list.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'name', value: list.name, weight: 1.0 },
{ name: 'description', value: list.description, weight: 0.7 },
],
query
);
if (score > 0) {
results.push({
id: list.id,
type: 'list',
appId: 'quotes',
title: list.name,
subtitle: truncateSubtitle(list.description) || 'Zitatsammlung',
appIcon: app?.icon,
appColor: app?.color,
href: '/quotes',
score,
matchedField,
});
}
}
return results.sort((a, b) => b.score - a.score).slice(0, limit);
},
};

View file

@ -13,7 +13,6 @@ const SPLIT_APP_ID_LIST = [
'chat',
'picture',
'cards',
'quotes',
'storage',
'presi',
'inventory',

View file

@ -232,7 +232,6 @@ export const dashboardStore = {
'calendar-events',
'chat-recent',
'contacts-favorites',
'quotes-quote',
'presi-decks',
] as WidgetType[]
).filter((type) => {

View file

@ -44,7 +44,6 @@ describe('WIDGET_REGISTRY', () => {
'calendar',
'chat',
'contacts',
'quotes',
'picture',
'cards',
'times',
@ -70,7 +69,6 @@ describe('WIDGET_REGISTRY', () => {
expect(types).toContain('calendar-events');
expect(types).toContain('chat-recent');
expect(types).toContain('contacts-favorites');
expect(types).toContain('quotes-quote');
expect(types).toContain('picture-recent');
expect(types).toContain('clock-timers');
expect(types).toContain('storage-usage');

View file

@ -17,7 +17,6 @@ export type WidgetType =
| 'chat-recent' // Chat API: recent conversations
| 'contacts-favorites' // Contacts API: favorite contacts
| 'contacts-recent' // Contacts: recently updated
| 'quotes-quote' // Quotes API: daily inspiration quote
| 'picture-recent' // Picture API: recent generations
| 'clock-timers' // Clock: active timers and alarms
| 'storage-usage' // Storage: file storage stats
@ -120,7 +119,6 @@ export interface WidgetMeta {
| 'calendar'
| 'chat'
| 'contacts'
| 'quotes'
| 'picture'
| 'cards'
| 'storage'
@ -206,15 +204,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'contacts',
},
{
type: 'quotes-quote',
nameKey: 'dashboard.widgets.quotes.title',
descriptionKey: 'dashboard.widgets.quotes.description',
icon: '💡',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'quotes',
},
{
type: 'picture-recent',
nameKey: 'dashboard.widgets.picture.title',

View file

@ -1,35 +0,0 @@
<script lang="ts">
import { setContext } from 'svelte';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import {
toFavorite,
toQuoteList,
type Favorite,
type QuoteList,
} from '$lib/modules/quotes/queries';
import type { LocalFavorite, LocalQuoteList } from '$lib/modules/quotes/types';
import { quotesStore } from '$lib/modules/quotes/stores/quotes.svelte';
import { quotesSettings } from '$lib/modules/quotes/stores/settings.svelte';
let { children } = $props();
// Initialize quotes stores
quotesStore.initialize();
quotesSettings.initialize();
// Provide reactive favorites & lists contexts for child routes
const allFavorites = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalFavorite>('quotesFavorites').toArray();
return locals.filter((f) => !f.deletedAt).map(toFavorite);
}, [] as Favorite[]);
setContext('favorites', allFavorites);
const allLists = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalQuoteList>('quotesLists').toArray();
return locals.filter((l) => !l.deletedAt).map(toQuoteList);
}, [] as QuoteList[]);
setContext('lists', allLists);
</script>
{@render children()}

View file

@ -1,69 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { quotesStore } from '$lib/modules/quotes/stores/quotes.svelte';
import { quotesSettings } from '$lib/modules/quotes/stores/settings.svelte';
import { QuotesEvents } from '@mana/shared-utils/analytics';
import QuoteCard from '$lib/modules/quotes/components/QuoteCard.svelte';
import { ArrowsClockwise } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
let isRefreshing = $state(false);
async function loadNewQuote() {
isRefreshing = true;
quotesStore.loadRandomQuote();
QuotesEvents.randomQuoteLoaded();
// Small delay for visual feedback
await new Promise((r) => setTimeout(r, 300));
isRefreshing = false;
}
</script>
<svelte:head>
<title>Quotes - {$_('home.dailyQuote')}</title>
</svelte:head>
<RoutePage appId="quotes">
<div class="max-w-2xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-foreground mb-2">{$_('home.dailyQuote')}</h1>
<p class="text-foreground-secondary">{$_('app.tagline')}</p>
</div>
<!-- Daily Quote Card -->
{#if quotesStore.currentQuote}
<div
class="mb-8 transition-[transform,colors,box-shadow] duration-300 {isRefreshing
? 'opacity-50 scale-95'
: ''}"
>
<QuoteCard
quote={quotesStore.currentQuote}
size="large"
showCategory={quotesSettings.showCategory}
showSource={quotesSettings.showSource}
/>
</div>
{/if}
<!-- New Quote Button -->
<div class="text-center">
<button
onclick={loadNewQuote}
disabled={isRefreshing}
class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors disabled:opacity-50"
>
<ArrowsClockwise size={20} class={isRefreshing ? 'animate-spin' : ''} />
{$_('home.newQuote')}
</button>
</div>
<!-- Quote Stats -->
<div class="mt-12 text-center">
<p class="text-sm text-foreground-muted">
{quotesStore.totalCount} Zitate in 10 Kategorien
</p>
</div>
</div>
</RoutePage>

View file

@ -1,121 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { QuotesEvents } from '@mana/shared-utils/analytics';
import { CATEGORIES, getQuotesByCategory, type Category } from '@quotes/content';
import { RoutePage } from '$lib/components/shell';
// Category data with icons and gradients
const categoryData: Record<
Category,
{ icon: string; gradient: string; labelKey: string; count: number }
> = {
weisheit: {
icon: '🧠',
gradient: 'from-violet-500 to-purple-600',
labelKey: 'categories.wisdom',
count: getQuotesByCategory('weisheit').length,
},
motivation: {
icon: '🔥',
gradient: 'from-orange-500 to-red-500',
labelKey: 'categories.motivation',
count: getQuotesByCategory('motivation').length,
},
liebe: {
icon: '❤️',
gradient: 'from-pink-500 to-rose-500',
labelKey: 'categories.love',
count: getQuotesByCategory('liebe').length,
},
leben: {
icon: '🌱',
gradient: 'from-emerald-500 to-cyan-500',
labelKey: 'categories.life',
count: getQuotesByCategory('leben').length,
},
erfolg: {
icon: '🏆',
gradient: 'from-indigo-500 to-purple-500',
labelKey: 'categories.success',
count: getQuotesByCategory('erfolg').length,
},
glueck: {
icon: '☀️',
gradient: 'from-yellow-400 to-orange-500',
labelKey: 'categories.happiness',
count: getQuotesByCategory('glueck').length,
},
freundschaft: {
icon: '🤝',
gradient: 'from-blue-500 to-indigo-500',
labelKey: 'categories.friendship',
count: getQuotesByCategory('freundschaft').length,
},
mut: {
icon: '🦁',
gradient: 'from-red-500 to-red-700',
labelKey: 'categories.courage',
count: getQuotesByCategory('mut').length,
},
hoffnung: {
icon: '🌈',
gradient: 'from-teal-500 to-sky-500',
labelKey: 'categories.hope',
count: getQuotesByCategory('hoffnung').length,
},
natur: {
icon: '🌿',
gradient: 'from-green-500 to-emerald-500',
labelKey: 'categories.nature',
count: getQuotesByCategory('natur').length,
},
humor: {
icon: '😄',
gradient: 'from-amber-400 to-yellow-500',
labelKey: 'categories.humor',
count: getQuotesByCategory('humor').length,
},
wissenschaft: {
icon: '🔬',
gradient: 'from-cyan-500 to-blue-600',
labelKey: 'categories.science',
count: getQuotesByCategory('wissenschaft').length,
},
kunst: {
icon: '🎨',
gradient: 'from-fuchsia-500 to-pink-600',
labelKey: 'categories.art',
count: getQuotesByCategory('kunst').length,
},
};
</script>
<svelte:head>
<title>Quotes - {$_('categories.title')}</title>
</svelte:head>
<RoutePage appId="quotes" backHref="/quotes">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-8">{$_('categories.title')}</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each CATEGORIES as category}
{@const data = categoryData[category]}
<button
onclick={() => {
QuotesEvents.categoryViewed(category);
goto(`/quotes/category/${category}`);
}}
class="group p-6 rounded-2xl bg-gradient-to-br {data.gradient} text-white text-left transition-transform hover:scale-105 hover:shadow-xl"
>
<div class="text-4xl mb-3">{data.icon}</div>
<h2 class="text-xl font-semibold mb-1">{$_(data.labelKey)}</h2>
<p class="text-foreground text-sm">
{$_('categories.quotes', { values: { count: data.count } })}
</p>
</button>
{/each}
</div>
</div>
</RoutePage>

View file

@ -1,134 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getQuotesByCategory, CATEGORIES, type Category, type Quote } from '@quotes/content';
import { quotesStore } from '$lib/modules/quotes/stores/quotes.svelte';
import { quotesSettings } from '$lib/modules/quotes/stores/settings.svelte';
import QuoteCard from '$lib/modules/quotes/components/QuoteCard.svelte';
import { CaretLeft, MagnifyingGlass } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
// Get category from URL
let category = $derived($page.params.category as Category);
// Validate category
let isValidCategory = $derived(CATEGORIES.includes(category));
// Get quotes for this category
let quotes = $derived(isValidCategory ? getQuotesByCategory(category) : []);
// Search & sort state
let searchTerm = $state('');
let sortBy = $state<'default' | 'author'>('default');
// Filtered and sorted quotes — `$derived.by` is the variant that
// takes a thunk; plain `$derived(expr)` only takes a single
// expression.
let displayedQuotes = $derived.by<Quote[]>(() => {
let filtered = quotes;
// Filter by search
if (searchTerm.length >= 2) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(
(q) =>
quotesStore.getText(q).toLowerCase().includes(lower) ||
q.author.toLowerCase().includes(lower)
);
}
// Sort
if (sortBy === 'author') {
return [...filtered].sort((a, b) => a.author.localeCompare(b.author));
}
return filtered;
});
// Category labels
const categoryLabels: Record<Category, string> = {
weisheit: 'categories.wisdom',
motivation: 'categories.motivation',
liebe: 'categories.love',
leben: 'categories.life',
erfolg: 'categories.success',
glueck: 'categories.happiness',
freundschaft: 'categories.friendship',
mut: 'categories.courage',
hoffnung: 'categories.hope',
natur: 'categories.nature',
humor: 'categories.humor',
wissenschaft: 'categories.science',
kunst: 'categories.art',
};
</script>
<svelte:head>
<title
>Quotes - {isValidCategory ? $_(categoryLabels[category]) : $_('categories.notFound')}</title
>
</svelte:head>
<RoutePage appId="quotes" backHref="/quotes" title="Kategorie">
<div class="max-w-3xl mx-auto">
<!-- Back button -->
<button
onclick={() => goto('/quotes/categories')}
class="flex items-center gap-2 text-foreground-secondary hover:text-foreground mb-6 transition-colors"
>
<CaretLeft size={20} />
{$_('categories.title')}
</button>
{#if isValidCategory}
<h1 class="text-3xl font-bold text-foreground mb-2">{$_(categoryLabels[category])}</h1>
<p class="text-foreground-secondary mb-6">
{$_('categories.quotes', { values: { count: quotes.length } })}
</p>
<!-- Search & Sort Bar -->
<div class="flex gap-3 mb-8">
<div class="relative flex-1">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-muted">
<MagnifyingGlass size={16} />
</div>
<input
type="text"
placeholder={$_('categories.searchInCategory')}
bind:value={searchTerm}
class="w-full pl-10 pr-4 py-2.5 rounded-xl bg-surface-elevated border border-border text-foreground text-sm focus:outline-none focus:border-primary transition-colors"
/>
</div>
<select
bind:value={sortBy}
class="px-3 py-2.5 rounded-xl bg-surface-elevated border border-border text-foreground text-sm"
>
<option value="default">{$_('categories.sortByDefault')}</option>
<option value="author">{$_('categories.sortByAuthor')}</option>
</select>
</div>
{#if displayedQuotes.length === 0 && searchTerm.length >= 2}
<div class="text-center py-12">
<p class="text-foreground-secondary">{$_('search.noResults')}</p>
</div>
{:else}
<div class="space-y-6">
{#each displayedQuotes as quote (quote.id)}
<QuoteCard {quote} showSource={quotesSettings.showSource} />
{/each}
</div>
{/if}
{:else}
<div class="text-center py-12">
<p class="text-foreground-secondary">{$_('categories.notFound')}</p>
<button
onclick={() => goto('/quotes/categories')}
class="mt-4 text-primary hover:underline"
>
{$_('categories.backToCategories')}
</button>
</div>
{/if}
</div>
</RoutePage>

View file

@ -1,139 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { redirectToPortal } from '$lib/auth/portal-redirect';
import { favoritesStore } from '$lib/modules/quotes/stores/favorites.svelte';
import { type Favorite } from '$lib/modules/quotes/queries';
import { getQuoteById, getQuoteText, type Quote } from '@quotes/content';
import { quotesSettings } from '$lib/modules/quotes/stores/settings.svelte';
import QuoteCard from '$lib/modules/quotes/components/QuoteCard.svelte';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { Heart, User } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
const allFavorites: { readonly value: Favorite[] } = getContext('favorites');
// Get favorite quotes
let favoriteQuotes = $derived(
allFavorites.value
.map((f) => getQuoteById(f.quoteId))
.filter((q): q is NonNullable<typeof q> => q !== undefined)
);
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuQuote = $state<Quote | null>(null);
function handleContextMenu(e: MouseEvent, quote: Quote) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuQuote = quote;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
if (!contextMenuQuote) return [];
const quote = contextMenuQuote;
return [
{
id: 'remove-favorite',
label: $_('favorites.removeFromFavorites'),
variant: 'danger',
action: () => favoritesStore.toggle(quote.id, allFavorites.value),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'copy',
label: $_('favorites.copyQuote'),
action: () => {
const text = getQuoteText(quote);
navigator.clipboard.writeText(`"${text}" — ${quote.author}`);
},
},
{
id: 'share',
label: $_('favorites.share'),
action: async () => {
const text = `"${getQuoteText(quote)}" — ${quote.author}`;
if (navigator.share) {
try {
await navigator.share({ text });
} catch {
// User cancelled or share failed, ignore
}
} else {
await navigator.clipboard.writeText(text);
}
},
},
];
}
</script>
<svelte:head>
<title>Quotes - {$_('favorites.title')}</title>
</svelte:head>
<RoutePage appId="quotes" backHref="/quotes">
<div class="max-w-3xl mx-auto">
<div class="flex items-center gap-3 mb-8">
<h1 class="text-3xl font-bold text-foreground">{$_('favorites.title')}</h1>
{#if authStore.isAuthenticated && favoriteQuotes.length > 0}
<span class="px-2.5 py-0.5 rounded-full text-sm font-medium bg-primary/10 text-primary">
{favoriteQuotes.length}
</span>
{/if}
</div>
{#if !authStore.isAuthenticated}
<!-- Not logged in -->
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<User size={20} class="mx-auto text-foreground-muted mb-4" />
<p class="text-foreground-secondary mb-4">{$_('favorites.loginPrompt')}</p>
<button
onclick={() => redirectToPortal()}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
{$_('auth.login')}
</button>
</div>
{:else if favoriteQuotes.length === 0}
<!-- Empty state -->
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<Heart size={20} class="mx-auto text-foreground-muted mb-4" />
<p class="text-lg font-medium text-foreground mb-2">{$_('favorites.empty')}</p>
<p class="text-foreground-secondary">{$_('favorites.emptyDescription')}</p>
</div>
{:else}
<!-- Favorites list -->
<div class="space-y-6">
{#each favoriteQuotes as quote (quote.id)}
<div oncontextmenu={(e) => handleContextMenu(e, quote)} role="listitem">
<QuoteCard
{quote}
showCategory={quotesSettings.showCategory}
showSource={quotesSettings.showSource}
/>
</div>
{/each}
</div>
{/if}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => {
contextMenuVisible = false;
contextMenuQuote = null;
}}
/>
</RoutePage>

View file

@ -1,217 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { redirectToPortal } from '$lib/auth/portal-redirect';
import { toast } from '$lib/stores/toast.svelte';
import { listsStore } from '$lib/modules/quotes/stores/lists.svelte';
import { type QuoteList } from '$lib/modules/quotes/queries';
import { QuotesEvents } from '@mana/shared-utils/analytics';
import { Plus, Trash, X, User, Archive } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
const allLists: { readonly value: QuoteList[] } = getContext('lists');
let saving = $state(false);
let deletingId = $state<string | null>(null);
let showCreateModal = $state(false);
let newListName = $state('');
let newListDescription = $state('');
async function createList() {
if (!newListName.trim() || saving) return;
saving = true;
try {
const created = await listsStore.createList(
newListName.trim(),
newListDescription.trim() || undefined
);
if (created) {
QuotesEvents.listCreated();
showCreateModal = false;
newListName = '';
newListDescription = '';
} else {
toast.error($_('common.error'));
}
} catch (error) {
console.error('Failed to create list:', error);
toast.error($_('common.error'));
} finally {
saving = false;
}
}
async function deleteList(listId: string) {
if (deletingId || !confirm($_('lists.confirmDelete'))) return;
deletingId = listId;
try {
const success = await listsStore.deleteList(listId);
if (success) {
QuotesEvents.listDeleted();
} else {
toast.error($_('lists.detail.toast.deleteError'));
}
} catch (error) {
console.error('Failed to delete list:', error);
toast.error($_('lists.detail.toast.deleteError'));
} finally {
deletingId = null;
}
}
</script>
<svelte:head>
<title>Quotes - {$_('lists.title')}</title>
</svelte:head>
<RoutePage appId="quotes" backHref="/quotes">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-foreground">{$_('lists.title')}</h1>
{#if authStore.isAuthenticated}
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
<Plus size={20} weight="bold" />
{$_('lists.create')}
</button>
{/if}
</div>
{#if !authStore.isAuthenticated}
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<div class="w-16 h-16 mx-auto text-foreground-muted mb-4 flex items-center justify-center">
<User size={64} />
</div>
<p class="text-foreground-secondary mb-4">{$_('lists.loginPrompt')}</p>
<button
onclick={() => redirectToPortal()}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
>
{$_('auth.login')}
</button>
</div>
{:else if allLists.value.length === 0}
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<div class="w-16 h-16 mx-auto text-foreground-muted mb-4 flex items-center justify-center">
<Archive size={64} />
</div>
<p class="text-lg font-medium text-foreground mb-2">{$_('lists.empty')}</p>
<p class="text-foreground-secondary">{$_('lists.emptyDescription')}</p>
</div>
{:else}
<div class="grid gap-4">
{#each allLists.value as list (list.id)}
<a
href="/quotes/lists/{list.id}"
class="block p-6 bg-surface-elevated rounded-2xl hover:shadow-lg transition-colors group"
>
<div class="flex items-start justify-between">
<div>
<h3
class="text-lg font-semibold text-foreground group-hover:text-primary transition-colors"
>
{list.name}
</h3>
{#if list.description}
<p class="text-foreground-secondary mt-1">{list.description}</p>
{/if}
<p class="text-sm text-foreground-muted mt-2">
{$_('lists.quoteCount', { values: { count: list.quoteIds.length } })}
</p>
</div>
<button
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteList(list.id);
}}
disabled={deletingId === list.id}
class="p-2 text-foreground-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
>
{#if deletingId === list.id}
<div
class="w-5 h-5 border-2 border-red-400 border-t-transparent rounded-full animate-spin"
></div>
{:else}
<Trash size={20} />
{/if}
</button>
</div>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-surface-elevated rounded-2xl w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-6 border-b border-border">
<h3 class="text-xl font-semibold text-foreground">{$_('lists.createModal.title')}</h3>
<button
onclick={() => (showCreateModal = false)}
class="p-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<X size={20} />
</button>
</div>
<div class="p-6 space-y-4">
<div>
<label for="quotes-list-name" class="block text-sm font-medium text-foreground mb-2"
>{$_('lists.nameLabel')} *</label
>
<input
id="quotes-list-name"
type="text"
bind:value={newListName}
placeholder={$_('lists.createModal.namePlaceholder')}
maxlength="50"
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary"
/>
</div>
<div>
<label
for="quotes-list-description"
class="block text-sm font-medium text-foreground mb-2"
>{$_('lists.descriptionLabel')}</label
>
<textarea
id="quotes-list-description"
bind:value={newListDescription}
placeholder={$_('lists.createModal.descriptionPlaceholder')}
maxlength="200"
rows="3"
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
</div>
<div class="flex justify-end gap-3 p-6 border-t border-border">
<button
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-foreground-secondary hover:text-foreground transition-colors"
>
{$_('common.cancel')}
</button>
<button
onclick={createList}
disabled={!newListName.trim() || saving}
class="px-6 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if saving}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
{/if}
{$_('lists.createModal.submit')}
</button>
</div>
</div>
</div>
{/if}
</RoutePage>

View file

@ -1,953 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _, locale } from 'svelte-i18n';
import { getContext } from 'svelte';
import { listsStore } from '$lib/modules/quotes/stores/lists.svelte';
import { findListById, type QuoteList } from '$lib/modules/quotes/queries';
import { authStore } from '$lib/stores/auth.svelte';
import { quotesStore } from '$lib/modules/quotes/stores/quotes.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { QUOTES, type Quote } from '@quotes/content';
import QuoteCard from '$lib/modules/quotes/components/QuoteCard.svelte';
import { MagnifyingGlass, X, PencilSimple, Plus, ListBullets, Trash } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
const allQuotes = QUOTES;
const allLists: { readonly value: QuoteList[] } = getContext('lists');
let isSaving = $state(false);
let isAdding = $state(false);
let removingQuoteId = $state<string | null>(null);
let searchTerm = $state('');
let isSearchOpen = $state(false);
let showEditModal = $state(false);
let showAddQuotesModal = $state(false);
let editName = $state('');
let editDescription = $state('');
let selectedQuoteIds = $state<Set<string>>(new Set());
// Reactive list from liveQuery context
let listId = $derived($page.params.id ?? '');
let list = $derived<QuoteList | undefined>(findListById(allLists.value, listId));
// Get quotes in this list
let listQuotes = $derived<Quote[]>(
list ? allQuotes.filter((quote: Quote) => list!.quoteIds.includes(quote.id)) : []
);
// Filter quotes by search
let filteredQuotes = $derived<Quote[]>(
listQuotes.filter(
(quote: Quote) =>
quotesStore.getText(quote).toLowerCase().includes(searchTerm.toLowerCase()) ||
quote.author.toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Get available quotes (not in this list)
let availableQuotes = $derived<Quote[]>(
allQuotes.filter((quote: Quote) => !list?.quoteIds.includes(quote.id))
);
function toggleSearch() {
isSearchOpen = !isSearchOpen;
if (!isSearchOpen) {
searchTerm = '';
}
}
function openEditModal() {
if (list) {
editName = list.name;
editDescription = list.description || '';
showEditModal = true;
}
}
function closeEditModal() {
showEditModal = false;
}
async function handleUpdateList() {
if (!list || !editName.trim() || isSaving) return;
isSaving = true;
try {
const updated = await listsStore.updateList(list.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
});
if (updated) {
toast.success($_('lists.detail.toast.updated'));
closeEditModal();
} else {
toast.error($_('lists.detail.toast.updateError'));
}
} finally {
isSaving = false;
}
}
async function handleDeleteList() {
if (list && confirm($_('lists.confirmDelete'))) {
const success = await listsStore.deleteList(list.id);
if (success) {
toast.info($_('lists.detail.toast.deleted'));
goto('/quotes/lists');
} else {
toast.error($_('lists.detail.toast.deleteError'));
}
}
}
function openAddQuotesModal() {
selectedQuoteIds = new Set();
showAddQuotesModal = true;
}
function closeAddQuotesModal() {
showAddQuotesModal = false;
selectedQuoteIds = new Set();
}
function toggleQuoteSelection(quoteId: string) {
if (selectedQuoteIds.has(quoteId)) {
selectedQuoteIds.delete(quoteId);
} else {
selectedQuoteIds.add(quoteId);
}
selectedQuoteIds = new Set(selectedQuoteIds);
}
async function handleAddQuotes() {
if (!list || isAdding) return;
isAdding = true;
try {
let successCount = 0;
for (const quoteId of selectedQuoteIds) {
const success = await listsStore.addQuoteToList(list.id, quoteId);
if (success) successCount++;
}
if (successCount > 0) {
toast.success($_('lists.detail.toast.quotesAdded', { values: { count: successCount } }));
}
closeAddQuotesModal();
} finally {
isAdding = false;
}
}
async function handleRemoveQuote(quoteId: string) {
if (!list || removingQuoteId || !confirm($_('lists.detail.removeConfirm'))) return;
removingQuoteId = quoteId;
try {
const success = await listsStore.removeQuoteFromList(list.id, quoteId);
if (success) {
toast.info($_('lists.detail.toast.quoteRemoved'));
} else {
toast.error($_('lists.detail.toast.removeError'));
}
} finally {
removingQuoteId = null;
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString($locale || 'de', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>{list?.name || $_('common.list')} - Quotes</title>
</svelte:head>
<RoutePage appId="quotes" backHref="/quotes/lists" title="Liste">
{#if !list}
<div class="error-state">
<h2>{$_('lists.detail.notFound')}</h2>
<p>{$_('lists.detail.notFoundDescription')}</p>
<a href="/quotes/lists" class="cta-button">{$_('lists.detail.backToLists')}</a>
</div>
{:else}
<div class="list-detail-page">
<!-- Header -->
<div class="header-container">
<div class="breadcrumb">
<a href="/quotes/lists">{$_('lists.detail.breadcrumb')}</a>
<span class="separator">/</span>
<span>{list.name}</span>
</div>
<div class="header-row">
<div class="header-content">
<h2>{list.name}</h2>
{#if list.description}
<p class="description">{list.description}</p>
{/if}
<div class="meta">
<span>{$_('lists.quoteCount', { values: { count: listQuotes.length } })}</span>
<span class="separator"></span>
<span
>{$_('lists.detail.lastEdited', {
values: { date: formatDate(list.updatedAt) },
})}</span
>
</div>
</div>
<div class="header-actions">
{#if listQuotes.length > 0}
<button class="icon-btn" onclick={toggleSearch} aria-label={$_('common.search')}>
{#if isSearchOpen}
<X size={20} />
{:else}
<MagnifyingGlass size={20} />
{/if}
</button>
{/if}
<button
class="icon-btn"
onclick={openEditModal}
aria-label={$_('lists.detail.editModal.title')}
>
<PencilSimple size={20} />
</button>
<button
class="icon-btn add-btn"
onclick={openAddQuotesModal}
aria-label={$_('lists.detail.addQuotes')}
>
<Plus size={20} weight="bold" />
</button>
</div>
</div>
{#if isSearchOpen}
<div class="search-bar">
<input
type="text"
placeholder={$_('lists.detail.searchPlaceholder')}
bind:value={searchTerm}
class="search"
/>
</div>
{/if}
</div>
<!-- Quotes Grid -->
{#if listQuotes.length === 0}
<div class="empty-state">
<div class="empty-icon">
<ListBullets size={64} />
</div>
<h3>{$_('lists.detail.emptyTitle')}</h3>
<p>{$_('lists.detail.emptyDescription')}</p>
<button class="cta-button" onclick={openAddQuotesModal}>
<Plus size={20} weight="bold" />
{$_('lists.detail.addQuotes')}
</button>
</div>
{:else if filteredQuotes.length === 0}
<div class="empty-state">
<div class="empty-icon">
<MagnifyingGlass size={64} />
</div>
<h3>{$_('lists.detail.noSearchResults')}</h3>
<p>{$_('lists.detail.noSearchResultsDescription')}</p>
</div>
{:else}
<div class="quotes-grid">
{#each filteredQuotes as quote (quote.id)}
<div class="quote-wrapper">
<QuoteCard {quote} />
<button
class="remove-btn"
onclick={() => handleRemoveQuote(quote.id)}
disabled={removingQuoteId === quote.id}
aria-label={$_('lists.detail.remove')}
>
{#if removingQuoteId === quote.id}
<div
class="w-4 h-4 border-2 border-red-400 border-t-transparent rounded-full animate-spin"
></div>
{:else}
<X size={16} />
{/if}
{$_('lists.detail.remove')}
</button>
</div>
{/each}
</div>
{/if}
{#if isSearchOpen && filteredQuotes.length > 0}
<div class="floating-results">
{$_('lists.detail.floatingResults', {
values: { filtered: filteredQuotes.length, total: listQuotes.length },
})}
</div>
{/if}
</div>
{/if}
<!-- Edit List Modal -->
{#if showEditModal}
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="modal-header">
<h3>{$_('lists.detail.editModal.title')}</h3>
<button class="close-btn" onclick={closeEditModal} aria-label={$_('common.close')}>
<X size={24} />
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="edit-name">{$_('lists.nameLabel')} *</label>
<input
id="edit-name"
type="text"
bind:value={editName}
class="form-input"
maxlength="50"
/>
</div>
<div class="form-group">
<label for="edit-description">{$_('lists.descriptionLabel')}</label>
<textarea
id="edit-description"
bind:value={editDescription}
class="form-textarea"
rows="3"
maxlength="200"
></textarea>
</div>
<button class="danger-btn" onclick={handleDeleteList}>
<Trash size={20} />
{$_('lists.detail.editModal.deleteList')}
</button>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick={closeEditModal}>{$_('common.cancel')}</button>
<button
class="btn btn-primary"
onclick={handleUpdateList}
disabled={!editName.trim() || isSaving}
>
{#if isSaving}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin inline-block mr-1"
></div>
{/if}
{$_('common.save')}
</button>
</div>
</div>
</div>
{/if}
<!-- Add Quotes Modal -->
{#if showAddQuotesModal}
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="modal-overlay"
onclick={closeAddQuotesModal}
onkeydown={(e) => e.key === 'Escape' && closeAddQuotesModal()}
tabindex="-1"
role="presentation"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="modal modal-large"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div class="modal-header">
<h3>{$_('lists.detail.addModal.title')}</h3>
<button class="close-btn" onclick={closeAddQuotesModal} aria-label={$_('common.close')}>
<X size={24} />
</button>
</div>
<div class="modal-body quote-selection">
{#each availableQuotes.slice(0, 50) as quote (quote.id)}
<label class="quote-option">
<input
type="checkbox"
checked={selectedQuoteIds.has(quote.id)}
onchange={() => toggleQuoteSelection(quote.id)}
/>
<div class="quote-preview">
<p class="quote-text">"{quotesStore.getText(quote)}"</p>
<p class="quote-author">--- {quote.author}</p>
</div>
</label>
{/each}
</div>
<div class="modal-footer">
<div class="selected-count">
{$_('lists.detail.addModal.selected', { values: { count: selectedQuoteIds.size } })}
</div>
<div class="footer-actions">
<button class="btn btn-secondary" onclick={closeAddQuotesModal}
>{$_('common.cancel')}</button
>
<button
class="btn btn-primary"
onclick={handleAddQuotes}
disabled={selectedQuoteIds.size === 0 || isAdding}
>
{#if isAdding}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin inline-block mr-1"
></div>
{/if}
{$_('lists.detail.addModal.submit', { values: { count: selectedQuoteIds.size } })}
</button>
</div>
</div>
</div>
</div>
{/if}
</RoutePage>
<style>
.list-detail-page {
max-width: 1200px;
margin: 0 auto;
padding-bottom: var(--spacing-2xl);
}
.error-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.header-container {
max-width: 700px;
margin: 0 auto var(--spacing-xl);
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.breadcrumb a {
color: rgb(var(--color-primary));
text-decoration: none;
transition: opacity var(--transition-fast);
}
.breadcrumb a:hover {
opacity: 0.8;
}
.breadcrumb .separator {
color: rgb(var(--color-text-tertiary));
}
.header-row {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.header-content {
flex: 1;
}
h2 {
font-size: 2rem;
margin: 0 0 var(--spacing-xs) 0;
color: rgb(var(--color-text-primary));
}
.description {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0 0 var(--spacing-sm) 0;
line-height: 1.5;
}
.meta {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 0.875rem;
color: rgb(var(--color-text-tertiary));
}
.meta .separator {
color: rgb(var(--color-border));
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: flex-start;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 1px solid rgb(var(--color-border));
cursor: pointer;
transition: all var(--transition-base);
}
.icon-btn:hover {
background: rgb(var(--color-background));
transform: scale(1.05);
}
.icon-btn.add-btn {
background: rgb(var(--color-primary));
color: white;
border: none;
}
.icon-btn.add-btn:hover {
opacity: 0.9;
}
.search-bar {
padding: var(--spacing-md);
background: rgb(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid rgb(var(--color-border));
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.quotes-grid {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
max-width: 700px;
margin: 0 auto;
}
.quote-wrapper {
position: relative;
}
.remove-btn {
display: none;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
position: absolute;
top: var(--spacing-sm);
right: var(--spacing-sm);
}
.quote-wrapper:hover .remove-btn {
display: flex;
}
.remove-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
/* Empty State */
.empty-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0 0 var(--spacing-xl) 0;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-xl);
background: rgb(var(--color-primary));
color: white;
border: none;
border-radius: var(--radius-full);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
text-decoration: none;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Floating Results */
.floating-results {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: var(--spacing-sm) var(--spacing-lg);
background: rgba(var(--color-surface), 0.95);
backdrop-filter: blur(10px);
border-radius: var(--radius-full);
border: 1px solid rgba(var(--color-border), 0.5);
box-shadow: var(--shadow-lg);
color: rgb(var(--color-text-secondary));
font-size: 0.875rem;
font-weight: 500;
z-index: 20;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: var(--spacing-lg);
}
.modal {
background: rgb(var(--color-surface-elevated));
border-radius: var(--radius-xl);
max-width: 500px;
width: 100%;
box-shadow: var(--shadow-xl);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-large {
max-width: 700px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid rgb(var(--color-border));
flex-shrink: 0;
}
.modal-header h3 {
font-size: 1.25rem;
margin: 0;
color: rgb(var(--color-text-primary));
}
.close-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgb(var(--color-text-secondary));
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
}
.close-btn:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.modal-body {
padding: var(--spacing-lg);
overflow-y: auto;
flex: 1;
}
.quote-selection {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.quote-option {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: rgb(var(--color-surface));
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.quote-option:hover {
border-color: rgb(var(--color-primary));
background: rgb(var(--color-background));
}
.quote-option input[type='checkbox'] {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.quote-preview {
flex: 1;
}
.quote-text {
font-size: 0.9375rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-xs) 0;
line-height: 1.5;
}
.quote-author {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
margin-bottom: var(--spacing-xs);
}
.form-input,
.form-textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
font-family: inherit;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.form-textarea {
resize: vertical;
}
.danger-btn {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: var(--spacing-xl);
width: 100%;
justify-content: center;
}
.danger-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border-top: 1px solid rgb(var(--color-border));
flex-shrink: 0;
}
.selected-count {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.footer-actions {
display: flex;
gap: var(--spacing-md);
}
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
transition: all var(--transition-base);
border: none;
}
.btn-secondary {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 1px solid rgb(var(--color-border));
}
.btn-secondary:hover {
background: rgb(var(--color-background));
}
.btn-primary {
background: rgb(var(--color-primary));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.list-detail-page {
padding-bottom: var(--spacing-xl);
}
.header-container {
max-width: 100%;
}
h2 {
font-size: 1.5rem;
}
.quotes-grid {
max-width: 100%;
}
.remove-btn {
display: flex;
position: static;
width: 100%;
margin-top: var(--spacing-sm);
}
.floating-results {
bottom: 5rem;
}
.modal {
margin: var(--spacing-sm);
}
.modal-footer {
flex-direction: column;
align-items: stretch;
}
.footer-actions {
width: 100%;
}
.footer-actions .btn {
flex: 1;
}
}
</style>

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,229 +0,0 @@
/**
* Quote categories
*/
declare const CATEGORIES: readonly ["motivation", "weisheit", "liebe", "leben", "erfolg", "glueck", "freundschaft", "mut", "hoffnung", "natur", "humor", "wissenschaft", "kunst"];
type Category = (typeof CATEGORIES)[number];
/**
* German labels for categories
*/
declare const CATEGORY_LABELS: Record<Category, string>;
/** Curated theme decks — cross-category collections around a topic. */
declare const THEME_DECKS: readonly [{
readonly id: "stoizismus";
readonly label: "Stoizismus";
readonly description: "Gelassenheit und innere Stärke";
readonly authors: readonly ["Marcus Aurelius", "Seneca", "Epiktet"];
}, {
readonly id: "feminismus";
readonly label: "Feminismus";
readonly description: "Gleichberechtigung und Selbstbestimmung";
readonly authors: readonly ["Simone de Beauvoir", "Virginia Woolf", "Maya Angelou", "Marie Curie", "Frida Kahlo"];
}, {
readonly id: "unternehmertum";
readonly label: "Unternehmertum";
readonly description: "Innovation und Durchhaltevermögen";
readonly authors: readonly ["Steve Jobs", "Henry Ford", "Thomas Edison", "Walt Disney"];
}, {
readonly id: "philosophie";
readonly label: "Philosophie";
readonly description: "Die großen Fragen des Lebens";
readonly authors: readonly ["Sokrates", "Platon", "Aristoteles", "Immanuel Kant", "Friedrich Nietzsche", "Konfuzius", "Laozi"];
}, {
readonly id: "literatur";
readonly label: "Literatur";
readonly description: "Worte der großen Dichter und Schriftsteller";
readonly authors: readonly ["Johann Wolfgang von Goethe", "Oscar Wilde", "Mark Twain", "William Shakespeare", "Rainer Maria Rilke"];
}];
type ThemeDeckId = (typeof THEME_DECKS)[number]['id'];
/**
* Get label for a category
*/
declare function getCategoryLabel(category: Category): string;
/**
* Check if a string is a valid category
*/
declare function isValidCategory(value: string): value is Category;
/**
* Supported languages for quote translations
*/
declare const SUPPORTED_LANGUAGES: readonly ["original", "de", "en", "it", "fr", "es"];
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
/**
* Original language of a quote
*/
declare const ORIGINAL_LANGUAGES: readonly ["de", "en", "fr", "es", "it", "la", "el", "zh", "sa", "ar", "fa", "ja", "ru", "pt", "nl", "da", "hi", "bn"];
type OriginalLanguage = (typeof ORIGINAL_LANGUAGES)[number];
/**
* Translated text object
*/
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
*/
interface AuthorBio {
de?: string;
en?: string;
it?: string;
fr?: string;
es?: string;
}
/**
* A quote with author, translations, and metadata
*/
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;
}
/**
* German inspirational quotes collection with multilingual support
*/
declare const QUOTES: Quote[];
/**
* Total number of quotes
*/
declare const QUOTE_COUNT: number;
/**
* Get a random quote
*/
declare function getRandomQuote(): Quote;
/**
* Get deterministic daily quote based on date
*/
declare function getDailyQuote(date?: Date): Quote;
/**
* Get quotes by category (uses pre-built index for O(1) lookups).
*/
declare function getQuotesByCategory(category: Category): Quote[];
/**
* Get a random quote from a specific category
*/
declare function getRandomQuoteByCategory(category: Category): Quote | null;
/**
* Search quotes by text or author (searches in specified language, defaults to German)
*/
declare function searchQuotes(searchText: string, language?: SupportedLanguage): Quote[];
/**
* Get a quote by ID
*/
declare function getQuoteById(id: string): Quote | undefined;
/**
* Get quote by index (1-based)
*/
declare function getQuoteByIndex(index: number): Quote | null;
/**
* Get all categories with counts
*/
declare function getAllCategories(): {
category: Category;
label: string;
count: number;
}[];
/**
* Find category by name (partial match)
*/
declare function getCategoryByName(name: string): Category | null;
/**
* Get quote text in a specific language
*/
declare function getQuoteText(quote: Quote, language?: SupportedLanguage): string;
/**
* Format a quote for display
*/
declare function formatQuote(quote: Quote, language?: SupportedLanguage): string;
/**
* Format a quote with number
*/
declare function formatQuoteWithNumber(quote: Quote, number: number, language?: SupportedLanguage): string;
/**
* Get total quote count
*/
declare function getTotalCount(): number;
/**
* Get quotes by tag
*/
declare function getQuotesByTag(tag: string): Quote[];
/**
* Get all unique tags
*/
declare function getAllTags(): string[];
/**
* Get quotes by author (substring match on name).
*/
declare function getQuotesByAuthor(author: string): Quote[];
/** Author summary for browse pages. */
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.
*/
declare function getAllAuthors(): AuthorInfo[];
/**
* Get verified quotes only
*/
declare function getVerifiedQuotes(): Quote[];
/**
* Get quotes by year range
*/
declare function getQuotesByYearRange(startYear: number, endYear: number): Quote[];
/**
* Get quotes by original language
*/
declare function getQuotesByOriginalLanguage(language: string): Quote[];
/**
* Get quotes for a curated theme deck.
*/
declare function getQuotesByThemeDeck(deckId: ThemeDeckId): Quote[];
/**
* Fuzzy search matches even with typos using bigram similarity.
* Falls back to simple substring match for short queries.
*/
declare function fuzzySearchQuotes(query: string, language?: SupportedLanguage, threshold?: number): Quote[];
export { type AuthorBio, type AuthorInfo, CATEGORIES, CATEGORY_LABELS, type Category, ORIGINAL_LANGUAGES, type OriginalLanguage, QUOTES, QUOTE_COUNT, type Quote, SUPPORTED_LANGUAGES, type SupportedLanguage, THEME_DECKS, type ThemeDeckId, type TranslatedText, formatQuote, formatQuoteWithNumber, fuzzySearchQuotes, getAllAuthors, getAllCategories, getAllTags, getCategoryByName, getCategoryLabel, getDailyQuote, getQuoteById, getQuoteByIndex, getQuoteText, getQuotesByAuthor, getQuotesByCategory, getQuotesByOriginalLanguage, getQuotesByTag, getQuotesByThemeDeck, getQuotesByYearRange, getRandomQuote, getRandomQuoteByCategory, getTotalCount, getVerifiedQuotes, isValidCategory, searchQuotes };

View file

@ -1,229 +0,0 @@
/**
* Quote categories
*/
declare const CATEGORIES: readonly ["motivation", "weisheit", "liebe", "leben", "erfolg", "glueck", "freundschaft", "mut", "hoffnung", "natur", "humor", "wissenschaft", "kunst"];
type Category = (typeof CATEGORIES)[number];
/**
* German labels for categories
*/
declare const CATEGORY_LABELS: Record<Category, string>;
/** Curated theme decks — cross-category collections around a topic. */
declare const THEME_DECKS: readonly [{
readonly id: "stoizismus";
readonly label: "Stoizismus";
readonly description: "Gelassenheit und innere Stärke";
readonly authors: readonly ["Marcus Aurelius", "Seneca", "Epiktet"];
}, {
readonly id: "feminismus";
readonly label: "Feminismus";
readonly description: "Gleichberechtigung und Selbstbestimmung";
readonly authors: readonly ["Simone de Beauvoir", "Virginia Woolf", "Maya Angelou", "Marie Curie", "Frida Kahlo"];
}, {
readonly id: "unternehmertum";
readonly label: "Unternehmertum";
readonly description: "Innovation und Durchhaltevermögen";
readonly authors: readonly ["Steve Jobs", "Henry Ford", "Thomas Edison", "Walt Disney"];
}, {
readonly id: "philosophie";
readonly label: "Philosophie";
readonly description: "Die großen Fragen des Lebens";
readonly authors: readonly ["Sokrates", "Platon", "Aristoteles", "Immanuel Kant", "Friedrich Nietzsche", "Konfuzius", "Laozi"];
}, {
readonly id: "literatur";
readonly label: "Literatur";
readonly description: "Worte der großen Dichter und Schriftsteller";
readonly authors: readonly ["Johann Wolfgang von Goethe", "Oscar Wilde", "Mark Twain", "William Shakespeare", "Rainer Maria Rilke"];
}];
type ThemeDeckId = (typeof THEME_DECKS)[number]['id'];
/**
* Get label for a category
*/
declare function getCategoryLabel(category: Category): string;
/**
* Check if a string is a valid category
*/
declare function isValidCategory(value: string): value is Category;
/**
* Supported languages for quote translations
*/
declare const SUPPORTED_LANGUAGES: readonly ["original", "de", "en", "it", "fr", "es"];
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
/**
* Original language of a quote
*/
declare const ORIGINAL_LANGUAGES: readonly ["de", "en", "fr", "es", "it", "la", "el", "zh", "sa", "ar", "fa", "ja", "ru", "pt", "nl", "da", "hi", "bn"];
type OriginalLanguage = (typeof ORIGINAL_LANGUAGES)[number];
/**
* Translated text object
*/
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
*/
interface AuthorBio {
de?: string;
en?: string;
it?: string;
fr?: string;
es?: string;
}
/**
* A quote with author, translations, and metadata
*/
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;
}
/**
* German inspirational quotes collection with multilingual support
*/
declare const QUOTES: Quote[];
/**
* Total number of quotes
*/
declare const QUOTE_COUNT: number;
/**
* Get a random quote
*/
declare function getRandomQuote(): Quote;
/**
* Get deterministic daily quote based on date
*/
declare function getDailyQuote(date?: Date): Quote;
/**
* Get quotes by category (uses pre-built index for O(1) lookups).
*/
declare function getQuotesByCategory(category: Category): Quote[];
/**
* Get a random quote from a specific category
*/
declare function getRandomQuoteByCategory(category: Category): Quote | null;
/**
* Search quotes by text or author (searches in specified language, defaults to German)
*/
declare function searchQuotes(searchText: string, language?: SupportedLanguage): Quote[];
/**
* Get a quote by ID
*/
declare function getQuoteById(id: string): Quote | undefined;
/**
* Get quote by index (1-based)
*/
declare function getQuoteByIndex(index: number): Quote | null;
/**
* Get all categories with counts
*/
declare function getAllCategories(): {
category: Category;
label: string;
count: number;
}[];
/**
* Find category by name (partial match)
*/
declare function getCategoryByName(name: string): Category | null;
/**
* Get quote text in a specific language
*/
declare function getQuoteText(quote: Quote, language?: SupportedLanguage): string;
/**
* Format a quote for display
*/
declare function formatQuote(quote: Quote, language?: SupportedLanguage): string;
/**
* Format a quote with number
*/
declare function formatQuoteWithNumber(quote: Quote, number: number, language?: SupportedLanguage): string;
/**
* Get total quote count
*/
declare function getTotalCount(): number;
/**
* Get quotes by tag
*/
declare function getQuotesByTag(tag: string): Quote[];
/**
* Get all unique tags
*/
declare function getAllTags(): string[];
/**
* Get quotes by author (substring match on name).
*/
declare function getQuotesByAuthor(author: string): Quote[];
/** Author summary for browse pages. */
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.
*/
declare function getAllAuthors(): AuthorInfo[];
/**
* Get verified quotes only
*/
declare function getVerifiedQuotes(): Quote[];
/**
* Get quotes by year range
*/
declare function getQuotesByYearRange(startYear: number, endYear: number): Quote[];
/**
* Get quotes by original language
*/
declare function getQuotesByOriginalLanguage(language: string): Quote[];
/**
* Get quotes for a curated theme deck.
*/
declare function getQuotesByThemeDeck(deckId: ThemeDeckId): Quote[];
/**
* Fuzzy search matches even with typos using bigram similarity.
* Falls back to simple substring match for short queries.
*/
declare function fuzzySearchQuotes(query: string, language?: SupportedLanguage, threshold?: number): Quote[];
export { type AuthorBio, type AuthorInfo, CATEGORIES, CATEGORY_LABELS, type Category, ORIGINAL_LANGUAGES, type OriginalLanguage, QUOTES, QUOTE_COUNT, type Quote, SUPPORTED_LANGUAGES, type SupportedLanguage, THEME_DECKS, type ThemeDeckId, type TranslatedText, formatQuote, formatQuoteWithNumber, fuzzySearchQuotes, getAllAuthors, getAllCategories, getAllTags, getCategoryByName, getCategoryLabel, getDailyQuote, getQuoteById, getQuoteByIndex, getQuoteText, getQuotesByAuthor, getQuotesByCategory, getQuotesByOriginalLanguage, getQuotesByTag, getQuotesByThemeDeck, getQuotesByYearRange, getRandomQuote, getRandomQuoteByCategory, getTotalCount, getVerifiedQuotes, isValidCategory, searchQuotes };

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,24 +0,0 @@
{
"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

@ -1,103 +0,0 @@
/**
* 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

@ -1,44 +0,0 @@
// 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

@ -1,107 +0,0 @@
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

@ -1,339 +0,0 @@
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

@ -1,16 +0,0 @@
{
"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

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

View file

@ -78,12 +78,6 @@
"dev:sync": "cd services/mana-sync && JWKS_URL=http://localhost:3001/api/auth/jwks DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_sync ./server",
"dev:sync:build": "cd services/mana-sync && go build -o server ./cmd/server",
"dev:chat:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"",
"quotes:dev": "turbo run dev --filter=quotes...",
"dev:quotes:mobile": "pnpm --filter @quotes/mobile dev",
"dev:quotes:web": "pnpm --filter @quotes/web dev",
"dev:quotes:landing": "pnpm --filter @quotes/landing dev",
"dev:quotes:app": "pnpm dev:quotes:web",
"dev:quotes:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:quotes:web\"",
"contacts:dev": "turbo run dev --filter=contacts...",
"dev:contacts:mobile": "pnpm --filter @contacts/mobile dev",
"dev:contacts:web": "pnpm --filter @contacts/web dev",
@ -184,18 +178,17 @@
"deploy:landing:chat": "pnpm --filter @chat/landing build && npx wrangler pages deploy apps/chat/apps/landing/dist --project-name=chat-landing",
"deploy:landing:picture": "pnpm --filter @picture/landing build && npx wrangler pages deploy apps/picture/apps/landing/dist --project-name=picture-landing",
"deploy:landing:mana": "pnpm --filter @mana/landing build && npx wrangler pages deploy apps/mana/apps/landing/dist --project-name=mana-landing",
"deploy:landing:quotes": "pnpm --filter @quotes/landing build && npx wrangler pages deploy apps/quotes/apps/landing/dist --project-name=quotes-landing",
"deploy:landing:presi": "pnpm --filter @presi/landing build && npx wrangler pages deploy apps/presi/apps/landing/dist --project-name=presi-landing",
"deploy:landing:mail": "pnpm --filter @mail/landing build && npx wrangler pages deploy apps/mail/apps/landing/dist --project-name=mail-landing",
"deploy:landing:moodlit": "pnpm --filter @moodlit/landing build && npx wrangler pages deploy apps/moodlit/apps/landing/dist --project-name=moodlit-landing",
"deploy:landing:it": "pnpm --filter @mana/it-landing build && npx wrangler pages deploy services/it-landing/dist --project-name=it-landing",
"deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:mana && pnpm deploy:landing:quotes && pnpm deploy:landing:presi && pnpm deploy:landing:mail && pnpm deploy:landing:contacts && pnpm deploy:landing:todo",
"deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:mana && pnpm deploy:landing:presi && pnpm deploy:landing:mail && pnpm deploy:landing:contacts && pnpm deploy:landing:todo",
"dev:docs": "pnpm --filter @mana/docs dev",
"build:docs": "pnpm --filter @mana/docs build",
"deploy:docs": "pnpm --filter @mana/docs build && npx wrangler pages deploy apps/docs/dist --project-name=mana-docs",
"cf:login": "npx wrangler login",
"cf:projects:list": "npx wrangler pages project list",
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create mana-landing --production-branch=main && npx wrangler pages project create quotes-landing --production-branch=main",
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create mana-landing --production-branch=main",
"dev:search": "cd services/mana-search && PORT=3021 SEARXNG_URL=http://localhost:8080 REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=${REDIS_PASSWORD:-devpassword} go run ./cmd/server",
"dev:crawler": "cd services/mana-crawler && go run ./cmd/server",
"dev:credits": "cd ../mana/services/mana-credits && bun run --hot src/index.ts",
@ -220,7 +213,6 @@
"dev:storage:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:presi:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:traces:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:quotes:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:quotes:web\"",
"dev:skilltree:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:skilltree:web\"",
"dev:photos:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:photos:web\"",
"dev:inventory:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:inventory:web\"",

View file

@ -190,23 +190,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'quotes',
name: 'Quotes',
description: {
de: 'Tägliche Inspiration',
en: 'Daily Inspiration',
},
longDescription: {
de: 'Entdecke inspirierende Zitate und Weisheiten für jeden Tag.',
en: 'Discover inspiring quotes and wisdom for every day.',
},
icon: APP_ICONS.quotes,
color: '#f59e0b',
comingSoon: false,
status: 'beta',
requiredTier: 'guest',
},
{
id: 'wisekeep',
name: 'WiseKeep',