mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 15:41:09 +02:00
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
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:
parent
dd1bab09d5
commit
ac15de280b
48 changed files with 23 additions and 3381 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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' };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {{c1::Deutschland}}."
|
||||
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">{{c1::Wort}}</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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
348
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue