mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 07:43:37 +02:00
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
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:
parent
1b637b9aa7
commit
001548c74d
63 changed files with 5 additions and 11938 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -138,7 +138,6 @@ const APP_SUBDOMAINS = new Set([
|
|||
'chat',
|
||||
'calendar',
|
||||
'contacts',
|
||||
'quotes',
|
||||
'skilltree',
|
||||
'cards',
|
||||
'storage',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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">💡</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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
>
|
||||
«{quotesStore.getText(quote)}»
|
||||
</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>
|
||||
|
|
@ -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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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">
|
||||
“{quoteText}”
|
||||
</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}
|
||||
·
|
||||
{/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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -13,7 +13,6 @@ const SPLIT_APP_ID_LIST = [
|
|||
'chat',
|
||||
'picture',
|
||||
'cards',
|
||||
'quotes',
|
||||
'storage',
|
||||
'presi',
|
||||
'inventory',
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ export const dashboardStore = {
|
|||
'calendar-events',
|
||||
'chat-recent',
|
||||
'contacts-favorites',
|
||||
'quotes-quote',
|
||||
'presi-decks',
|
||||
] as WidgetType[]
|
||||
).filter((type) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
2280
apps/quotes/packages/content/dist/index.cjs
vendored
2280
apps/quotes/packages/content/dist/index.cjs
vendored
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
229
apps/quotes/packages/content/dist/index.d.cts
vendored
229
apps/quotes/packages/content/dist/index.d.cts
vendored
|
|
@ -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 };
|
||||
229
apps/quotes/packages/content/dist/index.d.ts
vendored
229
apps/quotes/packages/content/dist/index.d.ts
vendored
|
|
@ -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 };
|
||||
2223
apps/quotes/packages/content/dist/index.js
vendored
2223
apps/quotes/packages/content/dist/index.js
vendored
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
12
package.json
12
package.json
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue