chore(decommission): remove cards module from mana web app
Some checks are pending
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
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

Cards-Modul war im unified mana-Frontend tief verzahnt. Cardecky
ist seit 2026-05-08 standalone auf cardecky.mana.how — Dual-Stack
ist nicht das Ziel. Entfernt:

  - apps/mana/apps/web/src/lib/modules/cards/ (UI + stores + queries
    + collections + module.config + tools + cloze + fsrs + render)
  - apps/mana/apps/web/src/routes/(app)/cards/ (alle Routes)
  - apps/mana/apps/web/src/lib/i18n/locales/cards/ (5 Locales)
  - apps/mana/apps/web/src/lib/search/providers/cards.ts
  - apps/mana/apps/web/src/lib/components/dashboard/widgets/
    CardsProgressWidget.svelte + 'cards-progress' WidgetType-Eintrag

Cross-Refs aufgeräumt:
  - app-registry/apps.ts: Cards-Icon-Import + registerApp-Block raus
  - shared-branding/mana-apps.ts: 'cards'-App-Eintrag raus
  - data/cross-app-queries.ts: useCardsProgress + Cards-Queries-Block
    raus (Konsument war nur das gelöschte Dashboard-Widget)
  - data/seed-registry.ts: CARDS_GUEST_SEED-Import + register-Aufruf
  - data/module-registry.ts: cardsModuleConfig-Import + Eintrag
  - data/privacy/exposed-records.ts: Cards-Block (cardDecks visibility)
  - data/tools/init.ts: cardsTools-Import + registerTools
  - modules/website/embeds.ts: 'cards.decks'-Source + resolveCardDecks
  - apps/mana/apps/web/package.json: @mana/cards-core dependency
  - pnpm-lock.yaml regeneriert
  - dashboard.test.ts: cards-progress-Assertion

Dexie-Tabellen `cardDecks`/`cardReviews`/`cards` (lokal pro User-IndexedDB)
und ggf. mana_platform.cards.* in der prod-DB werden NICHT in diesem
Commit gedroppt — bleibt offen als separater Migrations-Schritt, sobald
sicher ist dass kein anderer Pfad mehr darauf zugreift.

Type-check (svelte-check) 7669 files 0 errors.

Rollback: git checkout cards-decommission-base -- apps/mana/apps/web

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 20:36:33 +02:00
parent dd1bab09d5
commit ac15de280b
48 changed files with 23 additions and 3381 deletions

View file

@ -67,7 +67,6 @@
"@mana/shared-links": "workspace:*",
"@mana/shared-llm": "workspace:*",
"@mana/shared-privacy": "workspace:*",
"@mana/cards-core": "workspace:*",
"@mana/shared-stores": "workspace:*",
"@mana/shared-tags": "workspace:*",
"@mana/shared-tailwind": "workspace:*",

View file

@ -22,7 +22,6 @@ import {
ChatCircle,
Clock,
Quotes,
Cards,
Image,
MusicNotes,
Camera,
@ -597,16 +596,8 @@ registerApp({
},
});
registerApp({
id: 'cards',
name: 'Cards',
color: '#EF4444',
icon: Cards,
views: {
list: { load: () => import('$lib/modules/cards/ListView.svelte') },
detail: { load: () => import('$lib/modules/cards/views/DetailView.svelte') },
},
});
// Cards-Modul: dekommissioniert 2026-05-08, Cards lebt jetzt als
// standalone-App auf cardecky.mana.how (git.mana.how/till/cards).
registerApp({
id: 'picture',

View file

@ -18,7 +18,6 @@ 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 CardsProgressWidget from './widgets/CardsProgressWidget.svelte';
import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
import MusicLibraryWidget from './widgets/MusicLibraryWidget.svelte';
@ -51,7 +50,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
'contacts-recent': RecentContactsWidget,
'quotes-quote': QuoteWidget,
'picture-recent': PictureRecentWidget,
'cards-progress': CardsProgressWidget,
'clock-timers': ClockTimersWidget,
'storage-usage': StorageUsageWidget,
'music-library': MusicLibraryWidget,

View file

@ -1,55 +0,0 @@
<script lang="ts">
/**
* CardsProgressWidget - Learning progress (local-first)
*/
import { _ } from 'svelte-i18n';
import { useCardsProgress } from '$lib/data/cross-app-queries';
const progress = useCardsProgress();
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎴</span>
{$_('dashboard.widgets.cards.title')}
</h3>
</div>
{#if progress.loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else}
<div class="mb-3 grid grid-cols-2 gap-3">
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold">{progress.value.totalCards}</div>
<div class="text-xs text-muted-foreground">Karten</div>
</div>
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold">{progress.value.cardsLearned}</div>
<div class="text-xs text-muted-foreground">Gelernt</div>
</div>
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold">{progress.value.totalDecks}</div>
<div class="text-xs text-muted-foreground">Decks</div>
</div>
<div class="rounded-lg bg-surface-hover p-2 text-center">
<div class="text-lg font-bold text-primary">{progress.value.dueForReview}</div>
<div class="text-xs text-muted-foreground">Fällig</div>
</div>
</div>
{#if progress.value.dueForReview > 0}
<a
href="/cards"
class="block rounded-lg bg-primary/10 py-2 text-center text-sm font-medium text-primary hover:bg-primary/20"
>
{progress.value.dueForReview} Karten wiederholen →
</a>
{/if}
{/if}
</div>

View file

@ -19,7 +19,6 @@ import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
import type { LocalFile } from '$lib/modules/storage/types';
import type { LocalSong, LocalPlaylist } from '$lib/modules/music/types';
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types';
import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/types';
// ─── Todo Queries ───────────────────────────────────────────
@ -278,43 +277,7 @@ export function useRecentDecks(limit = 5) {
}
// ─── Cards Queries ─────────────────────────────────────────
interface CardsProgress {
totalDecks: number;
totalCards: number;
cardsLearned: number;
dueForReview: number;
decks: LocalCardDeck[];
}
/** Cards learning progress. */
export function useCardsProgress() {
return useLiveQueryWithDefault(
async (): Promise<CardsProgress> => {
const decks = await db.table<LocalCardDeck>('cardDecks').toArray();
const cards = await db.table<LocalCard>('cards').toArray();
const activeDecks = decks.filter((d) => !d.deletedAt);
const activeCards = cards.filter((c) => !c.deletedAt);
const now = new Date().toISOString();
const dueCards = activeCards.filter((c) => c.nextReview && c.nextReview <= now);
// Phase 6: cardDecks.name is encrypted — the widget renders the
// deck names so they need decryption. Counts work plaintext.
const { decryptRecords } = await import('./crypto');
const decryptedDecks = await decryptRecords('cardDecks', activeDecks);
return {
totalDecks: activeDecks.length,
totalCards: activeCards.length,
cardsLearned: activeCards.filter((c) => (c.reviewCount ?? 0) > 0).length,
dueForReview: dueCards.length,
decks: decryptedDecks,
};
},
{
totalDecks: 0,
totalCards: 0,
cardsLearned: 0,
dueForReview: 0,
decks: [] as LocalCardDeck[],
}
);
}
// Cards-Modul ist 2026-05-08 dekommissioniert (eigenständig auf
// cardecky.mana.how). Cross-App-Progress-Widgets, die Cards-Daten
// gezeigt haben, müssen entweder entfernt werden oder gegen die
// Cardecky-API queren — heute kein Konsument im mana-Frontend.

View file

@ -56,7 +56,6 @@ import { calendarModuleConfig } from '$lib/modules/calendar/module.config';
import { contactsModuleConfig } from '$lib/modules/contacts/module.config';
import { chatModuleConfig } from '$lib/modules/chat/module.config';
import { pictureModuleConfig } from '$lib/modules/picture/module.config';
import { cardsModuleConfig } from '$lib/modules/cards/module.config';
import { quotesModuleConfig } from '$lib/modules/quotes/module.config';
import { musicModuleConfig } from '$lib/modules/music/module.config';
import { storageModuleConfig } from '$lib/modules/storage/module.config';
@ -119,7 +118,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
contactsModuleConfig,
chatModuleConfig,
pictureModuleConfig,
cardsModuleConfig,
quotesModuleConfig,
musicModuleConfig,
storageModuleConfig,

View file

@ -211,18 +211,6 @@ const TABLES: TableConfig[] = [
return memosStore.setVisibility(id, next);
},
},
{
module: 'cards',
collection: 'cardDecks',
moduleLabel: 'Karten (Decks)',
encrypted: true,
title: (r) => asString(r.name),
href: (id) => `/cards/deck/${id}`,
setVisibility: async (id, next) => {
const { deckStore } = await import('$lib/modules/cards/stores/decks.svelte');
return deckStore.setVisibility(id, next);
},
},
{
module: 'presi',
collection: 'presiDecks',

View file

@ -22,7 +22,6 @@ import { MOODLIT_GUEST_SEED } from '$lib/modules/moodlit/collections';
import { CONTACTS_GUEST_SEED } from '$lib/modules/contacts/collections';
import { CALENDAR_GUEST_SEED } from '$lib/modules/calendar/collections';
import { CHAT_GUEST_SEED } from '$lib/modules/chat/collections';
import { CARDS_GUEST_SEED } from '$lib/modules/cards/collections';
import { SKILLTREE_GUEST_SEED } from '$lib/modules/skilltree/collections';
import { TODO_GUEST_SEED } from '$lib/modules/todo/collections';
import { NOTES_GUEST_SEED } from '$lib/modules/notes/collections';
@ -63,7 +62,6 @@ register(MOODLIT_GUEST_SEED);
register(CONTACTS_GUEST_SEED);
register(CALENDAR_GUEST_SEED);
register(CHAT_GUEST_SEED);
register(CARDS_GUEST_SEED);
register(SKILLTREE_GUEST_SEED);
register(TODO_GUEST_SEED);
register(NOTES_GUEST_SEED);

View file

@ -16,7 +16,6 @@ import { contactsTools } from '$lib/modules/contacts/tools';
import { bodyTools } from '$lib/modules/body/tools';
import { financeTools } from '$lib/modules/finance/tools';
import { dreamsTools } from '$lib/modules/dreams/tools';
import { cardsTools } from '$lib/modules/cards/tools';
import { timesTools } from '$lib/modules/times/tools';
import { socialEventsTools } from '$lib/modules/events/tools';
import { musicTools } from '$lib/modules/music/tools';
@ -68,7 +67,6 @@ export function initTools(): void {
registerTools(bodyTools);
registerTools(financeTools);
registerTools(dreamsTools);
registerTools(cardsTools);
registerTools(timesTools);
registerTools(socialEventsTools);
registerTools(musicTools);

View file

@ -1,82 +0,0 @@
{
"app": {
"name": "Cards",
"description": "KI-Lernkarten"
},
"nav": {
"decks": "Decks",
"study": "Lernen",
"stats": "Statistiken",
"settings": "Einstellungen"
},
"deck": {
"create": "Deck erstellen",
"edit": "Deck bearbeiten",
"delete": "Deck löschen",
"empty": "Noch keine Decks",
"cards": "Karten",
"study": "Lernen starten",
"addCard": "Karte hinzufügen",
"importCards": "Karten importieren",
"generateWithAI": "Mit KI generieren"
},
"card": {
"front": "Vorderseite",
"back": "Rückseite",
"edit": "Karte bearbeiten",
"delete": "Karte löschen",
"hint": "Hinweis"
},
"study": {
"again": "Nochmal",
"hard": "Schwer",
"good": "Gut",
"easy": "Einfach",
"showAnswer": "Antwort zeigen",
"complete": "Abgeschlossen!",
"cardsRemaining": "Karten übrig",
"streak": "Serie"
},
"stats": {
"studied": "Gelernt",
"mastered": "Gemeistert",
"accuracy": "Genauigkeit",
"reviewsDue": "Fällige Wiederholungen"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"back": "Zurück",
"loading": "Lädt...",
"error": "Fehler",
"success": "Erfolgreich"
},
"progress": {
"page_title_html": "Fortschritt - Cards - Mana",
"heading": "Fortschritt",
"subtitle": "Verfolge deinen Lernfortschritt",
"stat_decks": "Decks",
"stat_total_cards": "Karten gesamt",
"stat_due": "Fällig zur Wiederholung",
"section_overview": "Decks Übersicht",
"empty_title": "Noch keine Lernsitzungen.",
"empty_hint": "Erstelle ein Deck und beginne zu lernen!",
"deck_cards": "{n} Karten"
},
"detail": {
"not_found": "Deck nicht gefunden",
"confirm_delete": "Deck wirklich löschen?",
"toast_deleted": "Deck gelöscht",
"placeholder_name": "Deck-Name...",
"name_fallback": "Unbenannt",
"prop_color": "Farbe",
"prop_visibility": "Sichtbarkeit",
"prop_cards": "Karten",
"prop_last_studied": "Zuletzt gelernt",
"section_description": "Beschreibung",
"placeholder_description": "Beschreibung hinzufügen...",
"meta_created": "Erstellt: {date}",
"meta_updated": "Bearbeitet: {date}"
}
}

View file

@ -1,82 +0,0 @@
{
"app": {
"name": "Cards",
"description": "AI Flashcards"
},
"nav": {
"decks": "Decks",
"study": "Study",
"stats": "Statistics",
"settings": "Settings"
},
"deck": {
"create": "Create Deck",
"edit": "Edit Deck",
"delete": "Delete Deck",
"empty": "No decks yet",
"cards": "Cards",
"study": "Start Studying",
"addCard": "Add Card",
"importCards": "Import Cards",
"generateWithAI": "Generate with AI"
},
"card": {
"front": "Front",
"back": "Back",
"edit": "Edit Card",
"delete": "Delete Card",
"hint": "Hint"
},
"study": {
"again": "Again",
"hard": "Hard",
"good": "Good",
"easy": "Easy",
"showAnswer": "Show Answer",
"complete": "Complete!",
"cardsRemaining": "cards remaining",
"streak": "Streak"
},
"stats": {
"studied": "Studied",
"mastered": "Mastered",
"accuracy": "Accuracy",
"reviewsDue": "Reviews due"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"back": "Back",
"loading": "Loading...",
"error": "Error",
"success": "Success"
},
"progress": {
"page_title_html": "Progress - Cards - Mana",
"heading": "Progress",
"subtitle": "Track your learning progress",
"stat_decks": "Decks",
"stat_total_cards": "Total cards",
"stat_due": "Due for review",
"section_overview": "Decks overview",
"empty_title": "No study sessions yet.",
"empty_hint": "Create a deck and start studying!",
"deck_cards": "{n} cards"
},
"detail": {
"not_found": "Deck not found",
"confirm_delete": "Really delete this deck?",
"toast_deleted": "Deck deleted",
"placeholder_name": "Deck name...",
"name_fallback": "Untitled",
"prop_color": "Color",
"prop_visibility": "Visibility",
"prop_cards": "Cards",
"prop_last_studied": "Last studied",
"section_description": "Description",
"placeholder_description": "Add a description...",
"meta_created": "Created: {date}",
"meta_updated": "Edited: {date}"
}
}

View file

@ -1,82 +0,0 @@
{
"app": {
"name": "Cards",
"description": "Flashcards con IA"
},
"nav": {
"decks": "Mazos",
"study": "Estudiar",
"stats": "Estadísticas",
"settings": "Ajustes"
},
"deck": {
"create": "Crear mazo",
"edit": "Editar mazo",
"delete": "Eliminar mazo",
"empty": "Aún no hay mazos",
"cards": "Tarjetas",
"study": "Empezar a estudiar",
"addCard": "Añadir tarjeta",
"importCards": "Importar tarjetas",
"generateWithAI": "Generar con IA"
},
"card": {
"front": "Anverso",
"back": "Reverso",
"edit": "Editar tarjeta",
"delete": "Eliminar tarjeta",
"hint": "Pista"
},
"study": {
"again": "Otra vez",
"hard": "Difícil",
"good": "Bien",
"easy": "Fácil",
"showAnswer": "Mostrar respuesta",
"complete": "¡Completado!",
"cardsRemaining": "tarjetas restantes",
"streak": "Racha"
},
"stats": {
"studied": "Estudiadas",
"mastered": "Dominadas",
"accuracy": "Precisión",
"reviewsDue": "Repasos pendientes"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"back": "Atrás",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito"
},
"progress": {
"page_title_html": "Progreso - Cards - Mana",
"heading": "Progreso",
"subtitle": "Sigue tu progreso de aprendizaje",
"stat_decks": "Mazos",
"stat_total_cards": "Tarjetas en total",
"stat_due": "Pendientes de repasar",
"section_overview": "Resumen de mazos",
"empty_title": "Aún no hay sesiones de estudio.",
"empty_hint": "¡Crea un mazo y empieza a estudiar!",
"deck_cards": "{n} tarjetas"
},
"detail": {
"not_found": "Mazo no encontrado",
"confirm_delete": "¿Eliminar realmente este mazo?",
"toast_deleted": "Mazo eliminado",
"placeholder_name": "Nombre del mazo...",
"name_fallback": "Sin título",
"prop_color": "Color",
"prop_visibility": "Visibilidad",
"prop_cards": "Tarjetas",
"prop_last_studied": "Último estudio",
"section_description": "Descripción",
"placeholder_description": "Añadir una descripción...",
"meta_created": "Creado: {date}",
"meta_updated": "Editado: {date}"
}
}

View file

@ -1,82 +0,0 @@
{
"app": {
"name": "Cards",
"description": "Flashcards IA"
},
"nav": {
"decks": "Paquets",
"study": "Étudier",
"stats": "Statistiques",
"settings": "Paramètres"
},
"deck": {
"create": "Créer un paquet",
"edit": "Modifier le paquet",
"delete": "Supprimer le paquet",
"empty": "Pas encore de paquets",
"cards": "Cartes",
"study": "Commencer à étudier",
"addCard": "Ajouter une carte",
"importCards": "Importer des cartes",
"generateWithAI": "Générer avec l'IA"
},
"card": {
"front": "Recto",
"back": "Verso",
"edit": "Modifier la carte",
"delete": "Supprimer la carte",
"hint": "Indice"
},
"study": {
"again": "Encore",
"hard": "Difficile",
"good": "Bien",
"easy": "Facile",
"showAnswer": "Afficher la réponse",
"complete": "Terminé !",
"cardsRemaining": "cartes restantes",
"streak": "Série"
},
"stats": {
"studied": "Étudiées",
"mastered": "Maîtrisées",
"accuracy": "Précision",
"reviewsDue": "Révisions à faire"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"back": "Retour",
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès"
},
"progress": {
"page_title_html": "Progression - Cards - Mana",
"heading": "Progression",
"subtitle": "Suis ta progression d'apprentissage",
"stat_decks": "Paquets",
"stat_total_cards": "Cartes au total",
"stat_due": "À réviser",
"section_overview": "Vue d'ensemble des paquets",
"empty_title": "Pas encore de sessions d'étude.",
"empty_hint": "Crée un paquet et commence à étudier !",
"deck_cards": "{n} cartes"
},
"detail": {
"not_found": "Paquet introuvable",
"confirm_delete": "Vraiment supprimer ce paquet ?",
"toast_deleted": "Paquet supprimé",
"placeholder_name": "Nom du paquet...",
"name_fallback": "Sans titre",
"prop_color": "Couleur",
"prop_visibility": "Visibilité",
"prop_cards": "Cartes",
"prop_last_studied": "Dernière révision",
"section_description": "Description",
"placeholder_description": "Ajouter une description...",
"meta_created": "Créé : {date}",
"meta_updated": "Modifié : {date}"
}
}

View file

@ -1,82 +0,0 @@
{
"app": {
"name": "Cards",
"description": "Flashcard IA"
},
"nav": {
"decks": "Mazzi",
"study": "Studia",
"stats": "Statistiche",
"settings": "Impostazioni"
},
"deck": {
"create": "Crea mazzo",
"edit": "Modifica mazzo",
"delete": "Elimina mazzo",
"empty": "Nessun mazzo ancora",
"cards": "Carte",
"study": "Inizia a studiare",
"addCard": "Aggiungi carta",
"importCards": "Importa carte",
"generateWithAI": "Genera con IA"
},
"card": {
"front": "Fronte",
"back": "Retro",
"edit": "Modifica carta",
"delete": "Elimina carta",
"hint": "Suggerimento"
},
"study": {
"again": "Ancora",
"hard": "Difficile",
"good": "Bene",
"easy": "Facile",
"showAnswer": "Mostra risposta",
"complete": "Completato!",
"cardsRemaining": "carte rimanenti",
"streak": "Serie"
},
"stats": {
"studied": "Studiate",
"mastered": "Padroneggiate",
"accuracy": "Precisione",
"reviewsDue": "Ripetizioni in scadenza"
},
"common": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"back": "Indietro",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo"
},
"progress": {
"page_title_html": "Progresso - Cards - Mana",
"heading": "Progresso",
"subtitle": "Segui i tuoi progressi di studio",
"stat_decks": "Mazzi",
"stat_total_cards": "Carte totali",
"stat_due": "Da ripassare",
"section_overview": "Panoramica mazzi",
"empty_title": "Ancora nessuna sessione di studio.",
"empty_hint": "Crea un mazzo e inizia a studiare!",
"deck_cards": "{n} carte"
},
"detail": {
"not_found": "Mazzo non trovato",
"confirm_delete": "Eliminare davvero questo mazzo?",
"toast_deleted": "Mazzo eliminato",
"placeholder_name": "Nome del mazzo...",
"name_fallback": "Senza titolo",
"prop_color": "Colore",
"prop_visibility": "Visibilità",
"prop_cards": "Carte",
"prop_last_studied": "Ultimo studio",
"section_description": "Descrizione",
"placeholder_description": "Aggiungi una descrizione...",
"meta_created": "Creato: {date}",
"meta_updated": "Modificato: {date}"
}
}

View file

@ -1,126 +0,0 @@
<!--
Cardecky — Workbench ListView (in-mana cards module).
Deck list with card counts and due-now indicator. Per GUIDELINES §12,
shows a dezenten Hinweis auf die Cardecky-Standalone-App, einmal
schließbar (localStorage).
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
import type { LocalDeck, LocalCard, LocalCardReview } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate }: ViewProps = $props();
const STANDALONE_HINT_KEY = 'cardecky-standalone-hint-dismissed';
let standaloneHintDismissed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem(STANDALONE_HINT_KEY) === '1'
);
function dismissStandaloneHint() {
standaloneHintDismissed = true;
try {
localStorage.setItem(STANDALONE_HINT_KEY, '1');
} catch {
// localStorage unavailable (private mode etc.) — UI-state alone reicht.
}
}
const decksQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalDeck>('cardDecks').toArray();
return all.filter((d) => !d.deletedAt);
}, [] as LocalDeck[]);
const cardsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalCard>('cards').toArray();
return all.filter((c) => !c.deletedAt);
}, [] as LocalCard[]);
const reviewsQuery = useLiveQueryWithDefault(async () => {
const nowIso = new Date().toISOString();
const due = await db
.table<LocalCardReview>('cardReviews')
.where('due')
.belowOrEqual(nowIso)
.toArray();
return due.filter((r) => !r.deletedAt);
}, [] as LocalCardReview[]);
const decks = $derived(decksQuery.value);
const cards = $derived(cardsQuery.value);
const dueReviews = $derived(reviewsQuery.value);
const cardIdToDeckId = $derived(new Map(cards.map((c) => [c.id, c.deckId])));
const dueByDeck = $derived.by(() => {
const counts = new Map<string, number>();
for (const r of dueReviews) {
const deckId = cardIdToDeckId.get(r.cardId);
if (!deckId) continue;
counts.set(deckId, (counts.get(deckId) ?? 0) + 1);
}
return counts;
});
const totalDue = $derived(dueReviews.length);
function cardsInDeck(deckId: string): number {
return cards.filter((c) => c.deckId === deckId).length;
}
</script>
{#if !standaloneHintDismissed}
<div
class="mb-2 flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"
role="note"
>
<span class="flex-1 text-muted-foreground">
Cardecky gibt es jetzt auch als eigenständige App auf
<a
href="https://cardecky.mana.how"
target="_blank"
rel="noopener"
class="underline hover:text-foreground">cardecky.mana.how</a
> — gleiche Daten, fokussierte UI.
</span>
<button
type="button"
onclick={dismissStandaloneHint}
class="text-muted-foreground hover:text-foreground"
aria-label="Hinweis schließen">×</button
>
</div>
{/if}
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Decks">
{#snippet header()}
<span class="flex-1">{decks.length} Decks</span>
<span class="text-warning/80">{totalDue} fällig</span>
{/snippet}
{#snippet item(deck)}
{@const due = dueByDeck.get(deck.id) ?? 0}
<button
onclick={() =>
navigate('detail', {
deckId: deck.id,
_siblingIds: decks.map((d) => d.id),
_siblingKey: 'deckId',
})}
class="mb-2 w-full rounded-md border border-border px-3 py-2.5 text-left transition-colors hover:bg-muted/50 min-h-[44px]"
>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div>
<p class="flex-1 truncate text-sm font-medium text-foreground">{deck.name}</p>
{#if due > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-600">
{due} fällig
</span>
{/if}
<span class="text-xs text-muted-foreground">{cardsInDeck(deck.id)}</span>
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-muted-foreground">{deck.description}</p>
{/if}
</button>
{/snippet}
</BaseListView>

View file

@ -1,6 +0,0 @@
/**
* Cards module review fan-out is now sourced from `@mana/cards-core`.
* Thin re-export so existing local imports keep working.
*/
export { subIndexesFor } from '@mana/cards-core';

View file

@ -1,13 +0,0 @@
/**
* Cards module cloze parser is now sourced from `@mana/cards-core`.
* Thin re-export so existing local imports keep working.
*/
export {
tokenize,
clusterIndexes,
clusters,
renderCloze,
type ClozeCluster,
type RenderedCloze,
} from '@mana/cards-core';

View file

@ -1,60 +0,0 @@
/**
* Cards module collection accessors and guest seed data.
*
* Tables in the unified DB: cardDecks, cards, cardReviews, cardStudyBlocks.
*/
import { db } from '$lib/data/database';
import type { LocalDeck, LocalCard, LocalCardReview, LocalCardStudyBlock } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const cardDeckTable = db.table<LocalDeck>('cardDecks');
export const cardTable = db.table<LocalCard>('cards');
export const cardReviewTable = db.table<LocalCardReview>('cardReviews');
export const cardStudyBlockTable = db.table<LocalCardStudyBlock>('cardStudyBlocks');
// ─── Guest Seed ────────────────────────────────────────────
const ONBOARDING_DECK_ID = 'onboarding-deck';
export const CARDS_GUEST_SEED = {
cardDecks: [
{
id: ONBOARDING_DECK_ID,
name: 'Erste Schritte',
description: 'Lerne Cards kennen mit diesen Beispiel-Karteikarten.',
color: '#6366f1',
cardCount: 3,
},
],
cards: [
{
id: 'card-1',
deckId: ONBOARDING_DECK_ID,
front: 'Was ist Cards?',
back: 'Cards ist eine Karteikarten-App zum effizienten Lernen mit Spaced Repetition.',
difficulty: 1,
reviewCount: 0,
order: 0,
},
{
id: 'card-2',
deckId: ONBOARDING_DECK_ID,
front: 'Wie funktioniert Spaced Repetition?',
back: 'Karten, die du gut kennst, werden seltener gezeigt. Schwierige Karten erscheinen haufiger, bis du sie beherrschst.',
difficulty: 2,
reviewCount: 0,
order: 1,
},
{
id: 'card-3',
deckId: ONBOARDING_DECK_ID,
front: 'Wie erstelle ich ein neues Deck?',
back: 'Klicke auf den + Button auf der Decks-Seite, um ein neues Deck mit eigenen Karteikarten zu erstellen.',
difficulty: 1,
reviewCount: 0,
order: 2,
},
],
};

View file

@ -1,95 +0,0 @@
<script lang="ts">
/**
* CardFace — renders one learnable unit (a single subIndex of a card)
* for any Phase-1 card type. Stateless: the parent owns `showBack`,
* `typedAnswer`, and any timing.
*
* - basic / basic-reverse subIndex 0: prompt = front, answer = back
* - basic-reverse subIndex 1: prompt = back, answer = front
* - cloze subIndex N: cloze.renderCloze(text, N)
* - type-in: prompt = front, answer = back,
* plus an input the user types into.
*/
import type { Card } from '../types';
import { renderCloze } from '../cloze';
import { renderMarkdown } from '../render';
interface Props {
card: Card;
subIndex: number;
showBack: boolean;
typedAnswer?: string;
onTypedAnswer?: (value: string) => void;
}
let { card, subIndex, showBack, typedAnswer = '', onTypedAnswer }: Props = $props();
const view = $derived.by(() => {
switch (card.type) {
case 'basic':
case 'type-in':
return {
prompt: renderMarkdown(card.fields.front ?? ''),
answer: renderMarkdown(card.fields.back ?? ''),
expected: card.fields.back ?? '',
};
case 'basic-reverse':
return subIndex === 0
? {
prompt: renderMarkdown(card.fields.front ?? ''),
answer: renderMarkdown(card.fields.back ?? ''),
expected: card.fields.back ?? '',
}
: {
prompt: renderMarkdown(card.fields.back ?? ''),
answer: renderMarkdown(card.fields.front ?? ''),
expected: card.fields.front ?? '',
};
case 'cloze': {
const r = renderCloze(card.fields.text ?? '', subIndex);
const extra = card.fields.extra
? `<div class="mt-3 text-sm text-muted-foreground">${renderMarkdown(card.fields.extra)}</div>`
: '';
return { prompt: r.front + extra, answer: r.back + extra, expected: r.answer };
}
default:
return { prompt: '', answer: '', expected: '' };
}
});
const isTypeIn = $derived(card.type === 'type-in');
const matched = $derived(
isTypeIn && typedAnswer.trim().toLowerCase() === view.expected.trim().toLowerCase()
);
</script>
<article class="space-y-4">
<div class="rounded-xl border border-border bg-card p-6 text-lg leading-relaxed">
{@html view.prompt}
</div>
{#if isTypeIn}
<input
class="w-full rounded-lg border border-border bg-card px-3 py-2 text-base"
type="text"
placeholder="Antwort eingeben…"
value={typedAnswer}
oninput={(e) => onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)}
disabled={showBack}
/>
{/if}
{#if showBack}
<div
class="rounded-xl border-2 p-6 text-lg leading-relaxed
{isTypeIn
? matched
? 'border-green-500 bg-green-500/5'
: 'border-red-500 bg-red-500/5'
: 'border-primary bg-primary/5'}"
>
{@html view.answer}
</div>
{/if}
</article>

View file

@ -1,140 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { deckStore } from '../stores/decks.svelte';
import { TagField, ColorPicker, COLORS_12, DEFAULT_COLOR } from '@mana/shared-ui';
import { useAllTags } from '@mana/shared-stores';
interface Props {
open?: boolean;
onClose?: () => void;
}
let { open = $bindable(false), onClose }: Props = $props();
let title = $state('');
let description = $state('');
let color = $state(DEFAULT_COLOR);
let submitting = $state(false);
let selectedTagIds = $state<string[]>([]);
const allTags = useAllTags();
async function handleSubmit() {
if (!title.trim()) return;
submitting = true;
const deck = await deckStore.createDeck({
title: title.trim(),
description: description.trim() || undefined,
});
submitting = false;
if (deck) {
title = '';
description = '';
open = false;
onClose?.();
}
}
function handleClose() {
open = false;
onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50"
onclick={handleClose}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
tabindex="-1"
role="presentation"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="w-full max-w-md rounded-t-xl sm:rounded-xl border border-border bg-card p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] sm:mx-4"
onclick={(e) => e.stopPropagation()}
>
<h2 class="mb-4 text-xl font-semibold text-foreground">Neues Deck erstellen</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<div>
<label for="deck-title" class="mb-1 block text-sm font-medium text-foreground">
Titel
</label>
<input
id="deck-title"
type="text"
bind:value={title}
placeholder="z.B. Spanisch Vokabeln"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
required
/>
</div>
<div>
<label for="deck-desc" class="mb-1 block text-sm font-medium text-foreground">
Beschreibung
</label>
<textarea
id="deck-desc"
bind:value={description}
placeholder="Worum geht es in diesem Deck?"
class="min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
></textarea>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Tags</span>
<TagField
tags={allTags.value}
selectedIds={selectedTagIds}
onChange={(ids) => (selectedTagIds = ids)}
/>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Farbe</span>
<ColorPicker
colors={[...COLORS_12]}
selectedColor={color}
onColorChange={(c) => (color = c)}
size="sm"
/>
</div>
{#if deckStore.error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{deckStore.error}
</div>
{/if}
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
onclick={handleClose}
>
{$_('common.cancel')}
</button>
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm text-white disabled:opacity-50"
disabled={submitting || !title.trim()}
>
{submitting ? $_('common.creating') : 'Deck erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -1,51 +0,0 @@
<script lang="ts">
import { Card } from '@mana/shared-ui';
import type { Deck } from '../types';
interface Props {
deck: Deck;
onclick?: () => void;
}
let { deck, onclick }: Props = $props();
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<Card variant="outlined" interactive {onclick} fullWidth class="text-left">
<div class="space-y-3">
<!-- Color strip -->
<div class="h-1 w-12 rounded-full" style="background: {deck.color}"></div>
<!-- Title -->
<h3 class="text-lg font-semibold text-foreground line-clamp-2">{deck.title}</h3>
<!-- Description -->
{#if deck.description}
<p class="text-sm text-muted-foreground line-clamp-2">
{deck.description}
</p>
{/if}
<!-- Footer -->
<div
class="flex items-center justify-between border-t border-border pt-2 text-sm text-muted-foreground"
>
<div class="flex items-center gap-2">
<span>{deck.cardCount || 0} Karten</span>
{#if deck.visibility === 'public'}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Öffentlich
</span>
{/if}
</div>
<span>{formatDate(deck.updatedAt)}</span>
</div>
</div>
</Card>

View file

@ -1,6 +0,0 @@
/**
* Cards module FSRS wrapper is now sourced from `@mana/cards-core`.
* Thin re-export so existing local imports keep working.
*/
export { newReview, gradeReview } from '@mana/cards-core';

View file

@ -1,28 +0,0 @@
/**
* Cards module barrel exports.
*/
export { deckStore } from './stores/decks.svelte';
export { cardStore } from './stores/cards.svelte';
export {
useAllDecks,
useDeck,
useCardsByDeck,
toDeck,
toCard,
getDeckById,
getPublicDecks,
getCardCountForDeck,
getDueCards,
} from './queries';
export { cardDeckTable, cardTable, CARDS_GUEST_SEED } from './collections';
export type {
LocalDeck,
LocalCard,
Deck,
Card,
CreateDeckInput,
UpdateDeckInput,
CreateCardInput,
UpdateCardInput,
} from './types';

View file

@ -1,12 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const cardsModuleConfig: ModuleConfig = {
appId: 'cards',
tables: [
{ name: 'cardDecks', syncName: 'decks' },
{ name: 'cards' },
{ name: 'deckTags' },
{ name: 'cardReviews' },
{ name: 'cardStudyBlocks' },
],
};

View file

@ -1,201 +0,0 @@
/**
* Reactive queries & pure helpers for Cards uses Dexie liveQuery on the unified DB.
*
* Uses table names: cardDecks, cards.
*/
import { liveQuery } from 'dexie';
import { deriveUpdatedAt } from '$lib/data/sync';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
import type {
CardFields,
CardType,
LocalDeck,
LocalCard,
LocalCardReview,
Deck,
Card,
CardReview,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
title: local.name,
description: local.description ?? undefined,
color: local.color,
visibility: local.visibility ?? 'space',
tags: [],
cardCount: local.cardCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
/**
* Promote any LocalCard row including legacy pre-Phase-0 ones to
* the canonical {type, fields} shape. Readers must go through this so
* the rest of the app sees one schema.
*
* - Phase-0+ rows: returned as-is, with `front`/`back` derived from
* fields for the convenience accessors on the DTO.
* - Legacy rows (only `front`/`back` set): synthesised as
* {type: 'basic', fields: {front, back}}.
*/
export function toLogicalCard(local: LocalCard): {
type: CardType;
fields: CardFields;
front: string;
back: string;
} {
const type: CardType = local.type ?? 'basic';
const fields: CardFields = local.fields ?? {
front: local.front ?? '',
back: local.back ?? '',
};
const front = fields.front ?? local.front ?? '';
const back = fields.back ?? local.back ?? '';
return { type, fields, front, back };
}
export function toCard(local: LocalCard): Card {
const { type, fields, front, back } = toLogicalCard(local);
return {
id: local.id,
deckId: local.deckId,
type,
fields,
front,
back,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
// Legacy fields surfaced for pre-Phase-0 UI. Populated only when the
// underlying row carries them.
difficulty: local.difficulty,
nextReview: local.nextReview ?? undefined,
reviewCount: local.reviewCount,
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All decks, auto-updates on any change. */
export function useAllDecks() {
return liveQuery(async () => {
const visible = (
await scopedForModule<LocalDeck, string>('cards', 'cardDecks').toArray()
).filter((d) => !d.deletedAt);
const decrypted = await decryptRecords('cardDecks', visible);
return decrypted.map(toDeck);
});
}
/** Single deck by ID. Auto-updates on any change. */
export function useDeck(deckId: string) {
return liveQuery(async () => {
const local = await db.table<LocalDeck>('cardDecks').get(deckId);
if (!local || local.deletedAt) return null;
const decrypted = await decryptRecord('cardDecks', { ...local });
return toDeck(decrypted);
});
}
/** All cards for a specific deck, sorted by order. Auto-updates on any change. */
export function useCardsByDeck(deckId: string) {
return liveQuery(async () => {
const visible = (
await db.table<LocalCard>('cards').where('deckId').equals(deckId).sortBy('order')
).filter((c) => !c.deletedAt);
const decrypted = await decryptRecords('cards', visible);
return decrypted.map(toCard);
});
}
/**
* All reviews that are due now (or overdue), optionally filtered by
* deck. Joined with the parent card so the UI can render the prompt
* immediately without a second lookup.
*
* Sorted by `due` ascending so the oldest-due learnable unit comes
* first that's the natural session order.
*/
export function useDueReviews(deckId?: string) {
return liveQuery(async () => {
const nowIso = new Date().toISOString();
const due = await db
.table<LocalCardReview>('cardReviews')
.where('due')
.belowOrEqual(nowIso)
.toArray();
const live = due.filter((r) => !r.deletedAt);
if (live.length === 0) return [] as { review: CardReview; card: Card }[];
const cardIds = [...new Set(live.map((r) => r.cardId))];
const cardRows = await db.table<LocalCard>('cards').where('id').anyOf(cardIds).toArray();
const decryptedCards = await decryptRecords(
'cards',
cardRows.filter((c) => !c.deletedAt)
);
const cardById = new Map(decryptedCards.map((c) => [c.id, toCard(c)] as const));
return live
.filter((r) => {
const c = cardById.get(r.cardId);
if (!c) return false;
if (deckId && c.deckId !== deckId) return false;
return true;
})
.sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0))
.map((r) => ({ review: toCardReview(r), card: cardById.get(r.cardId)! }));
});
}
/** Just the reviews row, no card join — useful in the session UI mid-grade. */
export function useReview(reviewId: string) {
return liveQuery(async () => {
const r = await db.table<LocalCardReview>('cardReviews').get(reviewId);
if (!r || r.deletedAt) return null;
return toCardReview(r);
});
}
function toCardReview(r: LocalCardReview): CardReview {
return {
id: r.id,
cardId: r.cardId,
subIndex: r.subIndex,
state: r.state,
stability: r.stability,
difficulty: r.difficulty,
due: r.due,
reps: r.reps,
lapses: r.lapses,
lastReview: r.lastReview,
elapsedDays: r.elapsedDays,
scheduledDays: r.scheduledDays,
};
}
// ─── Pure Helper Functions ─────────────────────────────────
export function getDeckById(decks: Deck[], id: string): Deck | undefined {
return decks.find((d) => d.id === id);
}
export function getPublicDecks(decks: Deck[]): Deck[] {
return decks.filter((d) => d.visibility === 'public');
}
export function getCardCountForDeck(cards: Card[], deckId: string): number {
return cards.filter((c) => c.deckId === deckId).length;
}
export function getDueCards(cards: Card[]): Card[] {
const now = new Date().toISOString();
return cards.filter((c) => c.nextReview && c.nextReview <= now);
}

View file

@ -1,7 +0,0 @@
/**
* Cards module Markdown render helper is now sourced from
* `@mana/cards-core`. Thin re-export so existing local imports keep
* working.
*/
export { renderMarkdown, type RenderOptions } from '@mana/cards-core';

View file

@ -1,187 +0,0 @@
/**
* Card Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*
* Phase 0+: writes the new {type, fields} shape AND mirrors basic-card
* content to the legacy front/back columns so older mana builds keep
* rendering. Every create/update fans out to cardReviews via
* reviewStore.ensureReviewsForCard().
*/
import { CardsEvents } from '@mana/shared-utils/analytics';
import { cardTable, cardDeckTable } from '../collections';
import { toCard, toLogicalCard } from '../queries';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import type {
CardFields,
CardType,
LocalCard,
Card,
CreateCardInput,
UpdateCardInput,
} from '../types';
import { reviewStore } from './reviews.svelte';
let error = $state<string | null>(null);
/**
* Build the {type, fields} pair from a CreateCardInput. Accepts the
* convenience `front`/`back` shortcut for basic cards and falls back
* to an explicit `fields` map for cloze and friends.
*/
function resolveTypeAndFields(input: CreateCardInput): {
type: CardType;
fields: CardFields;
} {
const type = input.type ?? 'basic';
if (input.fields) return { type, fields: input.fields };
if (type === 'cloze') return { type, fields: { text: input.front ?? '' } };
return { type, fields: { front: input.front ?? '', back: input.back ?? '' } };
}
/** Mirror basic-card text into the legacy columns for older clients. */
function legacyMirror(type: CardType, fields: CardFields): { front?: string; back?: string } {
if (type === 'basic' || type === 'basic-reverse' || type === 'type-in') {
return { front: fields.front ?? '', back: fields.back ?? '' };
}
if (type === 'cloze') {
// Surface the cloze source on `front` so legacy list-views show
// something meaningful rather than an empty row.
return { front: fields.text ?? '', back: '' };
}
return {};
}
export const cardStore = {
get error() {
return error;
},
async createCard(input: CreateCardInput, currentCardCount: number = 0): Promise<Card | null> {
error = null;
try {
const { type, fields } = resolveTypeAndFields(input);
const legacy = legacyMirror(type, fields);
const newLocal: LocalCard = {
id: crypto.randomUUID(),
deckId: input.deckId,
type,
fields,
order: currentCardCount,
...legacy,
};
const plaintextSnapshot = toCard(newLocal);
await encryptRecord('cards', newLocal);
await cardTable.add(newLocal);
const deck = await cardDeckTable.get(input.deckId);
if (deck) {
await cardDeckTable.update(input.deckId, {
cardCount: (deck.cardCount || 0) + 1,
});
}
await reviewStore.ensureReviewsForCard({ id: newLocal.id, type, fields });
emitDomainEvent('CardCreated', 'cards', 'cards', newLocal.id, {
cardId: newLocal.id,
deckId: input.deckId,
});
CardsEvents.cardCreated();
return plaintextSnapshot;
} catch (err: any) {
error = err.message || 'Failed to create card';
console.error('Create card error:', err);
return null;
}
},
async updateCard(id: string, updates: UpdateCardInput) {
error = null;
try {
const existing = await cardTable.get(id);
if (!existing) return;
const decrypted = await decryptRecord('cards', { ...existing });
const current = toLogicalCard(decrypted as LocalCard);
const nextType: CardType = updates.type ?? current.type;
const nextFields: CardFields = updates.fields
? updates.fields
: updates.front !== undefined || updates.back !== undefined
? nextType === 'cloze'
? { ...current.fields, text: updates.front ?? current.fields.text ?? '' }
: {
...current.fields,
front: updates.front ?? current.fields.front ?? '',
back: updates.back ?? current.fields.back ?? '',
}
: current.fields;
const legacy = legacyMirror(nextType, nextFields);
const diff: Partial<LocalCard> = {
type: nextType,
fields: nextFields,
...legacy,
};
if (updates.order !== undefined) diff.order = updates.order;
if (updates.difficulty !== undefined) diff.difficulty = updates.difficulty;
await encryptRecord('cards', diff);
await cardTable.update(id, diff);
const structuralChange =
updates.type !== undefined ||
updates.fields !== undefined ||
(nextType === 'cloze' && updates.front !== undefined);
if (structuralChange) {
await reviewStore.ensureReviewsForCard({ id, type: nextType, fields: nextFields });
}
} catch (err: any) {
error = err.message || 'Failed to update card';
console.error('Update card error:', err);
}
},
async deleteCard(id: string, deckId?: string) {
error = null;
try {
const now = new Date().toISOString();
await cardTable.update(id, { deletedAt: now });
await reviewStore.softDeleteForCard(id);
CardsEvents.cardDeleted();
if (deckId) {
const deck = await cardDeckTable.get(deckId);
if (deck) {
await cardDeckTable.update(deckId, {
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
});
}
}
} catch (err: any) {
error = err.message || 'Failed to delete card';
console.error('Delete card error:', err);
}
},
async reorderCards(cardIds: string[]) {
error = null;
try {
for (let i = 0; i < cardIds.length; i++) {
await cardTable.update(cardIds[i], { order: i });
}
} catch (err: any) {
error = err.message || 'Failed to reorder cards';
console.error('Reorder cards error:', err);
}
},
clearError() {
error = null;
},
};

View file

@ -1,167 +0,0 @@
/**
* Deck Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { CardsEvents } from '@mana/shared-utils/analytics';
import { db } from '$lib/data/database';
import { cardDeckTable, cardTable } from '../collections';
import { toDeck } from '../queries';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import { defaultVisibilityFor, type VisibilityLevel } from '@mana/shared-privacy';
import { createBlock, updateBlock } from '$lib/data/time-blocks/service';
import type { LocalDeck } from '../types';
import type { Deck, CreateDeckInput, UpdateDeckInput } from '../types';
let error = $state<string | null>(null);
export const deckStore = {
get error() {
return error;
},
async createDeck(input: CreateDeckInput): Promise<Deck | null> {
error = null;
try {
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
name: input.title,
description: input.description ?? null,
color: '#6366f1',
cardCount: 0,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
const plaintextSnapshot = toDeck(newLocal);
await encryptRecord('cardDecks', newLocal);
await cardDeckTable.add(newLocal);
CardsEvents.deckCreated();
return plaintextSnapshot;
} catch (err: any) {
error = err.message || 'Failed to create deck';
console.error('Create deck error:', err);
return null;
}
},
async updateDeck(id: string, updates: UpdateDeckInput) {
error = null;
try {
const localUpdates: Partial<LocalDeck> = {};
if (updates.title !== undefined) localUpdates.name = updates.title;
if (updates.description !== undefined) localUpdates.description = updates.description;
const diff: Partial<LocalDeck> = {
...localUpdates,
};
await encryptRecord('cardDecks', diff);
await cardDeckTable.update(id, diff);
} catch (err: any) {
error = err.message || 'Failed to update deck';
console.error('Update deck error:', err);
}
},
/**
* Flip a deck's visibility. Public decks surface in the cards
* embed-resolver on the user's website.
*/
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await cardDeckTable.get(id);
if (!existing) throw new Error(`Deck ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return;
const stamp = new Date().toISOString();
await cardDeckTable.update(id, {
visibility: next,
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,
});
emitDomainEvent('VisibilityChanged', 'cards', 'cardDecks', id, {
recordId: id,
collection: 'cardDecks',
before,
after: next,
});
},
async deleteDeck(id: string) {
error = null;
try {
const now = new Date().toISOString();
// Atomic cascade: deck + all child cards are soft-deleted in one
// Dexie transaction. If any write fails, the whole operation aborts —
// no orphaned cards left pointing at a deleted deck.
await db.transaction('rw', cardDeckTable, cardTable, async () => {
const cards = await cardTable.where('deckId').equals(id).toArray();
for (const card of cards) {
await cardTable.update(card.id, { deletedAt: now });
}
await cardDeckTable.update(id, { deletedAt: now });
});
CardsEvents.deckDeleted();
} catch (err: any) {
error = err.message || 'Failed to delete deck';
console.error('Delete deck error:', err);
}
},
async startStudySession(deckId: string): Promise<string | null> {
const deck = await cardDeckTable.get(deckId);
if (!deck) return null;
// Don't start a second session if one is already active
if (deck.activeStudyBlockId) return deck.activeStudyBlockId;
const decrypted = await decryptRecord('cardDecks', { ...deck });
const deckName = decrypted?.name ?? 'Deck';
const now = new Date().toISOString();
const timeBlockId = await createBlock({
startDate: now,
endDate: null,
isLive: true,
kind: 'logged',
type: 'study',
sourceModule: 'cards',
sourceId: deckId,
title: `${deckName} lernen`,
color: '#0ea5e9',
});
await cardDeckTable.update(deckId, {
activeStudyBlockId: timeBlockId,
lastStudied: now,
});
return timeBlockId;
},
async endStudySession(deckId: string): Promise<void> {
const deck = await cardDeckTable.get(deckId);
if (!deck?.activeStudyBlockId) return;
const now = new Date().toISOString();
await updateBlock(deck.activeStudyBlockId, {
endDate: now,
isLive: false,
});
await cardDeckTable.update(deckId, {
activeStudyBlockId: null,
});
},
clearError() {
error = null;
},
};

View file

@ -1,97 +0,0 @@
/**
* Card-Review Store FSRS scheduling state.
*
* Reviews are plaintext (no encryptRecord) cardReviews is in
* `plaintext-allowlist.ts` because the scheduler must query by `due`
* to find what's fällig today.
*
* Three operations the rest of the module needs:
* - ensureReviewsForCard: create the right number of subIndex rows
* for a card, soft-delete obsolete ones (e.g. when a cloze cluster
* gets removed). Idempotent safe to call after every card edit.
* - grade: apply a user rating, persist the next FSRS state.
* - softDeleteForCard: cascade soft-delete when a card is deleted.
*/
import { cardReviewTable } from '../collections';
import { newReview, gradeReview as fsrsGrade } from '../fsrs';
import { subIndexesFor } from '../card-reviews';
import type { CardFields, CardType, LocalCardReview, ReviewGrade } from '../types';
let error = $state<string | null>(null);
export const reviewStore = {
get error() {
return error;
},
/**
* Reconcile the cardReviews rows for a card with what the card
* structurally needs. New subIndexes get a fresh review; obsolete
* ones get soft-deleted. Returns the live set of reviews.
*/
async ensureReviewsForCard(card: {
id: string;
type: CardType;
fields: CardFields;
}): Promise<LocalCardReview[]> {
error = null;
try {
const existing = await cardReviewTable.where('cardId').equals(card.id).toArray();
const live = existing.filter((r) => !r.deletedAt);
const liveByIdx = new Map(live.map((r) => [r.subIndex, r]));
const wanted = subIndexesFor(card);
const wantedSet = new Set(wanted);
const nowIso = new Date().toISOString();
for (const subIndex of wanted) {
if (!liveByIdx.has(subIndex)) {
const r = newReview({ cardId: card.id, subIndex });
await cardReviewTable.add(r);
liveByIdx.set(subIndex, r);
}
}
for (const r of live) {
if (!wantedSet.has(r.subIndex)) {
await cardReviewTable.update(r.id, { deletedAt: nowIso });
liveByIdx.delete(r.subIndex);
}
}
return [...liveByIdx.values()].sort((a, b) => a.subIndex - b.subIndex);
} catch (err: any) {
error = err.message || 'Failed to ensure reviews';
console.error('Ensure reviews error:', err);
return [];
}
},
async grade(reviewId: string, grade: ReviewGrade): Promise<LocalCardReview | null> {
error = null;
try {
const existing = await cardReviewTable.get(reviewId);
if (!existing) return null;
const next = fsrsGrade(existing, grade);
await cardReviewTable.put(next);
return next;
} catch (err: any) {
error = err.message || 'Failed to grade review';
console.error('Grade review error:', err);
return null;
}
},
async softDeleteForCard(cardId: string): Promise<void> {
const reviews = await cardReviewTable.where('cardId').equals(cardId).toArray();
const now = new Date().toISOString();
for (const r of reviews) {
if (!r.deletedAt) await cardReviewTable.update(r.id, { deletedAt: now });
}
},
clearError() {
error = null;
},
};

View file

@ -1,83 +0,0 @@
/**
* Study-Block Store daily aggregate of learning activity.
*
* One row per local date with counters. The streak query walks back
* from today; finding a gap (no row, or cardsReviewed=0) ends the
* streak. Plaintext, no encryption.
*
* Why a daily aggregate row instead of just summing cardReviews?
* Because the streak is a UI-hot read we want it cheap ( 30 row
* lookups) regardless of how many reviews exist in total.
*/
import { cardStudyBlockTable } from '../collections';
import type { LocalCardStudyBlock } from '../types';
let error = $state<string | null>(null);
function localDateKey(d: Date = new Date()): string {
// YYYY-MM-DD in the user's local timezone — matches LocalCardStudyBlock.date.
const y = d.getFullYear();
const m = `${d.getMonth() + 1}`.padStart(2, '0');
const day = `${d.getDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
}
export const studyBlockStore = {
get error() {
return error;
},
/**
* Record one review against today's block. Creates the row on the
* first review of the day. Idempotent across concurrent calls only
* within a Dexie transaction for now we accept the small chance of
* an off-by-one race; real users grade one card at a time.
*/
async recordReview(durationMs: number, count: number = 1): Promise<void> {
error = null;
try {
const date = localDateKey();
const existing = await cardStudyBlockTable.where('date').equals(date).first();
if (existing && !existing.deletedAt) {
await cardStudyBlockTable.update(existing.id, {
cardsReviewed: existing.cardsReviewed + count,
durationMs: existing.durationMs + durationMs,
});
} else {
const row: LocalCardStudyBlock = {
id: crypto.randomUUID(),
date,
cardsReviewed: count,
durationMs,
};
await cardStudyBlockTable.add(row);
}
} catch (err: any) {
error = err.message || 'Failed to record review';
console.error('Record review error:', err);
}
},
/**
* Walk back from today; return how many consecutive days have at
* least one reviewed card. Stops at the first gap. Caps at 365 days
* to keep the worst case bounded.
*/
async getRecentStreak(): Promise<number> {
const today = new Date();
let streak = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const row = await cardStudyBlockTable.where('date').equals(localDateKey(d)).first();
if (!row || row.deletedAt || row.cardsReviewed <= 0) break;
streak++;
}
return streak;
},
clearError() {
error = null;
},
};

View file

@ -1,19 +0,0 @@
/**
* Ucards 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 deckTagOps = createTagLinkOps({
table: () => db.table('deckTags'),
entityIdField: 'deckId',
});

View file

@ -1,25 +0,0 @@
import type { ModuleTool } from '$lib/data/tools/types';
import { cardStore } from './stores/cards.svelte';
export const cardsTools: ModuleTool[] = [
{
name: 'create_card',
module: 'cards',
description: 'Erstellt eine neue Lernkarte (Flashcard)',
parameters: [
{ name: 'deckId', type: 'string', description: 'ID des Decks', required: true },
{ name: 'front', type: 'string', description: 'Vorderseite (Frage)', required: true },
{ name: 'back', type: 'string', description: 'Rueckseite (Antwort)', required: true },
],
async execute(params) {
const card = await cardStore.createCard({
deckId: params.deckId as string,
front: params.front as string,
back: params.back as string,
});
return card
? { success: true, data: card, message: 'Lernkarte erstellt' }
: { success: false, message: 'Fehler beim Erstellen der Karte' };
},
},
];

View file

@ -1,24 +0,0 @@
/**
* Cardecky / cards module types are now sourced from `@mana/cards-core`
* so the standalone cardecky.mana.how app and this in-mana module stay in sync.
*
* This file is a thin re-export to keep existing
* `from './types'` / `from '$lib/modules/cards/types'` imports working.
*/
export type {
CardType,
CardFields,
LocalDeck,
LocalCard,
LocalCardReview,
LocalCardStudyBlock,
Deck,
Card,
CardReview,
CreateDeckInput,
UpdateDeckInput,
CreateCardInput,
UpdateCardInput,
ReviewGrade,
} from '@mana/cards-core';

View file

@ -1,147 +0,0 @@
<!--
Cards — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { formatDate } from '$lib/i18n/format';
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { deckStore } from '../stores/decks.svelte';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import type { ViewProps } from '$lib/app-registry';
import type { LocalDeck, LocalCard } from '../types';
import { _ } from 'svelte-i18n';
let { params, goBack }: ViewProps = $props();
let deckId = $derived(params.deckId as string);
let editName = $state('');
let editDescription = $state('');
let editColor = $state('#6366f1');
const detail = useDetailEntity<LocalDeck>({
id: () => deckId,
table: 'decks',
onLoad: (val) => {
editName = val.name;
editDescription = val.description ?? '';
editColor = val.color ?? '#6366f1';
},
});
let cardCount = $state(0);
$effect(() => {
const sub = liveQuery(async () =>
db
.table<LocalCard>('cards')
.where('deckId')
.equals(deckId)
.filter((c) => !c.deletedAt)
.count()
).subscribe((val) => {
cardCount = val ?? 0;
});
return () => sub.unsubscribe();
});
async function saveField() {
detail.blur();
await deckStore.updateDeck(deckId, {
title: editName.trim() || detail.entity?.name || $_('cards.detail.name_fallback'),
description: editDescription.trim() || undefined,
});
// Color is not in UpdateDeckInput, update directly
await db.table('decks').update(deckId, {
color: editColor,
});
}
</script>
<DetailViewShell
entity={detail.entity}
loading={detail.loading}
notFoundLabel={$_('cards.detail.not_found')}
confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel={$_('cards.detail.confirm_delete')}
onConfirmDelete={() =>
detail.deleteWithUndo({
label: $_('cards.detail.toast_deleted'),
delete: () => deckStore.deleteDeck(deckId),
goBack,
})}
>
{#snippet body(deck)}
<input
class="title-input"
bind:value={editName}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('cards.detail.placeholder_name')}
/>
<div class="properties">
<div class="prop-row">
<span class="prop-label">{$_('cards.detail.prop_color')}</span>
<input
type="color"
class="color-input"
bind:value={editColor}
onfocus={detail.focus}
onblur={saveField}
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('cards.detail.prop_visibility')}</span>
<VisibilityPicker
level={deck.visibility ?? 'space'}
onChange={(next: VisibilityLevel) => deckStore.setVisibility(deckId, next)}
disabledLevels={['unlisted']}
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('cards.detail.prop_cards')}</span>
<span class="prop-value">{cardCount}</span>
</div>
{#if deck.lastStudied}
<div class="prop-row">
<span class="prop-label">{$_('cards.detail.prop_last_studied')}</span>
<span class="prop-value">{formatDate(new Date(deck.lastStudied))}</span>
</div>
{/if}
</div>
<div class="section">
<span class="section-label">{$_('cards.detail.section_description')}</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('cards.detail.placeholder_description')}
rows={3}
></textarea>
</div>
<div class="meta">
<span
>{$_('cards.detail.meta_created', {
values: { date: formatDate(new Date(deck.createdAt ?? '')) },
})}</span
>
{#if deck.updatedAt}
<span
>{$_('cards.detail.meta_updated', {
values: { date: formatDate(new Date(deck.updatedAt)) },
})}</span
>
{/if}
</div>
{/snippet}
</DetailViewShell>

View file

@ -35,7 +35,6 @@ import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types';
import type { LocalQuiz } from '$lib/modules/quiz/types';
import type { LocalSocialEvent } from '$lib/modules/events/types';
import type { LocalMemo } from '$lib/modules/memoro/types';
import type { LocalDeck as LocalCardDeck } from '$lib/modules/cards/types';
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types';
import type { LocalAugurEntry } from '$lib/modules/augur/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
@ -91,9 +90,11 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
case 'memoro.memos':
items = await resolveMemos(props);
break;
case 'cards.decks':
items = await resolveCardDecks(props);
break;
// 'cards.decks' source: dekommissioniert 2026-05-08 (Cards
// eigenständig auf cardecky.mana.how, kein Local-Dexie-Embed
// mehr). Falls Public-Cardecky-Decks später website-embeddable
// werden, käme das über die Cardecky-API und einen neuen
// `cardecky.decks`-Source-Typ.
case 'presi.decks':
items = await resolvePresiDecks(props);
break;
@ -837,34 +838,9 @@ async function resolveMemos(_props: ModuleEmbedProps): Promise<EmbedItem[]> {
});
}
/**
* Card-decks: shareable-flashcard-collection teaser. Returns decks
* flipped to 'public' with their card count as subtitle.
*
* Whitelist: title + "N Karten". Card fronts/backs, difficulty
* scores, and review history all stay private the deck is a
* unit; its cards belong to the play-experience (future
* unlisted-share flow), not the public teaser.
*/
async function resolveCardDecks(_props: ModuleEmbedProps): Promise<EmbedItem[]> {
let decks = await db.table<LocalCardDeck>('cardDecks').toArray();
decks = decks.filter((d) => !d.deletedAt && canEmbedOnWebsite(d.visibility ?? 'private'));
if (decks.length === 0) return [];
const decrypted = (await decryptRecords('cardDecks', decks)) as LocalCardDeck[];
// Newest first.
decrypted.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''));
return decrypted.map((d) => {
const count = d.cardCount ?? 0;
return {
title: d.name,
subtitle: `${count} ${count === 1 ? 'Karte' : 'Karten'}`,
};
});
}
// resolveCardDecks: dekommissioniert 2026-05-08, Cards lebt eigenständig
// auf cardecky.mana.how. Public-Deck-Embeds für Cardecky kommen später
// über die Cardecky-API.
/**
* Presi-decks: "talks I've given" teaser. Returns decks flipped to

View file

@ -1,75 +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('cards');
export const cardsSearchProvider: SearchProvider = {
appId: 'cards',
appName: 'Cards',
appIcon: app?.icon,
appColor: app?.color,
searchableTypes: ['deck', 'card'],
async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
// Search decks
const decks = await db.table('cardDecks').toArray();
for (const deck of decks) {
if (deck.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'name', value: deck.name, weight: 1.0 },
{ name: 'description', value: deck.description, weight: 0.7 },
],
query
);
if (score > 0) {
results.push({
id: deck.id,
type: 'deck',
appId: 'cards',
title: deck.name,
subtitle: truncateSubtitle(deck.description) || 'Deck',
appIcon: app?.icon,
appColor: app?.color,
href: `/cards/${deck.id}`,
score,
matchedField,
});
}
}
// Search cards (front/back)
const cards = await db.table('cards').toArray();
for (const card of cards) {
if (card.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'front', value: card.front, weight: 1.0 },
{ name: 'back', value: card.back, weight: 0.8 },
],
query
);
if (score > 0) {
results.push({
id: card.id,
type: 'card',
appId: 'cards',
title: truncateSubtitle(card.front, 60) || 'Karte',
subtitle: truncateSubtitle(card.back, 60),
appIcon: app?.icon,
appColor: app?.color,
href: `/cards/${card.deckId}`,
score,
matchedField,
});
}
}
return results.sort((a, b) => b.score - a.score).slice(0, limit);
},
};

View file

@ -20,7 +20,7 @@ export function registerAllProviders(registry: SearchRegistry): void {
);
registry.registerLazy('chat', () => import('./chat').then((m) => m.chatSearchProvider));
registry.registerLazy('storage', () => import('./storage').then((m) => m.storageSearchProvider));
registry.registerLazy('cards', () => import('./cards').then((m) => m.cardsSearchProvider));
// '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('music', () => import('./music').then((m) => m.musicSearchProvider));

View file

@ -75,7 +75,6 @@ describe('WIDGET_REGISTRY', () => {
expect(types).toContain('contacts-favorites');
expect(types).toContain('quotes-quote');
expect(types).toContain('picture-recent');
expect(types).toContain('cards-progress');
expect(types).toContain('clock-timers');
expect(types).toContain('storage-usage');
expect(types).toContain('music-library');

View file

@ -19,7 +19,6 @@ export type WidgetType =
| 'contacts-recent' // Contacts: recently updated
| 'quotes-quote' // Quotes API: daily inspiration quote
| 'picture-recent' // Picture API: recent generations
| 'cards-progress' // Cards API: learning progress
| 'clock-timers' // Clock: active timers and alarms
| 'storage-usage' // Storage: file storage stats
| 'music-library' // Music: music library stats
@ -232,15 +231,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'picture',
},
{
type: 'cards-progress',
nameKey: 'dashboard.widgets.cards.title',
descriptionKey: 'dashboard.widgets.cards.description',
icon: '🎴',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'cards',
},
{
type: 'clock-timers',
nameKey: 'dashboard.widgets.clock.title',

View file

@ -1,15 +0,0 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllDecks } from '$lib/modules/cards/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allDecks = useAllDecks();
// Provide data to child components via Svelte context
setContext('cardDecks', allDecks);
</script>
{@render children()}

View file

@ -1,78 +0,0 @@
<script lang="ts">
import { Cards, Books, ChartBar, MagnifyingGlass } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
const quickLinks = [
{
href: '/cards/decks',
icon: Books,
label: 'Meine Decks',
description: 'Alle Kartendecks',
color: 'bg-indigo-500',
},
{
href: '/cards/explore',
icon: MagnifyingGlass,
label: 'Entdecken',
description: 'Offentliche Decks',
color: 'bg-green-500',
},
{
href: '/cards/progress',
icon: ChartBar,
label: 'Fortschritt',
description: 'Lernstatistiken',
color: 'bg-amber-500',
},
];
</script>
<svelte:head>
<title>Cards - Mana</title>
</svelte:head>
<RoutePage appId="cards">
<div class="mx-auto max-w-3xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Cards</h1>
<p class="text-muted-foreground mt-1 text-sm">Karteikarten & Spaced Repetition</p>
</header>
<!-- Cards Icon -->
<div class="mb-8 rounded-xl border border-border bg-card p-6">
<div class="flex items-center gap-4">
<div class="rounded-full bg-indigo-500/10 p-3">
<Cards size={32} class="text-indigo-500" />
</div>
<div>
<div class="text-xl font-bold text-foreground">Lerne effizienter</div>
<div class="text-muted-foreground text-sm">
Karteikarten erstellen, organisieren und mit Spaced Repetition lernen
</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{#each quickLinks as link}
<a
href={link.href}
class="rounded-xl border border-border bg-card p-4 transition-[border-color,box-shadow] hover:border-primary/50 hover:shadow-lg group"
>
<div class="flex flex-col items-center gap-3 text-center">
<div
class="{link.color} rounded-full p-3 text-white transition-transform group-hover:scale-110"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>
</RoutePage>

View file

@ -1,75 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/modules/cards/stores/decks.svelte';
import DeckCard from '$lib/modules/cards/components/DeckCard.svelte';
import CreateDeckModal from '$lib/modules/cards/components/CreateDeckModal.svelte';
import type { Deck } from '$lib/modules/cards/types';
import { RoutePage } from '$lib/components/shell';
// Get live query data from layout context
const allDecks: { readonly value: Deck[] } = getContext('cardDecks');
let showCreateModal = $state(false);
function handleDeckClick(deckId: string) {
goto(`/cards/decks/${deckId}`);
}
</script>
<svelte:head>
<title>Meine Decks - Cards - Mana</title>
</svelte:head>
<RoutePage appId="cards" backHref="/cards">
<div class="mx-auto max-w-5xl space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Meine Decks</h1>
<p class="text-muted-foreground mt-1 text-sm">Organisiere deine Lernmaterialien in Decks</p>
</div>
<button
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showCreateModal = true)}
>
<span>+</span>
Neues Deck
</button>
</div>
<!-- Error State -->
{#if deckStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">
<p class="font-medium">{$_('common.error_loading')}</p>
<p class="mt-1 text-sm">{deckStore.error}</p>
</div>
{:else if (allDecks?.value ?? []).length === 0}
<!-- Empty State -->
<div class="py-16 text-center">
<div class="mb-4 text-6xl">📚</div>
<h3 class="mb-2 text-xl font-semibold text-foreground">Noch keine Decks</h3>
<p class="mb-6 text-muted-foreground">
Erstelle dein erstes Deck, um mit dem Lernen zu beginnen.
</p>
<button
class="rounded-lg bg-primary px-6 py-2 text-sm text-white"
onclick={() => (showCreateModal = true)}
>
Erstes Deck erstellen
</button>
</div>
{:else}
<!-- Decks Grid -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each allDecks.value as deck (deck.id)}
<DeckCard {deck} onclick={() => handleDeckClick(deck.id)} />
{/each}
</div>
{/if}
</div>
<!-- Create Deck Modal -->
<CreateDeckModal bind:open={showCreateModal} />
</RoutePage>

View file

@ -1,410 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/modules/cards/stores/decks.svelte';
import { cardStore } from '$lib/modules/cards/stores/cards.svelte';
import { useDeck, useCardsByDeck, useDueReviews } from '$lib/modules/cards/queries';
import type { Deck, Card, CardType } from '$lib/modules/cards/types';
import { renderMarkdown } from '$lib/modules/cards/render';
import { ArrowLeft, Trash, Plus, ShareNetwork } from '@mana/shared-icons';
import { ShareModal } from '@mana/shared-uload';
import { RoutePage } from '$lib/components/shell';
let deckId = $derived($page.params.id ?? '');
let showDeleteConfirm = $state(false);
let deleting = $state(false);
let showShare = $state(false);
let shareUrl = $derived(
`${typeof window !== 'undefined' ? window.location.origin : ''}/cards/decks/${deckId}`
);
// New card form
let showNewCardForm = $state(false);
let newCardType = $state<CardType>('basic');
let newCardFront = $state('');
let newCardBack = $state('');
let newCardCloze = $state('');
// svelte-ignore state_referenced_locally
const currentDeck = useDeck(deckId);
// svelte-ignore state_referenced_locally
const deckCards = useCardsByDeck(deckId);
// svelte-ignore state_referenced_locally
const dueReviews = useDueReviews(deckId);
let deck = $derived(($currentDeck as Deck | null | undefined) ?? null);
let cards = $derived(($deckCards as Card[] | undefined) ?? []);
let dueCount = $derived(
($dueReviews as { review: unknown; card: unknown }[] | undefined)?.length ?? 0
);
const cardTypeOptions: { value: CardType; label: string; hint: string }[] = [
{ value: 'basic', label: 'Standard', hint: 'Vorderseite → Rückseite' },
{ value: 'basic-reverse', label: 'Beidseitig', hint: 'Lernt in beide Richtungen' },
{ value: 'cloze', label: 'Lückentext', hint: 'Markiere mit {{c1::Wort}}' },
{ value: 'type-in', label: 'Eintippen', hint: 'Antwort wird verglichen' },
];
function canSubmit(): boolean {
if (newCardType === 'cloze') return newCardCloze.trim().length > 0;
return newCardFront.trim().length > 0 && newCardBack.trim().length > 0;
}
async function handleDelete() {
if (!deckId) return;
deleting = true;
await deckStore.deleteDeck(deckId);
deleting = false;
goto('/cards/decks');
}
async function handleCreateCard() {
if (!canSubmit()) return;
if (newCardType === 'cloze') {
await cardStore.createCard(
{ deckId, type: 'cloze', fields: { text: newCardCloze.trim() } },
cards.length
);
} else {
await cardStore.createCard(
{
deckId,
type: newCardType,
front: newCardFront.trim(),
back: newCardBack.trim(),
},
cards.length
);
}
newCardFront = '';
newCardBack = '';
newCardCloze = '';
showNewCardForm = false;
}
async function handleDeleteCard(cardId: string) {
if (!confirm('Karte wirklich löschen?')) return;
await cardStore.deleteCard(cardId, deckId);
}
function typeBadge(type: CardType): string {
switch (type) {
case 'basic':
return 'Standard';
case 'basic-reverse':
return 'Beidseitig';
case 'cloze':
return 'Lückentext';
case 'type-in':
return 'Eintippen';
default:
return type;
}
}
function previewSummary(card: Card): { primary: string; secondary: string } {
if (card.type === 'cloze') {
const text = card.fields.text ?? '';
return { primary: text.slice(0, 140), secondary: '' };
}
return {
primary: card.fields.front ?? card.front ?? '',
secondary: card.fields.back ?? card.back ?? '',
};
}
</script>
<svelte:head>
<title>{deck?.title || 'Deck'} — Cards — Mana</title>
</svelte:head>
<RoutePage appId="cards" backHref="/cards/decks" title="Deck">
{#if deck}
<div class="mx-auto max-w-5xl space-y-6">
<button
onclick={() => goto('/cards/decks')}
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={16} />
Zurück zu Decks
</button>
<!-- Deck Header -->
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="mb-2 flex items-center gap-3">
<div class="h-3 w-3 rounded-full" style="background: {deck.color}"></div>
<h1 class="text-2xl font-bold text-foreground">{deck.title}</h1>
</div>
{#if deck.description}
<p class="text-muted-foreground">{deck.description}</p>
{/if}
</div>
<div class="flex items-center gap-2">
{#if deck.visibility === 'public'}
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs text-primary">Öffentlich</span
>
{/if}
<button
onclick={() => (showShare = true)}
class="rounded-lg border border-border p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Kurzlink teilen"
>
<ShareNetwork size={16} />
</button>
<button
class="rounded-lg border border-destructive/30 p-2 text-destructive transition-colors hover:bg-destructive/10"
onclick={() => (showDeleteConfirm = true)}
aria-label="Deck löschen"
>
<Trash size={16} />
</button>
</div>
</div>
<!-- Action row: Lernen + Stats -->
<div class="flex flex-wrap items-center gap-3">
<button
class="flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
onclick={() => goto(`/cards/learn/${deckId}`)}
disabled={dueCount === 0}
>
Lernen
{#if dueCount > 0}
<span class="rounded-full bg-background/20 px-2 py-0.5 text-xs">{dueCount} fällig</span>
{/if}
</button>
{#if dueCount === 0 && cards.length > 0}
<span class="text-sm text-muted-foreground">
Heute alles gelernt — schau später wieder rein.
</span>
{/if}
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-foreground">{cards.length}</div>
<div class="text-sm text-muted-foreground">Karten gesamt</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-amber-500">{dueCount}</div>
<div class="text-sm text-muted-foreground">Fällig</div>
</div>
</div>
<!-- Add Card Button -->
<div class="flex items-center gap-3">
<button
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showNewCardForm = true)}
>
<Plus size={16} />
Neue Karte
</button>
</div>
<!-- New Card Form -->
{#if showNewCardForm}
<div class="rounded-xl border border-primary bg-card p-4">
<h3 class="mb-3 font-medium text-foreground">Neue Karte</h3>
<!-- Type picker -->
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
{#each cardTypeOptions as opt}
<button
type="button"
onclick={() => (newCardType = opt.value)}
class="rounded-lg border p-2 text-left text-sm transition-colors {newCardType ===
opt.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border hover:bg-muted/50'}"
>
<div class="font-medium">{opt.label}</div>
<div class="text-xs text-muted-foreground">{opt.hint}</div>
</button>
{/each}
</div>
<div class="space-y-3">
{#if newCardType === 'cloze'}
<div>
<label for="card-cloze" class="mb-1 block text-sm text-muted-foreground">
Text mit Lücken
</label>
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="card-cloze"
bind:value={newCardCloze}
placeholder="Berlin ist die Hauptstadt von &#123;&#123;c1::Deutschland&#125;&#125;."
class="min-h-[100px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
autofocus
></textarea>
<p class="mt-1 text-xs text-muted-foreground">
Markiere mit
<code class="rounded bg-muted px-1">&#123;&#123;c1::Wort&#125;&#125;</code>
— optional Hinweis: <code class="rounded bg-muted px-1">::Hinweis</code>.
</p>
</div>
{:else}
<div>
<label for="card-front" class="mb-1 block text-sm text-muted-foreground">
Vorderseite
</label>
<!-- svelte-ignore a11y_autofocus -->
<input
id="card-front"
type="text"
bind:value={newCardFront}
placeholder="Frage oder Begriff…"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
autofocus
/>
</div>
<div>
<label for="card-back" class="mb-1 block text-sm text-muted-foreground">
Rückseite
</label>
<textarea
id="card-back"
bind:value={newCardBack}
placeholder="Antwort oder Erklärung…"
class="min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
></textarea>
</div>
{/if}
<div class="flex justify-end gap-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={() => {
showNewCardForm = false;
newCardFront = '';
newCardBack = '';
newCardCloze = '';
}}
>
Abbrechen
</button>
<button
class="rounded-lg bg-primary px-4 py-1.5 text-sm text-white disabled:opacity-50"
onclick={handleCreateCard}
disabled={!canSubmit()}
>
Karte erstellen
</button>
</div>
</div>
</div>
{/if}
<!-- Cards List -->
<div class="rounded-xl border border-border bg-card">
<h2 class="border-b border-border p-4 text-lg font-semibold text-foreground">
Karten ({cards.length})
</h2>
{#if cards.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-4xl">📝</div>
<p class="text-muted-foreground">Noch keine Karten. Erstelle deine erste Karte!</p>
<button
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showNewCardForm = true)}
>
Karte hinzufügen
</button>
</div>
{:else}
<div class="divide-y divide-border">
{#each cards as card, i (card.id)}
{@const preview = previewSummary(card)}
<div class="flex items-start gap-4 p-4">
<span class="mt-1 text-xs text-muted-foreground">{i + 1}.</span>
<div class="min-w-0 flex-1 space-y-1">
<div class="prose prose-sm max-w-none text-foreground dark:prose-invert">
{@html renderMarkdown(preview.primary)}
</div>
{#if preview.secondary}
<div class="prose prose-sm max-w-none text-muted-foreground dark:prose-invert">
{@html renderMarkdown(preview.secondary)}
</div>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{typeBadge(card.type)}
</span>
<button
class="rounded p-1 text-muted-foreground hover:text-destructive"
onclick={() => handleDeleteCard(card.id)}
aria-label="Karte löschen"
>
<Trash size={14} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={() => (showDeleteConfirm = false)}
onkeydown={(e) => e.key === 'Escape' && (showDeleteConfirm = false)}
tabindex="-1"
role="presentation"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="mx-4 w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<h3 class="mb-2 text-xl font-semibold text-foreground">Deck löschen?</h3>
<p class="mb-6 text-muted-foreground">
Möchtest du "{deck.title}" wirklich löschen? Diese Aktion kann nicht rückgängig
gemacht werden und löscht auch alle Karten in diesem Deck.
</p>
<div class="flex justify-end gap-3">
<button
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
onclick={() => (showDeleteConfirm = false)}
>
Abbrechen
</button>
<button
class="rounded-lg bg-destructive px-4 py-2 text-sm text-white disabled:opacity-50"
disabled={deleting}
onclick={handleDelete}
>
{deleting ? 'Lösche…' : 'Deck löschen'}
</button>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div class="py-16 text-center">
<p class="text-muted-foreground">Deck nicht gefunden</p>
<button
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => goto('/cards/decks')}
>
Zurück zu Decks
</button>
</div>
{/if}
<ShareModal
visible={showShare}
onClose={() => (showShare = false)}
url={shareUrl}
title={deck?.title ?? ''}
source="cards"
description={deck?.description ?? ''}
/>
</RoutePage>

View file

@ -1,33 +0,0 @@
<script lang="ts">
import { MagnifyingGlass } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Entdecken - Cards - Mana</title>
</svelte:head>
<RoutePage appId="cards" backHref="/cards">
<div class="mx-auto max-w-5xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Entdecken</h1>
<p class="text-muted-foreground mt-1 text-sm">
Offentliche Decks aus der Community entdecken
</p>
</div>
<div class="rounded-xl border border-border bg-card">
<div class="py-16 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10"
>
<MagnifyingGlass size={32} class="text-primary" />
</div>
<h3 class="mb-2 text-xl font-semibold text-foreground">Entdecken-Feature</h3>
<p class="text-muted-foreground">
Offentliche Decks durchsuchen und entdecken — kommt bald!
</p>
</div>
</div>
</div>
</RoutePage>

View file

@ -1,175 +0,0 @@
<script lang="ts">
/**
* Learn session — the Phase-1 core gameloop.
*
* Shows the next due card from the deck, reveals on Space, takes a
* 1-4 grade via key or button, persists FSRS state + a study-block
* tick, and moves on. Session ends when the queue empties; the user
* can leave any time, the next visit picks up where we left off
* (state lives in cardReviews, not the page).
*/
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { useDueReviews, useDeck } from '$lib/modules/cards/queries';
import { reviewStore } from '$lib/modules/cards/stores/reviews.svelte';
import { studyBlockStore } from '$lib/modules/cards/stores/study-blocks.svelte';
import CardFace from '$lib/modules/cards/components/CardFace.svelte';
import type { Card, CardReview, ReviewGrade } from '$lib/modules/cards/types';
const deckId = $derived(page.params.deckId as string);
const dueQuery = $derived(useDueReviews(deckId));
const deckQuery = $derived(useDeck(deckId));
let queue = $state<{ review: CardReview; card: Card }[]>([]);
let currentIndex = $state(0);
let showBack = $state(false);
let typedAnswer = $state('');
let sessionCount = $state(0);
let sessionStartedAt = $state(Date.now());
let cardShownAt = $state(Date.now());
const current = $derived(queue[currentIndex]);
const deckTitle = $derived($deckQuery?.title ?? 'Deck');
// Snapshot the queue once per visit so the user finishes what's in
// front of them — otherwise a freshly-graded review getting its new
// `due` tomorrow would vanish from the list mid-session and break
// the "X of N" counter.
$effect(() => {
const snap = $dueQuery;
if (snap && queue.length === 0 && snap.length > 0) {
queue = snap;
}
});
function reveal() {
if (!showBack && current) showBack = true;
}
async function grade(g: ReviewGrade) {
if (!current || !showBack) return;
const elapsedMs = Date.now() - cardShownAt;
await reviewStore.grade(current.review.id, g);
await studyBlockStore.recordReview(elapsedMs);
sessionCount++;
nextCard();
}
function nextCard() {
showBack = false;
typedAnswer = '';
cardShownAt = Date.now();
if (currentIndex < queue.length - 1) {
currentIndex++;
} else {
currentIndex = queue.length; // sentinel — finished
}
}
function handleKey(e: KeyboardEvent) {
if (e.target && (e.target as HTMLElement).tagName === 'INPUT') return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (!showBack) reveal();
return;
}
if (showBack && (e.key === '1' || e.key === '2' || e.key === '3' || e.key === '4')) {
e.preventDefault();
grade(Number(e.key) as ReviewGrade);
}
}
onMount(() => {
window.addEventListener('keydown', handleKey);
sessionStartedAt = Date.now();
cardShownAt = Date.now();
});
onDestroy(() => window.removeEventListener('keydown', handleKey));
const finished = $derived(queue.length > 0 && currentIndex >= queue.length);
const empty = $derived(queue.length === 0 && $dueQuery?.length === 0);
</script>
<div class="mx-auto max-w-2xl space-y-6 px-4 py-8">
<header class="flex items-center justify-between">
<div>
<button
class="text-sm text-muted-foreground hover:underline"
onclick={() => goto(`/cards/decks/${deckId}`)}
>
{deckTitle}
</button>
<h1 class="mt-1 text-xl font-semibold">Lernen</h1>
</div>
{#if queue.length > 0 && !finished}
<div class="text-sm text-muted-foreground">
{Math.min(currentIndex + 1, queue.length)} / {queue.length}
</div>
{/if}
</header>
{#if empty}
<div class="rounded-xl border border-border bg-card p-8 text-center">
<div class="text-2xl">Alles gelernt</div>
<p class="mt-2 text-sm text-muted-foreground">
Komm später wieder — fällige Karten erscheinen automatisch.
</p>
<button
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => goto(`/cards/decks/${deckId}`)}
>
Zurück zum Deck
</button>
</div>
{:else if finished}
<div class="rounded-xl border border-border bg-card p-8 text-center">
<div class="text-2xl">Session abgeschlossen</div>
<p class="mt-2 text-sm text-muted-foreground">
{sessionCount} Karten in {Math.round((Date.now() - sessionStartedAt) / 1000)} s.
</p>
<button
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => goto(`/cards/decks/${deckId}`)}
>
Fertig
</button>
</div>
{:else if current}
<CardFace
card={current.card}
subIndex={current.review.subIndex}
{showBack}
{typedAnswer}
onTypedAnswer={(v) => (typedAnswer = v)}
/>
{#if !showBack}
<button class="w-full rounded-lg bg-primary py-3 text-base text-white" onclick={reveal}>
Aufdecken <span class="ml-2 text-xs opacity-70">(Leertaste)</span>
</button>
{:else}
<div class="grid grid-cols-4 gap-2">
<button class="rounded-lg bg-red-500 py-3 text-sm text-white" onclick={() => grade(1)}>
Nochmal
<div class="text-xs opacity-70">1</div>
</button>
<button class="rounded-lg bg-orange-500 py-3 text-sm text-white" onclick={() => grade(2)}>
Schwer
<div class="text-xs opacity-70">2</div>
</button>
<button class="rounded-lg bg-green-500 py-3 text-sm text-white" onclick={() => grade(3)}>
Gut
<div class="text-xs opacity-70">3</div>
</button>
<button class="rounded-lg bg-blue-500 py-3 text-sm text-white" onclick={() => grade(4)}>
Leicht
<div class="text-xs opacity-70">4</div>
</button>
</div>
{/if}
{:else}
<div class="text-center text-sm text-muted-foreground">Lade…</div>
{/if}
</div>

View file

@ -1,84 +0,0 @@
<script lang="ts">
import { formatDate } from '$lib/i18n/format';
import { getContext } from 'svelte';
import { ChartBar } from '@mana/shared-icons';
import type { Deck } from '$lib/modules/cards/types';
import { RoutePage } from '$lib/components/shell';
import { _ } from 'svelte-i18n';
// Get live query data from layout context
const allDecks: { readonly value: Deck[] } = getContext('cardDecks');
let decks = $derived(allDecks?.value ?? []);
let totalCards = $derived(decks.reduce((sum, d) => sum + (d.cardCount || 0), 0));
</script>
<svelte:head>
<title>{$_('cards.progress.page_title_html')}</title>
</svelte:head>
<RoutePage appId="cards" backHref="/cards">
<div class="mx-auto max-w-5xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('cards.progress.heading')}</h1>
<p class="text-muted-foreground mt-1 text-sm">{$_('cards.progress.subtitle')}</p>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-foreground">{decks.length}</div>
<div class="text-sm text-muted-foreground">{$_('cards.progress.stat_decks')}</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-foreground">{totalCards}</div>
<div class="text-sm text-muted-foreground">{$_('cards.progress.stat_total_cards')}</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-orange-500">0</div>
<div class="text-sm text-muted-foreground">{$_('cards.progress.stat_due')}</div>
</div>
</div>
<!-- Decks Breakdown -->
<div class="rounded-xl border border-border bg-card">
<h2 class="border-b border-border p-4 text-lg font-semibold text-foreground">
<span class="flex items-center gap-2">
<ChartBar size={20} />
{$_('cards.progress.section_overview')}
</span>
</h2>
{#if decks.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-4xl">🎯</div>
<p class="text-muted-foreground">{$_('cards.progress.empty_title')}</p>
<p class="mt-2 text-sm text-muted-foreground">{$_('cards.progress.empty_hint')}</p>
</div>
{:else}
<div class="divide-y divide-border">
{#each decks as deck (deck.id)}
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<div class="h-3 w-3 rounded-full" style="background: {deck.color}"></div>
<div>
<div class="font-medium text-foreground">{deck.title}</div>
<div class="text-sm text-muted-foreground">
{$_('cards.progress.deck_cards', { values: { n: deck.cardCount || 0 } })}
</div>
</div>
</div>
<div class="text-right">
<div class="text-sm text-muted-foreground">
{formatDate(new Date(deck.updatedAt), {
day: '2-digit',
month: 'short',
})}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</RoutePage>

View file

@ -153,23 +153,9 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'cards',
name: 'Cardecky',
description: {
de: 'KI Karteikarten',
en: 'AI Flashcards',
},
longDescription: {
de: 'Lerne intelligenter mit KI-generierten Karteikarten und Spaced Repetition.',
en: 'Learn smarter with AI-generated flashcards and spaced repetition.',
},
icon: APP_ICONS.cards,
color: '#8b5cf6',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
// Cards/Cardecky: dekommissioniert 2026-05-08 — eigenständig auf
// cardecky.mana.how (git.mana.how/till/cards). App-Eintrag bleibt
// nur in der Standalone-App.
{
id: 'quiz',
name: 'Quiz',

348
pnpm-lock.yaml generated
View file

@ -183,105 +183,6 @@ importers:
specifier: ~5.9.2
version: 5.9.3
apps/cards: {}
apps/cards/apps/web:
dependencies:
'@mana/cards-core':
specifier: workspace:*
version: link:../../../../packages/cards-core
'@mana/local-store':
specifier: workspace:*
version: link:../../../../packages/local-store
'@mana/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
'@mana/shared-auth-ui':
specifier: workspace:*
version: link:../../../../packages/shared-auth-ui
'@mana/shared-branding':
specifier: workspace:*
version: link:../../../../packages/shared-branding
'@mana/shared-icons':
specifier: workspace:*
version: link:../../../../packages/shared-icons
'@mana/shared-privacy':
specifier: workspace:*
version: link:../../../../packages/shared-privacy
'@mana/shared-pwa':
specifier: workspace:*
version: link:../../../../packages/shared-pwa
'@mana/shared-stores':
specifier: workspace:*
version: link:../../../../packages/shared-stores
'@mana/shared-tailwind':
specifier: workspace:*
version: link:../../../../packages/shared-tailwind
'@mana/shared-theme':
specifier: workspace:*
version: link:../../../../packages/shared-theme
'@mana/shared-theme-ui':
specifier: workspace:*
version: link:../../../../packages/shared-theme-ui
'@mana/shared-types':
specifier: workspace:*
version: link:../../../../packages/shared-types
'@mana/shared-utils':
specifier: workspace:*
version: link:../../../../packages/shared-utils
dexie:
specifier: ^4.4.1
version: 4.4.2
jszip:
specifier: ^3.10.1
version: 3.10.1
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
sql.js:
specifier: ^1.14.1
version: 1.14.1
devDependencies:
'@mana/shared-vite-config':
specifier: workspace:*
version: link:../../../../packages/shared-vite-config
'@sveltejs/adapter-node':
specifier: ^5.0.0
version: 5.5.4(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
'@sveltejs/kit':
specifier: ^2.47.1
version: 2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/vite-plugin-svelte':
specifier: ^5.0.4
version: 5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.2.2(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@types/node':
specifier: ^22.10.5
version: 22.19.17
'@types/sql.js':
specifier: ^1.4.11
version: 1.4.11
'@vite-pwa/sveltekit':
specifier: ^1.1.0
version: 1.1.0(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
svelte:
specifier: ^5.41.0
version: 5.55.1
svelte-check:
specifier: ^4.3.3
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.9.3)
tailwindcss:
specifier: ^4.1.17
version: 4.2.2
typescript:
specifier: ^5.7.2
version: 5.9.3
vite:
specifier: ^6.0.7
version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
apps/chat: {}
apps/chat/apps/landing:
@ -561,9 +462,6 @@ importers:
'@huggingface/transformers':
specifier: ^4.0.0
version: 4.0.1
'@mana/cards-core':
specifier: workspace:*
version: link:../../../../packages/cards-core
'@mana/credits':
specifier: workspace:^
version: link:../../../../packages/credits
@ -1745,34 +1643,6 @@ importers:
specifier: ^5.0.0
version: 5.9.3
packages/cards-core:
dependencies:
'@mana/local-store':
specifier: workspace:*
version: link:../local-store
'@mana/shared-privacy':
specifier: workspace:*
version: link:../shared-privacy
isomorphic-dompurify:
specifier: ^3.7.1
version: 3.7.1(@noble/hashes@2.0.1)
marked:
specifier: ^17.0.5
version: 17.0.6
ts-fsrs:
specifier: ^5.3.2
version: 5.3.2
devDependencies:
'@types/node':
specifier: ^24.10.1
version: 24.12.2
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/credits:
devDependencies:
svelte:
@ -2581,34 +2451,6 @@ importers:
specifier: ^2.0.0
version: 2.1.9(@types/node@24.12.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.1)
services/cards-server:
dependencies:
'@mana/shared-hono':
specifier: workspace:*
version: link:../../packages/shared-hono
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(@types/sql.js@1.4.11)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)(sql.js@1.14.1)
hono:
specifier: ^4.7.0
version: 4.12.12
jose:
specifier: ^6.1.2
version: 6.2.2
postgres:
specifier: ^3.4.5
version: 3.4.9
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
typescript:
specifier: ^5.9.3
version: 5.9.3
services/mana-ai:
dependencies:
'@mana/shared-ai':
@ -5944,76 +5786,6 @@ packages:
resolution: {integrity: sha512-Z+CZ3QaosfFaTqvhQsIktyGrjFjSC0Fa4EMph4mqKnWhmyoGICsV/8QK+8HpXut6zV7zwfWwqDmEjtk1Qf6EgQ==}
engines: {node: '>=14.0.0'}
'@napi-rs/canvas-android-arm64@0.1.100':
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.100':
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.100':
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-linux-x64-musl@0.1.100':
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.100':
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
engines: {node: '>= 10'}
'@nestjs/cli@10.4.9':
resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==}
engines: {node: '>= 16.14'}
@ -11649,9 +11421,6 @@ packages:
engines: {node: '>=16.x'}
hasBin: true
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@ -11934,9 +11703,6 @@ packages:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@ -12269,9 +12035,6 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -12323,9 +12086,6 @@ packages:
libphonenumber-js@1.12.41:
resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lighthouse-logger@1.4.2:
resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
@ -13497,10 +13257,6 @@ packages:
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
pdfjs-dist@5.7.284:
resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==}
engines: {node: '>=22.13.0 || >=24'}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@ -13837,9 +13593,6 @@ packages:
resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@ -14224,9 +13977,6 @@ packages:
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@ -14539,9 +14289,6 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@ -14926,9 +14673,6 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@ -20190,54 +19934,6 @@ snapshots:
'@mozilla/readability@0.5.0': {}
'@napi-rs/canvas-android-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.100':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
optional: true
'@napi-rs/canvas@0.1.100':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.100
'@napi-rs/canvas-darwin-arm64': 0.1.100
'@napi-rs/canvas-darwin-x64': 0.1.100
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-musl': 0.1.100
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
optional: true
'@nestjs/cli@10.4.9':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@ -22996,7 +22692,8 @@ snapshots:
'@types/earcut@3.0.0': {}
'@types/emscripten@1.41.5': {}
'@types/emscripten@1.41.5':
optional: true
'@types/eslint-scope@3.7.7':
dependencies:
@ -23175,6 +22872,7 @@ snapshots:
dependencies:
'@types/emscripten': 1.41.5
'@types/node': 22.19.17
optional: true
'@types/stack-utils@2.0.3': {}
@ -27854,8 +27552,6 @@ snapshots:
dependencies:
queue: 6.0.2
immediate@3.0.6: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@ -28152,8 +27848,6 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isarray@1.0.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
@ -28740,13 +28434,6 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@ -28783,10 +28470,6 @@ snapshots:
libphonenumber-js@1.12.41: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lighthouse-logger@1.4.2:
dependencies:
debug: 2.6.9
@ -30451,10 +30134,6 @@ snapshots:
pako: 1.0.11
tslib: 1.14.1
pdfjs-dist@5.7.284:
optionalDependencies:
'@napi-rs/canvas': 0.1.100
pend@1.2.0: {}
performance-now@2.1.0:
@ -30701,8 +30380,6 @@ snapshots:
proc-log@4.2.0: {}
process-nextick-args@2.0.1: {}
progress@2.0.3: {}
prom-client@15.1.3:
@ -31372,16 +31049,6 @@ snapshots:
dependencies:
pify: 2.3.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@ -31821,8 +31488,6 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
@ -32189,7 +31854,8 @@ snapshots:
sprintf-js@1.1.3: {}
sql.js@1.14.1: {}
sql.js@1.14.1:
optional: true
stack-utils@2.0.6:
dependencies:
@ -32308,10 +31974,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1