diff --git a/.env.development b/.env.development index 3dc68bcad..4f9c72b9c 100644 --- a/.env.development +++ b/.env.development @@ -23,10 +23,6 @@ PUBLIC_GLITCHTIP_DSN= # Mana Core Auth Service MANA_AUTH_URL=http://localhost:3001 -# Auth-Portal-UI (Login/Register/Reset, getrennt vom Auth-API-Service). -# In Prod identisch mit MANA_AUTH_URL (nginx splittet /api/* zu mana-auth, -# Rest zu mana-auth-web), lokal aber eigener Port (mana-auth-web :3002). -MANA_AUTH_WEB_URL=http://localhost:3002 # Mana Credits Service MANA_CREDITS_URL=http://localhost:3061 # Mana Media Service (CAS, thumbnails, Photos gallery) diff --git a/.env.macmini.example b/.env.macmini.example index d500a7d03..960fdc0cb 100644 --- a/.env.macmini.example +++ b/.env.macmini.example @@ -21,7 +21,7 @@ # Compose project name (pinned, do not change) # ============================================ # All Mac Mini containers were originally created under this project -# name, which mismatches the current directory name (managarten). +# name, which mismatches the current directory name (mana-monorepo). # Pinning the project name here means anyone running 'docker compose ...' # from the repo root automatically lands in the same project as the # already-running containers, instead of silently spawning a duplicate diff --git a/.github/workflows/cd-macmini.yml b/.github/workflows/cd-macmini.yml index 8a75ff396..84b544d70 100644 --- a/.github/workflows/cd-macmini.yml +++ b/.github/workflows/cd-macmini.yml @@ -55,7 +55,7 @@ concurrency: cancel-in-progress: false # Don't cancel in-progress deploys env: - PROJECT_DIR: /Users/mana/projects/managarten + PROJECT_DIR: /Users/mana/projects/mana-monorepo COMPOSE_FILE: docker-compose.macmini.yml ENV_FILE: .env.macmini DOCKER_BUILDKIT: 1 @@ -339,17 +339,12 @@ jobs: # If a service has no Drizzle config or no schema diff this is # a fast no-op. We must source POSTGRES_PASSWORD from the env # file because the workflow env doesn't carry it. - # - # `. "$ENV_FILE"` (bash source) breaks on DOTENV-Werte, die - # ungequotete Leerzeichen enthalten — z.B. `MANA_AI_PUBLIC_KEY_PEM` - # mit `-----BEGIN PUBLIC KEY-----…`: bash interpretiert "PUBLIC" - # als nächstes Command und scheitert mit `PUBLIC: command not - # found`. Backup-Script hat dasselbe Problem (Commit 97e285bc6). - # Wir lesen daher gezielt nur die benötigten Variablen via grep - # statt die ganze Datei als Shell-Script zu sourcen. echo "=== Applying schema migrations ===" - PG_PASSWORD=$(grep -E '^POSTGRES_PASSWORD=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed 's/^"\(.*\)"$/\1/; s/^'"'"'\(.*\)'"'"'$/\1/') - PG_PASSWORD="${PG_PASSWORD:-mana123}" + set -a + # shellcheck source=/dev/null + . "$ENV_FILE" + set +a + PG_PASSWORD="${POSTGRES_PASSWORD:-mana123}" # `drizzle-kit` reads `drizzle.config.ts`, which itself # `import {defineConfig} from 'drizzle-kit'`. Node's resolver diff --git a/.github/workflows/mirror-to-forgejo.yml b/.github/workflows/mirror-to-forgejo.yml index 55f950024..9b0f20415 100644 --- a/.github/workflows/mirror-to-forgejo.yml +++ b/.github/workflows/mirror-to-forgejo.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Mirror to Forgejo via SSH run: | - cd /Users/mana/projects/managarten + cd /Users/mana/projects/mana-monorepo # Stash any local changes so pull never fails git stash --quiet 2>/dev/null || true @@ -25,5 +25,5 @@ jobs: # Push to Forgejo via localhost SSH (runner is on the Mac Mini) GIT_SSH_COMMAND='ssh -p 2222 -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no' \ - git push ssh://git@localhost:2222/till/managarten.git main 2>&1 + git push ssh://git@localhost:2222/till/mana-monorepo.git main 2>&1 echo "Mirrored to Forgejo" diff --git a/CLAUDE.md b/CLAUDE.md index 9c699c686..bf8e39cc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,7 @@ Enforced by `pnpm run validate:turbo` (`scripts/validate-no-recursive-turbo.mjs` | `@mana/shared-branding` | App registry, tiers, branding | | `@mana/shared-types` | Common TS types | | `@mana/shared-utils` | Utility functions | -| `@mana/shared-ui` | **Svelte-5-Komponenten-Bibliothek** (Pills, Modals, Toast, Quick-Input, Skeletons …). **Heimat seit 2026-05-09: `mana/packages/shared-ui` und `npm.mana.how`** — die Kopie hier ist eingefroren bis zum Rückbau dieses Repos. Bei Änderungen in mana/ zuerst, dann erst hierher. | +| `@mana/shared-ui` | React Native UI components | | `@mana/shared-theme` | Theme config | | `@mana/shared-i18n` | i18n | | `@mana/shared-privacy` | Unified visibility/privacy system: `VisibilityLevel` enum + zod schema + `` + predicates (`canEmbedOnWebsite`, …). Plan: [`docs/plans/visibility-system.md`](docs/plans/visibility-system.md). Rollout per-module, not yet adopted anywhere. | diff --git a/README.md b/README.md index 457739a04..2759e7b95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Managarten +# Mana Monorepo -Der Garten der mana-Apps — ein selbst-gehostetes Multi-App-Ökosystem unter `mana.how` mit geteilten Packages und einheitlichem Tooling. Teil der mana-e.V.-Plattform. +Monorepo containing all Mana projects — a self-hosted multi-app ecosystem with shared packages and unified tooling. ## Projects @@ -62,7 +62,7 @@ See [CLAUDE.md](./CLAUDE.md) for comprehensive development documentation. ## Architecture ``` -managarten/ +mana-monorepo/ ├── apps/ # Product applications ├── services/ # Microservices (auth, search, LLM, bots) ├── packages/ # Shared packages diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index cf7b8f407..ee26ba904 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -1,6 +1,6 @@ # Troubleshooting Guide -Common issues and solutions for the managarten. +Common issues and solutions for the mana-monorepo. ## Table of Contents @@ -409,7 +409,7 @@ docker run --rm --entrypoint cat test /app/dist/ai/ai.service.js ### Related Issues -- [Commit d69cc607](https://github.com/Memo-2023/managarten/commit/d69cc607) - Fixed type-only ConfigService import in AiService +- [Commit d69cc607](https://github.com/Memo-2023/mana-monorepo/commit/d69cc607) - Fixed type-only ConfigService import in AiService - TypeScript `import type` vs `import {}` - both erase at compile time - Docker layer caching can hide fixes if source wasn't properly copied @@ -425,7 +425,7 @@ docker run --rm --entrypoint cat test /app/dist/ai/ai.service.js If you encounter an issue not covered here: -1. Check the [GitHub Issues](https://github.com/Memo-2023/managarten/issues) +1. Check the [GitHub Issues](https://github.com/Memo-2023/mana-monorepo/issues) 2. Review recent commits that may have introduced the issue 3. Run `pnpm clean` and `pnpm install` to reset 4. Create a new issue with full error logs diff --git a/apps/cards/COMPETITORS_2026-05.md b/apps/cards/COMPETITORS_2026-05.md new file mode 100644 index 000000000..377f042ba --- /dev/null +++ b/apps/cards/COMPETITORS_2026-05.md @@ -0,0 +1,353 @@ +# Cardecky — Konkurrenz-Analyse (Mai 2026) + +> Stand: 2026-05-07. Quellen primär aus offiziellen Pricing-Seiten, G2/Trustpilot/Reddit/HN sowie Wikipedia/Crunchbase. Wo Daten fehlen oder nicht öffentlich sind, ist das explizit vermerkt. Preise schwanken regional/saisonal — die hier genannten Zahlen sind Listenpreise USD, sofern nicht anders angegeben. + +--- + +## 1. Executive Summary + +- **Anki bleibt der unschlagbare technische Gold-Standard**, aber UX-Schwächen (FSRS-„Difficulty Hell", Plugin-Hölle, kein natives Cloud-Sync mit Bildern) und der $25 iOS-Preis sind reale Lücken, in die wir stoßen können. Die Übergabe an AnkiHub im Februar 2026 könnte mittelfristig die Open-Source-Dynamik verändern — Beobachten lohnt. +- **Quizlet hat seine eigene Userbase verärgert**: Trustpilot 1.4/5, massive Beschwerden über Paywalls für Funktionen, die früher gratis waren. Genau dieses Vertrauensvakuum füllen Knowt und potenziell wir. +- **AI-Karten-Generierung ist Tischeinsatz, kein Differenzierer mehr.** Quizlet, Quizgecko, Knowt, RemNote, Wisdolia, sogar Memrise haben es. PDF-Import + KI ist erwartete Baseline. +- **Die „beautiful Anki"-Lücke ist umkämpft**: Mochi (5$/mo), RemNote (8$/mo), Noji (vormals AnkiPro). Cardecky mit _kostenlosem_ Sync sticht heraus — niemand sonst bietet die Kombination Markdown + FSRS + Cloud-Sync gratis. Das ist unsere wichtigste objektive Differenzierung. +- **Brand-Sniping ist real und schädlich**: AnkiPro (jetzt Noji) und AnkiApp (jetzt AlgoApp) haben sich einen Ruf als „Anki-Klone, die täuschen" erarbeitet — inkl. eines 10-tägigen Sync-Outages bei AnkiPro im Mai 2025. Lehre für uns: nie Anki im Namen führen, Kompatibilität sauber kommunizieren. + +--- + +## 2. Vergleichstabelle + +| Konkurrent | USP-Kurz | Lizenz | Free-Tier | Pro-Preis | Bedrohung | +| ------------------------------ | -------------------------------------- | --------------------------- | -------------------------------- | ----------------------------------------------- | -------------------------------- | +| **Anki (Desktop/Web/Android)** | Tech-Gold-Standard, FSRS, Add-ons | AGPL-3.0 | Voll-Funktional gratis | $0 (iOS: $24.99-29.99 lifetime) | **Hoch** | +| **AnkiHub** | Kollaborative Anki-Decks (USMLE-Fokus) | proprietär (auf Anki-Basis) | Trial | $5/mo | Mittel (Power-User) | +| **Quizlet** | Marktführer Volumen + Schule | proprietär | Sehr eingeschränkt, viele Ads | $35.99/Jahr (Plus), ~$45/Jahr (Unlimited) | **Hoch** (Reichweite) | +| **RemNote** | Notes + SR Hybrid | proprietär | Großzügig (3 PDFs, 5 Image-Occ.) | $8/mo annual (Pro) | Mittel | +| **Mochi** | Markdown, Local-First, schickes UI | proprietär | Single-Device | $5/mo (Sync) | **Hoch** (direkter Wettbewerber) | +| **Brainscape** | Confidence-Based-Repetition | proprietär | Limited Decks | ~$19.99/mo, $79.99 lifetime | Gering-Mittel | +| **Memrise** | Sprachen + AI-Buddies | proprietär | Eingeschränkt | $130.99/Jahr, $199.99 lifetime | Gering (Nische Sprachen) | +| **SuperMemo** | Algorithmus-Urvater (SM-20) | proprietär | Monatstrial Mobile | ~9.90$/mo Mobile, ~$66 Desktop perp. | Gering (Nische, sperrige UX) | +| **AnkiPro / Noji** | „Anki-Look" mit modernem UI | proprietär | mit Ads/Limits | nicht öffentlich klar (~$5-10/mo) | Mittel (Brand-Verwirrung) | +| **AnkiApp / AlgoApp** | Cloud-First Closed-Source | proprietär | Limited | Subscription (Details schwammig) | Gering (Reputation kaputt) | +| **Quizgecko** | AI-First (Quizzes, Podcasts) | proprietär | 1 AI-Lesson/Monat | $16/mo (Pro), $29 (Ultra) | Mittel (AI-Side) | +| **Knowt** | „Free Quizlet-Alternative" + AI | proprietär | Sehr großzügig | $9.99/mo (Ultra) | **Hoch** (gleiches Spielfeld) | +| **Wisdolia** | Browser-Ext: Karten aus Webcontent | proprietär | 50 Sets/Monat | $2.50/mo, $25/Jahr | Gering | +| **Mnemosyne** | Open-Source, Forschungs-Datasammlung | GPL | Voll gratis | — | Sehr gering | +| **Traverse** | Mind-Maps + SR (Mandarin Blueprint) | proprietär | Free-Plan | $15/mo Member, $35/User Enterprise | Gering | +| **Cerego** | Enterprise B2B Adaptive Learning | proprietär | — | ab $8.33/mo Indiv., Enterprise on req. | Sehr gering (B2B) | +| **NeuraCache** | Notion/Obsidian-Sync für SR | proprietär | Limited | 14d Trial → Pro (Preis nicht klar dokumentiert) | Gering | + +> Threat-Ranking: nur **Anki, Quizlet, Mochi, Knowt** sind Top-Bedrohungen für Cardeckys Kernzielgruppe. RemNote, Quizgecko, AnkiPro/Noji sind Nebenfront. + +--- + +## 3. Detail-Sektion pro Konkurrent + +### 3.1 Anki (Desktop / AnkiWeb / AnkiDroid / AnkiMobile) + +- **URL:** https://apps.ankiweb.net/ +- **Plattformen:** Windows, macOS, Linux (Desktop), Web (AnkiWeb), Android (AnkiDroid), iOS (AnkiMobile) +- **USP:** Der etablierte technische Standard für Spaced Repetition; mächtig, erweiterbar (Add-ons), FSRS v6 nativ, riesiges Deck-Ökosystem (insbes. Medizin: AnKing). +- **Lizenz:** AGPL-3.0 (Desktop, AnkiDroid, Web). AnkiMobile iOS proprietär (finanziert die Open-Source-Arbeit). +- **Kosten:** Desktop / Web / Android **kostenlos**. AnkiMobile iOS: **$24.99-29.99 einmalig (Lifetime)**. AnkiHub-Cloud-Decks: $5/Monat (separat). +- **User loben:** Mächtig & flexibel; FSRS-Wirksamkeit; freie Decks (insbes. AnKing Step Deck mit 100k+ Studenten); Dauerhaftigkeit (seit 2006). +- **User kritisieren:** Steile UX-Lernkurve; FSRS-„Difficulty Hell" (Karten reifen langsam, Reviews explodieren); Plugin-Brüche zwischen Versionen; iOS-Preis abschreckend; Sync-Setup für Bilder/Audio umständlich. +- **Firma & Geschichte:** Damien Elmes (Australien), gestartet 5.10.2006 ursprünglich für Japanisch-Lernen. Im **Februar 2026** angekündigt, dass AnkiHub (Austin, TX) Business-Operations und Open-Source-Stewardship übernimmt — Anki bleibt Open Source, keine externen Investoren, Versprechen „no enshittification". +- **Bedrohungsgrad: Hoch.** Power-User-Standard, riesiges Decks-Ökosystem, kostenlos. Wir können sie nicht im technischen Spielfeld schlagen — wir müssen über UX, Onboarding und „Anki-Import-Bridge" gewinnen. + +Quellen: [Anki Wikipedia]() · [AnkiMobile App Store](https://apps.apple.com/us/app/ankimobile-flashcards/id373493387) · [Class Central: Anki founder steps back](https://www.classcentral.com/report/anki-founder-steps-back/) · [AnkiHub](https://www.ankihub.net/) · [Difficulty Hell in Anki](https://skerritt.blog/difficulty-hell-in-anki/) · [Anki FSRS-Forum](https://forums.ankiweb.net/c/fsrs/41) + +--- + +### 3.2 Quizlet + +- **URL:** https://quizlet.com/ +- **Plattformen:** Web, iOS, Android. +- **USP:** Massen-Marktführer mit der größten Bibliothek shared decks (Schul-/Hochschul-Vokabeln); inzwischen stark AI-fokussiert (Q-Chat, Magic Notes, Coconote-Akquisition Feb 2026, ChatGPT-Integration März 2026). +- **Lizenz:** Proprietär. +- **Kosten:** Free (sehr eingeschränkt + Werbung). **Plus: $35.99/Jahr (~$2.99/mo)**. **Plus Unlimited: ~$44.99/Jahr** (entfernt Limits wie 3 Practice Tests/Monat, 20 Learn-Runden/Monat). +- **User loben:** Riesige Library shared sets; einfacher Einstieg; Multi-Device gut etabliert; AI-generierte Practice-Tests sind brauchbar. +- **User kritisieren:** **Trustpilot 1.4/5** aus 500+ Reviews. Aggressives Paywalling von Features, die früher gratis waren (Learn-Mode, Test, Lernrunden-Limit); Werbeflut im Free-Tier; Export-Möglichkeiten eingeschränkt (Lock-in); Bugs. +- **Firma & Geschichte:** 2005 gegründet von Andrew Sutherland (damals 15, Albany High School CA) für eigene Französisch-Vokabeln. Bootstrap bis 2015, dann $12M USV/Costanoa. 2020 $30M Series C bei General Atlantic, **$1B Bewertung**. Sitz San Francisco. Insgesamt ~$62M raised. CEO seit 2022 Lex Bayer. **Februar 2026: Akquisition Coconote**. +- **Bedrohungsgrad: Hoch (Reichweite), aber verwundbar.** Das Trustpilot-Desaster ist eine Steilvorlage. Unsere Chance: Quizlet-Refugees mit „so gut wie früher Quizlet, dazu FSRS und ohne Paywall-Gärten" abholen. + +Quellen: [Quizlet Wikipedia](https://en.wikipedia.org/wiki/Quizlet) · [Trustpilot Quizlet (1.4/5)](https://www.trustpilot.com/review/www.quizlet.com) · [Quizlet $1B Bewertung TechCrunch](https://techcrunch.com/2020/05/13/quizlet-valued-at-1-billion-as-it-raises-millions-during-a-global-pandemic/) · [Crunchbase Quizlet](https://www.crunchbase.com/organization/quizlet) · [Navigating Quizlet's Controversial Changes](https://medium.com/@maxtan0626/navigating-quizlets-controversial-changes-afeb97aafd1e) + +--- + +### 3.3 RemNote + +- **URL:** https://www.remnote.com/ +- **Plattformen:** Web, macOS, Windows, iOS, Android. +- **USP:** Hybrid aus Outliner-Notetaking (Roam-/Logseq-ähnlich) und integrierten SR-Karten — Karten entstehen direkt im Notiz-Flow via `::`-Syntax. Plus PDF-Annotation und Image-Occlusion. +- **Lizenz:** Proprietär. +- **Kosten:** Free (3 PDF-Annotationen, 5 Image-Occlusion-Karten). **Pro: $8/Monat annual ($96/Jahr)** oder $10/mo monthly. +- **User loben:** Notes + Cards in _einem_ Workflow; flexible nested Outline-Struktur; PDF-Annotation; AI-Generierung aus Notizen/PDFs. +- **User kritisieren:** Steile Lernkurve („nichts versteht man in 10 Min"); UI als überladen empfunden; **Performance-Probleme** (langsam beim Laden großer Datenbanken, iPad-Stabilität); Bugs nach Beta-Updates; non-English-Support schwach. +- **Firma & Geschichte:** Gegründet 2019 von Martin Schneider (MIT) und Moritz Wallawitsch (Berlin, HTW). Sitz: USA. **$2.8M Seed (Sept 2021)** unter General Catalyst. Hat 2025 ~$2M Revenue mit ~18 Personen erreicht. +- **Bedrohungsgrad: Mittel.** Andere Zielgruppe (PKM-Power-User, Studenten, die Notes wollen). Cardecky ist fokussierter — wir müssen die „nur-Karten"-Nische gegen ihre Hybrid-Erweiterung verteidigen. + +Quellen: [RemNote Pricing](https://www.remnote.com/pricing) · [Crunchbase RemNote](https://www.crunchbase.com/organization/remnote) · [RemNote Reviews Product Hunt](https://www.producthunt.com/products/remnote/reviews) · [RemNote Performance-Forum](https://forum.remnote.io/t/remnote-is-my-dream-pkm-yet-its-too-slow-am-i-doing-something-wrong/10920) · [Latka RemNote $2M ARR](https://getlatka.com/companies/remnote.com) + +--- + +### 3.4 Mochi + +- **URL:** https://mochi.cards/ +- **Plattformen:** macOS (Intel/AS), Windows, Linux Desktop, iOS, Android, Web. +- **USP:** Markdown-First, sauberes minimalistisches UI, Local-First mit Offline-Support; Image-Occlusion und Anki-`.apkg`-Import als First-Class-Feature ohne Plugin-Frickelei. **FSRS seit Mid-2025 unterstützt.** +- **Lizenz:** Proprietär. +- **Kosten:** Free (Single-Device, alle Karten lokal). **Pro: $5/Monat** (Sync, mehrere Geräte, Translations). +- **User loben:** „schönes" UI; intuitiver als Anki; sofortiges Karten-Lernen ohne Onboarding-Friktion; Anki-Import als Plugin-Free-Feature; Markdown-Workflow. +- **User kritisieren:** Sync nur in Pro (Single-Device-Free fühlt sich begrenzt an); algorithm war bis Mid-2025 schwächer als Anki (FSRS-Beta hat das gefixt); kleinere Community → weniger shared decks; Solo-Developer (Bus-Faktor). +- **Firma & Geschichte:** Solo-Projekt von **Matthew Steedman**, eigenfinanziert, Forum auf forum.mochi.cards. Keine externen Investoren öffentlich bekannt. +- **Bedrohungsgrad: Hoch — direktester Wettbewerber.** Praktisch identische Positionierung (Markdown, schickes UI, modern, FSRS, Local-First). Unterschied: Mochi nimmt $5/mo für Sync — **wir bieten Sync gratis**, das ist unsere stärkste objektive Differenzierung gegen Mochi. + +Quellen: [Mochi Cards](https://mochi.cards/) · [Mochi App Store](https://apps.apple.com/us/app/mochi-flashcards-and-notes/id1507775056) · [First Impressions of Mochi (borretti.me)](https://borretti.me/article/first-impressions-mochi) · [Bunpro: If you don't like Anki, try Mochi](https://community.bunpro.jp/t/if-you-dont-like-anki-consider-giving-mochi-a-try/59955) · [Mochi Changelog](https://mochi.cards/changelog/) + +--- + +### 3.5 Brainscape + +- **URL:** https://www.brainscape.com/ +- **Plattformen:** Web, iOS, Android. +- **USP:** „Confidence-Based Repetition" — User raten Selbsteinschätzung auf 1-5-Skala (statt SM-2/FSRS), als wissenschaftlich vermarktetes Schedule-System. Große kuratierte Decks-Library und EDU/Enterprise-Vertrieb. +- **Lizenz:** Proprietär. +- **Kosten:** Free (limitierter Zugang zu Deck-Bibliothek). **Pro: ~$19.99/Monat** (Discounts bei Jahres-/Lifetime-Plan). **Lifetime: $79.99**. +- **User loben:** Kuratierte Content-Bibliothek; klares Lernkonzept; schickes UI; gute Statistiken; Collaboration-Features für Teams. +- **User kritisieren:** Algorithmus weniger anpassbar als Anki/FSRS; Pro-Preis wird als hoch empfunden; Free-Tier-Decks sehr begrenzt; weniger Power-User-Features. +- **Firma & Geschichte:** Gegründet von Andrew Cohen (Idee 2006 Panama-Spanisch-Excel-Macro, später Master's Columbia EdTech). Sitz: New York. Founding Team: Cohen, Andy Lutz, Jay Stramel, Jonathan Thomas, Ron Cadet (2018). >$3M raised bis 2015. +- **Bedrohungsgrad: Gering-Mittel.** Andere Zielgruppe (Pro-Decks-Käufer, EDU-Markt). Wir konkurrieren wenig direkt. + +Quellen: [Brainscape](https://www.brainscape.com/) · [G2 Brainscape Reviews](https://www.g2.com/products/brainscape/reviews) · [Brainscape Wikipedia](https://en.wikipedia.org/wiki/Brainscape) · [How Brainscape Was Born](https://www.brainscape.com/academy/how-brainscape-was-born/) + +--- + +### 3.6 Memrise + +- **URL:** https://www.memrise.com/ +- **Plattformen:** Web, iOS, Android. +- **USP:** Sprachen-Fokus mit Native-Speaker-Videos und seit 2024/25 stark ausgebaute „AI Buddies" (Grammar Buddy, Translator Buddy, Culture Buddy, MemBot Chatbot auf GPT-Basis). +- **Lizenz:** Proprietär. +- **Kosten:** Free (limitiert + Ads). **Monthly $27.99**, **Annual $130.99 (~$11/mo)**, **Lifetime $199.99** (oft Discounts bis 50%). +- **User loben:** Native-Speaker-Video-Clips als Alleinstellungsmerkmal vs Duolingo; AI-Buddies bringen Konversationspraxis; gut für Vokabel-Aufbau. +- **User kritisieren:** Schwach in Grammatik; nicht für Fortgeschrittene; AI-Buddies hinter Paywall; teure Subscription verglichen mit Konkurrenten; legendäre community-„mems"-Funktion wurde entfernt (alte Community vergrätzt). +- **Firma & Geschichte:** Gegründet 2010 von **Ed Cooke** (Grand Master of Memory), **Ben Whately** und **Greg Detre** (Princeton-Neurowissenschaftler). Oxford-Trio. Sitz London. **$25.3M raised** über 7 Runden / 10 Investoren. Profitabel seit Ende 2016. **72M registrierte User (2024)**. +- **Bedrohungsgrad: Gering.** Sprach-Lerner-Nische, kaum Überlappung mit unserer generischen SR-Zielgruppe. + +Quellen: [Memrise](https://www.memrise.com/) · [Memrise Wikipedia](https://en.wikipedia.org/wiki/Memrise) · [Crunchbase Memrise](https://www.crunchbase.com/organization/memrise) · [Business of Apps: Memrise Statistics 2026](https://www.businessofapps.com/data/memrise-statistics/) + +--- + +### 3.7 SuperMemo + +- **URL:** https://www.supermemo.com/ (Web/Mobile) · https://supermemo.store/ (Desktop) +- **Plattformen:** Windows Desktop (Premium-Version), Web, iOS, Android, Browser-API. +- **USP:** Originator des Spaced-Repetition-Konzepts (1985 ff.) — Algorithmen SM-2 bis aktuell **SM-20 (2026)**. Die Desktop-Version hat Funktionen, die andere SR-Tools nicht haben (Incremental Reading, Concept Maps). +- **Lizenz:** Proprietär. +- **Kosten:** Mobile/Web: 1 Monat free, danach **~9.90 USD/EUR pro Monat**. Desktop SuperMemo 19 (Windows): **~$66 perpetual** (Käufer März 2026 bekommen kostenloses Upgrade auf SuperMemo 20). API: Early Access, 100 Repetitions/Tag gratis. +- **User loben:** Algorithmus-Tiefe; Incremental Reading; SM-20 als state-of-the-art; Hardcore-Power-User-Tool. +- **User kritisieren:** UI „aus den 1990ern"; sperrige Bedienung; Desktop-only für viele Features; Mobile-App stark eingeschränkt; Preis vs Anki nicht zu rechtfertigen für 95% der User. +- **Firma & Geschichte:** SuperMemo World Sp. z o.o., gegründet **5. Juli 1991** in Poznań, Polen, von Krzysztof Biedalak und **Piotr Wozniak** (mit Tomasz Kuehn, Janusz Murakowski, Marczello Georgiew). Wozniak begann SuperMemo 1.0 schon 13.12.1987. +- **Bedrohungsgrad: Gering.** Nische für Algorithmus-Enthusiasten und Incremental-Reading-Fans. Keine reale UX-Bedrohung für uns. + +Quellen: [SuperMemo Wikipedia](https://en.wikipedia.org/wiki/SuperMemo) · [SuperMemo Store](https://supermemo.store/products/supermemo-19-for-windows) · [Algorithm SM-18](https://supermemo.guru/wiki/Algorithm_SM-18) · [Piotr Wozniak](https://supermemo.guru/wiki/Piotr_Wozniak) · [SuperMemo iOS App Store](https://apps.apple.com/us/app/supermemo-effective-learning/id982498980) + +--- + +### 3.8 AnkiPro / Noji + +- **URL:** https://noji.io/ (vormals ankipro.net) +- **Plattformen:** iOS, Android, Web. +- **USP:** „Anki-Look-and-Feel" mit modernem UI und Cloud-Sync — verkauft sich aktiv als „die einfachere Anki-Variante". Nicht kompatibel mit echtem Anki (auch nicht mit `.apkg`-Decks ohne Workarounds). +- **Lizenz:** Proprietär. +- **Kosten:** Free mit Werbung/Limits. Pro-Subscription, Preise nicht prominent — nach Reports im Bereich **$5-10/mo** oder Jahresplan. +- **User loben:** Schickes Mobile-UI; einfacher Onboarding-Flow; Cross-Device-Sync „out of the box"; community Decks. +- **User kritisieren:** **Brand-Verwirrung** (User dachten, sie laden „echtes" Anki herunter); **10-Tage-Sync-Outage Mai 2025** mit Datenverlust für viele User; Lock-in (Export-Tools wurden vom Anbieter blockiert, ein Migrations-Tool erhielt einen **Rickroll-Response** von AnkiPro); offizielles Anki-Team distanziert sich. +- **Firma & Geschichte:** Anki Pro UAB; Co-Founder **Maksim Abramchuk** (im Crunchbase) und **Andrew Bond** (LinkedIn). 2021 gestartet, 2024/25 Rebrand zu **Noji**. Sitz nicht eindeutig öffentlich (LinkedIn-Indikatoren UK/Osteuropa). +- **Bedrohungsgrad: Mittel.** Nicht weil sie technisch besser sind, sondern weil Anki-Suchende auf sie reinfallen. **Lehre für Cardecky: Brand-Hygiene**. Wir sind „Cardecky" — nie „Anki" im Marketing, klare Trennung kommunizieren, Anki-Import sauber als Bridge dokumentieren. + +Quellen: [Anki knockoffs (offizielle Anki FAQ)](https://faqs.ankiweb.net/anki-knockoffs.html) · [AnkiPro Ripoff Forum](https://forums.ankiweb.net/t/ankipro-another-ripoff-anki-app/11791) · [Anki Users Get Rickrolled](https://broderic.blog/post/anki-users-get-rickrolled/) · [Noji App Store](https://apps.apple.com/us/app/noji-flashcards-anki-method/id1573585542) · [Crunchbase Anki Pro](https://www.crunchbase.com/organization/anki-pro) · [Speakada: Official Anki vs Fake Apps](https://speakada.com/official-anki-vs-fake-apps-the-critical-mistake-costing-language-learners-hours/) + +--- + +### 3.9 AnkiApp / AlgoApp + +- **URL:** https://www.algoapp.ai/ (vormals ankiapp.com) +- **Plattformen:** iOS, Android, Web, Desktop. +- **USP:** Closed-Source Cloud-First Karten-App, die seit Jahren den Namen „Anki" ausnutzt. **In manchen Regionen (z. B. japanischer App Store) firmiert sie weiterhin als „AnkiApp"**. +- **Lizenz:** Proprietär. +- **Kosten:** Free + Subscription-Tiers (Details vage, oft als „Trial-Trap" kritisiert). +- **User loben:** Funktioniert auf allen Plattformen; Cloud-Sync inkludiert; einfaches UI. +- **User kritisieren:** **Komplette Brand-Täuschung**; kein Import/Export zu echtem Anki; aggressive Subscription-Walls; Reviews mit „nichts mit echtem Anki zu tun" als wiederkehrendes Muster; Reputation in der Community unter null. +- **Firma & Geschichte:** AlgoApp Inc., gegründet **2021**, Sitz **San Mateo, CA**. Vor Kurzem von AnkiApp zu AlgoApp umbenannt (Anki-Brand-Druck wurde zu groß), aber teils noch unter altem Namen aktiv. +- **Bedrohungsgrad: Gering.** Reputation kaputt; informierte User meiden sie aktiv. Hauptthema für uns ist nicht Wettbewerb, sondern Brand-Hygiene-Lehre (siehe AnkiPro). + +Quellen: [Anki knockoffs FAQ](https://faqs.ankiweb.net/anki-knockoffs.html) · [AlgoApp on Anki Forum](https://forums.ankiweb.net/t/algoapp-still-using-ankiapp-name-in-japanese-app-store/69103) · [Crunchbase AlgoApp](https://www.crunchbase.com/organization/algoapp) · [Pitchbook AlgoApp](https://pitchbook.com/profiles/company/495884-44) + +--- + +### 3.10 Quizgecko + +- **URL:** https://quizgecko.com/ +- **Plattformen:** Web, iOS, Android. +- **USP:** AI-First-Workflow: aus PDF / Text / URL → Quizzes + Karten + Notizen + **Audio-Podcasts** (Notebook-LM-ähnlich). SR ist sekundär. +- **Lizenz:** Proprietär. +- **Kosten:** **Basic Free (1 AI-Lesson/Monat)**. **Pro $16/mo** (annual). **Ultra $29/mo** (50 Podcasts/mo, Custom Prompts). Business $32/mo (API + Branding). +- **User loben:** Vielseitige Output-Formate (Quiz/Karten/Podcast); guter PDF-Parser; multi-Question-Types. +- **User kritisieren:** SR ist „mitgeliefert" aber nicht der Fokus; Free-Tier sehr eng (1 Lesson); Pro-Preis hoch verglichen mit dedicated AI-Card-Tools. +- **Firma & Geschichte:** Privates Startup, kleinere Bekanntheit, keine prominente Funding-Information öffentlich. +- **Bedrohungsgrad: Mittel (in der AI-Front).** Wir konkurrieren am AI-Generierungs-Feature. Für reines SR-Lernen ist Quizgecko keine Bedrohung; für „ich habe ein Skript und will lernen" schon. Unser Konter: AI-Generierung ist bei uns „free with sync" und dann _dauerhaft_ in einem echten SR-System. + +Quellen: [Quizgecko](https://quizgecko.com/) · [Quizgecko Pricing](https://quizgecko.com/pricing) · [Toosio Quizgecko Review 2026](https://toosio.com/tool/quizgecko-ai-quiz-flashcard-podcast-generator) + +--- + +### 3.11 Knowt + +- **URL:** https://knowt.com/ +- **Plattformen:** Web, iOS, Android. +- **USP:** Positioniert sich explizit als **„free Quizlet alternative"**. Importiert Quizlet-Sets direkt, hat ähnliche Study-Modes (Learn, matching, practice tests, „Knowt Play") plus AI-Generierung aus Notizen/PDFs. +- **Lizenz:** Proprietär. +- **Kosten:** **Sehr großzügiges Free-Tier** (unlimited Karten, alle Study-Modes, basic AI mit monatlichen Limits). **Ultra: $9.99/mo annual** (Snap & Solve, unlimited AI). Manche Listen nennen einen $12.50/mo Premium. +- **User loben:** „Endlich Quizlet ohne Paywall"; Quizlet-Import funktioniert; AI-Note-zu-Karten brauchbar; Free-Tier wirklich nutzbar. +- **User kritisieren:** Hauptsächlich Schüler-/US-Highschool-Zielgruppe (für Erwachsene weniger durchdacht); AI-Limits im Free-Tier; SR-Algorithmus weniger ausgereift als Anki/FSRS. +- **Firma & Geschichte:** US-Startup, primär Studenten-Zielgruppe, keine prominente Funding-Information öffentlich verfügbar. +- **Bedrohungsgrad: Hoch (gleiches Spielfeld).** Beide Apps positionieren „free + AI + bessere UX als Quizlet". Unsere Differenzierung: **FSRS v6, Markdown, echtes Local-First-PWA-Modell, Anki-Import inkl. Bilder/Audio**. Knowt ist webbasiert, wir sind installierbar offline-first. + +Quellen: [Knowt](https://knowt.com/) · [Knowt vs Quizlet (StudyGenie 2026)](https://studygenie.io/blog/knowt-vs-quizlet) · [Best Quizlet Alternatives 2026](https://kvistly.com/blog/best-quizlet-alternatives) + +--- + +### 3.12 Wisdolia + +- **URL:** https://www.wisdolia.com/ (vorrangig als Chrome-Extension) +- **Plattformen:** Chrome Extension; Karten-Export zu Anki möglich. +- **USP:** Generiert Karten aus _jeder Webseite, PDF oder YouTube-Video_ in Sekunden — sehr fokussiert auf den „Capture beim Browsen"-Use-Case. +- **Lizenz:** Proprietär. +- **Kosten:** **Free: 50 Sets/Monat** (Limit: 15 PDF-Seiten, 12 Min YouTube). **Pro: $2.50/mo oder $25/Jahr** (unlimited). +- **User loben:** Spielerisch billig; Browser-Extension-Workflow ist reibungsarm; Anki-Export als Bridge. +- **User kritisieren:** Kein eigenes SR-System mit eigener Tiefe (eher Generator als Lern-App); Browser-only beschränkt. +- **Firma & Geschichte:** Kleines indie-Projekt; keine prominente Funding-Information öffentlich. +- **Bedrohungsgrad: Gering.** Komplementäres Tool eher als Wettbewerber — wer Wisdolia nutzt, exportiert oft _zu Anki_ (oder zu uns, wenn wir Wisdolia-Export sauber importieren). + +Quellen: [Wisdolia (Findmyaitool)](https://findmyaitool.com/tool/wisdolia) · [Wisdolia Plain English Walkthrough](https://plainenglish.io/artificial-intelligence/wisdolia-ai-generate-flashcards-anywhere-on-the-web-with-google-chrome-extension) + +--- + +### 3.13 Mnemosyne + +- **URL:** https://mnemosyne-proj.org/ +- **Plattformen:** Windows, macOS, Linux Desktop; Android (eingeschränkt). +- **USP:** Open-Source-Alternative zu Anki mit explizitem **Forschungs-Fokus**: Nutzer können (opt-in) anonyme Lerndaten beitragen, die seit 2006 zur Untersuchung von Langzeitgedächtnis gesammelt werden. +- **Lizenz:** GPL. +- **Kosten:** Komplett gratis. Kein Sync. +- **User loben:** Sauber, leichtgewichtig, ehrlich akademisch; gut für Forschung; lange Geschichte (>20 Jahre). +- **User kritisieren:** UI veraltet; Mobile-Support schwach (Android-App OK, iOS quasi nichts); kleine Community; weniger Decks als Anki. +- **Firma & Geschichte:** Community-Projekt um Peter Bienstman (Belgien). Letzte Release März 2026 — aktiv aber langsam. +- **Bedrohungsgrad: Sehr gering.** Akademisches Nischen-Tool, andere Zielgruppe. + +Quellen: [Mnemosyne Wikipedia]() · [Mnemosyne Project](https://mnemosyne-proj.org/) · [GitHub Mnemosyne](https://github.com/mnemosyne-proj/mnemosyne) + +--- + +### 3.14 Traverse + +- **URL:** https://traverse.link/ +- **Plattformen:** Web, iOS, Android. +- **USP:** Kombiniert Mind-Mapping + Note-Taking + SR-Karten in einer App; offizielle Integration mit „Mandarin Blueprint" (Chinesisch-Lernkurs). +- **Lizenz:** Proprietär. +- **Kosten:** Free, **Member $15/mo**, **Enterprise $35/User/mo**. +- **User loben:** Mind-Map + Karten kombiniert ist konzeptionell stark für Sprachen/komplexe Domains; Mandarin-Community schätzt es. +- **User kritisieren:** Member-Preis hoch; relativ kleine Bekanntheit außerhalb Mandarin-Sub-Community; nicht so viel feature parity mit Anki. +- **Firma & Geschichte:** Indie-Startup, primär Bootstrap; keine prominente Funding-Information öffentlich. +- **Bedrohungsgrad: Gering.** Andere Zielgruppe (visuelles Lernen, Sprachen). Keine direkte Konkurrenz. + +Quellen: [Traverse.link](https://traverse.link/) · [Traverse.link Capterra 2026](https://www.capterra.com/p/234102/Traverse/) + +--- + +### 3.15 Cerego + +- **URL:** https://www.cerego.com/ +- **Plattformen:** Web (B2B-Plattform). +- **USP:** Enterprise-Adaptive-Learning mit Versprechen „4-5× schnelleres Lernen, 90% Retention"; AI/ML-basierte Personalisierung. **Verkauft sich an Unternehmen, nicht Endkunden**. +- **Lizenz:** Proprietär. +- **Kosten:** Indiv. ab **$8.33/mo**, Enterprise ab 500 Seats individuell verhandelt (nicht öffentlich). +- **User loben:** Solide Lerneffekte in Enterprise-Trainings; gutes Reporting; sauberes UI. +- **User kritisieren:** Nicht für Selbstlerner gemacht; teuer für Einzelne; deck-Erstellungs-Workflow umständlich für Privatuser. +- **Firma & Geschichte:** US-Firma, in der Vergangenheit mehrfach pivotiert (B2C → B2B). Keine aktuelle Funding-Info. +- **Bedrohungsgrad: Sehr gering.** B2B, andere Welt. + +Quellen: [Cerego](https://www.cerego.com/) · [Cerego G2](https://www.g2.com/products/cerego/reviews) · [Cerego Capterra 2026](https://www.capterra.com/p/169739/Cerego/) + +--- + +### 3.16 NeuraCache + +- **URL:** https://neuracache.com/ +- **Plattformen:** iOS, Android. +- **USP:** SR-Karten **synchronisiert mit Notion / Obsidian / Logseq / Roam / Evernote / OneNote**, automatisches Extrahieren markierter Notizen → Karten. „Bridge"-Tool für PKM-Nutzer. +- **Lizenz:** Proprietär. +- **Kosten:** 14-Tage-Trial Pro. Pro-Subscription oder One-Time Lifetime; konkrete 2026-Preise nicht klar dokumentiert auf der öffentlichen Seite. +- **User loben:** Notion-/Obsidian-Sync ist die Killer-Funktion; spart Doppelarbeit für PKM-Power-User. +- **User kritisieren:** Klein, indie; UI weniger poliert als Mochi; Pricing intransparent; eher Mobile-only. +- **Firma & Geschichte:** Indie-Developer, geringe öffentliche Sichtbarkeit. +- **Bedrohungsgrad: Gering.** PKM-Nische; keine Überlappung mit unserer Generalist-Zielgruppe. + +Quellen: [NeuraCache](https://neuracache.com/) · [NeuraCache App Store](https://apps.apple.com/us/app/neuracache-spaced-repetition/id1450923453) · [NeuraCache AlternativeTo](https://alternativeto.net/software/neuracache/about/) + +--- + +## 4. Schluss-Empfehlung: 3 Differenzierungs-Hebel für Cardecky + +### Hebel 1: **„Free Sync" konsequent ausspielen** + +Niemand sonst bietet die Kombination, die wir liefern — _Markdown + FSRS + Multi-Device-Cloud-Sync inkl. Bilder/Audio + PWA + AI-Generierung_, alles im Free-Tier. Konkurrenten wollen für Sync Geld: + +- Mochi: $5/mo +- AnkiMobile iOS: $25-30 einmalig +- Quizlet: Sync ja, aber Features paywallen +- RemNote: Pro-Limit (3 PDFs) +- Brainscape: $20/mo + +**Action:** Marketing-Hauptbotschaft auf Pricing-Seite und Landingpage explizit machen: _„Sync gratis, immer. Karten gehören dir, lokal und in der Cloud."_ Gegen Mochi besonders direkt vergleichen. Wenn wir später monetarisieren, sollte Sync NIE in den Pro-Tier wandern — unser Reputations-Anker. + +### Hebel 2: **Anki-Migration als First-Class-Feature, ohne Brand-Sniping** + +Anki bleibt Power-User-Standard, aber Anki-User klagen über UX, FSRS-Tweaking und iOS-Preis. Sie sind die wertvollste Migrations-Zielgruppe (lange Lern-Historie, 100k+ Karten). Wir importieren bereits inkl. Bilder/Audio — das ist Gold. + +**Action:** + +- Eine dezidierte Landingpage `cardecky.com/from-anki` mit ehrlichem Vergleich (was wir besser machen, was Anki noch besser kann), Migrationsanleitung, und expliziter Distanzierung von AnkiPro/AnkiApp/Noji. +- Eine ehrliche Story dazu („Wir sind nicht Anki. Wir sind Cardecky. Aber wir respektieren deine Anki-Karten."). Das positioniert uns als seriöse Alternative gegen die Brand-Sniper. +- Für Bonus-Punkte: Imports von Mochi-Decks und Quizlet-Sets ebenfalls anbieten — Knowt lebt davon, wir können das auch. + +### Hebel 3: **„Local-First PWA" als Tech-Identität, nicht nur Implementierungsdetail** + +Cardeckys Local-First + PWA-Architektur ist konzeptionell anders als Quizlet/Knowt (Web-First) und besser als Mochi auf iOS (App-Store-Friktion). Wir sind installierbar, offline-funktional, ohne App Store. Das schlägt mehrere Fliegen: + +- Kein iOS-30%-Tax (vs AnkiMobile-Modell, das deshalb $25 kostet) +- Kein Vendor-Lock-in (Daten bleiben im Browser/lokal nutzbar) +- Kein Werbe-Modell nötig (vs Quizlet) +- Schnelles Auto-Update (vs Anki-Plugin-Brüche) + +**Action:** Konsequent „Local-First PWA" in Tech-Marketing nutzen (HN, Reddit /r/Anki, /r/medicalschool, indie-hacker-Communities). Genau dort sitzen Quizlet-Wechsler und Anki-frustrierte Med-Studenten, die diesen technischen Pitch verstehen. + +--- + +## Bonus: Was wir _nicht_ tun sollten + +- **Nicht „Anki" im Namen führen** — siehe AnkiPro/AnkiApp Reputation. „Cardecky" ist neutral, freundlich, und distanziert sich klar. +- **Nicht die SR-Algorithmus-Race spielen** — FSRS v6 reicht. SuperMemo SM-20 ist kein Marketing-Argument für 99% der User. +- **Nicht in Sprach-Lernen pivotieren** — Memrise und Duolingo besitzen das Feld, andere Mechaniken nötig. +- **Nicht alle AI-Features paywallen** — Knowt zeigt: ein großzügiges Free-Tier mit AI ist der Hebel gegen Quizlet. +- **Nicht Sync paywallen** — siehe Hebel 1. Das ist unser Anker-Wert. + +--- + +## Methodische Hinweise + +- Recherche durchgeführt 2026-05-07 via WebSearch (offizielle Pricing-Seiten, G2, Trustpilot, Capterra, Crunchbase, Wikipedia, Reddit, Anki-Forums, Hacker News). +- Einige Konkurrenten (NeuraCache, Quizgecko, Traverse, kleinere Indie-Tools) haben begrenzt öffentlich verfügbare Daten zu Funding/Team — wo Daten fehlen, ist „nicht öffentlich bekannt" eingetragen statt Spekulation. +- AnkiPro/Noji ist besonders intransparent (eigene Pricing-Seite versteckt klare Tier-Liste, Zahlen aus Reviews); wir sollten das im Auge behalten, wenn wir gegen sie konkurrieren. +- Quizlet-Bewertung mit „verwundbar" basiert real auf dem **Trustpilot-1.4/5** und der breiten Reddit-Stimmung — das ist eine echte Marktchance, kein Wunschdenken. diff --git a/apps/cards/GUIDELINES.md b/apps/cards/GUIDELINES.md new file mode 100644 index 000000000..38f8096b0 --- /dev/null +++ b/apps/cards/GUIDELINES.md @@ -0,0 +1,367 @@ +# Cardecky — Projekt-Leitlinien + +Verbindliche Regeln für den Spinoff. Ziel: in wenigen Wochen ein +ausspielbares Web-MVP, das ausschließlich seinen *Core Gameloop* +beherrscht und alles andere von zentralen Mana-Bausteinen erbt. + +**Status:** Planungsphase, noch kein Code. +**Name:** Cardecky. +**App-Domain:** `cardecky.mana.how` (Subdomain unter `*.mana.how`, SSO über mana-auth). +**Marketing-Landing:** `cardecky.com` (eigene Domain, statisch, SEO/Akquise — keine Auth, leitet auf `cardecky.mana.how` für die App). +**Zugang:** offen für jeden eingeloggten Mana-User (`requiredTier: 'public'`, kein Beta-Gate). + +## 1. Mission in einem Satz + +Die schönste, einfachste Karteikarten-App mit Spaced Repetition — +zuerst nur Web, später Mobile, KI-Generierung als Phase 2. + +## 2. Game-Dev-Prinzip: zuerst nur der Core Gameloop + +Wie bei einem Spielprototyp gilt: alles, was nicht zum Loop gehört, +wird zurückgestellt. Erst wenn der Loop sich gut anfühlt und Nutzer ihn +freiwillig wiederholen, wird gebaut, was drumherum gehört. + +### Der Core Gameloop von Cardecky + +``` +Start + │ + ▼ +"Du hast N Karten heute fällig" ─────► (wenn 0: "Alles gelernt — komm später wieder") + │ + ▼ +[Lernen starten] + │ + ▼ +Vorderseite zeigen ──► User denkt ──► Tap/Space ──► Rückseite zeigen + │ + ▼ +Selbst-Bewertung: 1=nochmal · 2=schwer · 3=gut · 4=leicht + │ + ▼ +FSRS rechnet next-due ──► nächste Karte (oder Session-Ende) + │ + ▼ +Session-Ende: "X Karten gelernt, nächste in Y Stunden" + │ + └─► zurück zum Start +``` + +Sekundäre Loops (Karten erstellen, Decks verwalten) werden gebaut, sind +aber UI-arm. **Tertiäre Loops (KI-Generierung, Voice, Sharing) sind +Phase 2 und werden in Phase 1 nicht angefasst.** + +### Was Phase 1 enthält + +- Decks anlegen / löschen / umbenennen +- Karten manuell erstellen (Markdown-Inhalt) +- **Kartentypen:** Basic, Basic + Reverse, Cloze, Type-In (siehe §6) +- Lernsession mit FSRS v6, **inklusive per-User-Parameter-Tuning** +- "Heute fällig"-Übersicht + Streak-Zähler +- Tags auf Decks (das Modul hat sie ohnehin schon, raus wäre Mehrarbeit) +- PWA-installierbar, offline-fähig +- Auth via mana-auth, Sync via mana-sync + +### Was Phase 1 absichtlich NICHT enthält + +- KI-Generierung von Karten (kein PDF-Upload, keine Bild→Karte) +- Voice/TTS-Lernen +- Anki-Import / Export +- Statistik-Dashboards (nur Streak + Tagessumme) +- Public Decks / Marktplatz / Sharing +- Stripe / Bezahlung +- Mobile-App (PWA-tauglich aber kein Expo) +- Eigene Domain & Marketing-Landing +- Mehrsprachigkeit über Deutsch hinaus +- Bilder / Audio in Karten +- Image-Occlusion-Karten, Audio-Karten, Multiple-Choice +- Custom Card-Templates / WYSIWYG-Editor +- Erweiterte Suche + +Jede dieser Features ist legitim — aber nur, wenn der Loop steht. + +## 3. Goldene Regeln + +1. **Simpel schlägt vollständig.** Wenn ein Feature nicht zum Core Gameloop gehört, kommt es in einen Phase-2-Backlog, nicht in den Code. +2. **Open Source only.** Jede Library, jedes Tool, jeder Dienst muss eine OSI-konforme Lizenz haben (MIT, Apache 2.0, BSD, MPL, AGPL akzeptabel). Keine Closed-Source-SDKs, keine proprietären APIs als Pflichtabhängigkeit. +3. **Bevorzugt was im Verein schon läuft.** Neue Technologie nur einführen, wenn ein konkreter Engpass es verlangt und kein vorhandenes Tool es löst. +4. **Zentrale Mana-Dienste statt Eigenbau.** Auth, Sync, Analytics, Notifications, Media usw. werden NICHT neu gebaut — siehe §5. +5. **Local-First wie der Rest des Verein-Stacks.** IndexedDB als Quelle der Wahrheit, Sync nach Postgres im Hintergrund. +6. **`cardecky.mana.how` als Subdomain unter `*.mana.how`.** Kein eigenes Auth-System, kein eigenes Hosting-Setup — Eintrag in `PRODUCTION_TRUSTED_ORIGINS` + Cloudflare-Tunnel-Route reichen. +7. **Eine UI-Schicht, ein Theme.** Wir verwenden `@mana/shared-theme(-ui)` und `@mana/shared-ui` so weit es geht — kein paralleles Design-System. +8. **Erweiterbare Daten, simples UI.** Das Datenmodell denkt zukünftige Kartentypen mit (siehe §6), das UI zeigt in Phase 1 nur die vier definierten Typen. + +## 4. Tech-Stack (Phase 1) + +Alles bereits im Verein verwendet, alles OSI-Open-Source. + +### Frontend +| Schicht | Wahl | Lizenz | +|---|---|---| +| Framework | SvelteKit 2 | MIT | +| UI-Sprache | Svelte 5 (Runes) | MIT | +| Sprache | TypeScript 5 | Apache-2.0 | +| Styling | Tailwind CSS 4 | MIT | +| Build/Dev | Vite | MIT | +| PWA | `@vite-pwa/sveltekit` (über `@mana/shared-pwa`) | MIT | +| Icons | über `@mana/shared-icons` | MIT | +| Markdown-Render | `marked` + `DOMPurify` | MIT | + +### Datenhaltung (Client) +| Schicht | Wahl | Lizenz | +|---|---|---| +| Local Store | IndexedDB via Dexie | Apache-2.0 | +| Local-Store-Wrapper | `@mana/local-store` (intern) | — | +| Verschlüsselung | AES-GCM-256 via `@mana/shared-crypto` (Phase 2 — Hooks bereits an allen Schreib-/Lese-Pfaden, Wirkung deferred bis Vault-Server-Roundtrip steht; siehe `src/lib/data/crypto.ts`) | — | + +### Spaced Repetition +| Schicht | Wahl | Lizenz | +|---|---|---| +| Algorithmus | FSRS v6 (Free Spaced Repetition Scheduler) | BSD-3 | +| TS-Implementation | `ts-fsrs` (offizielle Portierung, mit Optimizer) | MIT | +| Per-User-Tuning | `ts-fsrs`-Optimizer, läuft client-seitig nach ≥ 50 Reviews | MIT | + +### Deployment +| Schicht | Wahl | Lizenz | +|---|---|---| +| Adapter | `@sveltejs/adapter-node` | MIT | +| Container | Docker, hinter Cloudflare Tunnel | Apache-2.0 | +| Host | Mac mini (siehe `docker-compose.macmini.yml`) | — | + +### Tooling +| Schicht | Wahl | Lizenz | +|---|---|---| +| Paket-Manager | pnpm 9 | MIT | +| Monorepo-Orchestrierung | Turborepo (vorhanden) | MPL-2.0 | +| Linting | ESLint (`@mana/eslint-config`) | MIT | +| Formatierung | Prettier | MIT | +| Tests (Unit) | Vitest | MIT | +| Tests (E2E) | Playwright | Apache-2.0 | +| TS-Config | `@mana/test-config`, `@mana/shared-vite-config` | — | + +### Backend in Phase 1: keiner + +Phase 1 braucht **keinen eigenen Service**. Lese-/Schreibpfad geht +ausschließlich über IndexedDB → `mana-sync` (existiert) → Postgres. + +Erst wenn KI-Generierung (Phase 2) dazukommt, entsteht +`services/cards-server` (Hono + Bun, analog zu allen anderen +Verein-Services). + +## 5. Zentrale Mana-Bausteine (Pflicht in Phase 1) + +### Services (laufen bereits, nur konsumieren) +| Service | Port | Wofür in Cardecky | +|---|---|---| +| `mana-auth` | 3001 | SSO, JWT, Sessions, Tier-Claims. Cardecky-Origin in `PRODUCTION_TRUSTED_ORIGINS` eintragen. | +| `mana-sync` | 3050 | Sync der `cards`-AppId-Daten (Decks, Karten, Reviews, StudyBlocks). | +| `mana-user` | 3062 | Profilinfos / Settings. | +| `mana-analytics` | 3064 | Page-Views, Loop-Events (siehe §11). | +| `mana-events` | 3115 | Domain-Events für Streak-Logik. | +| `mana-notify` | 3040 | "Du hast X Karten fällig"-Push (Phase 1.5). | +| `mana-credits` | 3061 | **Erst Phase 2** (KI-Generierung). | +| `mana-subscriptions` | 3063 | **Erst Phase 2** (Pro-Tier). | +| `mana-llm`, `mana-stt`, `mana-tts` | – | **Erst Phase 2.** | +| `mana-media` | 3015 | **Erst wenn Bilder in Karten erlaubt sind.** | + +### Workspace-Pakete (`@mana/*`) +| Paket | Wofür in Cardecky | +|---|---| +| `@mana/shared-auth` | Client-seitiger Auth-Hook (SSO-Flow, JWT-Handling). | +| `@mana/shared-auth-ui` | Login/Logout-Komponenten. | +| `@mana/shared-hono` | (sobald cards-server existiert) Auth-/Health-/Error-Middleware. | +| `@mana/shared-branding` | App-Registry-Eintrag (Tier=`public`, Branding, Subdomain). | +| `@mana/shared-types` | Geteilte TS-Typen. | +| `@mana/shared-utils` | Utility-Funktionen. | +| `@mana/shared-ui` | UI-Komponenten. | +| `@mana/shared-theme`, `@mana/shared-theme-ui` | Theme-Tokens, Dark/Light. | +| `@mana/shared-tailwind` | Tailwind-Preset. | +| `@mana/shared-i18n` | Übersetzungsfundament (Phase 1: nur DE registriert). | +| `@mana/shared-icons` | Icon-Set. | +| `@mana/shared-privacy` | Visibility-Enum für Decks (Sharing erst Phase 2, aber Feld vorbereitet). | +| `@mana/shared-crypto` | AES-GCM-256 für sensible Felder. | +| `@mana/shared-pwa` | Manifest, Service-Worker, Install-Prompt. | +| `@mana/shared-vite-config` | Vite-Defaults. | +| `@mana/shared-error-tracking` | Error-Reporting. | +| `@mana/shared-logger` | Strukturiertes Logging (Server-Seite, sobald relevant). | +| `@mana/shared-stores` | Geteilte Local-Store-Helpers. | +| `@mana/shared-tags` | Tags auf Decks. | +| `@mana/local-store` | Dexie-Setup, Sync-Hooks. | +| `@mana/eslint-config` | Lint-Regeln. | +| `@mana/test-config` | Vitest-Defaults. | +| `@mana/feedback` | In-App-Feedback-Widget. | +| `@mana/help` | Hilfe-Overlay. | + +**Erst Phase 2 oder später:** `@mana/shared-llm`, `@mana/shared-ai`, +`@mana/local-llm`, `@mana/local-stt`, `@mana/credits`, `@mana/qr-export`, +`@mana/wallpaper-generator`, `@mana/website-blocks`, +`@mana/shared-research`, `@mana/shared-uload`, `@mana/shared-storage`. + +### Datenpfad + +Cardecky übernimmt 1:1 das Mana-Datenpfad-Pattern: + +``` +User-Aktion → Store → encryptRecord → Dexie → Hooks (_pendingChanges) + → mana-sync → Postgres (mana_platform.cards.*) → andere Clients +``` + +appId = `cards`. Tabellen: `cardDecks`, `cards`, `cardReviews`, +`cardStudyBlocks`, `deckTags`. + +## 6. Datenmodell — erweiterbar gedacht + +Heutiges Modul kennt nur `front`/`back`. Damit weitere Kartentypen +ohne Schema-Bruch dazukommen, wechseln wir auf ein **Felder-Map + +Typ-Diskriminator**: + +```ts +type CardType = + | 'basic' // Phase 1: front/back + | 'basic-reverse' // Phase 1: erzeugt zwei Lernrichtungen aus einer Karte + | 'cloze' // Phase 1: Lückentext, eine Subkarte pro Cluster + | 'type-in' // Phase 1: User tippt Antwort, exact-match-Vergleich + | 'image-occlusion' // Phase 2 + | 'audio' // Phase 2 + | 'multiple-choice' // ggf. Phase 2 + +interface LocalCard extends BaseRecord { + deckId: string + type: CardType + fields: Record // basic: { front, back } · cloze: { text, extra? } + // FSRS-State liegt nicht hier, sondern in cardReviews (1:N pro Subkarte) + order: number +} + +interface LocalCardReview extends BaseRecord { + cardId: string + subIndex: number // basic-reverse → 0|1, cloze → c1, c2, … + stability: number // FSRS + difficulty: number // FSRS + due: string // ISO + reps: number + lapses: number + state: 'new' | 'learning' | 'review' | 'relearning' + lastReview?: string +} + +interface LocalCardStudyBlock extends BaseRecord { + date: string // YYYY-MM-DD + cardsReviewed: number + durationMs: number +} +``` + +**Cloze-Syntax:** Anki-kompatibel: `{{c1::Wort}}`, `{{c1::Wort::Hinweis}}`. +Eine Cloze-Karte mit Cluster `c1`+`c2` erzeugt 2 Reviews +(`subIndex 1`, `subIndex 2`). + +**Markdown:** `marked` + `DOMPurify` rendern Front/Back. Cloze-Tags +werden vor dem Markdown-Parser zu HTML-Spans umgewandelt, damit sie im +Render erhalten bleiben. + +**Migration aus dem Bestand:** existierende `front`/`back`-Karten werden +beim ersten Schema-Upgrade auf `type='basic'` mit +`fields={front, back}` migriert. Alte Spalten bleiben für eine +Übergangsversion lesbar (siehe `docs/DATABASE_MIGRATIONS.md`). + +## 7. Daten-Contract mit dem mana-Modul + +Wichtig: das **bestehende `cards`-Modul in der Mana-Web-App bleibt +erhalten**. Cardecky und das mana-Modul schreiben in dieselben +Postgres-Tabellen. + +Daher gilt: +- Schema-Änderungen werden **gemeinsam** im mana-Modul und im + Cardecky-Code rolled out (nie nur auf einer Seite). +- Encryption-Registry-Einträge müssen in beiden Frontends identisch + sein (Field-Allowlist). +- Migrationen über `docs/DATABASE_MIGRATIONS.md`. + +**Reihenfolge:** Phase 0 (mana-Modul um neue Tabellen + Kartentyp-Felder ++ FSRS erweitern) wird **vor** dem Standalone-Build durchgezogen. So +gibt es nie zwei Wahrheiten zur Datenstruktur. + +## 8. Definition of Done für Phase 1 + +Phase 1 ist fertig, wenn: + +1. Ein eingeloggter Mana-User kann auf `cardecky.mana.how` + - mindestens ein Deck anlegen, + - Karten manuell hinzufügen (Basic, Basic+Reverse, Cloze, Type-In), + - Markdown im Front/Back nutzen (Bold, Listen, Code, Links), + - eine Lernsession starten und mit FSRS-Bewertung durchspielen, + - die App schließen und am nächsten Tag die richtigen fälligen Karten wiederfinden. +2. FSRS-Per-User-Tuning läuft automatisch nach ≥ 50 Reviews und überschreibt die Default-Parameter. +3. Die App ist als PWA installierbar und offline-bedienbar (Karten lernen ohne Netz). +4. Auth läuft komplett über mana-auth (kein Eigen-Login). +5. Daten landen in Postgres und sind im bestehenden mana-Modul sichtbar (gleiche Datenquelle, kein Drift). +6. `pnpm validate:all` grün. +7. Mindestens drei Smoke-E2E-Tests (Playwright): + - „Login → Deck anlegen → Basic-Karte → Lernsession → bewerten" + - „Cloze-Karte mit zwei Clustern → erzeugt zwei Subkarten" + - „Type-In: korrekte Antwort = grün, falsche = rot" +8. Container baut & läuft auf dem Mac mini hinter Cloudflare Tunnel (`cardecky.mana.how`). + +Alles andere ist Phase 2. + +## 9. Repo-Struktur (Phase 1) + +``` +apps/cards/ +├── apps/ +│ └── web/ # SvelteKit-App, einziges Surface in Phase 1 +│ ├── src/ +│ │ ├── lib/ +│ │ │ ├── data/ # Dexie + Sync-Anbindung +│ │ │ ├── fsrs/ # ts-fsrs-Wrapper + Optimizer-Hook +│ │ │ ├── cards/ # Kartentyp-Renderer (basic, cloze, type-in) +│ │ │ ├── stores/ # Decks, Cards, Reviews, StudyBlocks +│ │ │ └── ui/ # Komponenten (DeckList, CardEditor, Session) +│ │ └── routes/ +│ │ ├── +layout.svelte +│ │ ├── +page.svelte # Heute fällig + Decks +│ │ ├── decks/[id]/+page.svelte # Deck-Detail + Karten +│ │ └── learn/[deckId]/+page.svelte # Lernsession +│ ├── package.json +│ ├── svelte.config.js +│ └── vite.config.ts +├── GUIDELINES.md # ← dieses Dokument +└── README.md +``` + +`apps/cards/apps/mobile/` und `apps/cards/apps/landing/` sind erst +Phase 2/3. + +## 10. PR-Checkliste + +Bei jedem Pull-Request gefragt: + +- Gehört die Änderung zum Core Gameloop? +- Wenn nein: rechtfertigt sie sich aus einer Pflicht (Auth, Sync, Build)? +- Wird ein bestehendes `@mana/*` Paket genutzt statt neu zu bauen? +- Ist jede neue Dependency Open-Source und im Verein bereits in Verwendung? +- Sind Datenmodell-Änderungen mit dem mana-Modul konsistent? +- Bricht die Änderung das Versprechen "Erweiterbare Daten, simples UI"? + +## 11. Analytics-Events (Mindestumfang Phase 1) + +Über `mana-analytics`: + +- `cards_session_started` — `{ deckId, dueCount }` +- `cards_card_rated` — `{ cardId, type, grade (1–4), elapsedMs }` +- `cards_session_completed` — `{ deckId, cardCount, durationMs }` +- `cards_deck_created` — `{ deckId }` +- `cards_card_created` — `{ deckId, type }` +- `cards_fsrs_optimized` — `{ reviewCount, paramsHash }` +- `cards_pwa_installed` — Standard-PWA-Event + +Reicht für die Core-Loop-Validierung. Mehr Events erst, wenn eine +konkrete Frage entsteht, die Daten beantworten sollen. + +## 12. Hinweis im mana-Modul + +Sobald `cardecky.mana.how` live ist, bekommt das mana-Modul einen +**dezenten** Hinweis (z.B. ein Banner oder Badge über der ListView): +"Cardecky gibt es jetzt auch als eigenständige App". Kein Pop-up, kein +forcierter Redirect — User entscheiden selbst. diff --git a/apps/cards/README.md b/apps/cards/README.md new file mode 100644 index 000000000..75891b4a9 --- /dev/null +++ b/apps/cards/README.md @@ -0,0 +1,34 @@ +# Cardecky + +Spaced-repetition flashcards on **cardecky.mana.how**. + +Phase-1 standalone web app. The frontend lives here; data, auth, and +sync are shared with the rest of the Mana stack: + +- **Auth:** mana-auth (SSO), `*.mana.how` +- **Sync:** mana-sync, app-id `cards` +- **Storage:** `mana_platform.cards.*` (Postgres, RLS) + +The same `cards` data backs the **mana** built-in Cardecky module at +`mana.how/cards`. Schema changes ship to both frontends together — see +`apps/cards/GUIDELINES.md`. + +## Layout + +``` +apps/cards/ +├── apps/ +│ └── web/ # SvelteKit 2 + Svelte 5 — the Phase-1 surface +├── GUIDELINES.md # Project rules (read first) +└── README.md +``` + +`apps/cards/apps/mobile/` and any production `apps/cards/apps/landing/` +will land in Phase 2/3. + +## Quick start + +```bash +pnpm install +pnpm --filter @cards/web dev # cardecky.mana.how on http://localhost:5180 +``` diff --git a/apps/cards/apps/web/.gitignore b/apps/cards/apps/web/.gitignore new file mode 100644 index 000000000..29d6acad1 --- /dev/null +++ b/apps/cards/apps/web/.gitignore @@ -0,0 +1,7 @@ +node_modules +.DS_Store +.svelte-kit +build +.env +.env.* +!.env.example diff --git a/apps/cards/apps/web/Dockerfile b/apps/cards/apps/web/Dockerfile new file mode 100644 index 000000000..4f4174f67 --- /dev/null +++ b/apps/cards/apps/web/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +# Cardecky Standalone — cardecky.mana.how. Mirrors apps/manavoxel/apps/web/Dockerfile. + +# ─── Stage 1: Build ────────────────────────────────────────── +FROM sveltekit-base:local AS builder + +ARG PUBLIC_MANA_AUTH_URL=http://mana-auth:3001 +ARG PUBLIC_SYNC_SERVER_URL=http://mana-sync:3050 +ENV PUBLIC_MANA_AUTH_URL=$PUBLIC_MANA_AUTH_URL +ENV PUBLIC_SYNC_SERVER_URL=$PUBLIC_SYNC_SERVER_URL + +# Cards-specific app sources. The shared @mana/* packages already live in +# the sveltekit-base image; we only copy what's unique to this app. +COPY apps/cards/apps/web ./apps/cards/apps/web +COPY packages/cards-core ./packages/cards-core + +RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ + pnpm install --no-frozen-lockfile --ignore-scripts + +WORKDIR /app/apps/cards/apps/web +RUN pnpm exec svelte-kit sync +RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build + +# ─── Stage 2: Production ───────────────────────────────────── +FROM node:20-alpine AS production + +WORKDIR /app/apps/cards/apps/web + +COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm +COPY --from=builder /app/apps/cards/apps/web/node_modules ./node_modules +COPY --from=builder /app/apps/cards/apps/web/build ./build +COPY --from=builder /app/apps/cards/apps/web/package.json ./ + +EXPOSE 5180 + +ENV NODE_ENV=production +ENV PORT=5180 +ENV HOST=0.0.0.0 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:5180/ || exit 1 + +CMD ["node", "build"] diff --git a/apps/cards/apps/web/package.json b/apps/cards/apps/web/package.json new file mode 100644 index 000000000..5869ae34a --- /dev/null +++ b/apps/cards/apps/web/package.json @@ -0,0 +1,48 @@ +{ + "name": "@cards/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 5180", + "build": "vite build", + "preview": "vite preview --port 5180", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings" + }, + "devDependencies": { + "@mana/shared-vite-config": "workspace:*", + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.47.1", + "@sveltejs/vite-plugin-svelte": "^5.0.4", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^22.10.5", + "@types/sql.js": "^1.4.11", + "@vite-pwa/sveltekit": "^1.1.0", + "svelte": "^5.41.0", + "svelte-check": "^4.3.3", + "tailwindcss": "^4.1.17", + "typescript": "^5.7.2", + "vite": "^6.0.7" + }, + "dependencies": { + "@mana/cards-core": "workspace:*", + "@mana/local-store": "workspace:*", + "@mana/shared-auth": "workspace:*", + "@mana/shared-auth-ui": "workspace:*", + "@mana/shared-branding": "workspace:*", + "@mana/shared-icons": "workspace:*", + "@mana/shared-privacy": "workspace:*", + "@mana/shared-pwa": "workspace:*", + "@mana/shared-stores": "workspace:*", + "@mana/shared-tailwind": "workspace:*", + "@mana/shared-theme": "workspace:*", + "@mana/shared-theme-ui": "workspace:*", + "@mana/shared-types": "workspace:*", + "@mana/shared-utils": "workspace:*", + "dexie": "^4.4.1", + "jszip": "^3.10.1", + "pdfjs-dist": "^5.7.284", + "sql.js": "^1.14.1" + } +} diff --git a/apps/cards/apps/web/src/app.css b/apps/cards/apps/web/src/app.css new file mode 100644 index 000000000..94506ec9d --- /dev/null +++ b/apps/cards/apps/web/src/app.css @@ -0,0 +1,63 @@ +@import 'tailwindcss'; +@import '@mana/shared-tailwind/themes.css'; +@import '@mana/shared-tailwind/sources.css'; + +/* Phase A — Cards now lives on the unified @mana/shared-theme tokens. + The placeholder --color-cards-* palette is gone; everything goes + through `--color-{background,foreground,surface,muted,…}` from + shared-tailwind. The runtime `createThemeStore({ appId: 'cards' })` + in +layout.svelte writes the live variant + mode onto the + document. */ + +/* Cloze rendering — produced by @mana/cards-core/render. Uses the + active app accent so the highlight follows the Cards brand. */ +.cloze-blank { + background: hsl(var(--color-app-accent) / 0.18); + border-radius: 0.25rem; + padding: 0.05rem 0.4rem; + color: hsl(var(--color-app-accent)); + font-style: italic; +} + +mark.cloze-active { + background: hsl(var(--color-success) / 0.2); + color: hsl(var(--color-success)); + padding: 0.05rem 0.25rem; + border-radius: 0.25rem; +} + +/* Minimal styling for HTML produced by marked() — Tailwind v4 ships + without typography plugin so we set the basics by hand. */ +.card-content :where(p, ul, ol) { + margin-block: 0.5rem; +} +.card-content :where(ul) { + list-style: disc; + padding-inline-start: 1.25rem; +} +.card-content :where(ol) { + list-style: decimal; + padding-inline-start: 1.25rem; +} +.card-content :where(code) { + background: hsl(var(--color-muted) / 0.6); + padding: 0.1rem 0.3rem; + border-radius: 0.25rem; + font-size: 0.95em; +} +.card-content :where(pre) { + background: hsl(var(--color-muted) / 0.4); + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; +} +.card-content :where(a) { + color: hsl(var(--color-app-accent)); + text-decoration: underline; +} +.card-content :where(strong) { + font-weight: 600; +} +.card-content :where(em) { + font-style: italic; +} diff --git a/apps/cards/apps/web/src/app.d.ts b/apps/cards/apps/web/src/app.d.ts new file mode 100644 index 000000000..3b4b2bb75 --- /dev/null +++ b/apps/cards/apps/web/src/app.d.ts @@ -0,0 +1,16 @@ +// Virtual modules provided by vite-plugin-pwa (wrapped by @vite-pwa/sveltekit): +// - virtual:pwa-info → pwaInfo.webManifest.linkTag for +// - virtual:pwa-register/svelte → useRegisterSW() Svelte-store hook +/// +/// + +declare global { + namespace App { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Locals {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface PageData {} + } +} + +export {}; diff --git a/apps/cards/apps/web/src/app.html b/apps/cards/apps/web/src/app.html new file mode 100644 index 000000000..470d4ca25 --- /dev/null +++ b/apps/cards/apps/web/src/app.html @@ -0,0 +1,14 @@ + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/cards/apps/web/src/hooks.server.ts b/apps/cards/apps/web/src/hooks.server.ts new file mode 100644 index 000000000..846ce98e2 --- /dev/null +++ b/apps/cards/apps/web/src/hooks.server.ts @@ -0,0 +1,41 @@ +import type { Handle } from '@sveltejs/kit'; + +/** + * Inject the runtime client URLs into on every SSR'd page. + * + * `@mana/shared-auth-ui` reads `window.__PUBLIC_MANA_AUTH_URL__` to know + * where to POST /api/v1/auth/login (and friends). Without this hook the + * client falls back to a relative URL → 404 on cardecky.mana.how. + * + * `process.env.PUBLIC_MANA_*_URL_CLIENT` come from the container + * environment (docker-compose.macmini.yml). $env/static/public would + * bake the URLs at build time; we want runtime so the same image can + * serve dev and prod. + */ + +const PUBLIC_MANA_AUTH_URL_CLIENT = + process.env.PUBLIC_MANA_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_AUTH_URL || ''; +const PUBLIC_MANA_SYNC_URL_CLIENT = + process.env.PUBLIC_MANA_SYNC_URL_CLIENT || process.env.PUBLIC_MANA_SYNC_URL || ''; +const PUBLIC_MANA_LLM_URL_CLIENT = + process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || ''; +const PUBLIC_MANA_MEDIA_URL_CLIENT = + process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || ''; +const PUBLIC_CARDS_API_URL_CLIENT = + process.env.PUBLIC_CARDS_API_URL_CLIENT || process.env.PUBLIC_CARDS_API_URL || ''; + +export const handle: Handle = async ({ event, resolve }) => { + return resolve(event, { + transformPageChunk: ({ html }) => { + const envScript = + ``; + return html.replace('', `${envScript}`); + }, + }); +}; diff --git a/apps/cards/apps/web/src/lib/ai/generate.ts b/apps/cards/apps/web/src/lib/ai/generate.ts new file mode 100644 index 000000000..265741222 --- /dev/null +++ b/apps/cards/apps/web/src/lib/ai/generate.ts @@ -0,0 +1,118 @@ +/** + * AI card generation — text → list of basic cards via mana-llm. + * + * Uses mana-llm's OpenAI-compatible /v1/chat/completions endpoint with + * a system prompt that constrains the output to a JSON array. We strip + * Markdown code fences before parsing because most chat models wrap + * JSON output in ```json blocks even when explicitly told not to. + * + * No streaming — we need the full JSON before we can show anything. + * Phase-2 ideas: chunk long inputs, PDF parsing, image OCR. + */ + +const SYSTEM_PROMPT = `Du bist ein Karteikarten-Generator. Aus dem vom Nutzer gegebenen Text erstellst du Lernkarten zum Auswendiglernen. + +Regeln: +- Antworte AUSSCHLIESSLICH mit einem JSON-Array, ohne Erklärung, ohne Markdown-Code-Fences. +- Schema: [{"front": "Frage oder Begriff", "back": "Antwort"}, ...] +- 5–15 Karten je nach Textlänge. +- Front: kurze, präzise Frage oder ein Begriff. Back: prägnante Antwort, max. 2 Sätze. +- Eine Karte pro klar abgegrenzter Faktenerinnerung — nicht ganze Absätze umkopieren. +- Sprache: dieselbe wie der Quelltext.`; + +export interface GeneratedCard { + front: string; + back: string; +} + +function llmUrl(): string { + if (typeof window !== 'undefined') { + const fromWindow = (window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }) + .__PUBLIC_MANA_LLM_URL__; + if (fromWindow) return fromWindow.replace(/\/$/, ''); + } + return 'http://localhost:3025'; +} + +function stripCodeFences(s: string): string { + return s + .replace(/^\s*```(?:json|javascript|js)?\s*/i, '') + .replace(/\s*```\s*$/i, '') + .trim(); +} + +function defaultModel(): string { + if (typeof window !== 'undefined') { + const fromWindow = (window as unknown as { __PUBLIC_CARDS_AI_MODEL__?: string }) + .__PUBLIC_CARDS_AI_MODEL__; + if (fromWindow) return fromWindow; + } + // mana-llm proxies many providers — this id matches what the + // playground module uses as a sensible default. Adjust per env via + // __PUBLIC_CARDS_AI_MODEL__ injection. + return 'gpt-4o-mini'; +} + +export async function generateCardsFromText( + source: string, + opts: { model?: string; signal?: AbortSignal } = {} +): Promise { + const trimmed = source.trim(); + if (!trimmed) return []; + + const res = await fetch(`${llmUrl()}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: opts.signal, + body: JSON.stringify({ + model: opts.model ?? defaultModel(), + temperature: 0.3, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: trimmed }, + ], + }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`mana-llm: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`); + } + + const json = (await res.json()) as { + choices?: { message?: { content?: string } }[]; + }; + const raw = json.choices?.[0]?.message?.content?.trim(); + if (!raw) throw new Error('Leere Antwort vom LLM erhalten.'); + + let parsed: unknown; + try { + parsed = JSON.parse(stripCodeFences(raw)); + } catch (e) { + throw new Error(`LLM-Antwort war kein gültiges JSON:\n${raw.slice(0, 200)}`); + } + + if (!Array.isArray(parsed)) { + throw new Error('LLM-Antwort ist kein Array.'); + } + + const cards: GeneratedCard[] = []; + for (const item of parsed) { + if ( + typeof item === 'object' && + item !== null && + typeof (item as GeneratedCard).front === 'string' && + typeof (item as GeneratedCard).back === 'string' + ) { + const c = item as GeneratedCard; + if (c.front.trim() && c.back.trim()) { + cards.push({ front: c.front.trim(), back: c.back.trim() }); + } + } + } + + if (cards.length === 0) { + throw new Error('Keine gültigen Karten in der LLM-Antwort gefunden.'); + } + return cards; +} diff --git a/apps/cards/apps/web/src/lib/ai/pdf.ts b/apps/cards/apps/web/src/lib/ai/pdf.ts new file mode 100644 index 000000000..9cb5655a8 --- /dev/null +++ b/apps/cards/apps/web/src/lib/ai/pdf.ts @@ -0,0 +1,56 @@ +/** + * PDF text extraction using pdfjs-dist. + * + * Loads each page, walks the text layer, joins items with spaces and + * pages with double newlines so the LLM gets a structured input. We + * don't try to preserve columns / tables — the use case is "feed me + * the prose so I can make cards", not document fidelity. + * + * Worker is wired via Vite's `?worker` suffix so the heavy parsing + * happens off the main thread (PDF extraction is CPU-heavy). + */ + +import * as pdfjs from 'pdfjs-dist'; +import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker'; + +let workerWired = false; +function ensureWorker() { + if (workerWired) return; + pdfjs.GlobalWorkerOptions.workerPort = new PdfjsWorker(); + workerWired = true; +} + +export interface PdfExtractResult { + text: string; + pageCount: number; +} + +export async function extractTextFromPdf(file: File | Blob): Promise { + ensureWorker(); + const buffer = await file.arrayBuffer(); + const doc = await pdfjs.getDocument({ data: new Uint8Array(buffer) }).promise; + + const pages: string[] = []; + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const content = await page.getTextContent(); + const pieces: string[] = []; + for (const item of content.items) { + if (typeof (item as { str?: string }).str === 'string') { + pieces.push((item as { str: string }).str); + } + } + pages.push( + pieces + .join(' ') + .replace(/[ \t]+/g, ' ') + .trim() + ); + } + + await doc.destroy(); + return { + text: pages.filter(Boolean).join('\n\n'), + pageCount: doc.numPages, + }; +} diff --git a/apps/cards/apps/web/src/lib/anki/import.ts b/apps/cards/apps/web/src/lib/anki/import.ts new file mode 100644 index 000000000..b01176c17 --- /dev/null +++ b/apps/cards/apps/web/src/lib/anki/import.ts @@ -0,0 +1,212 @@ +/** + * Apply a `ParsedAnki` to the local DB. + * + * Strategy: every Anki deck becomes one of our decks (1:1, name-mapped). + * Card content is HTML-sanitized to plain Markdown / inline media tags + * before save. Reviews are auto-generated by reviewStore.ensureReviewsForCard + * — the imported cards become "new" in the FSRS sense, no inherited schedule. + * + * Media: every referenced file is uploaded to mana-media first; the + * resulting URL replaces the original Anki filename in the field text. + * Files referenced from no card are skipped — many Anki decks bundle + * orphaned media that bloats the upload time. + * + * No de-dupe: re-importing the same .apkg adds duplicate decks. The UI + * warns about this once we decide it matters. + */ + +import { deckStore } from '../stores/decks.svelte'; +import { cardStore } from '../stores/cards.svelte'; +import { uploadCardMedia, MediaUploadError } from '../media/upload'; +import { sanitizeAnkiHtml, type ParsedAnki } from './parse'; + +export interface ImportResult { + decksCreated: number; + cardsCreated: number; + mediaUploaded: number; + mediaFailed: number; + failed: number; +} + +export interface MediaProgress { + uploaded: number; + total: number; +} + +const MEDIA_CONCURRENCY = 4; +// Anki's always quotes; we also catch [sound:foo.mp3]. +const IMG_RE = /]*\bsrc=["']([^"']+)["']/gi; +const SOUND_RE = /\[sound:([^\]]+)\]/g; + +function collectMediaRefs(parsed: ParsedAnki): Set { + const refs = new Set(); + for (const card of parsed.cards) { + for (const value of Object.values(card.fields)) { + let m: RegExpExecArray | null; + IMG_RE.lastIndex = 0; + while ((m = IMG_RE.exec(value))) refs.add(m[1]); + SOUND_RE.lastIndex = 0; + while ((m = SOUND_RE.exec(value))) refs.add(m[1]); + } + } + return refs; +} + +async function uploadOne( + filename: string, + parsed: ParsedAnki +): Promise<{ filename: string; url: string | null }> { + const entry = parsed.mediaByFilename.get(filename); + if (!entry) return { filename, url: null }; + try { + const blob = await entry.async('blob'); + const file = new File([blob], filename, { type: guessMime(filename) }); + const media = await uploadCardMedia(file); + return { filename, url: media.url }; + } catch (e) { + if (e instanceof MediaUploadError) { + console.warn(`[anki] media upload failed: ${filename}`, e.message); + } else { + console.warn(`[anki] media upload failed: ${filename}`, e); + } + return { filename, url: null }; + } +} + +function guessMime(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + const map: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + oga: 'audio/ogg', + wav: 'audio/wav', + m4a: 'audio/mp4', + mp4: 'video/mp4', + webm: 'video/webm', + }; + return map[ext] ?? 'application/octet-stream'; +} + +async function uploadAllMedia( + parsed: ParsedAnki, + onProgress?: (p: MediaProgress) => void +): Promise<{ urlByFilename: Map; uploaded: number; failed: number }> { + const referenced = [...collectMediaRefs(parsed)].filter((f) => parsed.mediaByFilename.has(f)); + const urlByFilename = new Map(); + let uploaded = 0; + let failed = 0; + + if (referenced.length === 0) { + onProgress?.({ uploaded: 0, total: 0 }); + return { urlByFilename, uploaded, failed }; + } + + let nextIdx = 0; + async function worker() { + while (true) { + const idx = nextIdx++; + if (idx >= referenced.length) return; + const result = await uploadOne(referenced[idx], parsed); + if (result.url) { + urlByFilename.set(result.filename, result.url); + uploaded++; + } else { + failed++; + } + onProgress?.({ uploaded: uploaded + failed, total: referenced.length }); + } + } + + await Promise.all(Array.from({ length: MEDIA_CONCURRENCY }, () => worker())); + return { urlByFilename, uploaded, failed }; +} + +export async function importParsedAnki( + parsed: ParsedAnki, + opts: { onMediaProgress?: (p: MediaProgress) => void } = {} +): Promise { + const result: ImportResult = { + decksCreated: 0, + cardsCreated: 0, + mediaUploaded: 0, + mediaFailed: 0, + failed: 0, + }; + + // 1) Media — upload before any cards so the field-text rewrite has + // real URLs to point at. Empty in the no-media case. + const { urlByFilename, uploaded, failed } = await uploadAllMedia(parsed, opts.onMediaProgress); + result.mediaUploaded = uploaded; + result.mediaFailed = failed; + + // 2) Decks — Anki "::" hierarchy flattened to " / ". + const ankiIdToDeckId = new Map(); + for (const ankiDeck of parsed.decks) { + const title = ankiDeck.name.replace(/::/g, ' / '); + const created = await deckStore.createDeck({ title, description: 'Aus Anki importiert' }); + if (!created) { + result.failed++; + continue; + } + ankiIdToDeckId.set(ankiDeck.ankiId, created.id); + result.decksCreated++; + } + + // Fallback deck for cards whose Anki deck wasn't in the parsed list + // (the "Default" deck Anki uses for orphans, mostly). + const ensureFallbackDeck = (() => { + let id: string | null = null; + return async () => { + if (id) return id; + const created = await deckStore.createDeck({ + title: 'Anki-Import', + description: 'Karten ohne explizites Quell-Deck', + }); + if (created) { + id = created.id; + result.decksCreated++; + } + return id; + }; + })(); + + // 3) Cards — sanitize each field with the media URL map. + const orderByDeck = new Map(); + for (const card of parsed.cards) { + let targetDeckId = ankiIdToDeckId.get(card.ankiDeckId); + if (!targetDeckId) { + const fallback = await ensureFallbackDeck(); + if (!fallback) { + result.failed++; + continue; + } + targetDeckId = fallback; + } + + const cleanFields: Record = {}; + for (const [key, value] of Object.entries(card.fields)) { + cleanFields[key] = sanitizeAnkiHtml(value, urlByFilename); + } + + const order = orderByDeck.get(targetDeckId) ?? 0; + orderByDeck.set(targetDeckId, order + 1); + + const created = await cardStore.createCard( + { deckId: targetDeckId, type: card.type, fields: cleanFields }, + order + ); + if (created) { + result.cardsCreated++; + } else { + result.failed++; + } + } + + return result; +} diff --git a/apps/cards/apps/web/src/lib/anki/parse.ts b/apps/cards/apps/web/src/lib/anki/parse.ts new file mode 100644 index 000000000..afccaadc7 --- /dev/null +++ b/apps/cards/apps/web/src/lib/anki/parse.ts @@ -0,0 +1,247 @@ +/** + * Parse an Anki .apkg / .colpkg file in the browser. + * + * .apkg = ZIP archive containing a SQLite collection (`collection.anki2` + * or `collection.anki21`) plus media files. We open the SQLite blob with + * sql.js (WASM-backed in-browser SQLite) and walk Anki's three core + * tables: `col` (collection meta with JSON-encoded models + decks), + * `notes` (the user-typed content), and `cards` (one row per learnable + * unit — basic = 1, basic-reverse = 2, cloze = N). + * + * MVP scope: basic + basic-reverse + cloze. Image/audio media is + * skipped (Phase 2). Review history is skipped — FSRS state will be + * regenerated on first sight. + */ + +import JSZip, { type JSZipObject } from 'jszip'; +import initSqlJs, { type Database } from 'sql.js'; +import type { CardType } from '@mana/cards-core'; + +export interface ParsedDeck { + ankiId: string; // Anki's numeric deck id, stringified + name: string; // "Studies::Spanish" — Anki uses :: as separator +} + +export interface ParsedCard { + ankiDeckId: string; + type: CardType; + fields: Record; +} + +export interface ParsedAnki { + decks: ParsedDeck[]; + cards: ParsedCard[]; + skipped: number; + warnings: string[]; + /** + * Mapping from the original media filename (as referenced in card + * fields, e.g. `paris.jpg` or `audio_001.mp3`) to its ZIP entry. Anki + * stores files numerically (`0`, `1`, …) and the JSON manifest + * (`media`) maps numbers → original names; we flip that here so the + * importer can look up by the name it sees in the field text. + */ + mediaByFilename: Map; +} + +interface AnkiModel { + id: number; + name: string; + type: number; // 0 = standard, 1 = cloze + flds: { name: string }[]; + tmpls: { name: string }[]; +} + +interface AnkiDeckJson { + id: number; + name: string; +} + +let SQL: Awaited> | null = null; +async function getSql() { + if (SQL) return SQL; + SQL = await initSqlJs({ locateFile: (file) => `/${file}` }); + return SQL; +} + +export async function parseApkg(file: File | Blob): Promise { + const zip = await JSZip.loadAsync(await file.arrayBuffer()); + + const collectionEntry = zip.file('collection.anki21') ?? zip.file('collection.anki2'); + if (!collectionEntry) { + throw new Error( + 'Keine Anki-Collection-Datei in der .apkg gefunden (erwartet: collection.anki21 oder collection.anki2).' + ); + } + + const sqliteBytes = await collectionEntry.async('uint8array'); + const sql = await getSql(); + const db: Database = new sql.Database(sqliteBytes); + + const mediaByFilename = await extractMediaManifest(zip); + + try { + const result = extract(db); + return { ...result, mediaByFilename }; + } finally { + db.close(); + } +} + +async function extractMediaManifest(zip: JSZip): Promise> { + const out = new Map(); + const manifestEntry = zip.file('media'); + if (!manifestEntry) return out; + let manifest: Record; + try { + manifest = JSON.parse(await manifestEntry.async('string')); + } catch { + return out; + } + for (const [numericKey, originalName] of Object.entries(manifest)) { + const entry = zip.file(numericKey); + if (entry) out.set(originalName, entry); + } + return out; +} + +// Internal extract returns everything except media — that's plumbed in +// at the parseApkg layer so the SQLite-only path stays focused. +type ExtractResult = Omit; +function extract(db: Database): ExtractResult { + const colRow = db.exec('SELECT models, decks FROM col LIMIT 1'); + if (colRow.length === 0 || colRow[0].values.length === 0) { + throw new Error('Anki-Collection ist leer.'); + } + const [modelsJson, decksJson] = colRow[0].values[0] as [string, string]; + const models: Record = JSON.parse(modelsJson); + const decksMap: Record = JSON.parse(decksJson); + + const decks: ParsedDeck[] = Object.values(decksMap) + .filter((d) => d.id !== 1) // Anki's "Default" deck has id 1; skip if empty later + .map((d) => ({ ankiId: String(d.id), name: d.name })); + + // Pre-load notes into a Map so we don't hit SQLite per card. + type NoteRow = { id: string; mid: string; flds: string }; + const notesById = new Map(); + const notesRes = db.exec('SELECT id, mid, flds FROM notes'); + if (notesRes.length > 0) { + for (const row of notesRes[0].values) { + const [id, mid, flds] = row as [number, number, string]; + notesById.set(String(id), { id: String(id), mid: String(mid), flds }); + } + } + + const warnings: string[] = []; + const cards: ParsedCard[] = []; + let skipped = 0; + + const cardsRes = db.exec('SELECT nid, did, ord FROM cards'); + if (cardsRes.length === 0) + return { decks, cards: [], skipped: 0, warnings: ['Keine Karten gefunden.'] }; + + // We dedupe at the note level — Anki stores one DB-row per generated + // card (basic-reverse = 2 rows, cloze cluster c1+c2 = 2 rows). Our + // model regenerates these from `type` + `fields` automatically, so + // pulling each note once is enough. + const seenNotes = new Set(); + for (const row of cardsRes[0].values) { + const [nid, did] = row as [number, number, number]; + const noteKey = String(nid); + if (seenNotes.has(noteKey)) continue; + seenNotes.add(noteKey); + + const note = notesById.get(noteKey); + if (!note) { + skipped++; + continue; + } + const model = models[note.mid]; + if (!model) { + skipped++; + warnings.push(`Note ${nid}: unknown model ${note.mid}`); + continue; + } + + const fieldValues = note.flds.split('\x1f'); + const result = mapNoteToCard(model, fieldValues); + if (!result) { + skipped++; + continue; + } + cards.push({ ankiDeckId: String(did), ...result }); + } + + if (skipped > 0) warnings.unshift(`${skipped} Karten übersprungen (unbekannter Typ).`); + return { decks, cards, skipped, warnings }; +} + +function mapNoteToCard( + model: AnkiModel, + fields: string[] +): { type: CardType; fields: Record } | null { + // Cloze: exactly one input field with {{cN::...}} markup. + if (model.type === 1) { + const text = fields[0] ?? ''; + return { type: 'cloze', fields: { text, ...(fields[1] ? { extra: fields[1] } : {}) } }; + } + + // Standard: one or two templates → basic / basic-reverse. + if (model.type === 0) { + const front = fields[0] ?? ''; + const back = fields[1] ?? ''; + if (model.tmpls.length === 2) { + return { type: 'basic-reverse', fields: { front, back } }; + } + // 1 (or unusual N) → treat as basic. Custom multi-card templates + // lose their extra surfaces; the user-typed content survives. + return { type: 'basic', fields: { front, back } }; + } + + return null; +} + +/** + * Convert Anki's HTML / image / sound markup to plain text + Markdown. + * + * `mediaUrlByFilename` maps the filename Anki references in the field + * (e.g. `paris.jpg` for `` or `audio.mp3` for + * `[sound:audio.mp3]`) to its post-upload URL on mana-media. Anything + * not in the map is dropped silently — same as the no-media path. + */ +export function sanitizeAnkiHtml( + html: string, + mediaUrlByFilename: Map = new Map() +): string { + const imgReplaced = html.replace( + /]*\bsrc=["']([^"']+)["'][^>]*>/gi, + (_, src: string) => { + const url = mediaUrlByFilename.get(src); + return url ? `` : ''; + } + ); + const soundReplaced = imgReplaced.replace(/\[sound:([^\]]+)\]/g, (_, name: string) => { + const url = mediaUrlByFilename.get(name); + return url ? `` : ''; + }); + + return ( + soundReplaced + .replace(//gi, '\n') + .replace(/<\/?(?:b|strong)>/gi, '**') + .replace(/<\/?(?:i|em)>/gi, '*') + .replace(/<\/?p>/gi, '\n') + .replace(/<\/?div>/gi, '\n') + // Drop remaining HTML tags except the ones we just emitted + // (img/audio/video/source) — those need to survive into the + // rendered card. Negative lookahead does that in one pass. + .replace(/<(?!\/?(?:img|audio|video|source)\b)[^>]+>/gi, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim() + ); +} diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts new file mode 100644 index 000000000..6ed102197 --- /dev/null +++ b/apps/cards/apps/web/src/lib/api/cards-api.ts @@ -0,0 +1,505 @@ +/** + * Thin client for cards-server (https://cardecky-api.mana.how / dev :3072). + * + * The auth-store provides the JWT; we never read tokens from storage + * here directly so there's only one place that knows about token + * lifecycle (refresh, expiry, vault). + * + * All endpoints under /v1 require auth; the wrapper just always + * sends `Authorization: Bearer …`. Errors come back as Hono's + * `{ statusCode, message, details? }` shape — we surface that to + * callers via the typed `CardsApiError` so UIs can branch on it. + */ + +import { authStore } from '$lib/stores/auth.svelte'; + +function baseUrl(): string { + if (typeof window !== 'undefined') { + const fromWindow = (window as unknown as { __PUBLIC_CARDS_API_URL__?: string }) + .__PUBLIC_CARDS_API_URL__; + if (fromWindow) return fromWindow.replace(/\/$/, ''); + } + return 'http://localhost:3072'; +} + +export class CardsApiError extends Error { + constructor( + public status: number, + message: string, + public details?: unknown + ) { + super(message); + this.name = 'CardsApiError'; + } +} + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + body?: unknown; + signal?: AbortSignal; + /** + * - `true` (default): require an Authorization header — throws 401 if no token. + * - `'optional'`: include token if available, otherwise send anonymously. + * - `false`: never send a token. + */ + auth?: boolean | 'optional'; +} + +async function request(path: string, opts: RequestOptions = {}): Promise { + const headers: Record = {}; + if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; + if (opts.auth === 'optional') { + // Best-effort: include token if present, otherwise anonymous. + const token = await authStore.getValidToken?.(); + if (token) headers['Authorization'] = `Bearer ${token}`; + } else if (opts.auth !== false) { + const token = await authStore.getValidToken?.(); + if (!token) throw new CardsApiError(401, 'Not signed in'); + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(`${baseUrl()}${path}`, { + method: opts.method ?? 'GET', + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: opts.signal, + }); + + if (res.status === 204) return undefined as T; + + const text = await res.text(); + const json: unknown = text ? safeJsonParse(text) : null; + + if (!res.ok) { + const payload = (json ?? {}) as { message?: string; details?: unknown }; + throw new CardsApiError(res.status, payload.message ?? `HTTP ${res.status}`, payload.details); + } + return json as T; +} + +function safeJsonParse(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} + +// ─── Authors ──────────────────────────────────────────────── + +export interface Author { + userId: string; + slug: string; + displayName: string; + bio: string | null; + avatarUrl: string | null; + pseudonym: boolean; + verifiedMana: boolean; + verifiedCommunity: boolean; + bannedAt: string | null; +} + +export interface PublicAuthor { + slug: string; + displayName: string; + bio: string | null; + avatarUrl: string | null; + joinedAt: string; + pseudonym: boolean; + verifiedMana: boolean; + verifiedCommunity: boolean; + banned: boolean; +} + +export const cardsApi = { + authors: { + me: () => request('/v1/authors/me'), + upsertMe: (input: { + slug: string; + displayName: string; + bio?: string; + avatarUrl?: string; + pseudonym?: boolean; + }) => request('/v1/authors/me', { method: 'POST', body: input }), + bySlug: (slug: string) => request(`/v1/authors/${encodeURIComponent(slug)}`), + }, + decks: { + init: (input: { + slug: string; + title: string; + description?: string; + language?: string; + license?: string; + priceCredits?: number; + }) => request('/v1/decks', { method: 'POST', body: input }), + bySlug: (slug: string) => + request<{ + deck: PublicDeck; + latestVersion: PublicDeckVersion | null; + hasPurchased: boolean | null; + }>(`/v1/decks/${encodeURIComponent(slug)}`, { auth: 'optional' }), + publish: ( + slug: string, + input: { + semver: string; + changelog?: string; + cards: { type: string; fields: Record }[]; + } + ) => + request(`/v1/decks/${encodeURIComponent(slug)}/publish`, { + method: 'POST', + body: input, + }), + star: (slug: string) => + request<{ ok: true }>(`/v1/decks/${encodeURIComponent(slug)}/star`, { method: 'POST' }), + unstar: (slug: string) => + request<{ ok: true }>(`/v1/decks/${encodeURIComponent(slug)}/star`, { method: 'DELETE' }), + }, + explore: { + landing: () => + request<{ featured: DeckSummary[]; trending: DeckSummary[] }>('/v1/explore', { + auth: 'optional', + }), + browse: (params: { + q?: string; + tag?: string; + lang?: string; + author?: string; + sort?: 'recent' | 'popular' | 'trending'; + limit?: number; + offset?: number; + }) => { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)); + } + const path = `/v1/decks${qs.toString() ? '?' + qs.toString() : ''}`; + return request<{ items: DeckSummary[]; total: number }>(path, { auth: 'optional' }); + }, + tags: () => request('/v1/tags', { auth: 'optional' }), + }, + follows: { + follow: (authorSlug: string) => + request<{ ok: true }>(`/v1/authors/${encodeURIComponent(authorSlug)}/follow`, { + method: 'POST', + }), + unfollow: (authorSlug: string) => + request<{ ok: true }>(`/v1/authors/${encodeURIComponent(authorSlug)}/follow`, { + method: 'DELETE', + }), + }, + subscriptions: { + list: () => request('/v1/me/subscriptions'), + subscribe: (deckSlug: string) => + request<{ deckSlug: string; latestVersionId: string }>( + `/v1/decks/${encodeURIComponent(deckSlug)}/subscribe`, + { method: 'POST' } + ), + unsubscribe: (deckSlug: string) => + request<{ ok: true }>(`/v1/decks/${encodeURIComponent(deckSlug)}/subscribe`, { + method: 'DELETE', + }), + version: (deckSlug: string, semver: string) => + request( + `/v1/decks/${encodeURIComponent(deckSlug)}/versions/${encodeURIComponent(semver)}`, + { auth: 'optional' } + ), + diff: (deckSlug: string, fromSemver: string) => + request( + `/v1/decks/${encodeURIComponent(deckSlug)}/diff?from=${encodeURIComponent(fromSemver)}`, + { auth: 'optional' } + ), + }, + pullRequests: { + create: ( + deckSlug: string, + input: { + title: string; + body?: string; + diff: PullRequestDiffInput; + } + ) => + request(`/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests`, { + method: 'POST', + body: input, + }), + list: (deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') => { + const qs = status ? `?status=${status}` : ''; + return request( + `/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests${qs}`, + { auth: 'optional' } + ); + }, + get: (id: string) => request(`/v1/pull-requests/${id}`, { auth: 'optional' }), + merge: (id: string, opts: { newSemver?: string; mergeNote?: string } = {}) => + request<{ pullRequest: PullRequest; version: PublicDeckVersion }>( + `/v1/pull-requests/${id}/merge`, + { method: 'POST', body: opts } + ), + close: (id: string) => + request<{ ok: true }>(`/v1/pull-requests/${id}/close`, { method: 'POST' }), + reject: (id: string) => + request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }), + }, + moderation: { + report: (input: { + deckSlug: string; + cardContentHash?: string; + category: ReportCategory; + body?: string; + }) => request('/v1/reports', { method: 'POST', body: input }), + }, + admin: { + listReports: () => request('/v1/admin/reports'), + resolveReport: (id: string, input: { action: ResolveAction; notes?: string }) => + request<{ action: ResolveAction }>(`/v1/admin/reports/${id}/resolve`, { + method: 'POST', + body: input, + }), + takedownDeck: (slug: string, reason?: string) => + request<{ alreadyDown: boolean }>(`/v1/admin/decks/${encodeURIComponent(slug)}/takedown`, { + method: 'POST', + body: { reason }, + }), + restoreDeck: (slug: string) => + request<{ restored: boolean }>(`/v1/admin/decks/${encodeURIComponent(slug)}/restore`, { + method: 'POST', + body: {}, + }), + verifyAuthor: (slug: string, verifiedMana: boolean) => + request<{ authorSlug: string; verifiedMana: boolean }>( + `/v1/admin/authors/${encodeURIComponent(slug)}/verify`, + { method: 'POST', body: { verifiedMana } } + ), + }, + purchases: { + buy: (deckSlug: string) => + request(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, { + method: 'POST', + body: {}, + }), + listMine: () => request('/v1/me/purchases'), + }, + payouts: { + listMine: () => request('/v1/authors/me/payouts'), + }, + discussions: { + countsForDeck: (deckSlug: string) => + request>( + `/v1/decks/${encodeURIComponent(deckSlug)}/discussion-counts`, + { auth: 'optional' } + ), + listForCard: (contentHash: string) => + request(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, { + auth: 'optional', + }), + post: (contentHash: string, input: { deckSlug: string; body: string; parentId?: string }) => + request(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, { + method: 'POST', + body: input, + }), + hide: (id: string) => request<{ ok: true }>(`/v1/discussions/${id}/hide`, { method: 'POST' }), + }, +}; + +// Override author lookup to send token opportunistically — public reads. +cardsApi.authors.bySlug = (slug: string) => + request(`/v1/authors/${encodeURIComponent(slug)}`, { auth: 'optional' }); + +export interface DeckSummary { + slug: string; + title: string; + description: string | null; + language: string | null; + license: string; + priceCredits: number; + cardCount: number; + starCount: number; + subscriberCount: number; + isFeatured: boolean; + createdAt: string; + owner: { + slug: string; + displayName: string; + verifiedMana: boolean; + verifiedCommunity: boolean; + }; +} + +export interface TagDefinition { + id: string; + slug: string; + name: string; + parentId: string | null; + description: string | null; + curated: boolean; + createdAt: string; +} + +export interface PublicDeck { + id: string; + slug: string; + title: string; + description: string | null; + language: string | null; + license: string; + priceCredits: number; + ownerUserId: string; + latestVersionId: string | null; + isFeatured: boolean; + isTakedown: boolean; + createdAt: string; +} + +export interface PublicDeckVersion { + id: string; + deckId: string; + semver: string; + changelog: string | null; + contentHash: string; + cardCount: number; + publishedAt: string; + deprecatedAt: string | null; +} + +export interface PublishResult { + deck: PublicDeck; + version: PublicDeckVersion; + moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] }; +} + +export interface SubscriptionInfo { + deckSlug: string; + deckTitle: string; + deckDescription: string | null; + subscribedAt: string; + notifyUpdates: boolean; + currentVersionId: string | null; + latestVersionId: string | null; + updateAvailable: boolean; +} + +export interface ServerCard { + contentHash: string; + type: string; + fields: Record; + ord: number; +} + +export interface DeckVersionPayload { + id: string; + semver: string; + contentHash: string; + publishedAt: string; + changelog: string | null; + cards: ServerCard[]; +} + +export interface DiffPayload { + from: string; + to: string; + added: ServerCard[]; + changed: { previous: { contentHash: string }; next: ServerCard }[]; + unchanged: { contentHash: string; ord: number }[]; + removed: { contentHash: string }[]; +} + +export interface PullRequestDiffInput { + add: { type: string; fields: Record }[]; + modify: { previousContentHash: string; type: string; fields: Record }[]; + remove: { contentHash: string }[]; +} + +export type PullRequestStatus = 'open' | 'merged' | 'closed' | 'rejected'; + +export interface PullRequest { + id: string; + deckId: string; + authorUserId: string; + status: PullRequestStatus; + title: string; + body: string | null; + diff: { + add: { type: string; fields: Record }[]; + modify: { contentHash: string; fields: Record }[]; + remove: { contentHash: string }[]; + }; + mergedIntoVersionId: string | null; + createdAt: string; + resolvedAt: string | null; +} + +export type ReportCategory = 'spam' | 'copyright' | 'nsfw' | 'misinformation' | 'hate' | 'other'; + +export type ResolveAction = 'dismiss' | 'takedown' | 'ban-author'; + +export interface DeckReport { + id: string; + deckId: string; + versionId: string | null; + cardContentHash: string | null; + reporterUserId: string; + category: ReportCategory; + body: string | null; + status: 'open' | 'dismissed' | 'actioned'; + createdAt: string; +} + +export interface DeckReportItem extends DeckReport { + deckSlug: string; + deckTitle: string; +} + +export interface PurchaseResult { + purchase: { + id: string; + buyerUserId: string; + deckId: string; + versionId: string; + priceCredits: number; + authorShare: number; + manaShare: number; + purchasedAt: string; + refundedAt: string | null; + }; + payout: { + id: string; + authorUserId: string; + creditsGranted: number; + grantedAt: string; + } | null; + alreadyOwned: boolean; +} + +export interface BuyerPurchase { + id: string; + deckId: string; + deckSlug: string; + deckTitle: string; + priceCredits: number; + purchasedAt: string; + refundedAt: string | null; + versionId: string; + versionSemver: string; +} + +export interface AuthorPayout { + id: string; + purchaseId: string; + creditsGranted: number; + grantedAt: string; + deckSlug: string; + deckTitle: string; + priceCredits: number; +} + +export interface CardDiscussion { + id: string; + cardContentHash: string; + deckId: string; + authorUserId: string; + parentId: string | null; + body: string; + hidden: boolean; + createdAt: string; +} diff --git a/apps/cards/apps/web/src/lib/components/AiCardGen.svelte b/apps/cards/apps/web/src/lib/components/AiCardGen.svelte new file mode 100644 index 000000000..dedcee6e7 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/AiCardGen.svelte @@ -0,0 +1,209 @@ + + +
+
+ ✨ Karten aus Text generieren + {#if stage !== 'idle'} + + {/if} +
+ + {#if stage === 'idle' || stage === 'error'} + + {#if stage === 'error' && error} +

{error}

+ {/if} +
+
+ {source.length} Zeichen + {#if pdfStatus}📄 {pdfStatus}{/if} +
+
+ + +
+
+ + {:else if stage === 'reading-pdf'} +
{pdfStatus ?? 'Lese PDF…'}
+ {:else if stage === 'generating'} +
Modell denkt nach…
+ {:else if stage === 'preview'} +
+
+ {generated.length} Karten generiert. Wähle aus, was übernommen werden soll: +
+
    + {#each generated as card, i (i)} +
  • + + +
  • + {/each} +
+
+ + + +
+
+ {:else if stage === 'creating'} +
Lege Karten an…
+ {:else if stage === 'done'} +
✓ {createdCount} Karten angelegt.
+ + {/if} +
diff --git a/apps/cards/apps/web/src/lib/components/AnkiImport.svelte b/apps/cards/apps/web/src/lib/components/AnkiImport.svelte new file mode 100644 index 000000000..981074a5f --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/AnkiImport.svelte @@ -0,0 +1,187 @@ + + +
+
Aus Anki importieren
+ + {#if stage === 'idle'} + + +
e.preventDefault()} + ondrop={onDrop} + onclick={() => fileInput?.click()} + > +
📦 .apkg-Datei hier ablegen oder klicken
+
+ Basic, Basic + Reverse, Cloze · Bilder + Audio werden mit übernommen. +
+
+ + {:else if stage === 'parsing'} +
Lese {fileName}…
+ {:else if stage === 'preview' && parsed} +
+
+ Gefunden in + {fileName}: +
+
    +
  • {parsed.decks.length} {parsed.decks.length === 1 ? 'Deck' : 'Decks'}
  • +
  • {parsed.cards.length} {parsed.cards.length === 1 ? 'Karte' : 'Karten'}
  • + {#if mediaCount > 0} +
  • {mediaCount} Medien (Bilder/Audio)
  • + {/if} + {#if parsed.skipped > 0} +
  • {parsed.skipped} übersprungen (unbekannter Typ)
  • + {/if} +
+ {#if parsed.warnings.length > 0} +
+ Hinweise ({parsed.warnings.length}) +
    + {#each parsed.warnings.slice(0, 10) as w (w)}
  • {w}
  • {/each} +
+
+ {/if} +
+ + +
+
+ {:else if stage === 'uploading-media'} +
+
Lade Medien hoch · {mediaProgress.uploaded} / {mediaProgress.total}
+
+
+
+
+ {:else if stage === 'importing'} +
+ Importiere {parsed?.cards.length ?? 0} Karten… +
+ {:else if stage === 'done' && result} +
+
+ ✓ {result.cardsCreated} Karten in {result.decksCreated} + {result.decksCreated === 1 ? 'Deck' : 'Decks'} angelegt. +
+ {#if result.mediaUploaded > 0 || result.mediaFailed > 0} +
+ {result.mediaUploaded} Medien übernommen{#if result.mediaFailed > 0} + · {result.mediaFailed} fehlgeschlagen + {/if} +
+ {/if} + {#if result.failed > 0} +
{result.failed} Karten konnten nicht angelegt werden.
+ {/if} + +
+ {:else if stage === 'error'} +
+
Fehler: {error}
+ +
+ {/if} +
diff --git a/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte b/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte new file mode 100644 index 000000000..38723a5d2 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte @@ -0,0 +1,134 @@ + + + diff --git a/apps/cards/apps/web/src/lib/components/CardFace.svelte b/apps/cards/apps/web/src/lib/components/CardFace.svelte new file mode 100644 index 000000000..19d09cf1f --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/CardFace.svelte @@ -0,0 +1,194 @@ + + +{#if isTypeIn} + +
+
+ {@html view.prompt} +
+ + onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)} + disabled={showBack} + /> + + {#if showBack} +
+ {@html view.answer} +
+ {/if} +
+{:else} +
+ +
+{/if} + + diff --git a/apps/cards/apps/web/src/lib/components/CardsLogo.svelte b/apps/cards/apps/web/src/lib/components/CardsLogo.svelte new file mode 100644 index 000000000..556e51700 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/CardsLogo.svelte @@ -0,0 +1,13 @@ + + + + + + diff --git a/apps/cards/apps/web/src/lib/components/DeckCardList.svelte b/apps/cards/apps/web/src/lib/components/DeckCardList.svelte new file mode 100644 index 000000000..351216754 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/DeckCardList.svelte @@ -0,0 +1,104 @@ + + +
+
+

+ Karten {cards.length > 0 ? `(${cards.length})` : ''} +

+ {#if loading} + Lädt… + {/if} +
+ + {#if error} +

+ {error} +

+ {:else if cards.length === 0 && !loading} +

+ Diese Version enthält keine Karten. +

+ {:else} +
    + {#each cards as c (c.contentHash)} + {@const n = counts[c.contentHash] ?? 0} + {@const isOpen = openHash === c.contentHash} +
  • + + + {#if isOpen} + + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/apps/cards/apps/web/src/lib/components/DeckGrid.svelte b/apps/cards/apps/web/src/lib/components/DeckGrid.svelte new file mode 100644 index 000000000..def2e7c11 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/DeckGrid.svelte @@ -0,0 +1,62 @@ + + +{#if decks.length === 0} +

+ {emptyText} +

+{:else} + +{/if} diff --git a/apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte b/apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte new file mode 100644 index 000000000..97145ffed --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte @@ -0,0 +1,353 @@ + + +
e.key === 'Escape' && onClose()} + role="presentation" +> + + +
e.stopPropagation()} + > +
+

Deck veröffentlichen

+ +
+ + {#if stage === 'loading'} +
Lade Author-Profil…
+ {:else if stage === 'become-author'} +
+

+ Erstelle ein Author-Profil — andere User finden deine Decks unter + cardecky.mana.how/u/dein-slug. +

+
+ + +
+
+ + +
+ + {#if authorStore.error} +

{authorStore.error}

+ {/if} +
+ + +
+
+ {:else if stage === 'meta'} +
+

+ Veröffentlicht als cardecky.mana.how/d/{deckSlug || '...'} +

+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+

+ {cards.length} + {cards.length === 1 ? 'Karte' : 'Karten'} werden veröffentlicht. Das Deck durchläuft eine KI-Inhaltsprüfung + — offensichtlich harmloses Material geht direkt durch. +

+
+ + +
+
+ {:else if stage === 'publishing'} +
+ Lade {cards.length} Karten hoch und prüfe Inhalt… +
+ {:else if stage === 'done' && result} +
+
+ ✓ Veröffentlicht als Version {result.version.semver} +
+
+ {result.version.cardCount} Karten · Lizenz: {result.deck.license} +
+ {#if result.moderation.verdict === 'flag'} +
+ Inhalt wurde zur Moderations-Prüfung markiert ({result.moderation.categories.join( + ', ' + )}). Das Deck ist veröffentlicht; ein Mensch schaut bei Gelegenheit drüber. +
+ {/if} + +
+ {:else if stage === 'error'} +
+
Fehler: {error}
+ +
+ {/if} +
+
diff --git a/apps/cards/apps/web/src/lib/components/PullRequestsSection.svelte b/apps/cards/apps/web/src/lib/components/PullRequestsSection.svelte new file mode 100644 index 000000000..f52564aff --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/PullRequestsSection.svelte @@ -0,0 +1,233 @@ + + +
+
+

+ Pull Requests {prs.length > 0 ? `(${prs.length})` : ''} +

+ +
+ + {#if error} +

+ {error} +

+ {/if} + + {#if loading && prs.length === 0} +

+ Lädt… +

+ {:else if prs.length === 0} +

+ Noch keine Pull Requests. Abonnenten können Verbesserungen vorschlagen. +

+ {:else} +
    + {#each prs as pr (pr.id)} +
  • +
    +
    +
    + + {pr.status} + +

    {pr.title}

    +
    +

    + {diffSummary(pr)} · {new Date(pr.createdAt).toLocaleDateString('de-DE')} +

    +
    + +
    + + {#if expanded[pr.id]} + {#if pr.body} +

    {pr.body}

    + {/if} + + {#if pr.diff.modify.length > 0} +
    +
    Geändert
    +
      + {#each pr.diff.modify as m (m.contentHash)} +
    • +
      + ← {m.contentHash.slice(0, 12)}… +
      + {#each Object.entries(m.fields) as [k, v]} +
      + {k}: + {v} +
      + {/each} +
    • + {/each} +
    +
    + {/if} + + {#if pr.diff.add.length > 0} +
    +
    + Neu (+{pr.diff.add.length}) +
    +
      + {#each pr.diff.add as a, i (i)} +
    • +
      {a.type}
      + {#each Object.entries(a.fields) as [k, v]} +
      + {k}: + {v} +
      + {/each} +
    • + {/each} +
    +
    + {/if} + + {#if pr.diff.remove.length > 0} +
    +
    + Entfernt (−{pr.diff.remove.length}) +
    +
      + {#each pr.diff.remove as r (r.contentHash)} +
    • · {r.contentHash.slice(0, 12)}…
    • + {/each} +
    +
    + {/if} + + {#if pr.status === 'open' && viewerIsOwner} +
    + + + +
    + {/if} + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/apps/cards/apps/web/src/lib/components/ReportButton.svelte b/apps/cards/apps/web/src/lib/components/ReportButton.svelte new file mode 100644 index 000000000..1a7dd0b86 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/ReportButton.svelte @@ -0,0 +1,142 @@ + + +{#if authStore.isAuthenticated} + {#if variant === 'icon'} + + {:else} + + {/if} +{/if} + +{#if open} + +{/if} diff --git a/apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte b/apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte new file mode 100644 index 000000000..d2c8b6795 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte @@ -0,0 +1,93 @@ + + +
+
+ Lernaktivität + + {total} Karten · {activeDays} aktive {activeDays === 1 ? 'Tag' : 'Tage'} · letzte {weeks} Wochen + +
+
+ {#each columns as col, ci (ci)} +
+ {#each col as cell, ri (ri)} + {#if cell.date === null} +
+ {:else} +
+ {/if} + {/each} +
+ {/each} +
+
+ weniger + + + + + + mehr +
+
diff --git a/apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte b/apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte new file mode 100644 index 000000000..f8077a514 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte @@ -0,0 +1,188 @@ + + +{#if open} + +{/if} diff --git a/apps/cards/apps/web/src/lib/data/crypto.ts b/apps/cards/apps/web/src/lib/data/crypto.ts new file mode 100644 index 000000000..07df39beb --- /dev/null +++ b/apps/cards/apps/web/src/lib/data/crypto.ts @@ -0,0 +1,56 @@ +/** + * Encryption wrapper — Phase-1 stub. + * + * The full Mana crypto stack (vault server roundtrip, KEK-wrapped + * master key, recovery codes, zero-knowledge mode) lives in the mana + * web app under `apps/mana/.../data/crypto/`. Lifting it intact into + * the standalone Cards app is a Phase-2 task — it requires a vault + * client, key provider, and boot-race handling that aren't worth + * dragging in until we have the deployment story for them. + * + * For Phase 1 these helpers are intentionally identity functions: + * data lands in IndexedDB and on `mana-sync` as plaintext. Everything + * is wired up at the right call sites (stores → write, queries → read, + * sync.applyServerChanges → apply) so flipping to real encryption is a + * single-file change here, not a sweep through every store. + * + * Allowlist is the contract with the future vault. It mirrors the + * mana-modul registry exactly so when sync converges, the same fields + * are protected on both ends. + */ + +const ENCRYPTED_FIELDS: Record = { + cards: ['front', 'back', 'fields'], + cardDecks: ['name', 'description'], +}; + +/** + * Phase-1 identity. Phase-2 swap-in: import `wrapValue` from + * `@mana/shared-crypto`, fetch master key from the vault, encrypt + * each allowlisted field in place. + */ +export async function encryptRecord(tableName: string, record: T): Promise { + void ENCRYPTED_FIELDS[tableName]; + return record; +} + +export async function decryptRecord(_tableName: string, record: T): Promise { + return record; +} + +export async function decryptRecords( + tableName: string, + records: T[] +): Promise { + if (records.length === 0) return records; + return Promise.all(records.map((r) => decryptRecord(tableName, r))); +} + +/** + * Reports the fields that *will* be encrypted once the vault is on. + * Stays exported so the GUIDELINES audit script can prove parity with + * the mana-modul registry. + */ +export function encryptedFieldsFor(tableName: string): readonly string[] { + return ENCRYPTED_FIELDS[tableName] ?? []; +} diff --git a/apps/cards/apps/web/src/lib/data/database.ts b/apps/cards/apps/web/src/lib/data/database.ts new file mode 100644 index 000000000..7088f15e1 --- /dev/null +++ b/apps/cards/apps/web/src/lib/data/database.ts @@ -0,0 +1,163 @@ +/** + * Standalone Cards Dexie database. + * + * Phase-1 sync: every write to a sync-relevant table fires a Dexie hook + * that records a row into `_pendingChanges`. The sync engine drains + * that queue against `mana-sync` (POST /sync/cards). When server changes + * come back, they're applied with `beginApplying(table)` set so the + * hooks suppress queueing for those rows — otherwise client and server + * would ping-pong forever. + * + * Encryption is intentionally NOT wired here. Phase-1 ships plaintext; + * Etappe 3c.3 turns it on once the vault client is in place. + */ + +import Dexie, { type Table } from 'dexie'; +import type { LocalDeck, LocalCard, LocalCardReview, LocalCardStudyBlock } from '@mana/cards-core'; + +interface DeckTag { + id: string; + deckId: string; + tagId: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; +} + +/** Server protocol expects this shape on push. */ +export interface FieldChange { + value: unknown; + at: string; +} + +export type ChangeOp = 'insert' | 'update' | 'delete'; + +export interface PendingChange { + /** Auto-increment PK (Dexie ++id). */ + pk?: number; + table: string; + id: string; + op: ChangeOp; + fields?: Record; + data?: Record; + deletedAt?: string; + queuedAt: string; +} + +/** Tables whose writes are mirrored to mana-sync. */ +const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags'] as const; + +class CardsDatabase extends Dexie { + cardDecks!: Table; + cards!: Table; + cardReviews!: Table; + cardStudyBlocks!: Table; + deckTags!: Table; + _pendingChanges!: Table; + + constructor() { + super('cards'); + this.version(1).stores({ + cardDecks: 'id, lastStudied', + cards: 'id, deckId, order, [deckId+order]', + cardReviews: 'id, cardId, due, [cardId+subIndex], state', + cardStudyBlocks: 'id, date', + deckTags: 'id, deckId, tagId', + _pendingChanges: '++pk, table, queuedAt', + }); + // v2 — Phase δ.2: index `subscribedFromSlug` on cardDecks so the + // subscribe service can lookup-by-slug to avoid duplicating + // subscriptions on re-pull. + this.version(2).stores({ + cardDecks: 'id, lastStudied, subscribedFromSlug', + }); + // v3 — Phase δ.3: compound index on (deckId, serverContentHash) + // for the smart-merge lookup. Diff payloads reference cards by + // their content hash; we need O(1) lookups per (deck, hash) to + // classify each diff entry against local rows. + this.version(3).stores({ + cards: 'id, deckId, order, [deckId+order], [deckId+serverContentHash]', + }); + } +} + +export const db = new CardsDatabase(); + +export const cardDeckTable = db.cardDecks; +export const cardTable = db.cards; +export const cardReviewTable = db.cardReviews; +export const cardStudyBlockTable = db.cardStudyBlocks; +export const pendingChangesTable = db._pendingChanges; + +// ─── Server-apply suppression ────────────────────────────── + +const applying = new Set(); + +/** Mark a table as "currently applying server changes" — hooks skip + * queueing for the duration. Caller must always pair with `endApplying`. */ +export function beginApplying(tableName: string) { + applying.add(tableName); +} +export function endApplying(tableName: string) { + applying.delete(tableName); +} + +// ─── Field-meta diff ─────────────────────────────────────── + +function diffToFields( + previous: Record, + next: Record +): Record { + const at = new Date().toISOString(); + const out: Record = {}; + for (const key of Object.keys(next)) { + if (key.startsWith('_') || key === 'updatedAt') continue; + if (previous[key] === next[key]) continue; + out[key] = { value: next[key], at }; + } + return out; +} + +function snapshotForInsert(row: Record): Record { + const out: Record = {}; + for (const key of Object.keys(row)) { + if (key.startsWith('_')) continue; + out[key] = row[key]; + } + return out; +} + +// ─── Hook installation ───────────────────────────────────── + +function installSyncHooks(table: Table, name: string) { + table.hook('creating', (_pk, row) => { + if (applying.has(name)) return; + void db._pendingChanges.add({ + table: name, + id: row.id, + op: 'insert', + data: snapshotForInsert(row), + queuedAt: new Date().toISOString(), + }); + }); + + table.hook('updating', (mods, _pk, prev) => { + if (applying.has(name)) return; + const next = { ...prev, ...mods }; + const fields = diffToFields(prev, next); + if (Object.keys(fields).length === 0 && !('deletedAt' in mods)) return; + const isDelete = (mods as { deletedAt?: string }).deletedAt; + void db._pendingChanges.add({ + table: name, + id: prev.id, + op: isDelete ? 'delete' : 'update', + fields: Object.keys(fields).length > 0 ? fields : undefined, + deletedAt: isDelete ?? undefined, + queuedAt: new Date().toISOString(), + }); + }); +} + +for (const name of SYNC_TABLES) { + installSyncHooks(db.table(name), name); +} diff --git a/apps/cards/apps/web/src/lib/data/sync.ts b/apps/cards/apps/web/src/lib/data/sync.ts new file mode 100644 index 000000000..8889d1f54 --- /dev/null +++ b/apps/cards/apps/web/src/lib/data/sync.ts @@ -0,0 +1,290 @@ +/** + * Cards sync engine — talks to mana-sync (POST /sync/cards, GET /sync/cards/pull). + * + * Two loops, both polling-based for the Phase-1 MVP. WebSocket + * notifications can replace the pull poll later without changing + * anything outside this file. + * + * Push: drain `_pendingChanges` every 1s when there's anything queued. + * On success, delete drained rows and apply any server-changes + * the response carried back. Failures keep the rows queued — + * the next tick retries. + * + * Pull: every 5s, ask each sync table for changes since its cursor. + * Apply with suppression so the apply doesn't re-enqueue a push. + * Cursor lives in localStorage per table. + * + * Cursor format: ISO timestamp string. The server returns + * `syncedUntil` on push and we store that as a global push cursor; pull + * uses one cursor per collection. + */ + +import { browser } from '$app/environment'; +import { + beginApplying, + endApplying, + db, + pendingChangesTable, + type PendingChange, +} from './database'; +import { encryptRecord } from './crypto'; + +const APP_ID = 'cards'; +const PUSH_INTERVAL_MS = 1_000; +const PULL_INTERVAL_MS = 5_000; +const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags']; + +// ─── URL + Auth wiring ───────────────────────────────────── + +function getSyncUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_SYNC_URL__?: string }) + .__PUBLIC_MANA_SYNC_URL__; + if (injected) return injected; + } + return import.meta.env.DEV ? 'http://localhost:3050' : ''; +} + +interface AuthLike { + getValidToken?: () => Promise; + readonly isAuthenticated: boolean; +} + +let authProvider: AuthLike | null = null; + +// ─── Client ID ───────────────────────────────────────────── + +const CLIENT_ID_KEY = 'mana.cards.clientId'; + +function getClientId(): string { + if (!browser) return 'ssr'; + let id = localStorage.getItem(CLIENT_ID_KEY); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(CLIENT_ID_KEY, id); + } + return id; +} + +// ─── Cursors ─────────────────────────────────────────────── + +const PUSH_CURSOR_KEY = 'mana.cards.pushCursor'; +const PULL_CURSOR_KEY = (table: string) => `mana.cards.pullCursor.${table}`; + +function getPushCursor(): string { + if (!browser) return ''; + return localStorage.getItem(PUSH_CURSOR_KEY) || '1970-01-01T00:00:00.000Z'; +} +function setPushCursor(at: string) { + if (browser) localStorage.setItem(PUSH_CURSOR_KEY, at); +} +function getPullCursor(table: string): string { + if (!browser) return ''; + return localStorage.getItem(PULL_CURSOR_KEY(table)) || '1970-01-01T00:00:00.000Z'; +} +function setPullCursor(table: string, at: string) { + if (browser) localStorage.setItem(PULL_CURSOR_KEY(table), at); +} + +// ─── Server-Change shape ─────────────────────────────────── + +interface ServerChange { + eventId?: string; + schemaVersion?: number; + table: string; + id: string; + op: 'insert' | 'update' | 'delete'; + fields?: Record; + data?: Record; + deletedAt?: string; +} + +interface SyncResponse { + serverChanges: ServerChange[]; + conflicts: unknown[]; + syncedUntil: string; + hasMore?: boolean; +} + +// ─── Apply server changes ────────────────────────────────── + +async function applyServerChanges(changes: ServerChange[]) { + if (changes.length === 0) return; + const byTable = new Map(); + for (const c of changes) { + const arr = byTable.get(c.table) ?? []; + arr.push(c); + byTable.set(c.table, arr); + } + + for (const [table, list] of byTable) { + if (!SYNC_TABLES.includes(table)) continue; + const t = db.table(table); + beginApplying(table); + try { + for (const c of list) { + try { + if (c.op === 'delete') { + await t.update(c.id, { deletedAt: c.deletedAt ?? new Date().toISOString() }); + continue; + } + if (c.op === 'insert' && c.data) { + const row = { ...c.data, id: c.id }; + // Server data may already be ciphertext-on-the-wire when + // encryption flips on. Re-running encryptRecord on it is a + // safe no-op today (Phase-1 stub) and the right hook in + // Phase-2 because existing-ciphertext values are detected + // upstream via `isEncrypted(...)`. + await encryptRecord(table, row); + await t.put(row); + continue; + } + // update — merge fields + if (c.fields) { + const existing = (await t.get(c.id)) ?? { id: c.id }; + const merged: Record = { ...existing }; + for (const [k, v] of Object.entries(c.fields)) { + merged[k] = v.value; + } + await encryptRecord(table, merged); + await t.put(merged); + } + } catch (err) { + console.error('[cards-sync] apply failed', { table, id: c.id, op: c.op, err }); + } + } + } finally { + endApplying(table); + } + } +} + +// ─── Push ────────────────────────────────────────────────── + +async function flushPush(): Promise { + if (!authProvider?.isAuthenticated) return; + + const queued = await pendingChangesTable.orderBy('queuedAt').limit(500).toArray(); + if (queued.length === 0) return; + + const token = (await authProvider.getValidToken?.()) ?? null; + if (!token) return; + + const since = getPushCursor(); + const body = { + clientId: getClientId(), + appId: APP_ID, + since, + schemaVersion: 1, + changes: queued.map(toWireChange), + }; + + let res: Response; + try { + res = await fetch(`${getSyncUrl()}/sync/${APP_ID}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Id': getClientId(), + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + } catch (err) { + console.warn('[cards-sync] push network error', err); + return; + } + + if (!res.ok) { + console.warn('[cards-sync] push HTTP', res.status, await res.text().catch(() => '')); + return; + } + + const json = (await res.json()) as SyncResponse; + await pendingChangesTable.bulkDelete(queued.map((q) => q.pk!).filter((pk) => pk !== undefined)); + setPushCursor(json.syncedUntil); + await applyServerChanges(json.serverChanges ?? []); +} + +function toWireChange(p: PendingChange): ServerChange { + const out: ServerChange = { table: p.table, id: p.id, op: p.op }; + if (p.fields) out.fields = p.fields; + if (p.data) out.data = p.data; + if (p.deletedAt) out.deletedAt = p.deletedAt; + return out; +} + +// ─── Pull ────────────────────────────────────────────────── + +async function pollPull(): Promise { + if (!authProvider?.isAuthenticated) return; + const token = (await authProvider.getValidToken?.()) ?? null; + if (!token) return; + + for (const table of SYNC_TABLES) { + const since = getPullCursor(table); + const url = + `${getSyncUrl()}/sync/${APP_ID}/pull?collection=${encodeURIComponent(table)}` + + `&since=${encodeURIComponent(since)}`; + + let res: Response; + try { + res = await fetch(url, { + headers: { + 'X-Client-Id': getClientId(), + Authorization: `Bearer ${token}`, + }, + }); + } catch (err) { + console.warn('[cards-sync] pull network error', err); + continue; + } + + if (!res.ok) { + console.warn('[cards-sync] pull HTTP', res.status, table); + continue; + } + + const json = (await res.json()) as SyncResponse; + await applyServerChanges(json.serverChanges ?? []); + if (json.syncedUntil) setPullCursor(table, json.syncedUntil); + } +} + +// ─── Lifecycle ───────────────────────────────────────────── + +let pushTimer: ReturnType | null = null; +let pullTimer: ReturnType | null = null; +let pushBusy = false; +let pullBusy = false; + +export function startSync(authStore: AuthLike) { + authProvider = authStore; + if (!browser) return; + stopSync(); + pushTimer = setInterval(async () => { + if (pushBusy) return; + pushBusy = true; + try { + await flushPush(); + } finally { + pushBusy = false; + } + }, PUSH_INTERVAL_MS); + pullTimer = setInterval(async () => { + if (pullBusy) return; + pullBusy = true; + try { + await pollPull(); + } finally { + pullBusy = false; + } + }, PULL_INTERVAL_MS); +} + +export function stopSync() { + if (pushTimer) clearInterval(pushTimer); + if (pullTimer) clearInterval(pullTimer); + pushTimer = null; + pullTimer = null; +} diff --git a/apps/cards/apps/web/src/lib/index.ts b/apps/cards/apps/web/src/lib/index.ts new file mode 100644 index 000000000..648b5d03a --- /dev/null +++ b/apps/cards/apps/web/src/lib/index.ts @@ -0,0 +1,2 @@ +// place files you want to import through the `$lib` alias in this folder. +export {}; diff --git a/apps/cards/apps/web/src/lib/media/upload.ts b/apps/cards/apps/web/src/lib/media/upload.ts new file mode 100644 index 000000000..2a28d01e1 --- /dev/null +++ b/apps/cards/apps/web/src/lib/media/upload.ts @@ -0,0 +1,90 @@ +/** + * Upload an image or audio file to mana-media and get back a media id + * + a public URL ready to drop into a card field. + * + * Resolves the media base URL from window.__PUBLIC_MANA_MEDIA_URL__ + * (injected by hooks.server.ts) so the same code works in dev (when + * mana-media runs on localhost) and prod (https://media.mana.how). + * + * 25 MB hard-cap mirrors the website-upload pattern in mana-web. + */ + +const MAX_BYTES = 25 * 1024 * 1024; + +export class MediaUploadError extends Error { + constructor( + message: string, + public status?: number + ) { + super(message); + this.name = 'MediaUploadError'; + } +} + +function mediaBaseUrl(): string { + if (typeof window !== 'undefined') { + const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }) + .__PUBLIC_MANA_MEDIA_URL__; + if (fromWindow) return fromWindow.replace(/\/$/, ''); + } + return 'http://localhost:3015'; +} + +export interface UploadedMedia { + id: string; + url: string; + kind: 'image' | 'audio' | 'video' | 'other'; +} + +function classify(mime: string): UploadedMedia['kind'] { + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('audio/')) return 'audio'; + if (mime.startsWith('video/')) return 'video'; + return 'other'; +} + +export async function uploadCardMedia(file: File): Promise { + if (file.size > MAX_BYTES) { + throw new MediaUploadError(`Datei zu groß (max ${MAX_BYTES / 1024 / 1024} MB).`, 400); + } + const kind = classify(file.type); + if (kind === 'other') { + throw new MediaUploadError('Nur Bilder, Audio oder Video werden unterstützt.', 400); + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('app', 'cards'); + + const res = await fetch(`${mediaBaseUrl()}/api/v1/media/upload`, { + method: 'POST', + body: formData, + }); + if (!res.ok) { + throw new MediaUploadError(`Upload fehlgeschlagen (${res.status})`, res.status); + } + const data = (await res.json()) as { id?: string }; + if (!data.id) throw new MediaUploadError('Upload-Antwort ohne Media-ID.', 500); + + const variant = kind === 'image' ? '/file/medium' : '/file'; + return { + id: data.id, + url: `${mediaBaseUrl()}/api/v1/media/${data.id}${variant}`, + kind, + }; +} + +/** Snippet to drop into a card field. Markdown for images, raw HTML for + * audio/video so the user can also tweak attributes by hand later. */ +export function mediaToFieldSnippet(media: UploadedMedia, label: string): string { + switch (media.kind) { + case 'image': + return `![${label}](${media.url})`; + case 'audio': + return ``; + case 'video': + return ``; + default: + return media.url; + } +} diff --git a/apps/cards/apps/web/src/lib/queries.ts b/apps/cards/apps/web/src/lib/queries.ts new file mode 100644 index 000000000..869f7ae45 --- /dev/null +++ b/apps/cards/apps/web/src/lib/queries.ts @@ -0,0 +1,251 @@ +/** + * Reactive queries — standalone. + * + * Wraps Dexie's liveQuery so Svelte components get auto-updates whenever + * the underlying tables change. Type converters mirror the mana-modul + * shape so component code stays portable. + */ + +import { liveQuery } from 'dexie'; +import { + db, + cardDeckTable, + cardTable, + cardReviewTable, + cardStudyBlockTable, +} from './data/database'; +import { decryptRecord, decryptRecords } from './data/crypto'; +import type { + CardFields, + CardType, + Card, + CardReview, + Deck, + LocalCard, + LocalCardReview, + LocalDeck, +} from '@mana/cards-core'; + +// ─── 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 ?? 'private', + tags: [], + cardCount: local.cardCount, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(), + subscribedFromSlug: local.subscribedFromSlug, + subscribedAtVersion: local.subscribedAtVersion, + }; +} + +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: local.updatedAt ?? local.createdAt ?? new Date().toISOString(), + serverContentHash: local.serverContentHash, + }; +} + +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, + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllDecks() { + return liveQuery(async () => { + const all = await cardDeckTable.toArray(); + const visible = all.filter((d) => !d.deletedAt); + const decrypted = await decryptRecords('cardDecks', visible); + return decrypted.map(toDeck); + }); +} + +export function useDeck(deckId: string) { + return liveQuery(async () => { + const local = await cardDeckTable.get(deckId); + if (!local || local.deletedAt) return null; + const decrypted = await decryptRecord('cardDecks', { ...local }); + return toDeck(decrypted); + }); +} + +export function useCardsByDeck(deckId: string) { + return liveQuery(async () => { + const visible = (await cardTable.where('deckId').equals(deckId).sortBy('order')).filter( + (c) => !c.deletedAt + ); + const decrypted = await decryptRecords('cards', visible); + return decrypted.map(toCard); + }); +} + +/** + * All reviews due now (or overdue) optionally filtered by deck. Joined + * with the parent card so the learn session can render immediately. + */ +export function useDueReviews(deckId?: string) { + return liveQuery(async () => { + const nowIso = new Date().toISOString(); + const due = await cardReviewTable.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.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)! })); + }); +} + +export function useReview(reviewId: string) { + return liveQuery(async () => { + const r = await cardReviewTable.get(reviewId); + if (!r || r.deletedAt) return null; + return toCardReview(r); + }); +} + +/** + * Map of deckId → count of currently-due reviews. Used by the deck list + * so the user can see at a glance which deck wants attention without + * opening it. + */ +export function useDueCountByDeck() { + return liveQuery(async () => { + const nowIso = new Date().toISOString(); + const due = await cardReviewTable.where('due').belowOrEqual(nowIso).toArray(); + const live = due.filter((r) => !r.deletedAt); + if (live.length === 0) return new Map(); + + const cardIds = [...new Set(live.map((r) => r.cardId))]; + const cards = await cardTable.where('id').anyOf(cardIds).toArray(); + const cardToDeck = new Map(cards.filter((c) => !c.deletedAt).map((c) => [c.id, c.deckId])); + + const counts = new Map(); + for (const r of live) { + const deckId = cardToDeck.get(r.cardId); + if (!deckId) continue; + counts.set(deckId, (counts.get(deckId) ?? 0) + 1); + } + return counts; + }); +} + +/** + * Per-day review counts for the last `weeks * 7` days (default 12 weeks + * = 84 days). Used by the GitHub-style heatmap on the dashboard. Days + * with no row in cardStudyBlocks come back as count=0 so the renderer + * doesn't have to fill gaps itself. + */ +export function useStudyHeatmap(weeks: number = 12) { + return liveQuery(async () => { + const today = new Date(); + const localKey = (d: Date) => { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; + }; + + const days = weeks * 7; + const rows = await cardStudyBlockTable.toArray(); + const byDate = new Map(); + for (const r of rows) { + if (r.deletedAt) continue; + byDate.set(r.date, (byDate.get(r.date) ?? 0) + r.cardsReviewed); + } + + const out: { date: string; count: number }[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = localKey(d); + out.push({ date: key, count: byDate.get(key) ?? 0 }); + } + return out; + }); +} + +/** + * Days-in-a-row with at least one review. Walks back from today; the + * first day with no row (or a soft-deleted/empty one) ends the count. + * Capped at 365 to bound the worst-case scan. + */ +export function useStreak() { + return liveQuery(async () => { + const today = new Date(); + const localKey = (d: Date) => { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; + }; + + 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(localKey(d)).first(); + if (!row || row.deletedAt || row.cardsReviewed <= 0) break; + streak++; + } + return streak; + }); +} diff --git a/apps/cards/apps/web/src/lib/services/subscribe.ts b/apps/cards/apps/web/src/lib/services/subscribe.ts new file mode 100644 index 000000000..24c179fb8 --- /dev/null +++ b/apps/cards/apps/web/src/lib/services/subscribe.ts @@ -0,0 +1,314 @@ +/** + * Subscribe to a marketplace deck and pull its latest version into + * the local Dexie. Phase δ.2 — initial pull only; smart-merge of + * subsequent updates lands in δ.3 via `applySubscriptionUpdate` + * (placeholder export below). + * + * The subscribed deck shows up alongside own decks but is marked + * `subscribedFromSlug` + `subscribedAtVersion` so the UI can hide + * mutate controls and show an "Update available" indicator when + * cards-server reports a newer version. + */ + +import { cardsApi, CardsApiError } from '$lib/api/cards-api'; +import type { ServerCard } from '$lib/api/cards-api'; +import { cardDeckTable, cardTable } from '$lib/data/database'; +import { reviewStore } from '$lib/stores/reviews.svelte'; +import type { CardType, LocalCard, LocalDeck } from '@mana/cards-core'; + +const ALLOWED_TYPES: CardType[] = [ + 'basic', + 'basic-reverse', + 'cloze', + 'type-in', + 'image-occlusion', + 'audio', + 'multiple-choice', +]; + +function asCardType(t: string): CardType { + return (ALLOWED_TYPES as string[]).includes(t) ? (t as CardType) : 'basic'; +} + +export interface SubscribeResult { + deckId: string; + cardCount: number; +} + +export async function subscribeAndPull(deckSlug: string): Promise { + // 1. Tell the server we're subscribed (idempotent, returns the + // version we should pull). + const sub = await cardsApi.subscriptions.subscribe(deckSlug); + + // 2. Fetch the deck metadata so we know title/description/etc. + const { deck, latestVersion } = await cardsApi.decks.bySlug(deckSlug); + if (!latestVersion) { + throw new Error('Subscribed but the deck has no published version yet'); + } + + // 3. Fetch the version's cards (full payload). + const version = await cardsApi.subscriptions.version(deckSlug, latestVersion.semver); + + // 4. Already subscribed locally? Don't duplicate — refresh in + // place. Phase δ.3 will swap this for a real diff-apply. + const existingDeck = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + + const now = new Date().toISOString(); + const localDeck: LocalDeck = existingDeck ?? { + id: crypto.randomUUID(), + name: deck.title, + description: deck.description, + color: '#6366f1', + cardCount: version.cards.length, + visibility: 'private', + createdAt: now, + updatedAt: now, + subscribedFromSlug: deckSlug, + subscribedAtVersion: latestVersion.semver, + }; + + if (existingDeck) { + await cardDeckTable.update(existingDeck.id, { + name: deck.title, + description: deck.description, + cardCount: version.cards.length, + subscribedAtVersion: latestVersion.semver, + updatedAt: now, + }); + } else { + await cardDeckTable.add(localDeck); + } + + // 5. Replace cards (initial-pull strategy; δ.3 keeps FSRS state). + if (existingDeck) { + const oldCards = await cardTable.where('deckId').equals(existingDeck.id).toArray(); + for (const c of oldCards) { + if (!c.deletedAt) await cardTable.update(c.id, { deletedAt: now }); + } + } + + for (const sc of version.cards) { + const card: LocalCard = { + id: crypto.randomUUID(), + deckId: localDeck.id, + type: asCardType(sc.type), + fields: sc.fields, + order: sc.ord, + serverContentHash: sc.contentHash, + createdAt: now, + updatedAt: now, + }; + await cardTable.add(card); + await reviewStore.ensureReviewsForCard({ + id: card.id, + type: card.type as CardType, + fields: card.fields ?? {}, + }); + } + + return { deckId: localDeck.id, cardCount: version.cards.length }; +} + +export async function unsubscribe(deckSlug: string): Promise { + await cardsApi.subscriptions.unsubscribe(deckSlug); + const local = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + if (!local) return; + const now = new Date().toISOString(); + const cards = await cardTable.where('deckId').equals(local.id).toArray(); + for (const c of cards) { + if (!c.deletedAt) await cardTable.update(c.id, { deletedAt: now }); + } + await cardDeckTable.update(local.id, { deletedAt: now }); +} + +/** Helper: am I already subscribed locally to this slug? */ +export async function isSubscribedLocally(slug: string): Promise { + try { + const row = await cardDeckTable.where('subscribedFromSlug').equals(slug).first(); + return Boolean(row && !row.deletedAt); + } catch { + return false; + } +} + +export interface UpdatePreview { + from: string; + to: string; + added: number; + changed: number; + removed: number; + unchanged: number; +} + +/** + * Compute what would change if we pulled the latest version. Returns + * `null` if already on latest. Used by the deck-detail banner so the + * user sees "X neue, Y geänderte, Z entfernte" before committing. + */ +export async function previewUpdate(deckSlug: string): Promise { + const local = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + if (!local || local.deletedAt || !local.subscribedAtVersion) return null; + const diff = await cardsApi.subscriptions.diff(deckSlug, local.subscribedAtVersion); + if (diff.from === diff.to) return null; + return { + from: diff.from, + to: diff.to, + added: diff.added.length, + changed: diff.changed.length, + removed: diff.removed.length, + unchanged: diff.unchanged.length, + }; +} + +/** + * Smart-merge the latest server version into the local Dexie copy + * without losing FSRS state. + * + * - **unchanged**: leave the local card alone — its FSRS reviews + * stay attached and the learning schedule continues unbroken. + * - **changed**: lookup local card by previous-hash, update fields/ + * type/order/serverContentHash to the new values. FSRS reviews + * stay attached because we don't touch the card id. Re-runs + * ensureReviewsForCard so cloze-cluster fan-out matches the new + * content. + * - **added**: insert a new card with fresh FSRS reviews. + * - **removed**: soft-delete by content-hash + cascade reviews. + * + * Final step: bump local subscribedAtVersion + re-stamp server-side + * (POST /subscribe is idempotent and re-anchors the user's row). + */ +export async function applyUpdate(deckSlug: string): Promise { + const local = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + if (!local || local.deletedAt || !local.subscribedAtVersion) return null; + + const diff = await cardsApi.subscriptions.diff(deckSlug, local.subscribedAtVersion); + if (diff.from === diff.to) return null; + + const now = new Date().toISOString(); + + for (const r of diff.removed) { + const localCard = await cardTable + .where('[deckId+serverContentHash]') + .equals([local.id, r.contentHash]) + .first(); + if (localCard && !localCard.deletedAt) { + await cardTable.update(localCard.id, { deletedAt: now }); + await reviewStore.softDeleteForCard(localCard.id); + } + } + + for (const c of diff.changed) { + const localCard = await cardTable + .where('[deckId+serverContentHash]') + .equals([local.id, c.previous.contentHash]) + .first(); + if (!localCard) { + // Heuristic mismatch — treat as added. + await insertSubscribedCard(local.id, c.next, now); + continue; + } + const nextType = asCardType(c.next.type); + await cardTable.update(localCard.id, { + type: nextType, + fields: c.next.fields, + order: c.next.ord, + serverContentHash: c.next.contentHash, + updatedAt: now, + }); + await reviewStore.ensureReviewsForCard({ + id: localCard.id, + type: nextType, + fields: c.next.fields, + }); + } + + for (const a of diff.added) { + await insertSubscribedCard(local.id, a, now); + } + + for (const u of diff.unchanged) { + const localCard = await cardTable + .where('[deckId+serverContentHash]') + .equals([local.id, u.contentHash]) + .first(); + if (localCard && localCard.order !== u.ord) { + await cardTable.update(localCard.id, { order: u.ord, updatedAt: now }); + } + } + + const liveCards = await cardTable.where('deckId').equals(local.id).toArray(); + const liveCount = liveCards.filter((c) => !c.deletedAt).length; + await cardDeckTable.update(local.id, { + subscribedAtVersion: diff.to, + cardCount: liveCount, + updatedAt: now, + }); + + try { + await cardsApi.subscriptions.subscribe(deckSlug); + } catch { + // Idempotent server-side; if this fails the local pointer + // already advanced and the next sync will reconcile. + } + + return { + from: diff.from, + to: diff.to, + added: diff.added.length, + changed: diff.changed.length, + removed: diff.removed.length, + unchanged: diff.unchanged.length, + }; +} + +async function insertSubscribedCard(deckId: string, sc: ServerCard, now: string): Promise { + const card: LocalCard = { + id: crypto.randomUUID(), + deckId, + type: asCardType(sc.type), + fields: sc.fields, + order: sc.ord, + serverContentHash: sc.contentHash, + createdAt: now, + updatedAt: now, + }; + await cardTable.add(card); + await reviewStore.ensureReviewsForCard({ + id: card.id, + type: card.type as CardType, + fields: card.fields ?? {}, + }); +} + +/** + * One-shot poll of the user's subscriptions to see which decks have + * a newer version waiting. Powers the dashboard "Updates"-banner. + */ +export async function listSubscriptionUpdates(): Promise<{ slug: string; title: string }[]> { + let subs; + try { + subs = await cardsApi.subscriptions.list(); + } catch (e) { + if (e instanceof CardsApiError && e.status === 401) return []; + throw e; + } + return subs + .filter((s) => s.updateAvailable) + .map((s) => ({ slug: s.deckSlug, title: s.deckTitle })); +} diff --git a/apps/cards/apps/web/src/lib/stores/auth.svelte.ts b/apps/cards/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..ce4e9f88c --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,12 @@ +/** + * Auth Store — uses the shared Mana auth factory. + * + * SSO: tokens land in the shared `*.mana.how` storage so a user already + * signed into mana.how / cardecky.mana.how lands directly in the app + * without re-typing credentials. The factory wires up the token + * manager + refresh + storage adapter for us. + */ + +import { createManaAuthStore } from '@mana/shared-auth-ui'; + +export const authStore = createManaAuthStore(); diff --git a/apps/cards/apps/web/src/lib/stores/author.svelte.ts b/apps/cards/apps/web/src/lib/stores/author.svelte.ts new file mode 100644 index 000000000..84b94f289 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/author.svelte.ts @@ -0,0 +1,72 @@ +/** + * Author-state store. + * + * Lazily fetches the user's author row on first access. Runtime + * components never read the API directly — they go through this + * store, so refresh-on-mutation is centralised. + */ + +import { cardsApi, CardsApiError, type Author } from '$lib/api/cards-api'; + +let _author = $state(null); +let _loaded = $state(false); +let _loading = $state(false); +let _error = $state(null); + +export const authorStore = { + get author() { + return _author; + }, + get loaded() { + return _loaded; + }, + get loading() { + return _loading; + }, + get error() { + return _error; + }, + get isAuthor() { + return _loaded && _author !== null; + }, + + async load(force = false): Promise { + if (_loaded && !force) return _author; + _loading = true; + _error = null; + try { + _author = await cardsApi.authors.me(); + } catch (e) { + if (e instanceof CardsApiError && e.status === 401) { + // Not authed — caller's problem, don't poison the store. + _author = null; + } else { + _error = (e as Error).message ?? 'Konnte Author-Profil nicht laden'; + } + } finally { + _loaded = true; + _loading = false; + } + return _author; + }, + + async upsert(input: Parameters[0]): Promise { + _loading = true; + _error = null; + try { + _author = await cardsApi.authors.upsertMe(input); + return _author; + } catch (e) { + _error = (e as Error).message ?? 'Speichern fehlgeschlagen'; + return null; + } finally { + _loading = false; + } + }, + + reset() { + _author = null; + _loaded = false; + _error = null; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/cards.svelte.ts b/apps/cards/apps/web/src/lib/stores/cards.svelte.ts new file mode 100644 index 000000000..079ae5c62 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/cards.svelte.ts @@ -0,0 +1,165 @@ +/** + * Card Store — standalone. + * + * Writes the {type, fields} shape directly. Legacy mirror (front/back + * columns) kept on for cross-compat with the mana-modul data model + * once sync flips on. No encryption, no domain events — that's the + * deliberate Phase-1 simplification. + */ + +import { cardTable, cardDeckTable } from '../data/database'; +import { encryptRecord, decryptRecord } from '../data/crypto'; +import { reviewStore } from './reviews.svelte'; +import { + type CardFields, + type CardType, + type LocalCard, + type CreateCardInput, + type UpdateCardInput, +} from '@mana/cards-core'; + +let error = $state(null); + +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 ?? '' } }; +} + +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') { + return { front: fields.text ?? '', back: '' }; + } + return {}; +} + +export const cardStore = { + get error() { + return error; + }, + + async createCard( + input: CreateCardInput, + currentCardCount: number = 0 + ): Promise { + error = null; + try { + const { type, fields } = resolveTypeAndFields(input); + const legacy = legacyMirror(type, fields); + const now = new Date().toISOString(); + + const newLocal: LocalCard = { + id: crypto.randomUUID(), + deckId: input.deckId, + type, + fields, + order: currentCardCount, + createdAt: now, + updatedAt: now, + ...legacy, + }; + + 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, + updatedAt: now, + }); + } + + await reviewStore.ensureReviewsForCard({ id: newLocal.id, type, fields }); + return newLocal; + } 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 existingRaw = await cardTable.get(id); + if (!existingRaw) return; + const existing = await decryptRecord('cards', { ...existingRaw }); + + const currentType: CardType = existing.type ?? 'basic'; + const currentFields: CardFields = existing.fields ?? { + front: existing.front ?? '', + back: existing.back ?? '', + }; + + const nextType: CardType = updates.type ?? currentType; + const nextFields: CardFields = updates.fields + ? updates.fields + : updates.front !== undefined || updates.back !== undefined + ? nextType === 'cloze' + ? { ...currentFields, text: updates.front ?? currentFields.text ?? '' } + : { + ...currentFields, + front: updates.front ?? currentFields.front ?? '', + back: updates.back ?? currentFields.back ?? '', + } + : currentFields; + + const legacy = legacyMirror(nextType, nextFields); + const diff: Partial = { + type: nextType, + fields: nextFields, + updatedAt: new Date().toISOString(), + ...legacy, + }; + if (updates.order !== undefined) diff.order = updates.order; + + await encryptRecord('cards', diff as Record); + 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); + + if (deckId) { + const deck = await cardDeckTable.get(deckId); + if (deck) { + await cardDeckTable.update(deckId, { + cardCount: Math.max(0, (deck.cardCount || 0) - 1), + updatedAt: now, + }); + } + } + } catch (err: any) { + error = err.message || 'Failed to delete card'; + console.error('Delete card error:', err); + } + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/decks.svelte.ts b/apps/cards/apps/web/src/lib/stores/decks.svelte.ts new file mode 100644 index 000000000..79fcd0f01 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/decks.svelte.ts @@ -0,0 +1,78 @@ +/** + * Deck Store — standalone. + * + * Slim version of the mana-modul decks store: no time-blocks, no + * domain-events, no Mana-wide visibility hooks. Just CRUD against the + * standalone Dexie DB. + */ + +import { cardDeckTable, cardTable, db } from '../data/database'; +import { encryptRecord } from '../data/crypto'; +import type { CreateDeckInput, UpdateDeckInput, LocalDeck } from '@mana/cards-core'; + +let error = $state(null); + +export const deckStore = { + get error() { + return error; + }, + + async createDeck(input: CreateDeckInput): Promise { + error = null; + try { + const now = new Date().toISOString(); + const newLocal: LocalDeck = { + id: crypto.randomUUID(), + name: input.title, + description: input.description ?? null, + color: '#6366f1', + cardCount: 0, + visibility: 'private', + createdAt: now, + updatedAt: now, + }; + await encryptRecord('cardDecks', newLocal); + await cardDeckTable.add(newLocal); + return newLocal; + } 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 diff: Partial = { updatedAt: new Date().toISOString() }; + if (updates.title !== undefined) diff.name = updates.title; + if (updates.description !== undefined) diff.description = updates.description; + await encryptRecord('cardDecks', diff as Record); + await cardDeckTable.update(id, diff); + } catch (err: any) { + error = err.message || 'Failed to update deck'; + console.error('Update deck error:', err); + } + }, + + async deleteDeck(id: string) { + error = null; + try { + const now = new Date().toISOString(); + 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 }); + }); + } catch (err: any) { + error = err.message || 'Failed to delete deck'; + console.error('Delete deck error:', err); + } + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/reviews.svelte.ts b/apps/cards/apps/web/src/lib/stores/reviews.svelte.ts new file mode 100644 index 000000000..7caa295d1 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/reviews.svelte.ts @@ -0,0 +1,90 @@ +/** + * Card-Review Store — standalone. + * + * Plaintext, no encryption hook (Phase 1). Fan-out logic comes from + * @mana/cards-core; the only standalone bit is which Dexie table to write to. + */ + +import { cardReviewTable } from '../data/database'; +import { + newReview, + gradeReview as fsrsGrade, + subIndexesFor, + type CardFields, + type CardType, + type LocalCardReview, + type ReviewGrade, +} from '@mana/cards-core'; + +let error = $state(null); + +export const reviewStore = { + get error() { + return error; + }, + + async ensureReviewsForCard(card: { + id: string; + type: CardType; + fields: CardFields; + }): Promise { + 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 { + 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 { + 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; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts b/apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts new file mode 100644 index 000000000..91fb0b517 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts @@ -0,0 +1,65 @@ +/** + * Study-Block Store — standalone. + * + * Local daily-aggregate row for streak + per-day-stats. + */ + +import { cardStudyBlockTable } from '../data/database'; +import type { LocalCardStudyBlock } from '@mana/cards-core'; + +let error = $state(null); + +function localDateKey(d: Date = new Date()): string { + 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; + }, + + async recordReview(durationMs: number, count: number = 1): Promise { + 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); + } + }, + + async getRecentStreak(): Promise { + 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; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/theme.ts b/apps/cards/apps/web/src/lib/stores/theme.ts new file mode 100644 index 000000000..1260137f3 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/theme.ts @@ -0,0 +1,33 @@ +/** + * Cards Theme Store + * + * Uses the shared theme system. The Cards brand accent (#8b5cf6 from + * MANA_APPS) becomes `--color-app-accent` on document.documentElement + * so the existing `bg-app-accent` / `text-app-accent` utilities work + * everywhere — Lernen-CTA, cloze highlight, link colours, etc. + * + * The accent is theme-agnostic by design: it stays the same whether + * the user picks Lume / Nature / Stone / Ocean × Light / Dark, so the + * Cards identity reads consistently across variants. + */ +import { createThemeStore } from '@mana/shared-theme'; + +export type { ThemeMode, ThemeVariant, EffectiveMode } from '@mana/shared-theme'; + +// Cards brand: #8b5cf6 (violet-500) → HSL channels. +const CARDS_ACCENT_HSL = '258 90% 66%'; + +export const theme = createThemeStore({ + appId: 'cards', +}); + +/** + * Write the Cards app accent onto documentElement once at boot. The + * shared theme store doesn't know about per-app accents — it only + * touches the variant tokens — so we set this independently and it + * survives every variant switch. + */ +export function applyCardsAccent(): void { + if (typeof document === 'undefined') return; + document.documentElement.style.setProperty('--color-app-accent', CARDS_ACCENT_HSL); +} diff --git a/apps/cards/apps/web/src/lib/util/slug.ts b/apps/cards/apps/web/src/lib/util/slug.ts new file mode 100644 index 000000000..677d94af7 --- /dev/null +++ b/apps/cards/apps/web/src/lib/util/slug.ts @@ -0,0 +1,14 @@ +/** + * Best-effort slug suggestion. Server-side validateSlug is the + * authoritative gate; this just gives the user a sensible default + * to edit. + */ +export function slugify(input: string): string { + return input + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} diff --git a/apps/cards/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..0c6207d26 --- /dev/null +++ b/apps/cards/apps/web/src/routes/+layout.svelte @@ -0,0 +1,98 @@ + + + + {@html webManifestLink} + + +{#if isPublic} + {@render children()} +{:else} + +
+
+ + 🃏 Cards + + +
+ {#if streak > 0} + + 🔥 {streak} + + {/if} + + {#if authStore.user?.email} + + {/if} + +
+
+
+ + {@render children()} +
+{/if} diff --git a/apps/cards/apps/web/src/routes/+page.svelte b/apps/cards/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..f59253390 --- /dev/null +++ b/apps/cards/apps/web/src/routes/+page.svelte @@ -0,0 +1,156 @@ + + + + Cards + + +
+
+
+

Cards

+

+ {decks.length} + {decks.length === 1 ? 'Deck' : 'Decks'}{#if totalDue > 0} + · {totalDue} fällig + {/if} +

+
+ +
+ + {#if showNew} +
{ + e.preventDefault(); + handleCreate(); + }} + > + + + +
+ + +
+
+ {/if} + + {#if decks.length === 0 && !showNew} +
+
🃏
+

Noch keine Decks. Leg dein erstes an.

+ +
+ {:else} + + {/if} + +
+ +
+ +
+ +
+ +

+ Phase 1 · synct mit mana.how/cards +

+
diff --git a/apps/cards/apps/web/src/routes/admin/reports/+page.svelte b/apps/cards/apps/web/src/routes/admin/reports/+page.svelte new file mode 100644 index 000000000..70d766f0c --- /dev/null +++ b/apps/cards/apps/web/src/routes/admin/reports/+page.svelte @@ -0,0 +1,170 @@ + + + + Moderation — Cards + + +
+
+

Moderation-Inbox

+ {#if stage === 'ok'} + + {/if} +
+ + {#if stage === 'loading'} +

Lädt…

+ {:else if stage === 'forbidden' || !isAdmin} +

+ Nur Admins haben Zugang zur Moderation-Inbox. +

+ {:else if stage === 'error'} +

+ {error} +

+ {:else if reports.length === 0} +

+ Keine offenen Reports. +

+ {:else} +
    + {#each reports as r (r.id)} +
  • +
    +
    +
    + + {r.category} + + + {r.deckTitle} + + {#if r.cardContentHash} + · Karte {r.cardContentHash.slice(0, 8)}… + {/if} +
    +

    + {new Date(r.createdAt).toLocaleString('de-DE')} +

    +
    +
    + + {#if r.body} +

    + {r.body} +

    + {/if} + + {#if error} +

    {error}

    + {/if} + +
    + + + +
    +
  • + {/each} +
+ {/if} +
diff --git a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte new file mode 100644 index 000000000..692635ddd --- /dev/null +++ b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte @@ -0,0 +1,267 @@ + + + + {deck?.title ?? slug} — Cards + + +
+ {#if stage === 'loading'} +

Lade Deck…

+ {:else if stage === 'not-found'} +

+ Deck {slug} existiert nicht. +

+ {:else if stage === 'error'} +

+ {error} +

+ {:else if deck} +
+
+

{deck.title}

+ {#if deck.description} +

{deck.description}

+ {/if} +
+ +
+ {#if version} + + v{version.semver} + + {version.cardCount} Karten + {/if} + {deck.license} + {#if deck.language} + {deck.language.toUpperCase()} + {/if} + {#if deck.priceCredits > 0} + + {deck.priceCredits} 💎 + + {/if} +
+ + {#if version?.changelog} +
+

+ Changelog v{version.semver} +

+

{version.changelog}

+
+ {/if} + +
+ {#if authStore.isAuthenticated} + + + {#if subscribed} + + {#if subscribedDeckId} + + {/if} + {:else if isPaid && !canSubscribeNow && !isOwner} + + {:else} + + {#if isPaid && hasPurchased} + + ✓ Gekauft + + {/if} + {/if} + {:else} + + Anmelden um zu abonnieren + + {/if} +
+ + {#if error} +

{error}

+ {/if} + +
+ Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')} + {#if !isOwner} + + {/if} +
+ + {#if deck.isTakedown} +

+ Dieses Deck wurde von der Moderation entfernt. +

+ {/if} + + {#if version} + + {/if} + + +
+ {/if} + +

+ ← Marktplatz +

+
diff --git a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte new file mode 100644 index 000000000..79387ec37 --- /dev/null +++ b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte @@ -0,0 +1,547 @@ + + + + {deck?.title ?? 'Deck'} — Cards + + +
+ ← Decks + + {#if deck} +
+
+
+ +

{deck.title}

+
+ {#if deck.description} +

{deck.description}

+ {/if} +
+ +
+ + {#if isSubscribed} +
+
+
+
+ 📥 Abonniert · v{subscribedAtVersion} +
+

+ Aus dem Marktplatz von {subscribedFromSlug}. Karten sind read-only — Author entscheidet über Inhalte. Forken um eigene Variante + zu machen (Phase ε). +

+
+
+ {#if updatePreview} +
+ + Update auf v{updatePreview.to} verfügbar + + + +{updatePreview.added} neu · ~{updatePreview.changed} geändert · −{updatePreview.removed} + entfernt + + +
+ {/if} + {#if updateError} +

{updateError}

+ {/if} +
+ {/if} + +
+ + {#if !isSubscribed} + + {/if} + {#if dueCount === 0 && cards.length > 0} + Heute alles gelernt — schau später wieder rein. + {/if} +
+ +
+
+
{cards.length}
+
Karten
+
+
+
{dueCount}
+
Fällig
+
+
+ + {#if !isSubscribed} +
+ + +
+ {/if} + + {#if showAi} +
+ (showAi = false)} /> +
+ {/if} + + {#if showNew} +
+

Neue Karte

+ +
+ {#each cardTypeOptions as opt (opt.value)} + + {/each} +
+ +
+ {#if newType === 'cloze'} +
+
+ + + +
+ + +

+ Markiere mit + {{c1::Wort}} + — optional Hinweis: ::Hinweis. +

+
+ {:else} +
+
+ + + +
+ + +
+
+
+ + + +
+ +
+ {/if} + {#if attachError} +

{attachError}

+ {/if} +
+ + +
+
+
+ {/if} + +
+

+ Karten ({cards.length}) +

+ {#if cards.length === 0} +
+ Noch keine Karten. Erstelle deine erste! +
+ {:else} +
    + {#each cards as card, i (card.id)} + {@const p = preview(card)} +
  • + {i + 1}. +
    +
    + {@html renderMarkdown(p.primary)} +
    + {#if p.secondary} +
    + {@html renderMarkdown(p.secondary)} +
    + {/if} +
    +
    + + {typeBadge(card.type)} + + {#if !isSubscribed} + + {/if} +
    +
  • + {/each} +
+ {/if} +
+ + {#if confirmDelete} +
(confirmDelete = false)} + onkeydown={(e) => e.key === 'Escape' && (confirmDelete = false)} + role="presentation" + > + + +
e.stopPropagation()} + > +

Deck löschen?

+

+ "{deck.title}" wird mit allen Karten gelöscht. +

+
+ + +
+
+
+ {/if} + {:else} +
+ Deck nicht gefunden. + zurück +
+ {/if} +
+ +{#if showPublish && deck} + (showPublish = false)} /> +{/if} diff --git a/apps/cards/apps/web/src/routes/explore/+page.svelte b/apps/cards/apps/web/src/routes/explore/+page.svelte new file mode 100644 index 000000000..a0f60ce9a --- /dev/null +++ b/apps/cards/apps/web/src/routes/explore/+page.svelte @@ -0,0 +1,130 @@ + + + + Entdecken — Cards + + +
+
+

Entdecken

+

+ Decks aus dem Cards-Marktplatz — kostenlos lernen oder eigene veröffentlichen. +

+
+ +
{ + e.preventDefault(); + runSearch(); + }} + > + + +
+ + {#if stage === 'loading'} +

Lade Marktplatz…

+ {:else if stage === 'error'} +

+ {error} + +

+ {:else if stage === 'search'} +
+
+

+ {searchTotal} Treffer für „{searchQuery}" +

+ +
+ +
+ {:else if stage === 'landing'} + {#if featured.length > 0} +
+

+ 🛡️ Featured · vom Mana-Verein empfohlen +

+ +
+ {/if} + +
+

📈 Trending · letzte 7 Tage

+ +
+ {/if} + +

+ ← Eigene Decks +

+
diff --git a/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte new file mode 100644 index 000000000..6588788b0 --- /dev/null +++ b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte @@ -0,0 +1,226 @@ + + + + Lernen — {deckTitle} — Cards + + +
+
+
+ +

Lernen

+
+ {#if queue.length > 0 && !finished} +
+ {Math.min(currentIndex + 1, queue.length)} / {queue.length} +
+ {/if} +
+ + {#if empty} +
+
Alles gelernt
+

+ Komm später wieder — fällige Karten erscheinen automatisch. +

+ +
+ {:else if finished} +
+
Session abgeschlossen
+

+ {sessionCount} Karten in {Math.round((Date.now() - sessionStartedAt) / 1000)} s. +

+ +
+ {:else if current} + (typedAnswer = v)} + onReveal={reveal} + /> + + {#if canSuggest} +
+ + +
+ + {#if discussionsOpen && subscribedSlug && current?.card.serverContentHash} + + {/if} + {/if} + + {#if !showBack && current.card.type === 'type-in'} + + {:else if showBack} +
+ + + + +
+ {/if} + {:else} +
Lade…
+ {/if} +
+ +{#if subscribedSlug && current} + (suggestOpen = false)} + /> +{/if} diff --git a/apps/cards/apps/web/src/routes/login/+page.svelte b/apps/cards/apps/web/src/routes/login/+page.svelte new file mode 100644 index 000000000..b09129a41 --- /dev/null +++ b/apps/cards/apps/web/src/routes/login/+page.svelte @@ -0,0 +1,35 @@ + + + diff --git a/apps/cards/apps/web/src/routes/me/purchases/+page.svelte b/apps/cards/apps/web/src/routes/me/purchases/+page.svelte new file mode 100644 index 000000000..5f0d29f0f --- /dev/null +++ b/apps/cards/apps/web/src/routes/me/purchases/+page.svelte @@ -0,0 +1,130 @@ + + + + Meine Käufe — Cards + + +
+

Käufe & Auszahlungen

+ + {#if error} +

+ {error} +

+ {/if} + +
+
+

Käufe

+ Ausgegeben: {totalSpent} 💎 +
+ + {#if loading} +

+ Lädt… +

+ {:else if purchases.length === 0} +

+ Du hast noch keine Decks gekauft. +

+ {:else} +
    + {#each purchases as p (p.id)} +
  • +
    + + {p.deckTitle} + +

    + v{p.versionSemver} · {new Date(p.purchasedAt).toLocaleDateString('de-DE')} + {#if p.refundedAt} + Erstattet + {/if} +

    +
    + {p.priceCredits} 💎 +
  • + {/each} +
+ {/if} +
+ + {#if payouts.length > 0 || (!loading && payouts.length === 0)} +
+
+

+ Author-Auszahlungen +

+ Erhalten: {totalEarned} 💎 +
+ + {#if payouts.length === 0} +

+ Noch keine Auszahlungen — sobald jemand eines deiner kostenpflichtigen Decks kauft, landet + die Author-Beteiligung hier. +

+ {:else} +
    + {#each payouts as p (p.id)} +
  • +
    + + {p.deckTitle} + +

    + Verkauf {p.priceCredits} 💎 · gutgeschrieben {new Date( + p.grantedAt + ).toLocaleDateString('de-DE')} +

    +
    + +{p.creditsGranted} 💎 +
  • + {/each} +
+ {/if} +
+ {/if} +
diff --git a/apps/cards/apps/web/src/routes/register/+page.svelte b/apps/cards/apps/web/src/routes/register/+page.svelte new file mode 100644 index 000000000..098ca4162 --- /dev/null +++ b/apps/cards/apps/web/src/routes/register/+page.svelte @@ -0,0 +1,27 @@ + + + diff --git a/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte new file mode 100644 index 000000000..da381fdc1 --- /dev/null +++ b/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte @@ -0,0 +1,138 @@ + + + + {author?.displayName ?? '@' + slug} — Cards + + +
+ {#if stage === 'loading'} +

Lade Profil…

+ {:else if stage === 'not-found'} +

+ Profil @{slug} existiert nicht. +

+ {:else if stage === 'error'} +

+ {error} +

+ {:else if author} +
+ {#if author.avatarUrl} + + {:else} +
+ {author.displayName.slice(0, 1).toUpperCase()} +
+ {/if} +
+
+

{author.displayName}

+ {#if author.verifiedMana} + + 🛡️ Mana + + {/if} + {#if author.verifiedCommunity} + + ⭐ Community + + {/if} +
+

+ @{author.slug} · seit {new Date(author.joinedAt).toLocaleDateString('de-DE', { + year: 'numeric', + month: 'short', + })} +

+ {#if author.bio} +

{author.bio}

+ {/if} +
+ {#if authStore.isAuthenticated} + + {/if} +
+ +

+ {decks.length} + {decks.length === 1 ? 'Deck' : 'Decks'} +

+ + {/if} + +

+ ← Marktplatz +

+
diff --git a/apps/cards/apps/web/static/apple-touch-icon.png b/apps/cards/apps/web/static/apple-touch-icon.png new file mode 100644 index 000000000..d09ef49e5 Binary files /dev/null and b/apps/cards/apps/web/static/apple-touch-icon.png differ diff --git a/apps/cards/apps/web/static/favicon.svg b/apps/cards/apps/web/static/favicon.svg new file mode 100644 index 000000000..1f160f709 --- /dev/null +++ b/apps/cards/apps/web/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/cards/apps/web/static/pwa-192x192.png b/apps/cards/apps/web/static/pwa-192x192.png new file mode 100644 index 000000000..7cd2f8d33 Binary files /dev/null and b/apps/cards/apps/web/static/pwa-192x192.png differ diff --git a/apps/cards/apps/web/static/pwa-512x512.png b/apps/cards/apps/web/static/pwa-512x512.png new file mode 100644 index 000000000..2ab569c8b Binary files /dev/null and b/apps/cards/apps/web/static/pwa-512x512.png differ diff --git a/apps/cards/apps/web/static/sql-wasm-browser.wasm b/apps/cards/apps/web/static/sql-wasm-browser.wasm new file mode 100755 index 000000000..b32b66473 Binary files /dev/null and b/apps/cards/apps/web/static/sql-wasm-browser.wasm differ diff --git a/apps/cards/apps/web/static/sql-wasm.wasm b/apps/cards/apps/web/static/sql-wasm.wasm new file mode 100755 index 000000000..b32b66473 Binary files /dev/null and b/apps/cards/apps/web/static/sql-wasm.wasm differ diff --git a/apps/cards/apps/web/svelte.config.js b/apps/cards/apps/web/svelte.config.js new file mode 100644 index 000000000..fc92816a8 --- /dev/null +++ b/apps/cards/apps/web/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, +}; + +export default config; diff --git a/apps/cards/apps/web/tsconfig.json b/apps/cards/apps/web/tsconfig.json new file mode 100644 index 000000000..9637d322e --- /dev/null +++ b/apps/cards/apps/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true + } +} diff --git a/apps/cards/apps/web/vite.config.ts b/apps/cards/apps/web/vite.config.ts new file mode 100644 index 000000000..89272593f --- /dev/null +++ b/apps/cards/apps/web/vite.config.ts @@ -0,0 +1,20 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { SvelteKitPWA } from '@vite-pwa/sveltekit'; +import tailwindcss from '@tailwindcss/vite'; +import { createPWAConfig } from '@mana/shared-pwa'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + sveltekit(), + SvelteKitPWA( + createPWAConfig({ + name: 'Cards', + shortName: 'Cards', + description: 'Karteikarten mit Spaced Repetition', + themeColor: '#0a0a0a', + }) + ), + ], +}); diff --git a/apps/cards/docs/MARKETPLACE_PLAN.md b/apps/cards/docs/MARKETPLACE_PLAN.md new file mode 100644 index 000000000..d753bc7a4 --- /dev/null +++ b/apps/cards/docs/MARKETPLACE_PLAN.md @@ -0,0 +1,654 @@ +# Cardecky-Marktplatz — Plan + +> **Status**: Plan, kein Code. Stand 2026-05-07. +> **Goal-Setting**: Vollvision, kein MVP-Druck. Wir bauen die optimale Lösung. +> **Alignment**: User hat folgende Eckpunkte gesetzt: +> - Versionierte Decks + Live-Updates + Pull-Requests = ja, volle Vision +> - mana-credits zentral, sowohl für User-Käufe als auch Author-Verdienst +> - „Verified" zweigleisig: Mana-Verein-Kuration UND Community-Schwellen, mit unterschiedlichen Badges +> - Co-Learn-Sessions explizit **nicht** für Phase 1 — auf Phase 2 verschoben +> - Mobile-App auch später + +--- + +## 1. Mission + +**Die Karteikarten-Plattform mit der besten Lern-Community im Netz.** Wo qualitativ hochwertige Decks entstehen, gepflegt, geteilt und gelernt werden — und wo Lernende einander helfen. + +## 2. Was wir gegen die Konkurrenz aufbieten + +(verdichtet aus `apps/cards/COMPETITORS_2026-05.md`) + +| Differenzierer | Wir | Wer noch | +|---|---|---| +| Free Cloud-Sync | ✓ | niemand | +| Versionierte Decks mit Live-Updates | ✓ | nur AnkiHub (paywalled, Medizin-only) | +| Pull-Requests auf Decks | ✓ | niemand | +| Card-Discussions (inline pro Karte) | ✓ | niemand | +| AI-Karten + AI-Moderation + AI-Tags | ✓ | fragmentiert bei anderen | +| Open Source PWA | ✓ | nur Anki/Mnemosyne (Desktop) | +| Anki-Migration mit Bildern/Audio | ✓ (vorhanden) | niemand vollständig | +| Author-Followings + Activity-Feed | ✓ | niemand | +| Bezahlte Decks mit Author-Erlös via mana-credits | ✓ | nur Brainscape (eigenes Closed-Pricing) | +| Pseudonym + verifiziert kombinierbar | ✓ | niemand klar | + +## 3. Architektur-Prinzipien + +1. **API ist `/v1` ab Tag 1** — OpenAPI-Spec als Quelle der Wahrheit, Versionierungs-Bewusstsein eingebaut. +2. **Public-Decks leben separat** vom Local-First-Sync-Pfad (eigene Postgres-Tabellen, eigene Service, eigene RLS-Policies). Kein Vermischen mit `mana_sync.sync_changes`. +3. **Subscribed Decks sind unidirektional**: Author → Subscribers. Updates fließen einseitig. Wer ändern will, forkt. +4. **Content-Hash überall.** Jede Karte und jede Version bekommt einen deterministischen SHA-256 → Trust + Cache + Diff kostenlos. +5. **Lizenzen sind explizit + maschinen-lesbar** (SPDX-IDs: `CC0-1.0`, `CC-BY-4.0`, `CC-BY-SA-4.0`, plus eigener `Cardecky-Personal-Use-1.0` für Default-Käufe und `Cardecky-Pro-Only-1.0` für paid Decks). +6. **AI ist Moderator, nicht Gatekeeper** — KI-First-Pass + Human-Review-Eskalation. Niemals KI-allein-Take-down. +7. **Search ist von der DB entkoppelt** — Read-Only-Index, asynchron befüllt. Bricht der Search-Service, läuft der Marktplatz weiter. +8. **mana-credits ist die einzige Geld-Schnittstelle** — niemals Stripe direkt im cards-server. Alles geht über `/api/v1/credits/use`, `/credits/grant`, `/credits/reservations/*`. +9. **Anonymisiertes Lern-Verhalten**: aggregierte Stats sichtbar (z.B. „1.200 Lernende"), individuelles Lernverhalten nie öffentlich ohne explizites Opt-in. +10. **Keine Drittanbieter-Tracker.** Telemetrie ausschließlich über mana-analytics, opt-out möglich. + +## 4. Datenmodell + +Neues Schema `cards` in `mana_platform`. Alle Tabellen über `pgSchema('cards').table(...)` (Mana-Konvention). + +### 4.1 Authoren + +```sql +public_authors ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id), + slug text UNIQUE NOT NULL, -- @anna-lang + display_name text NOT NULL, + bio text, + avatar_url text, + joined_at timestamptz DEFAULT now(), + pseudonym boolean DEFAULT false, -- true = klarname versteckt + verified_mana boolean DEFAULT false, -- vom Verein verliehen + verified_community boolean DEFAULT false, -- automatisch ab Schwelle + banned_at timestamptz, -- soft-ban + banned_reason text +) +``` + +Drei Verifizierungs-Stufen mit unterschiedlichen Badges in der UI: + +| Status | Badge | Wer / wie | +|---|---|---| +| `verified_mana = true` | 🛡️ **Mana Verifiziert** | Manuell vom Mana-Verein vergeben (Lehrer, Profis, Sprachschulen, Ärzte). Nicht erkaufbar. | +| `verified_community = true` | ⭐ **Community Verifiziert** | Automatisch bei: ≥ 500 Stars über alle Decks ODER ≥ 3 featured Decks ODER ≥ 200 aktive Subscribers über alle Decks. Periodisch neu evaluiert. | +| beides | 🛡️⭐ Beide Badges | Mana + Community zusammen. | + +### 4.2 Decks + Versionen + +```sql +public_decks ( + id uuid PRIMARY KEY, + slug text UNIQUE NOT NULL, -- /decks/anna-lang/spanish-a2-vocab + title text NOT NULL, + description text, + language text, -- ISO-639-1 + license text NOT NULL, -- SPDX + price_credits integer DEFAULT 0, -- 0 = kostenlos + owner_user_id uuid NOT NULL REFERENCES public_authors(user_id), + latest_version_id uuid, -- → public_deck_versions + is_featured boolean DEFAULT false, + is_takedown boolean DEFAULT false, + takedown_at timestamptz, + takedown_reason text, + created_at timestamptz DEFAULT now(), + CONSTRAINT price_requires_license CHECK (price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0') +) + +public_deck_versions ( + id uuid PRIMARY KEY, + deck_id uuid NOT NULL REFERENCES public_decks(id), + semver text NOT NULL, -- 1.0.0, 1.1.0, 2.0.0 + changelog text, + content_hash text NOT NULL, -- SHA-256 of canonicalized cards + card_count integer NOT NULL, + published_at timestamptz DEFAULT now(), + deprecated_at timestamptz, + UNIQUE (deck_id, semver) +) + +public_deck_cards ( + id uuid PRIMARY KEY, + version_id uuid NOT NULL REFERENCES public_deck_versions(id), + type text NOT NULL, -- basic, basic-reverse, cloze, type-in + fields jsonb NOT NULL, -- {front, back} oder {text, extra} + ord integer NOT NULL, + content_hash text NOT NULL, -- per Karte: ermöglicht Smart-Merge + UNIQUE (version_id, ord) +) +``` + +### 4.3 Tags + Discovery + +```sql +tag_definitions ( + id uuid PRIMARY KEY, + slug text UNIQUE NOT NULL, + name text NOT NULL, + parent_id uuid REFERENCES tag_definitions(id), -- Hierarchie + description text, + curated boolean DEFAULT false -- vom Mana-Verein gepflegt +) + +deck_tags ( + deck_id uuid REFERENCES public_decks(id), + tag_id uuid REFERENCES tag_definitions(id), + PRIMARY KEY (deck_id, tag_id) +) +``` + +### 4.4 Engagement (Stars, Subscribes, Forks) + +```sql +deck_stars ( + user_id uuid REFERENCES auth.users(id), + deck_id uuid REFERENCES public_decks(id), + starred_at timestamptz DEFAULT now(), + PRIMARY KEY (user_id, deck_id) +) + +deck_subscriptions ( + user_id uuid REFERENCES auth.users(id), + deck_id uuid REFERENCES public_decks(id), + current_version_id uuid REFERENCES public_deck_versions(id), + subscribed_at timestamptz DEFAULT now(), + notify_updates boolean DEFAULT true, + PRIMARY KEY (user_id, deck_id) +) + +deck_forks ( + user_id uuid REFERENCES auth.users(id), + source_deck_id uuid REFERENCES public_decks(id), + source_version_id uuid REFERENCES public_deck_versions(id), + forked_at timestamptz DEFAULT now(), + PRIMARY KEY (user_id, source_deck_id, source_version_id) +) + +author_follows ( + follower_user_id uuid REFERENCES auth.users(id), + author_user_id uuid REFERENCES public_authors(user_id), + since timestamptz DEFAULT now(), + PRIMARY KEY (follower_user_id, author_user_id) +) +``` + +### 4.5 Pull-Requests + Discussions + +```sql +deck_pull_requests ( + id uuid PRIMARY KEY, + deck_id uuid REFERENCES public_decks(id), + author_user_id uuid REFERENCES auth.users(id), + status text NOT NULL, -- open, merged, closed, rejected + title text NOT NULL, + body text, + diff jsonb NOT NULL, -- {add: [...], modify: [...], remove: [...]} + merged_into_version uuid REFERENCES public_deck_versions(id), + created_at timestamptz DEFAULT now(), + resolved_at timestamptz +) + +card_discussions ( + id uuid PRIMARY KEY, + card_content_hash text NOT NULL, -- bindet sich an Karte, nicht an version + deck_id uuid REFERENCES public_decks(id), + author_user_id uuid REFERENCES auth.users(id), + parent_id uuid REFERENCES card_discussions(id), + body text NOT NULL, + hidden boolean DEFAULT false, + created_at timestamptz DEFAULT now() +) +``` + +### 4.6 Moderation + +```sql +deck_reports ( + id uuid PRIMARY KEY, + deck_id uuid REFERENCES public_decks(id), + version_id uuid REFERENCES public_deck_versions(id), + card_content_hash text, -- optional: Karte spezifisch + reporter_user_id uuid REFERENCES auth.users(id), + category text NOT NULL, -- spam, copyright, nsfw, misinformation, other + body text, + status text DEFAULT 'open', -- open, dismissed, actioned + resolved_by uuid, + resolved_at timestamptz, + resolution_notes text, + created_at timestamptz DEFAULT now() +) + +ai_moderation_log ( + id uuid PRIMARY KEY, + version_id uuid REFERENCES public_deck_versions(id), + verdict text NOT NULL, -- pass, flag, block + categories text[], -- spam, csam, hate, nsfw, ... + model text, -- "claude-3-5-sonnet" etc + rationale text, + human_reviewed boolean DEFAULT false, + human_overrode boolean DEFAULT false, + created_at timestamptz DEFAULT now() +) +``` + +### 4.7 mana-credits Integration + +```sql +deck_purchases ( + id uuid PRIMARY KEY, + buyer_user_id uuid REFERENCES auth.users(id), + deck_id uuid REFERENCES public_decks(id), + version_id uuid REFERENCES public_deck_versions(id), + price_credits integer NOT NULL, -- Snapshot zum Zeitpunkt des Kaufs + author_share integer NOT NULL, -- nach Verein-Cut + mana_share integer NOT NULL, + credits_transaction text, -- mana-credits ID + purchased_at timestamptz DEFAULT now(), + refunded_at timestamptz, + UNIQUE (buyer_user_id, deck_id) -- einmal Kauf reicht für Lifetime + alle Versionen +) + +author_payouts ( + id uuid PRIMARY KEY, + author_user_id uuid REFERENCES public_authors(user_id), + source_purchase_id uuid REFERENCES deck_purchases(id), + credits_granted integer NOT NULL, + credits_grant_id text, -- mana-credits grant ID + granted_at timestamptz DEFAULT now() +) +``` + +## 5. mana-credits Integration (Detail) + +Zwei-seitiger Marktplatz. mana-credits ist Single-Source-of-Truth fürs Geld. + +### 5.1 Kauf-Flow (Buyer) + +1. User klickt „Kaufen" auf paid Deck (Preis: z.B. 50 Credits) +2. cards-server checkt: Hat User schon dieses Deck? (deck_purchases) → wenn ja, sofort Zugriff +3. cards-server reserviert Credits via `POST mana-credits/api/v1/credits/reservations` (2-phase) +4. cards-server erstellt deck_purchases-Row (committed) +5. cards-server commit-released die Reservation → Credits abgebucht +6. cards-server erstellt author_payouts-Row → ruft `POST mana-credits/api/v1/internal/credits/grant` für den Author-Anteil +7. User bekommt sofortigen Zugriff: Deck wird in private Liste verschoben (User hat eine eigene Lokal-Kopie als Author-Subscription) + +**Was passiert wenn Author gebannt nach Kauf?** → Refund-Path (Phase γ Implementation): Admin kann Refund triggern → mana-credits → Reverse-Grant → User behält das Deck nicht mehr. + +### 5.2 Author-Auszahlungs-Modell + +- **Standard-Cut**: 80 % Author / 20 % Mana-Verein (Server-, Hosting-, Moderations-Kosten) +- **Verifizierte Authoren** (verified_mana): 90 % / 10 % +- **Mindestauszahlung**: keine — Credits werden direkt im mana-credits-Account gebucht, von dort kann der Author sie selbst nutzen oder per Stripe-Payout (mana-credits-Feature, falls vorhanden) abheben +- **Pricing-Range**: Free (0 Credits), oder 10–500 Credits (entspricht ungefähr 1–50 € — exakte Conversion siehe mana-credits packages) + +### 5.3 Käufer-Lebenszyklus + +- Einmal gekauft = Lifetime-Zugriff auf alle künftigen Versionen +- Bei major Version (e.g. 1.x → 2.0.0) **kein** zweiter Kauf nötig — Author behält die Verbesserungs-Pflicht +- Refund-Window: 30 Tage, automatisch verfügbar wenn ≤ 10 % der Karten gelernt wurden (Quizlet hat das, ist Best-Practice) + +### 5.4 Buyer-Protection bei Take-Down + +- Wenn Deck per Take-Down entfernt wird, behält Buyer Zugriff auf das letzte gesehene Snapshot (DSGVO-konform) +- Refund automatisch wenn Take-Down innerhalb 90 Tagen nach Kauf + +## 6. Service-Architektur + +### 6.1 `cards-server` (neu) + +- **Stack**: Hono + Bun (Mana-Konvention) +- **Port**: 3072 +- **Deps**: PostgreSQL (`mana_platform.cards.*`), Redis (Job-Queue für Indexing/Notifications) +- **Auth**: JWT via JWKS (mana-auth) +- **Routes**: siehe §7 + +### 6.2 `cards-search` (neu, später) + +- Eigene PostgreSQL-Instance mit pg_trgm + tsvector + pgvector +- Async-Indexer hört auf cards-server-Events („deck-published", „deck-updated") +- Optional: Meilisearch wenn Postgres FTS nicht reicht + +### 6.3 mana-llm (existierend, erweitert) + +- Embeddings für semantic search (jeden Deck-Description + Karte → 1536-dim Vector) +- Moderation-First-Pass (Klassifikation in spam/csam/hate/nsfw/etc.) +- Auto-Tag-Suggestions +- Auto-Summary für Deck-Beschreibungen + +### 6.4 mana-credits (existierend, erweitert) + +- Bestehende `/credits/use` und `/credits/reservations/*` für Kauf +- Bestehender `/internal/credits/grant` für Author-Auszahlung +- Vermutlich keine API-Erweiterung nötig + +### 6.5 mana-notify (existierend, erweitert) + +- Push-Notifications für Subscribe-Updates, neue Subscribers, neue Discussions/Replies, neue Stars (vom User konfigurierbar) + +### 6.6 mana-media (existierend) + +- Bilder/Audio in published Decks landen wie heute auch +- Pro Author-Tier ein Soft-Quota: Free 100MB, Verified 1GB, Mana 5GB + +## 7. API-Endpoints (Auswahl) + +OpenAPI-Spec wird die Quelle der Wahrheit; hier die wichtigsten Routes: + +### 7.1 Authoren + +``` +POST /v1/authors/me — Profil anlegen/updaten (slug, displayName, bio, avatar, pseudonym) +GET /v1/authors/:slug — Public Profile + Decks-Liste + Stats +GET /v1/authors/me/dashboard — Eigene Stats: Subscriber, Erlöse, Mod-Inbox +POST /v1/authors/:slug/follow — Folgen +DELETE /v1/authors/:slug/follow — Entfolgen +GET /v1/authors/me/feed — Personal Activity-Feed +``` + +### 7.2 Decks + +``` +POST /v1/decks — Deck als public registrieren (Init-Flow) +GET /v1/decks/:slug — Public Deck mit latest version +GET /v1/decks/:slug/versions — Versionsliste mit Changelogs +GET /v1/decks/:slug/versions/:semver — Specific Version + alle Karten +PATCH /v1/decks/:slug — Metadaten (title, description, license, price) + +POST /v1/decks/:slug/publish — Neue Version publishen (body: cards[], semver, changelog) + → triggert AI-Mod-Pass + → setzt latest_version_id + +POST /v1/decks/:slug/star — Star setzen +DELETE /v1/decks/:slug/star — Star entfernen + +POST /v1/decks/:slug/subscribe — Subscribe (lädt + sync'd Karten in lokale DB) +DELETE /v1/decks/:slug/subscribe — Unsubscribe + +POST /v1/decks/:slug/fork — Fork (lokale Kopie + Author-Lineage) + +POST /v1/decks/:slug/buy — Paid Deck kaufen (mana-credits-Flow) +POST /v1/decks/:slug/refund — Refund anfragen +``` + +### 7.3 Pull-Requests + +``` +GET /v1/decks/:slug/pull-requests — Liste +POST /v1/decks/:slug/pull-requests — Neuer PR (body: title, body, diff) +GET /v1/pull-requests/:id — Details +POST /v1/pull-requests/:id/merge — Author merged → erstellt neue Version +POST /v1/pull-requests/:id/close — Author schließt +POST /v1/pull-requests/:id/comments — Diskussion auf PR-Ebene +``` + +### 7.4 Discussions + +``` +GET /v1/cards/:contentHash/discussions — Threads für eine Karte (über Versionen hinweg) +POST /v1/cards/:contentHash/discussions — Neuer Thread / Reply +POST /v1/discussions/:id/hide — Author/Mod versteckt +``` + +### 7.5 Discovery + Search + +``` +GET /v1/explore — Featured + Trending + Categories (curated) +GET /v1/search?q=…&tag=…&lang=…&sort=… — Volltextsuche (FTS + semantic) +GET /v1/tags — Tag-Hierarchie +GET /v1/decks?author=…&tag=…&sort=…&p=… — Filtered Browse +``` + +### 7.6 Reports + Moderation + +``` +POST /v1/decks/:slug/report — User reportet Deck +POST /v1/cards/:contentHash/report — User reportet Karte +GET /v1/admin/reports — Admin-Inbox (verifizierte Mana-Mods only) +POST /v1/admin/decks/:slug/takedown — Admin entfernt Deck +POST /v1/admin/authors/:slug/ban — Admin sperrt Author +POST /v1/admin/authors/:slug/verify-mana — Mana-Verein-Badge vergeben +``` + +### 7.7 Notifications + +``` +GET /v1/notifications — Unread + recent +POST /v1/notifications/:id/read — Mark read +PATCH /v1/notifications/preferences — Settings (welche Events triggern Push) +``` + +## 8. UI / Routes (Cardecky-Frontend) + +``` +/explore — Featured + Trending + Tag-Tree + Search-Bar +/explore/search?q=… — Search-Result-Page +/explore/tag/:slug — Tag-Page + +/u/:slug — Author-Profil (Public) +/u/:slug/follow — Follow-Button im Header + +/d/:slug — Public-Deck-Detail-View + (Description, Stats, Latest-Karten-Preview, Subscribe/Fork/Star/Buy, Discussions) +/d/:slug/v/:semver — spezifische Version +/d/:slug/discussions — Alle Discussions zum Deck +/d/:slug/pull-requests — PRs +/d/:slug/pull-requests/:id — PR-Detail mit Diff-View + +/me/decks — Eigene private Decks (heute existiert) +/me/published — Eigene published Decks + Stats +/me/subscribed — Abonnierte Decks (mit Update-Indikator) +/me/forks — Geforkte Decks +/me/dashboard — Author-Dashboard (Erlöse, Subscriber-Wachstum) + +/feed — Personal Activity-Feed (Following-Activity + Updates) + +/admin/reports — Admin-Inbox (verified-mana-only) +/admin/decks — Take-Down-UI +/admin/authors — Verify + Ban +``` + +Zusätzlich: einige bestehende Komponenten erweitern (DeckDetail bekommt Subscribe-Button etc.). + +## 9. Cold-Start-Strategie + +Marktplatz ohne Decks ist nutzlos. Drei parallele Hebel: + +1. **Verein-Seed-Decks**: 50 hochwertige Decks selbst erstellen — sprachen (Top-3000 Vokabeln pro Sprache), Geschichte (TimeLine-Karten), Allgemeinwissen, Programmierung. Vom Mana-Team published, alle mit `verified_mana`-Badge. +2. **Anki-Top-100-Import-Service**: Wir bieten an, populäre Anki-Web-Decks (mit korrekter CC-BY-Lizenz) zu importieren und mit Original-Author-Attribution als Public-Decks anzulegen. Original-Author bekommt das `verified_mana`-Badge wenn er sich registriert. +3. **Influencer-Outreach**: Direkte Ansprache von 10-20 Anki-Power-Authoren (AnKing, etc.) mit dem Angebot eines verified-Status + sehr Author-freundlichem Cut. Wenn 1-2 wechseln, kommt ein Lawineneffekt. + +## 10. Risiken + Mitigationen + +| Risiko | Mitigation | +|---|---| +| Cold-Start (Marktplatz leer) | Seed + Anki-Import + Influencer (siehe §9) | +| Spam / Junk-Decks | AI-Mod-First-Pass + Report-System + Author-Ban-Flow | +| Copyright-Klagen (Lehrbuch-Karten) | Lizenz-Pflichtangabe + DMCA-Process + Take-Down-Workflow | +| Server-Kosten (Storage von Bildern/Audio) | Soft-Quotas pro Author-Tier (§6.6) + lossy compression im mana-media | +| AnkiHub als Konkurrent (Live-Updates Medizin) | „Alle Fachgebiete + gratis" als Counter; Med-Decks aktiv akquirieren | +| Mana-Credits-Verein-Cut zu hoch oder zu niedrig | A/B-Test verschiedener Cut-Verhältnisse; Best-Practice: ~80/20 für Standard, ~90/10 für Verified | +| Author-Frustration über fehlende Mobile-App | Klarer Roadmap-Hinweis + Mobile-Push-Notifications via PWA (heute geht das schon) | +| Discussions werden Toxic | Author-Owns-Their-Discussions (kann hide); Community-Mod (Verified-User können flaggen); klar dokumentierte Community-Guidelines | +| Mining/Scraping der Decks | Rate-limit auf API + Auth-Required für full-content; offene Snippets aber paywall am Voll-Inhalt | + +## 11. Phasenplan + +> **Co-Learn explizit ausgeklammert.** Mobile-App auch. + +### Phase α — Daten-Skelett (cards-server v0.1) + +- `services/cards-server/` SvelteKit-style Service-Setup, Hono + Bun + Drizzle +- Alle Schema-Tabellen + Migrationen (§4) +- API-Routes (CRUD-Niveau): Authoren, Decks, Versionen, Stars, Subscriptions +- OpenAPI-Spec +- Integration-Tests (Drizzle + Vitest) +- mana-auth-JWT-Middleware (`@mana/shared-hono`) +- Container in `docker-compose.macmini.yml` +- Cloudflare-Tunnel-Route `cardecky-api.mana.how` → `:3072` + +### Phase β — Author-Workflow ✅ shipped + +- ✅ „Author werden"-Flow im Frontend (Profil anlegen, slug claimen) +- ✅ „Publish"-Aktion auf Deck-Detail-Seite + - ✅ Lizenz-Picker (SPDX-Auswahl) + - ✅ Optional: Preis in Credits + - ⏳ Tags: Picker fehlt im Publish-Flow; Server-Schema steht +- ✅ Versioning: semver-Eingabe (Auto-Suggest pre-fill folgt in θ) +- ✅ Changelog-Editor +- ✅ AI-First-Pass-Moderation (mana-llm classify, Verdict im Publish-Result) +- ⏳ Author-Dashboard mit Subscriber-Counts: Erlöse jetzt unter `/me/purchases`, restliche Stats fehlen + +### Phase γ — Discovery-Frontend ✅ shipped (FTS minimal) + +- ✅ `/explore`-Seite mit Featured + Trending +- 🟡 Volltext-Suche: einfaches `ILIKE` über Title/Description; tsvector-Upgrade in Phase ι +- 🟡 Tag-Hierarchie: flach implementiert; baumartige Eltern-Kind-Navigation offen +- ✅ Author-Profile (`/u/`) + Follow-Button +- ⏳ Activity-Feed (wer hat was published / merged): nicht gebaut +- ✅ Star-System + +### Phase δ — Subscribe + Updates + Smart-Merge ✅ shipped + +- ✅ „Abonnieren"-Button → lädt aktuelle Version in lokale Cardecky-DB +- 🟡 Update-Detection: Polling beim Öffnen der Deck-Page; **kein** WebSocket-Push (kommt in θ/ι) +- ✅ **Smart-Merge**: Diff zwischen Versionen → unveränderte Karten behalten FSRS-State; geänderte erben FSRS-State über Ord-Pairing-Heuristik; neue + entfernte werden korrekt behandelt +- ✅ Diff-View „+N · ~N · −N" mit Apply-Button auf der Deck-Page +- ⏳ Push-Notifications für Subscribe-Updates via mana-notify: PR-/Verkaufs-Mails sind drin (ε.3, ζ.1), Update-Mail noch nicht + +### Phase ε — Pull-Requests + Discussions ✅ shipped + +- ✅ PR-Erstellen-UI: „✏️ Verbessern" auf `/learn/[id]` für Karten aus abonnierten Decks (modify oder remove) +- ✅ PR-Diff-Preview (flach, alle drei Blöcke `add` / `modify` / `remove`) +- ✅ Author-Merge-Workflow → erstellt neue Version atomar, bumped semver-Minor by default +- ✅ Inline-Discussion-Threads: in `/learn` (Toggle) + auf `/d/` (Karten-Liste mit Comment-Counts) +- ✅ Notify: Author bei neuem PR; PR-Author bei Merge/Reject (deterministische ExternalIDs für Dedup) +- ⏳ Mention-System (@username): nicht gebaut; Schema-Änderung später trivial +- 🟡 PR-Merge ist „stale-blind": kein Rebase / Konflikt-Detection (siehe §13a) + +### Phase ζ — mana-credits Marketplace 🟡 ζ.1 shipped, ζ.2 offen + +- ✅ Paid-Deck-Workflow End-to-End: 4-step Pipeline `reserve → INSERT purchase → commit → grant author + INSERT payout`, idempotent über `(buyer, deck)` +- ✅ Author-Auszahlungs-Pipeline: 80/20 Standard, 90/10 für `verifiedMana`-Authoren, kommt aus `config.authorPayout` (Basis-Punkte) +- ✅ Buyer-Dashboard `/me/purchases` mit Käufen + Author-Auszahlungs-Historie +- ⏳ **Refund-Workflow**: bewusst out-of-scope für ζ.1 (Author-Clawback ist konzeptuell heikel — siehe §13a) +- ⏳ **Reconciler**: bei Commit-/Grant-Failure nach Schritt 2 bleibt eine Purchase-Row mit `creditsTransaction = null` bzw. ohne Payout. Code logged, niemand fegt nach. Cron-Sweep in ζ.2 +- ⏳ Author-Payouts-CSV-Export für Steuern + +### Phase η — Moderation + Trust 🟡 η.1 shipped, η.2/η.3 offen + +- ✅ Report-Buttons auf Deck (`/d/`) + Discussion-Kommentare +- ✅ Admin-Inbox-UI (`/admin/reports`) mit Abweisen / Deck-Takedown / Author-Bann +- ✅ Take-Down-Workflow: transaktional, auto-closed parallele Reports + offene PRs auf demselben Deck, Mail an Author +- 🟡 Verified-Badge-Vergabe via API (`POST /v1/admin/authors/:slug/verify`); kein dediziertes UI +- ⏳ **Community-Verified Auto-Calculation**: Schema + Schwellwerte da; Cron-Job fehlt (η.2) +- ⏳ **Public Take-Down-Changelog**: Plan erwähnt das, nicht gebaut +- ⏳ **Verified-Mana-only Mods**: aktuell nur `role === 'admin'`; Plan-Vision ist „verified-mana darf auch resolven" — feiner Cut, später +- ⏳ Author-Ban-Process: Ban kaskadiert auf Decks ✅, aber kein Self-Service-Appeal-Flow für Author +- ⏳ Report-Spam-Schutz (Rate-Limit pro User+Deck): nicht da + +### Phase θ — Deep AI + +- Auto-Tag-Suggestions beim Publish (mana-llm) +- Auto-Summary für Decks (mana-llm Markdown-Render-tauglich) +- Audio-Vertonung mit mana-tts (Author opt-in: alle Karten als Audio generieren) +- Semantic-Search via Embeddings (mana-llm + pgvector) +- Personalized-Discovery („Empfohlen für dich" basierend auf Lern-Historie) + +### Phase ι — Optimierung + Skalierung + +- Search-Service als separater Pod (Meilisearch wenn Postgres FTS limitiert) +- CDN für public-deck-content (Cache + Geo-Distribution) +- Rate-Limiting + Anti-Scraping +- Real-time-Stats-Aggregation (Materialized Views) + +### Phasen die später kommen (explizit nicht in diesem Plan) + +- **Phase λ — Co-Learn-Sessions**: WebSocket-Multiplayer, gemeinsam lernen, Sehen-was-andere-machen +- **Phase μ — Mobile-Apps**: Expo-App (Cardecky-Standalone-Mobile) +- **Phase ν — Author-Tools**: Bulk-Edit-UI für Authoren mit großen Decks, Style-Templates, Author-Analytics-Deep-Dive +- **Phase ξ — Lern-Battles**: Asynchroner Wettkampf-Modus + +## 12. Konkrete Differenzierungs-Hebel — was geht wirklich nur bei uns + +1. **Gratis Cloud-Sync + Live-Updates auf abonnierte Decks**. Niemand sonst hat beides ohne Paywall. +2. **Pull-Requests auf Decks**. AnkiHub erlaubt das nicht so flüssig, andere gar nicht. „Lerne und verbessere mit" als Modus. +3. **Card-Discussions inline** — wenn ich beim Lernen eine Karte unverständlich finde, kann ich direkt fragen / ergänzen. Anki hat Plugin dafür, RemNote auch nicht. +4. **Authoren verdienen via mana-credits** — wir behandeln Authoren als 1st-Class-Konstrukt mit Erlös-Möglichkeit. Quizlet macht das nicht, AnkiWeb macht das nicht, Brainscape paywalled stattdessen die User. +5. **Open Source PWA** mit klarer Roadmap-Transparenz — Vertrauensvorsprung vs. Quizlet (closed, Trustpilot 1.4/5) und gegenüber AnkiPro/AnkiApp (closed-source, Brand-Sniper). +6. **Doppelte Verifizierungs-Stufen** mit unterschiedlichen Badges — Anki-Foren machen das ad-hoc; wir formalisieren es. +7. **AI als Moderator + Generator + Indexer** ohne Paywall — wir haben den eigenen mana-llm-Stack, Konkurrenten zahlen OpenAI per Call. + +## 13. Was wir NICHT tun + +- **Kein Decks-Bewertungssystem mit 1-5 Sternen**. Stars (Bookmarks) ja, Bewertungen nein — die werden gegamed (Quizlet-Erfahrung), und führen zu Author-Frust + Review-Bombing. +- **Kein Reddit-Style-Voting auf Karten / PRs / Discussions**. Wirkt cool, ruiniert die Community (Hacker-News-Effekt). Lieber „helpful"-Reactions in begrenzten Kategorien. +- **Kein „Karten der Woche" allein-algorithmisch**. Editorial-Pick (Mana-Verein) + Trending-Liste, aber niemals nur Algo, das landet immer beim niederschwelligsten Content. +- **Kein Anki-Bashing im Marketing**. Anki ist OSS, ehrlich, und wir wollen nicht ihre Audience entfremden — wir wollen sie ergänzen. Bridge nicht Burning. +- **Keine Pflicht-Klarnamen**. Pseudonyme bleiben gleichberechtigt. Verifizierung ist Bonus, nicht Pflicht. +- **Kein Marketplace-Cut über 30 %**. Apple-App-Store-Hass ist real, wir bleiben fair. + +## 13a. Bekannte Limitierungen / „macht später" + +**Phase ε (Pull-Requests + Discussions)** + +- **PR-Merge ist stale-blind**: `merge()` baut die neue Version aus `currentCards` zusammen, indem es Removes anwendet, dann Modifies-by-Hash, dann Adds. Wenn der Author zwischen PR-Open und Merge selbst eine Karte geändert hat, deren `previousContentHash` der PR matched, gewinnt **stumm** der PR — kein Konflikt-Hinweis. Akzeptabel solange wir wenige PRs/Tag haben; später entweder (a) PR-rebase mit `status=stale` bei Konflikt, oder (b) optimistic locking via `baseVersionId` auf der PR-Row mit Reject bei Mismatch. +- **Keine Multi-Card-Diff-Visualisierung**: PR-Diff-Preview zeigt jeden Block (`add` / `modify` / `remove`) flach. Bei großen PRs mit 50+ Karten unübersichtlich — Side-by-side-Vergleich pro modify wäre nett. +- **Discussion-Threading ist 1-Level**: Server speichert schon `parent_id`, aber das UI rendert flach. Bei Bedarf später ein Antworten-Button + visuelle Einrückung — kein Schema-Change nötig. +- **Card-Preview-Heuristik ist roh**: `` zieht `front` → `text` → erstes nicht-leeres Feld, strippt HTML, capt bei 140 Zeichen. Bei Cloze-Karten sieht der Leser den Roh-Text mit `{{c1::…}}`-Markern statt der maskierten Lern-Form. Kein Showstopper; später kann der Server eine `searchPreview`-Spalte schreiben. + +**Phase ζ (Paid Decks)** + +- **Refunds**: bewusst weggelassen. Author-Clawback ist konzeptuell heikel, weil der Author seinen Anteil nach Grant schon ausgegeben haben kann (→ 402 beim Reverse-Charge). Empfohlene ζ.2-Variante: Admin-only Refund, Buyer kriegt vollen Preis zurück, Author-Clawback nur best-effort, AGB-Klausel über Author-Cut-Risiko bei Refund. +- **Reconciler fehlt**: Wenn `commit` oder `grant` nach Schritt 2 fehlschlägt, bleibt eine Purchase-Row mit `creditsTransaction = null` bzw. ohne `author_payout`. Code logged das, aber niemand fegt nach. Cron-Sweep in ζ.2. +- **Buyer hat keinen Refund-Self-Service**: kein 30-Tage-Window-Knopf in der UI. Plan §5.3 sieht ihn vor; warten auf ζ.2. +- **CSV-Export für Steuern**: nicht drin. Easy add-on, sobald Verein die Steuerklärung 2026 vorbereitet. + +**Phase η (Moderation)** + +- **Verified-Mana-only Mods**: Admin-Gate ist aktuell `role === 'admin'`. Plan §11 sieht vor, dass auch verified-mana-Authoren Reports abarbeiten dürfen (mit eingeschränkten Aktionen). Würde nach den ersten 50 Reports sinnvoll, vorher over-engineered. +- **Community-Verified Cron**: Schema + Schwellwerte (`COMMUNITY_VERIFY_STARS=500`, `_FEATURED=3`, `_SUBSCRIBERS=200`) sind im config, aber kein Job berechnet `verified_community`. Add-on: ein Cron-Endpoint im internal API + SystemD-Timer auf Mac mini. +- **Public Take-Down-Changelog**: Plan erwähnt eine `/transparency`-Page — nicht gebaut. Bringt Trust, niedrige Priorität. +- **Appeal-Self-Service**: Author hat keinen Self-Service-Knopf für Restore. Bewusste Entscheidung — Appeals sollen menschlich sein, kein Self-Restore. +- **Report-Spam-Schutz**: ein User kann unbegrenzt Reports gegen ein Deck filen. Rate-Limit (max 1/User+Deck+Tag) wäre billig; kommt mit Phase ι. + +**Querschnittsthemen** + +- **Disk-Space auf der Build-Maschine** (Mac mini): aktuell ~6.7 GB frei. `pnpm store prune` als nächste Notbremse, falls cards-web-Builds enge Container-Layer brauchen. + +## 14. Offene Punkte die später entschieden werden müssen + +- **Mobile-Push-Notifications** für Subscribe-Updates: native PWA-Push reicht aktuell, aber Browser-API ist hin- und her — könnte Phase ι in einen eigenen Push-Service auslagern müssen. +- **Slack/Discord-Bots für Author-Updates**: nice-to-have, irgendwann. +- **Embed-Widget**: „Lerne dieses Deck auf meiner Webseite" mit IFrame — könnte Reichweite stark boosten. +- **API-Public**: API-Keys für Drittentwickler die eigene Tools rund um Cards bauen. +- **Backup für Subscriber**: Wenn ein Author published-Deck depubliziert, behalten Subscriber das letzte Snapshot (DSGVO-pflicht eh). +- **Internationalisierung der UI** (heute nur DE): nötig fürs internationale Publikum. + +## 15. Aktueller Stand 2026-05-07 + +| Phase | Status | Was läuft | Was fehlt | +|-------|--------|-----------|-----------| +| α — Skelett | ✅ | cards-server lebt auf 3072, Schema gepushed, JWT-Auth, Container in `docker-compose.macmini.yml`, Tunnel-Route `cardecky-api.mana.how` | — | +| β — Author-Workflow | ✅ | Profil-Claim, Publish, Lizenz, Preis, AI-Mod-Verdict | Tag-Picker im Publish, Author-Dashboard-Stats | +| γ — Discovery | ✅ | `/explore`, Stars, Follows, Author-Profile, Trending | tsvector-FTS, Tag-Tree, Activity-Feed | +| δ — Subscribe + Smart-Merge | ✅ | Pull, Smart-Merge mit FSRS-State-Erhalt, Diff-View | WebSocket-Push, Update-Mails | +| ε — PRs + Discussions | ✅ | PR-Erstellen / List / Merge / Reject / Close, Discussions auf `/learn` + `/d/`, Notify-Mails | Mention-System, PR-Rebase, Multi-Card-Diff-View, Discussion-Threading | +| ζ — Paid Decks | 🟡 ζ.1 | Buy-Flow, Author-Payout, Buyer-Dashboard | Refund, Reconciler, CSV-Export | +| η — Moderation | 🟡 η.1 | Reports, Admin-Inbox, Takedown, Ban-Cascade, Verify-API | Community-Verified-Cron, Public-Changelog, Verified-Mana-Mod-Permissions, Rate-Limit | +| θ — Deep AI | ⏳ | — | Auto-Tags, Auto-Summary, TTS, Embeddings, Personalized-Discovery | +| ι — Optimierung | ⏳ | — | Search-Service, CDN, Rate-Limiting, Materialized Views | +| λ / μ / ν / ξ | ⏳ | — | später (Co-Learn, Mobile, Author-Tools, Lern-Battles) | + +**Live-Domains**: `cardecky.mana.how` (Web) · `cardecky-api.mana.how` (API). + +**Nächste sinnvolle Schritte (Empfehlung)**: + +1. **ζ.2 Reconciler + minimaler Admin-Refund** — schließt das größte operative Loch im Paid-Flow. +2. **η.2 Community-Verified-Cron** — Plan-Vision der „doppelten Verifizierung" ist sonst nur halb umgesetzt; Cron ist klein. +3. **Update-Mail in δ.4** — Subscriber bekommen sonst nichts mit, wenn Author published. Dann ist die Notify-Story rund (PR-Open + PR-Merged + PR-Rejected + Verkauf + Takedown + Update). +4. **Phase θ starten** — Auto-Tags + Auto-Summary beim Publish via mana-llm: kostet wenig Code, viel Discovery-Hebel. + +--- + +*Plan erstellt: 2026-05-07. Owner: @till. Letzter Stand-Update: 2026-05-07 nach η.1.* diff --git a/apps/cards/package.json b/apps/cards/package.json new file mode 100644 index 000000000..b5f750efe --- /dev/null +++ b/apps/cards/package.json @@ -0,0 +1,9 @@ +{ + "name": "cards", + "version": "0.1.0", + "private": true, + "description": "Cardecky — Spaced-Repetition flashcards on cardecky.mana.how (Marketing-Landing: cardecky.com). Standalone Phase-1 frontend; data shared with the mana cards module via mana-sync.", + "scripts": { + "dev": "pnpm run --filter=@cards/* --parallel dev" + } +} diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 0f249b13e..d8d4ab5d6 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -16,10 +16,10 @@ export default defineConfig({ replacesTitle: false, }, social: { - github: 'https://github.com/Memo-2023/managarten', + github: 'https://github.com/mana/mana-monorepo', }, editLink: { - baseUrl: 'https://github.com/Memo-2023/managarten/edit/main/apps/docs/', + baseUrl: 'https://github.com/mana/mana-monorepo/edit/main/apps/docs/', }, customCss: ['./src/styles/custom.css'], sidebar: [ diff --git a/apps/docs/src/content/docs/architecture/security.mdx b/apps/docs/src/content/docs/architecture/security.mdx index a6432a0b7..823bc3a4e 100644 --- a/apps/docs/src/content/docs/architecture/security.mdx +++ b/apps/docs/src/content/docs/architecture/security.mdx @@ -16,7 +16,7 @@ Mana encrypts user-typed content with **AES-GCM-256** before it touches IndexedD ## What's encrypted -**27 tables** ship with at-rest encryption enabled. The full list is in [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/managarten/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md), but the highlights: +**27 tables** ship with at-rest encryption enabled. The full list is in [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md), but the highlights: | Module | Fields | |--------|--------| @@ -238,7 +238,7 @@ the full standard / ZK guarantees. ## Implementation references -For the architectural deep dive, code locations, and the complete rollout history (Phases 1–9 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/managarten/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md). +For the architectural deep dive, code locations, and the complete rollout history (Phases 1–9 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md). Key files: diff --git a/apps/docs/src/content/docs/deployment/mac-mini-server.mdx b/apps/docs/src/content/docs/deployment/mac-mini-server.mdx index aa16648ac..9c4e4fbbb 100644 --- a/apps/docs/src/content/docs/deployment/mac-mini-server.mdx +++ b/apps/docs/src/content/docs/deployment/mac-mini-server.mdx @@ -37,7 +37,7 @@ Requires `cloudflared` installed: `brew install cloudflare/cloudflare/cloudflare ## Directory Structure ``` -~/projects/managarten/ +~/projects/mana-monorepo/ ├── docker-compose.macmini.yml # Production compose file ├── .env.production # Production environment ├── scripts/mac-mini/ # Server management scripts @@ -53,7 +53,7 @@ Requires `cloudflared` installed: `brew install cloudflare/cloudflare/cloudflare ```bash ssh mana-server -cd ~/projects/managarten +cd ~/projects/mana-monorepo ./scripts/mac-mini/status.sh ``` @@ -71,7 +71,7 @@ chat-backend running (healthy) ```bash ssh mana-server -cd ~/projects/managarten +cd ~/projects/mana-monorepo ./scripts/mac-mini/deploy.sh ``` diff --git a/apps/docs/src/content/docs/deployment/overview.mdx b/apps/docs/src/content/docs/deployment/overview.mdx index 6c8ca5ce0..be773e25b 100644 --- a/apps/docs/src/content/docs/deployment/overview.mdx +++ b/apps/docs/src/content/docs/deployment/overview.mdx @@ -92,7 +92,7 @@ pnpm deploy:docs ssh mana-server # Pull latest changes -cd ~/projects/managarten +cd ~/projects/mana-monorepo git pull # Restart services @@ -135,7 +135,7 @@ npx wrangler pages deployment tail --project-name=chat-landing ```bash ssh mana-server -cd ~/projects/managarten +cd ~/projects/mana-monorepo # Revert to previous commit git checkout HEAD~1 diff --git a/apps/docs/src/content/docs/deployment/self-hosting.mdx b/apps/docs/src/content/docs/deployment/self-hosting.mdx index daa4866c2..43bfe7f3b 100644 --- a/apps/docs/src/content/docs/deployment/self-hosting.mdx +++ b/apps/docs/src/content/docs/deployment/self-hosting.mdx @@ -23,8 +23,8 @@ Run your own Mana instance using Docker Compose. 1. **Clone the repository** ```bash - git clone https://github.com/mana/managarten.git - cd managarten + git clone https://github.com/mana/mana-monorepo.git + cd mana-monorepo ``` 2. **Create environment file** diff --git a/apps/docs/src/content/docs/getting-started/quick-start.mdx b/apps/docs/src/content/docs/getting-started/quick-start.mdx index f4dcf2877..d42080eb8 100644 --- a/apps/docs/src/content/docs/getting-started/quick-start.mdx +++ b/apps/docs/src/content/docs/getting-started/quick-start.mdx @@ -24,8 +24,8 @@ Before you begin, ensure you have: 1. **Clone the repository** ```bash - git clone https://github.com/mana/managarten.git - cd managarten + git clone https://github.com/mana/mana-monorepo.git + cd mana-monorepo ``` 2. **Install dependencies** diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index e15d97356..c974e8202 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -12,7 +12,7 @@ hero: icon: right-arrow variant: primary - text: View on GitHub - link: https://github.com/mana/managarten + link: https://github.com/mana/mana-monorepo icon: external --- @@ -21,8 +21,8 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; ## Quick Start ```bash -git clone https://github.com/mana/managarten.git -cd managarten +git clone https://github.com/mana/mana-monorepo.git +cd mana-monorepo pnpm install pnpm docker:up pnpm dev:chat:full diff --git a/apps/mana/apps/landing/src/content/blueprints/003-payment-systems-stripe-vs-direct-debit.md b/apps/mana/apps/landing/src/content/blueprints/003-payment-systems-stripe-vs-direct-debit.md index b1145ae1f..14ced9518 100644 --- a/apps/mana/apps/landing/src/content/blueprints/003-payment-systems-stripe-vs-direct-debit.md +++ b/apps/mana/apps/landing/src/content/blueprints/003-payment-systems-stripe-vs-direct-debit.md @@ -484,7 +484,7 @@ Nach Analyse aller Optionen ist die Empfehlung: ```bash # Auf dem Server ssh mana-server - cd ~/projects/managarten + cd ~/projects/mana-monorepo git pull ./scripts/mac-mini/deploy.sh ``` diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-23-production-launch.md b/apps/mana/apps/landing/src/content/devlog/2026-01-23-production-launch.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-23-production-launch.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-23-production-launch.md index e34a30bec..385284c9d 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-23-production-launch.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-23-production-launch.md @@ -31,8 +31,6 @@ workingHours: end: '2026-01-24T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Heute war ein sehr produktiver Tag mit Fokus auf die **Produktivstellung der ManaCore Apps auf dem Mac Mini Server**. Die wichtigsten Errungenschaften: - **7 Apps live** auf https://mana.how (Auth, Dashboard, Chat, Todo, Calendar, Clock, Contacts) diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-24-guest-mode-improvements.md b/apps/mana/apps/landing/src/content/devlog/2026-01-24-guest-mode-improvements.md similarity index 91% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-24-guest-mode-improvements.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-24-guest-mode-improvements.md index 082841be7..448ff007f 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-24-guest-mode-improvements.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-24-guest-mode-improvements.md @@ -21,8 +21,6 @@ workingHours: end: '2026-01-25T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Fokussierter Tag mit **4 Commits** zur Verbesserung des Guest-Mode-Erlebnisses: - **Clock Guest Mode** - Alarms/Timers ohne Auth-Redirect ladbar diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-25-food-monitoring-infrastructure.md b/apps/mana/apps/landing/src/content/devlog/2026-01-25-food-monitoring-infrastructure.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-25-food-monitoring-infrastructure.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-25-food-monitoring-infrastructure.md index 475d1584f..91a661e29 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-25-food-monitoring-infrastructure.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-25-food-monitoring-infrastructure.md @@ -33,8 +33,6 @@ workingHours: end: '2026-01-26T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Massiver Tag mit **38 Commits** und Fokus auf neue App, Monitoring und Infrastructure: - **Food** - AI-powered Nutrition Tracking App mit Gemini diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-26-monitoring-auth-ai.md b/apps/mana/apps/landing/src/content/devlog/2026-01-26-monitoring-auth-ai.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-26-monitoring-auth-ai.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-26-monitoring-auth-ai.md index 842cb1285..deced6f13 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-26-monitoring-auth-ai.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-26-monitoring-auth-ai.md @@ -33,9 +33,6 @@ workingHours: end: '2026-01-27T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - Produktiver Tag mit Fokus auf **Monitoring-Infrastruktur**, **Email-Authentifizierung** und **lokale AI-Services**. Die wichtigsten Errungenschaften: - **Prometheus Metrics** für alle NestJS Backends diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-27-matrix-infrastructure-self-hosting.md b/apps/mana/apps/landing/src/content/devlog/2026-01-27-matrix-infrastructure-self-hosting.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-27-matrix-infrastructure-self-hosting.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-27-matrix-infrastructure-self-hosting.md index 19ede2fe9..795781a3f 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-27-matrix-infrastructure-self-hosting.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-27-matrix-infrastructure-self-hosting.md @@ -34,8 +34,6 @@ workingHours: end: '2026-01-28T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Strategischer Tag mit **11 Commits** und Fokus auf GDPR-Compliance und Developer Experience: - **Matrix Self-Hosting** - Eigene Synapse-Instanz für GDPR diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-28-massive-feature-sprint.md b/apps/mana/apps/landing/src/content/devlog/2026-01-28-massive-feature-sprint.md similarity index 98% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-28-massive-feature-sprint.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-28-massive-feature-sprint.md index 5133cc7de..a439a0de3 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-28-massive-feature-sprint.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-28-massive-feature-sprint.md @@ -34,8 +34,6 @@ workingHours: end: '2026-01-29T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Außergewöhnlich produktiver Tag (und Nacht!) mit **74 Commits** und mehreren großen neuen Features. Die wichtigsten Errungenschaften: - **SkillTree App** - Gamified Skill Tracking mit XP-System (MVP complete) diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-29-microservices-matrix-consolidation.md b/apps/mana/apps/landing/src/content/devlog/2026-01-29-microservices-matrix-consolidation.md similarity index 98% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-29-microservices-matrix-consolidation.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-29-microservices-matrix-consolidation.md index f8e8b39cf..dfcb20c54 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-29-microservices-matrix-consolidation.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-29-microservices-matrix-consolidation.md @@ -37,8 +37,6 @@ workingHours: end: '2026-01-30T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Außergewöhnlich produktiver Tag mit **55 Commits** - aufgeteilt in drei große Bereiche: - **3 neue Microservices** - mana-llm (LLM Abstraction), mana-crawler (Web Crawler), mana-notify (Notifications) diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-30-matrix-bots-llm-playground.md b/apps/mana/apps/landing/src/content/devlog/2026-01-30-matrix-bots-llm-playground.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-30-matrix-bots-llm-playground.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-30-matrix-bots-llm-playground.md index 1bafbcf2b..c8d08e9d5 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-30-matrix-bots-llm-playground.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-30-matrix-bots-llm-playground.md @@ -31,8 +31,6 @@ workingHours: end: '2026-01-31T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Produktiver Tag mit **41 Commits** und Fokus auf Matrix Bot Expansion und Developer Experience: - **8 neue Matrix Bots** - Spezialisierte Bots für Planta, Cards, Contacts, Picture, Chat, SkillTree, Presi, Questions diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-31-matrix-consolidation-voice-support.md b/apps/mana/apps/landing/src/content/devlog/2026-01-31-matrix-consolidation-voice-support.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-31-matrix-consolidation-voice-support.md rename to apps/mana/apps/landing/src/content/devlog/2026-01-31-matrix-consolidation-voice-support.md index 5f76cad83..3390bc741 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-01-31-matrix-consolidation-voice-support.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-01-31-matrix-consolidation-voice-support.md @@ -33,8 +33,6 @@ workingHours: end: '2026-02-01T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Intensiver Tag (und Nacht!) mit **52 Commits** - der Fokus lag auf der Konsolidierung der Matrix Bot Infrastruktur: - **@manacore/matrix-bot-common** - Neues Shared Package für alle 19 Matrix Bots diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-01-ssd-migration-i18n-cicd.md b/apps/mana/apps/landing/src/content/devlog/2026-02-01-ssd-migration-i18n-cicd.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-01-ssd-migration-i18n-cicd.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-01-ssd-migration-i18n-cicd.md index a20c7a3b7..15181193c 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-01-ssd-migration-i18n-cicd.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-01-ssd-migration-i18n-cicd.md @@ -33,9 +33,6 @@ workingHours: end: '2026-02-02T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - Produktiver Tag mit **42 Commits** und Fokus auf Infrastructure und Production Readiness: - **SSD Migration** - PostgreSQL und MinIO auf externe SSD verschoben diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-02-cross-domain-sso-mana-media.md b/apps/mana/apps/landing/src/content/devlog/2026-02-02-cross-domain-sso-mana-media.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-02-cross-domain-sso-mana-media.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-02-cross-domain-sso-mana-media.md index 231cb6ab7..44737c9b2 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-02-cross-domain-sso-mana-media.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-02-cross-domain-sso-mana-media.md @@ -33,8 +33,6 @@ workingHours: end: '2026-02-03T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Produktiver Tag mit **40 Commits** und Fokus auf nahtlose Authentifizierung über alle Apps: - **Cross-Domain SSO** - Single Sign-On für alle .mana.how Subdomains diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-11-photos-app-stt-tts-admin.md b/apps/mana/apps/landing/src/content/devlog/2026-02-11-photos-app-stt-tts-admin.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-11-photos-app-stt-tts-admin.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-11-photos-app-stt-tts-admin.md index a662a130e..c990b3d59 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-11-photos-app-stt-tts-admin.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-11-photos-app-stt-tts-admin.md @@ -34,8 +34,6 @@ workingHours: end: '2026-02-12T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Nach einer Woche Pause: **28 Commits** mit Fokus auf neue Apps und API-Infrastruktur: - **Photos App** - Neue App mit mana-media EXIF-Integration diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-12-gdpr-matrix-mac-mini.md b/apps/mana/apps/landing/src/content/devlog/2026-02-12-gdpr-matrix-mac-mini.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-12-gdpr-matrix-mac-mini.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-12-gdpr-matrix-mac-mini.md index 3d00bab61..39df18810 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-12-gdpr-matrix-mac-mini.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-12-gdpr-matrix-mac-mini.md @@ -33,8 +33,6 @@ workingHours: end: '2026-02-13T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - **22 Commits** mit Fokus auf DSGVO-Compliance, Mobile UX und Server-Stabilität: - **GDPR Self-Service** - Neue Endpoints für User Data Export diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-13-gift-codes-stripe-quotes.md b/apps/mana/apps/landing/src/content/devlog/2026-02-13-gift-codes-stripe-quotes.md similarity index 98% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-13-gift-codes-stripe-quotes.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-13-gift-codes-stripe-quotes.md index 985f71cfb..6e1fe36d1 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-13-gift-codes-stripe-quotes.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-13-gift-codes-stripe-quotes.md @@ -34,8 +34,6 @@ workingHours: end: '2026-02-14T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Ein massiver Tag mit **55 Commits** und mehreren Major Features: - **Gift Codes** - Credit-Gutscheine mit Code-Einlösung diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-14-matrix-stt-bot-gift-codes-onboarding.md b/apps/mana/apps/landing/src/content/devlog/2026-02-14-matrix-stt-bot-gift-codes-onboarding.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-14-matrix-stt-bot-gift-codes-onboarding.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-14-matrix-stt-bot-gift-codes-onboarding.md index 18d71c4ee..489e19e79 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-14-matrix-stt-bot-gift-codes-onboarding.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-14-matrix-stt-bot-gift-codes-onboarding.md @@ -35,8 +35,6 @@ workingHours: end: '2026-02-15T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Produktiver Valentinstag mit **56 Commits** und vielen neuen Features: - **Matrix STT Bot** - Speech-to-Text direkt in Matrix diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-15-onboarding-polish-calendar-views.md b/apps/mana/apps/landing/src/content/devlog/2026-02-15-onboarding-polish-calendar-views.md similarity index 94% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-15-onboarding-polish-calendar-views.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-15-onboarding-polish-calendar-views.md index 434b2c7ec..7d18fabd1 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-15-onboarding-polish-calendar-views.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-15-onboarding-polish-calendar-views.md @@ -21,8 +21,6 @@ workingHours: end: '2026-02-16T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Fokussierter Tag mit **7 Commits** für UI-Polish und kleinere Features: - **Onboarding Modal** - Kompaktes Design statt Fullscreen diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-16-lightwrite-pwa-stripe-sepa.md b/apps/mana/apps/landing/src/content/devlog/2026-02-16-lightwrite-pwa-stripe-sepa.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-16-lightwrite-pwa-stripe-sepa.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-16-lightwrite-pwa-stripe-sepa.md index da2d80b75..9b43ee471 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-16-lightwrite-pwa-stripe-sepa.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-16-lightwrite-pwa-stripe-sepa.md @@ -32,8 +32,6 @@ workingHours: end: '2026-02-17T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Massiver Tag mit **35 Commits** – neuer App-Launch, Monetarisierung und PWA-Rollout: - **LightWrite** - Beat/Lyrics Editor als Full-Stack App gelauncht diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-17-spiral-db-planta-bot-mana-values.md b/apps/mana/apps/landing/src/content/devlog/2026-02-17-spiral-db-planta-bot-mana-values.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-17-spiral-db-planta-bot-mana-values.md rename to apps/mana/apps/landing/src/content/devlog/2026-02-17-spiral-db-planta-bot-mana-values.md index aed2a5727..c92828f0c 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-02-17-spiral-db-planta-bot-mana-values.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-02-17-spiral-db-planta-bot-mana-values.md @@ -21,9 +21,6 @@ workingHours: end: '2026-02-18T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - Produktiver Tag mit **26 Commits** – neue Packages, Bot-Features und Dokumentation: - **spiral-db** - Pixel-basierte Spiral-Datenbank-Visualisierung diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-06-matrix-mobile-manalink.md b/apps/mana/apps/landing/src/content/devlog/2026-03-06-matrix-mobile-manalink.md similarity index 94% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-06-matrix-mobile-manalink.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-06-matrix-mobile-manalink.md index fd95cc3d3..4e8aa3798 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-06-matrix-mobile-manalink.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-06-matrix-mobile-manalink.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-07T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Fokussierter Tag mit **7 Commits** für den neuen Matrix Mobile Client: - **Manalink** - Expo React Native App für Matrix Chat diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-07-manalink-sdk55-fixes.md b/apps/mana/apps/landing/src/content/devlog/2026-03-07-manalink-sdk55-fixes.md similarity index 94% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-07-manalink-sdk55-fixes.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-07-manalink-sdk55-fixes.md index 1b17da77c..a546e4f44 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-07-manalink-sdk55-fixes.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-07-manalink-sdk55-fixes.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-08T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Bugfix-Tag mit **5 Commits** – Expo SDK Upgrade und kritische Fixes: - **SDK 55** - Manalink auf Expo SDK 55 aktualisiert diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-11-cd-pipeline-mac-mini-runner.md b/apps/mana/apps/landing/src/content/devlog/2026-03-11-cd-pipeline-mac-mini-runner.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-11-cd-pipeline-mac-mini-runner.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-11-cd-pipeline-mac-mini-runner.md index 3ad8f8b7b..e03b7eca2 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-11-cd-pipeline-mac-mini-runner.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-11-cd-pipeline-mac-mini-runner.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-12T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Infrastruktur-Tag mit **6 Commits** für die Deployment-Pipeline: - **CD Pipeline** - GitHub Actions mit self-hosted Runner diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-12-manalink-eas-build-fixes.md b/apps/mana/apps/landing/src/content/devlog/2026-03-12-manalink-eas-build-fixes.md similarity index 92% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-12-manalink-eas-build-fixes.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-12-manalink-eas-build-fixes.md index 43122a5ce..40a5d92b8 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-12-manalink-eas-build-fixes.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-12-manalink-eas-build-fixes.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-13T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Fokussierter Tag mit **5 Commits** für EAS Build Stabilität und Expo SDK 55 Kompatibilität: - **EAS Build Pre-Install Hook** - pnpm hoisted Mode für Monorepo Builds diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-15-traces-app-calendar-hardening.md b/apps/mana/apps/landing/src/content/devlog/2026-03-15-traces-app-calendar-hardening.md similarity index 94% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-15-traces-app-calendar-hardening.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-15-traces-app-calendar-hardening.md index 3cf730203..df6da2c64 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-15-traces-app-calendar-hardening.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-15-traces-app-calendar-hardening.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-16T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Intensiver Tag mit **8 Commits** für eine neue App-Integration und Production Hardening: - **Traces App** - AI City Guides mit NestJS Backend und Expo Mobile diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-17-mukke-mobile-e2e-tests-pre-commit.md b/apps/mana/apps/landing/src/content/devlog/2026-03-17-mukke-mobile-e2e-tests-pre-commit.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-17-mukke-mobile-e2e-tests-pre-commit.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-17-mukke-mobile-e2e-tests-pre-commit.md index cc4126ef2..e78c1d684 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-17-mukke-mobile-e2e-tests-pre-commit.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-17-mukke-mobile-e2e-tests-pre-commit.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-18T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Produktiver Tag mit **9 Commits** über mehrere Apps hinweg: - **Mukke** - Offline-first iOS Music Player mit Expo und SQLite diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-18-test-coverage-contacts-todo.md b/apps/mana/apps/landing/src/content/devlog/2026-03-18-test-coverage-contacts-todo.md similarity index 94% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-18-test-coverage-contacts-todo.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-18-test-coverage-contacts-todo.md index 71cf45693..36b64b114 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-18-test-coverage-contacts-todo.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-18-test-coverage-contacts-todo.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-19T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Fokussierter Tag mit **5 Commits** für Test Coverage und Code Cleanup: - **Contacts Web** - 62 Unit Tests für Stores, Utils und API Client diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-19-security-hardening-error-tracking.md b/apps/mana/apps/landing/src/content/devlog/2026-03-19-security-hardening-error-tracking.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-19-security-hardening-error-tracking.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-19-security-hardening-error-tracking.md index 35c3198d7..2fe804439 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-19-security-hardening-error-tracking.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-19-security-hardening-error-tracking.md @@ -33,8 +33,6 @@ workingHours: end: '2026-03-20T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Massiver Infrastruktur- und Security-Tag mit **74 Commits** über das gesamte Monorepo: - **Cross-App SSO Fix** - Fehlende trusted origins repariert, 47 SSO Contract Tests diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-20-cross-app-sso-infrastructure-fixes.md b/apps/mana/apps/landing/src/content/devlog/2026-03-20-cross-app-sso-infrastructure-fixes.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-20-cross-app-sso-infrastructure-fixes.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-20-cross-app-sso-infrastructure-fixes.md index dcacaf3ef..9ace8912e 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-20-cross-app-sso-infrastructure-fixes.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-20-cross-app-sso-infrastructure-fixes.md @@ -34,8 +34,6 @@ workingHours: end: '2026-03-20T21:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Intensiver Infrastruktur- und Bugfix-Tag mit **48 Commits**: Cross-App SSO endlich funktionsfähig, Calendar-Web von stale GHCR Image auf Local Build migriert, und eine Kette von Routing/CORS/Cookie-Problemen systematisch aufgelöst. - **SSO Cookie-Fix** - `SameSite=None` + Session-Cookie bei Login setzen diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-21-whopixels-refactor-storage-picture.md b/apps/mana/apps/landing/src/content/devlog/2026-03-21-whopixels-refactor-storage-picture.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-21-whopixels-refactor-storage-picture.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-21-whopixels-refactor-storage-picture.md index 5b4d2f79d..310ed3142 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-21-whopixels-refactor-storage-picture.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-21-whopixels-refactor-storage-picture.md @@ -33,8 +33,6 @@ workingHours: end: '2026-03-21T18:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Feature-reicher Tag über drei Projekte hinweg mit **7 Commits** und knapp **5.000 hinzugefügten Zeilen**: WhoPixels-Spiel komplett refactored (21 Verbesserungen), Storage mit Drag-Animationen und Integration Tests ausgebaut, Picture-Backend gehärtet. - ✅ **WhoPixels** — Monolithische RPGScene (1210 Zeilen) in 8 Module aufgeteilt diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-22-analytics-pwa-visualizer-error-tracking.md b/apps/mana/apps/landing/src/content/devlog/2026-03-22-analytics-pwa-visualizer-error-tracking.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-22-analytics-pwa-visualizer-error-tracking.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-22-analytics-pwa-visualizer-error-tracking.md index 587e94346..42a4366d7 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-22-analytics-pwa-visualizer-error-tracking.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-22-analytics-pwa-visualizer-error-tracking.md @@ -37,8 +37,6 @@ workingHours: end: '2026-03-23T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Produktiver Tag mit **26 Commits** über das gesamte Monorepo: - **Analytics** - Umami-Tracking zentralisiert + Event-Tracking für alle Apps und Landing Pages diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-23-abend-context-menus-llm-onboarding.md b/apps/mana/apps/landing/src/content/devlog/2026-03-23-abend-context-menus-llm-onboarding.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-23-abend-context-menus-llm-onboarding.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-23-abend-context-menus-llm-onboarding.md index 666586816..cbd0a687b 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-23-abend-context-menus-llm-onboarding.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-23-abend-context-menus-llm-onboarding.md @@ -34,8 +34,6 @@ workingHours: end: '2026-03-23T23:30' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Intensive Abend-Session mit **27 Commits** über **180 Dateien** und netto **+7.234 Zeilen**: - **Context Menus Everywhere** - Shared `ContextMenu`-Component aus `@manacore/shared-ui` in 12 Apps integriert diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-23-vormittag-manalink-prod-ready.md b/apps/mana/apps/landing/src/content/devlog/2026-03-23-vormittag-manalink-prod-ready.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-23-vormittag-manalink-prod-ready.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-23-vormittag-manalink-prod-ready.md index e6e91ac76..7becb35b2 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-23-vormittag-manalink-prod-ready.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-23-vormittag-manalink-prod-ready.md @@ -38,8 +38,6 @@ workingHours: end: '2026-03-23T13:30' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Extrem produktiver Vormittag mit **61 Commits** über **695 Dateien** und netto **+17.282 Zeilen**: - **Manalink Live** - Matrix-Chat-Client auf Production gebracht und auf link.mana.how deployed diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-24-abend-pwa-todo-infra.md b/apps/mana/apps/landing/src/content/devlog/2026-03-24-abend-pwa-todo-infra.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-24-abend-pwa-todo-infra.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-24-abend-pwa-todo-infra.md index 26c265df6..bebab7932 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-24-abend-pwa-todo-infra.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-24-abend-pwa-todo-infra.md @@ -35,8 +35,6 @@ workingHours: end: '2026-03-24T23:59' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Intensive Abend-Session mit **41 Commits** über **302 Dateien** und netto **+14.112 Zeilen**: - **PWA für alle Apps** — Icons, Meta-Tags und Offline-Prerender für 20 Web-Apps diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-24-vormittag-credits-dashboard-infra.md b/apps/mana/apps/landing/src/content/devlog/2026-03-24-vormittag-credits-dashboard-infra.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-24-vormittag-credits-dashboard-infra.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-24-vormittag-credits-dashboard-infra.md index c62c1f9c3..20bf13b24 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-24-vormittag-credits-dashboard-infra.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-24-vormittag-credits-dashboard-infra.md @@ -36,9 +36,6 @@ workingHours: end: '2026-03-24T12:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - Intensive Vormittags-Session mit **48 Commits** über **248 Dateien** und netto **+10.622 Zeilen**: - **Credits-Kostenübersicht** — Neuer "Kosten"-Tab zeigt alle 40+ Credit-Operationen nach App gruppiert diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-25-storage-auth-tags-analytics.md b/apps/mana/apps/landing/src/content/devlog/2026-03-25-storage-auth-tags-analytics.md similarity index 93% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-25-storage-auth-tags-analytics.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-25-storage-auth-tags-analytics.md index 9323208e2..7ef0d0023 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-25-storage-auth-tags-analytics.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-25-storage-auth-tags-analytics.md @@ -33,9 +33,6 @@ workingHours: end: '2026-03-25T21:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - Breiter Arbeitstag mit **44 Commits** über viele Bereiche — Analytics, Help, CityCorners, ManaLink, Infra und Dashboard: - **Analytics überall** — Custom Event Tracking in 8+ Apps, neuer ManaScore-Metrik diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-26-local-first-architektur-planung.md b/apps/mana/apps/landing/src/content/devlog/2026-03-26-local-first-architektur-planung.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-26-local-first-architektur-planung.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-26-local-first-architektur-planung.md index 16e098333..186911688 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-26-local-first-architektur-planung.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-26-local-first-architektur-planung.md @@ -35,9 +35,6 @@ workingHours: end: '2026-03-26T22:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - Massiver Tag mit **61 Commits** über **329 Dateien** und netto **+16.715 Zeilen** — Storage-Sprint, Auth-Features, Tag-System, Quotes-Polish, Infra und Architekturplanung: - **Storage-App komplett** — Von Score 72 auf 87: Previews, Audio-Player, Skeleton Screens, Sharing, Tagging, i18n, E2E-Tests diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-27-matrix-bot-go-consolidation.md b/apps/mana/apps/landing/src/content/devlog/2026-03-27-matrix-bot-go-consolidation.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-27-matrix-bot-go-consolidation.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-27-matrix-bot-go-consolidation.md index c3ebb4ab2..780580122 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-27-matrix-bot-go-consolidation.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-27-matrix-bot-go-consolidation.md @@ -31,8 +31,6 @@ workingHours: end: '2026-03-27T17:30' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - Einer der größten Architektur-Umbauten im ManaCore-Monorepo: **21 separate NestJS Matrix-Bot-Prozesse** komplett ersetzt durch **ein einziges Go-Binary mit Plugin-Architektur**. Dazu Legacy-Code aufgeräumt, CI/CD migriert und Docker-Compose aktualisiert. --- diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-28-gpu-offload-colima-migration.md b/apps/mana/apps/landing/src/content/devlog/2026-03-28-gpu-offload-colima-migration.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-28-gpu-offload-colima-migration.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-28-gpu-offload-colima-migration.md index 4f149a6f1..3cbbc611e 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-28-gpu-offload-colima-migration.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-28-gpu-offload-colima-migration.md @@ -28,9 +28,6 @@ contributors: handle: 'Till-JS' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - ## Das Problem: 16 GB reichen nicht Unser Mac Mini M4 hostet das komplette ManaCore-Oekosystem: 33 Docker-Container, PostgreSQL, Redis, MinIO, Forgejo, Matrix — plus Ollama (LLM), FLUX.2 (Bildgenerierung) und STT/TTS. Das Problem: diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-28-local-first-nestjs-elimination.md b/apps/mana/apps/landing/src/content/devlog/2026-03-28-local-first-nestjs-elimination.md similarity index 98% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-28-local-first-nestjs-elimination.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-28-local-first-nestjs-elimination.md index a1ef0a413..6f03c1f20 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-28-local-first-nestjs-elimination.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-28-local-first-nestjs-elimination.md @@ -33,8 +33,6 @@ workingHours: end: '2026-03-28T22:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Was passiert ist In zwei intensiven Sessions wurde die gesamte ManaCore-Backend-Architektur grundlegend transformiert. Der Kern: **Daten gehören dem Client, nicht dem Server.** Und: **NestJS ist zu viel Overhead für das, was wir brauchen.** diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-30-todo-ux-keyboard-navigation.md b/apps/mana/apps/landing/src/content/devlog/2026-03-30-todo-ux-keyboard-navigation.md similarity index 95% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-30-todo-ux-keyboard-navigation.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-30-todo-ux-keyboard-navigation.md index 1934e4671..900376dc9 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-30-todo-ux-keyboard-navigation.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-30-todo-ux-keyboard-navigation.md @@ -21,8 +21,6 @@ workingHours: end: '2026-03-30T18:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - Notizblock-Design mit liniertem Papier-Hintergrund und roter Margin-Linie diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-31-memoro-statuspage-todo-infra.md b/apps/mana/apps/landing/src/content/devlog/2026-03-31-memoro-statuspage-todo-infra.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-31-memoro-statuspage-todo-infra.md rename to apps/mana/apps/landing/src/content/devlog/2026-03-31-memoro-statuspage-todo-infra.md index ef23f7742..0bed08379 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-03-31-memoro-statuspage-todo-infra.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-03-31-memoro-statuspage-todo-infra.md @@ -35,8 +35,6 @@ workingHours: end: '2026-03-31T21:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Memoro** vollständig im Monorepo: Web-App (local-first, shared-auth-stores), Hono/Bun Server + Audio-Server ersetzen NestJS diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-01-unified-app-migration-phases-1-7.md b/apps/mana/apps/landing/src/content/devlog/2026-04-01-unified-app-migration-phases-1-7.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-01-unified-app-migration-phases-1-7.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-01-unified-app-migration-phases-1-7.md index 2ccd315fb..f3f9b79a2 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-01-unified-app-migration-phases-1-7.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-01-unified-app-migration-phases-1-7.md @@ -34,9 +34,6 @@ workingHours: end: '2026-04-01T23:05' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - ## Highlights - **ManaCore Unified App gestartet** — Same-Origin Web-App, die alle 26 Module unter einem Build/Domain bündelt. 7 Phasen in einem Tag. diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-02-unified-api-server-archive-25-apps.md b/apps/mana/apps/landing/src/content/devlog/2026-04-02-unified-api-server-archive-25-apps.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-02-unified-api-server-archive-25-apps.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-02-unified-api-server-archive-25-apps.md index 0f44342f3..92789578e 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-02-unified-api-server-archive-25-apps.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-02-unified-api-server-archive-25-apps.md @@ -33,8 +33,6 @@ workingHours: end: '2026-04-02T23:59' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Unified API Server** (`@manacore/api`): 17 Module-Server zusammengeführt, 25 standalone Web-Apps archiviert diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-03-habits-automations-stalwart-undo.md b/apps/mana/apps/landing/src/content/devlog/2026-04-03-habits-automations-stalwart-undo.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-03-habits-automations-stalwart-undo.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-03-habits-automations-stalwart-undo.md index 5614d1352..b3e83fe27 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-03-habits-automations-stalwart-undo.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-03-habits-automations-stalwart-undo.md @@ -34,8 +34,6 @@ workingHours: end: '2026-04-03T21:39' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Vier neue Module**: habits, automations, notes, finance, places (GPS) diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-04-mana-media-cas-effect-depth-fix.md b/apps/mana/apps/landing/src/content/devlog/2026-04-04-mana-media-cas-effect-depth-fix.md similarity index 93% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-04-mana-media-cas-effect-depth-fix.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-04-mana-media-cas-effect-depth-fix.md index 3ff33b260..60842326a 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-04-mana-media-cas-effect-depth-fix.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-04-mana-media-cas-effect-depth-fix.md @@ -22,8 +22,6 @@ workingHours: end: '2026-04-04T11:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **mana-media** wird zentrale Bild-Pipeline: Picture, Contacts, Planta, Storage, Food laden Bilder darüber hoch diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-05-timeblocks-mukke-music-rename-pwa.md b/apps/mana/apps/landing/src/content/devlog/2026-04-05-timeblocks-mukke-music-rename-pwa.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-05-timeblocks-mukke-music-rename-pwa.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-05-timeblocks-mukke-music-rename-pwa.md index 5cd9e24c0..25edb6e5d 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-05-timeblocks-mukke-music-rename-pwa.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-05-timeblocks-mukke-music-rename-pwa.md @@ -34,8 +34,6 @@ workingHours: end: '2026-04-05T21:14' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **TimeBlocks** als unified time model — Calendar, Habits, Tasks, Focus-Mode teilen sich eine Tabelle diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-07-encryption-phases-1-9-period-dreams-events.md b/apps/mana/apps/landing/src/content/devlog/2026-04-07-encryption-phases-1-9-period-dreams-events.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-07-encryption-phases-1-9-period-dreams-events.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-07-encryption-phases-1-9-period-dreams-events.md index 7998eddc4..fb71c9d55 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-07-encryption-phases-1-9-period-dreams-events.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-07-encryption-phases-1-9-period-dreams-events.md @@ -34,8 +34,6 @@ workingHours: end: '2026-04-07T23:57' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **At-Rest Encryption** in 9 Phasen ausgerollt: AES-GCM-256 für 27 Tabellen diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-08-voice-quick-add-llm-parsing.md b/apps/mana/apps/landing/src/content/devlog/2026-04-08-voice-quick-add-llm-parsing.md similarity index 98% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-08-voice-quick-add-llm-parsing.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-08-voice-quick-add-llm-parsing.md index 1105798e4..a5f7d8205 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-08-voice-quick-add-llm-parsing.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-08-voice-quick-add-llm-parsing.md @@ -36,9 +36,6 @@ workingHours: end: '2026-04-08T17:30' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - - ## Highlights - **Geteilte ``** ersetzt zwei kopierte MediaRecorder-Implementierungen in Dreams + Memoro und kommt jetzt in Notes, Todo workbench, Memoro workbench und standalone /memoro zum Einsatz diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-09-news-body-nutriphi-workbench-sprint.md b/apps/mana/apps/landing/src/content/devlog/2026-04-09-news-body-nutriphi-workbench-sprint.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-09-news-body-nutriphi-workbench-sprint.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-09-news-body-nutriphi-workbench-sprint.md index c21c7868f..53a71b8c8 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-09-news-body-nutriphi-workbench-sprint.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-09-news-body-nutriphi-workbench-sprint.md @@ -35,8 +35,6 @@ workingHours: end: '2026-04-09T23:59' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **News-Modul** — Backend-Ingester-Service, kuratierte RSS-Feeds, Client-Data-Layer, Workbench-ListView + Dashboard-Widget, "Interested"-Bookmark, Onboarding-Flow diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-10-journal-sync-billing-geocoding-a11y.md b/apps/mana/apps/landing/src/content/devlog/2026-04-10-journal-sync-billing-geocoding-a11y.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-10-journal-sync-billing-geocoding-a11y.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-10-journal-sync-billing-geocoding-a11y.md index 88cf20117..32cb40632 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-10-journal-sync-billing-geocoding-a11y.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-10-journal-sync-billing-geocoding-a11y.md @@ -34,8 +34,6 @@ workingHours: end: '2026-04-10T22:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Journal-Modul** — Tagebuch mit Voice-Capture, Mood-Tracking und Encryption diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-11-settings-geocoding-deploy-monitoring.md b/apps/mana/apps/landing/src/content/devlog/2026-04-11-settings-geocoding-deploy-monitoring.md similarity index 94% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-11-settings-geocoding-deploy-monitoring.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-11-settings-geocoding-deploy-monitoring.md index 232978922..76fa984b9 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-11-settings-geocoding-deploy-monitoring.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-11-settings-geocoding-deploy-monitoring.md @@ -33,8 +33,6 @@ workingHours: end: '2026-04-11T19:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Settings-Page Redesign** mit PillNav-Compute-Selector diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-12-ui-redesign-wallpaper-floating-input-drink.md b/apps/mana/apps/landing/src/content/devlog/2026-04-12-ui-redesign-wallpaper-floating-input-drink.md similarity index 94% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-12-ui-redesign-wallpaper-floating-input-drink.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-12-ui-redesign-wallpaper-floating-input-drink.md index 33f580597..d151b11f6 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-12-ui-redesign-wallpaper-floating-input-drink.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-12-ui-redesign-wallpaper-floating-input-drink.md @@ -34,8 +34,6 @@ workingHours: end: '2026-04-12T20:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Wallpaper-System** + sticky PageHeader — individualisierbare Hintergrundbilder für die App diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-13-companion-brain-sleep-stretch-recipes-mail.md b/apps/mana/apps/landing/src/content/devlog/2026-04-13-companion-brain-sleep-stretch-recipes-mail.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-13-companion-brain-sleep-stretch-recipes-mail.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-13-companion-brain-sleep-stretch-recipes-mail.md index 78a948bd4..bb3e0256f 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-13-companion-brain-sleep-stretch-recipes-mail.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-13-companion-brain-sleep-stretch-recipes-mail.md @@ -36,8 +36,6 @@ workingHours: end: '2026-04-13T23:00' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Companion Brain** — komplettes AI-Subsystem in 7 Phasen aufgebaut: Event Bus → Projections → Goals → Tools → Chat → Rituals → Memory diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-14-ai-workbench-actor-missions-grants.md b/apps/mana/apps/landing/src/content/devlog/2026-04-14-ai-workbench-actor-missions-grants.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-14-ai-workbench-actor-missions-grants.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-14-ai-workbench-actor-missions-grants.md index 0abc1bd16..2e27d2a39 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-14-ai-workbench-actor-missions-grants.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-14-ai-workbench-actor-missions-grants.md @@ -35,8 +35,6 @@ workingHours: end: '2026-04-14T23:59' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Actor-Attribution** auf allen Records, PendingChanges und DomainEvents — jeder Schreibvorgang ist einem Actor (user/ai/system) zugeordnet diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-15-multi-agent-workbench-news-research-settings-theme.md b/apps/mana/apps/landing/src/content/devlog/2026-04-15-multi-agent-workbench-news-research-settings-theme.md similarity index 97% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-15-multi-agent-workbench-news-research-settings-theme.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-15-multi-agent-workbench-news-research-settings-theme.md index e21606317..b7cae424d 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-15-multi-agent-workbench-news-research-settings-theme.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-15-multi-agent-workbench-news-research-settings-theme.md @@ -36,8 +36,6 @@ workingHours: end: '2026-04-15T23:59' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Multi-Agent Phase 4-7** — Policy pro Agent, Agents UI + Scene-Binding, Workbench Agent-Filter, Tag-basiertes Scoping diff --git a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-16-library-wetter-voice-interview-research-lab.md b/apps/mana/apps/landing/src/content/devlog/2026-04-16-library-wetter-voice-interview-research-lab.md similarity index 96% rename from apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-16-library-wetter-voice-interview-research-lab.md rename to apps/mana/apps/landing/src/content/devlog/2026-04-16-library-wetter-voice-interview-research-lab.md index 6fa92a823..f4d7d3417 100644 --- a/apps/mana/apps/landing/src/content/devlog/_legacy-sessions/2026-04-16-library-wetter-voice-interview-research-lab.md +++ b/apps/mana/apps/landing/src/content/devlog/2026-04-16-library-wetter-voice-interview-research-lab.md @@ -35,8 +35,6 @@ workingHours: end: '2026-04-16T23:59' --- -> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md). - ## Highlights - **Bibliothek-Modul** — Bücher, Filme, Serien, Comics mit Progress-Tracking, CRUD, Grid-View diff --git a/apps/mana/apps/landing/src/pages/devlog/[slug].astro b/apps/mana/apps/landing/src/pages/devlog/[slug].astro index 0306152c9..847e0f355 100644 --- a/apps/mana/apps/landing/src/pages/devlog/[slug].astro +++ b/apps/mana/apps/landing/src/pages/devlog/[slug].astro @@ -12,7 +12,7 @@ import { Icon } from 'astro-icon/components'; export async function getStaticPaths() { const posts = await getCollection('devlog'); return posts.map((post) => ({ - params: { slug: post.slug.replace(/^_legacy-sessions\//, '') }, + params: { slug: post.slug }, props: { post }, })); } diff --git a/apps/mana/apps/landing/src/pages/devlog/activity.astro b/apps/mana/apps/landing/src/pages/devlog/activity.astro index 1350179c6..9a7f25cdb 100644 --- a/apps/mana/apps/landing/src/pages/devlog/activity.astro +++ b/apps/mana/apps/landing/src/pages/devlog/activity.astro @@ -38,7 +38,7 @@ for (const post of sortedPosts) { linesAdded: existing.linesAdded + (post.data.stats?.linesAdded || 0), linesRemoved: existing.linesRemoved + (post.data.stats?.linesRemoved || 0), posts: [...existing.posts, post.data.title], - slug: post.slug.replace(/^_legacy-sessions\//, ''), + slug: post.slug, }); } @@ -602,7 +602,7 @@ const avgCommitsPerDay = totalDays > 0 ? Math.round(totalCommits / totalDays) : .reverse() .map((post, index) => (
diff --git a/apps/mana/apps/landing/src/pages/devlog/index.astro b/apps/mana/apps/landing/src/pages/devlog/index.astro index ad0b78a4f..fdee4c7c7 100644 --- a/apps/mana/apps/landing/src/pages/devlog/index.astro +++ b/apps/mana/apps/landing/src/pages/devlog/index.astro @@ -113,27 +113,15 @@ const formatDate = (date: Date) => { class="group flex items-center justify-between bg-gradient-to-r from-green-500/10 to-emerald-500/10 hover:from-green-500/20 hover:to-emerald-500/20 border border-green-500/30 rounded-xl p-4 transition-all" > @@ -147,7 +135,7 @@ const formatDate = (date: Date) => { class={`absolute -inset-0.5 bg-gradient-to-r ${colors.bg} rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur`} />
diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index 12cc32d57..58bd77768 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -59,7 +59,6 @@ "@mana/shared-ai": "workspace:*", "@mana/shared-auth": "workspace:*", "@mana/shared-auth-ui": "workspace:*", - "@simplewebauthn/browser": "^13.3.0", "@mana/shared-branding": "workspace:*", "@mana/shared-crypto": "workspace:*", "@mana/shared-error-tracking": "workspace:*", @@ -68,6 +67,7 @@ "@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:*", diff --git a/apps/mana/apps/web/src/hooks.server.ts b/apps/mana/apps/web/src/hooks.server.ts index 31b249e23..5c3ee4325 100644 --- a/apps/mana/apps/web/src/hooks.server.ts +++ b/apps/mana/apps/web/src/hooks.server.ts @@ -27,8 +27,6 @@ import { setSecurityHeaders } from '@mana/shared-utils/security-headers'; const PUBLIC_MANA_AUTH_URL_CLIENT = process.env.PUBLIC_MANA_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_AUTH_URL || ''; -const PUBLIC_AUTH_WEB_URL_CLIENT = - process.env.PUBLIC_AUTH_WEB_URL_CLIENT || process.env.PUBLIC_AUTH_WEB_URL || ''; const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || ''; const PUBLIC_SYNC_SERVER_URL_CLIENT = @@ -229,7 +227,6 @@ export const handle: Handle = async ({ event, resolve }) => { transformPageChunk: ({ html }) => { const envScript = ` + + diff --git a/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte b/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte index 8a76ea7e9..de027e8db 100644 --- a/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte +++ b/apps/mana/apps/web/src/lib/components/layout/RouteTierGate.svelte @@ -7,7 +7,6 @@ import { goto } from '$app/navigation'; import { locale } from 'svelte-i18n'; import { authStore } from '$lib/stores/auth.svelte'; - import { redirectToPortal } from '$lib/auth/portal-redirect'; interface Props { appName: string; @@ -72,7 +71,7 @@ class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90" style:border-color="hsl(var(--color-border, 0 0% 90%))" style:color="hsl(var(--color-foreground, 0 0% 9%))" - onclick={() => redirectToPortal()} + onclick={() => goto('/login')} > {isDE ? 'Anmelden' : 'Sign in'} diff --git a/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte b/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte index e92ba7eff..3a9229805 100644 --- a/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte +++ b/apps/mana/apps/web/src/lib/components/settings/sections/SecuritySection.svelte @@ -4,66 +4,25 @@ import { ShieldCheck } from '@mana/shared-icons'; import { PasskeyManager, TwoFactorSetup, AuditLog, SessionManager } from '@mana/shared-auth-ui'; import { authStore } from '$lib/stores/auth.svelte'; - import { - passkeys as passkeysClient, - sessions as sessionsClient, - twoFactor as twoFactorClient, - audit as auditClient, - type PasskeyEntry, - type SessionEntry, - type SecurityEvent, - } from '$lib/auth/settings-client'; import SettingsPanel from '../SettingsPanel.svelte'; import SettingsSectionHeader from '../SettingsSectionHeader.svelte'; import VaultSection from './VaultSection.svelte'; - let passkeys = $state([]); - let passkeyAvailable = $state(false); - let sessions = $state([]); + let passkeys = $state([]); + let sessions = $state([]); let sessionsLoading = $state(false); - let securityEvents = $state([]); + let securityEvents = $state([]); let securityEventsLoading = $state(false); - // Adapter: die UI-Komponenten erwarten `{ success, error? }`-Returns, - // unsere Settings-Client-Methoden werfen. Hier einmal zentral übersetzen. - function asResult(p: Promise): Promise<{ success: boolean; error?: string }> { - return p - .then(() => ({ success: true as const })) - .catch((e: unknown) => ({ - success: false as const, - error: e instanceof Error ? e.message : String(e), - })); - } - - async function handleEnable(password: string) { - try { - const r = await twoFactorClient.enable(password); - return { success: true as const, totpURI: r.uri, backupCodes: r.backupCodes }; - } catch (e) { - return { success: false as const, error: e instanceof Error ? e.message : String(e) }; - } - } - - async function handleGenerateBackupCodes(password: string) { - try { - const r = await twoFactorClient.generateBackupCodes(password); - return { success: true as const, backupCodes: r.backupCodes }; - } catch (e) { - return { success: false as const, error: e instanceof Error ? e.message : String(e) }; - } - } - onMount(async () => { if (!authStore.isAuthenticated) return; try { - const cap = await passkeysClient.capability(); - passkeyAvailable = cap.available; - passkeys = await passkeysClient.list(); + passkeys = await authStore.listPasskeys(); sessionsLoading = true; - sessions = await sessionsClient.list(); + sessions = await authStore.listSessions(); sessionsLoading = false; securityEventsLoading = true; - securityEvents = await auditClient.getSecurityEvents(); + securityEvents = await authStore.getSecurityEvents(); securityEventsLoading = false; } catch (e) { console.error('SecuritySection load failed:', e); @@ -85,12 +44,12 @@ asResult(passkeysClient.register(name))} - onDelete={(id) => asResult(passkeysClient.delete(id))} - onRename={(id, name) => asResult(passkeysClient.rename(id, name))} + passkeyAvailable={authStore.isPasskeyAvailable()} + onRegister={(name) => authStore.registerPasskey(name)} + onDelete={(id) => authStore.deletePasskey(id)} + onRename={(id, name) => authStore.renamePasskey(id, name)} onRefresh={async () => { - passkeys = await passkeysClient.list(); + passkeys = await authStore.listPasskeys(); }} primaryColor="hsl(var(--color-primary))" /> @@ -100,10 +59,10 @@ asResult(sessionsClient.revoke(id))} + onRevoke={(id) => authStore.revokeSession(id)} onRefresh={async () => { sessionsLoading = true; - sessions = await sessionsClient.list(); + sessions = await authStore.listSessions(); sessionsLoading = false; }} primaryColor="hsl(var(--color-primary))" @@ -113,9 +72,9 @@ asResult(twoFactorClient.disable(password))} - onGenerateBackupCodes={handleGenerateBackupCodes} + onEnable={(password) => authStore.enableTwoFactor(password)} + onDisable={(password) => authStore.disableTwoFactor(password)} + onGenerateBackupCodes={(password) => authStore.generateBackupCodes(password)} primaryColor="hsl(var(--color-primary))" /> @@ -130,7 +89,7 @@ loading={securityEventsLoading} onRefresh={async () => { securityEventsLoading = true; - securityEvents = await auditClient.getSecurityEvents(); + securityEvents = await authStore.getSecurityEvents(); securityEventsLoading = false; }} primaryColor="hsl(var(--color-primary))" diff --git a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts index 60e7734fb..c10d2ae4a 100644 --- a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts +++ b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts @@ -19,6 +19,7 @@ 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 ─────────────────────────────────────────── @@ -277,7 +278,43 @@ export function useRecentDecks(limit = 5) { } // ─── Cards Queries ───────────────────────────────────────── -// 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. + +interface CardsProgress { + totalDecks: number; + totalCards: number; + cardsLearned: number; + dueForReview: number; + decks: LocalCardDeck[]; +} + +/** Cards learning progress. */ +export function useCardsProgress() { + return useLiveQueryWithDefault( + async (): Promise => { + const decks = await db.table('cardDecks').toArray(); + const cards = await db.table('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[], + } + ); +} diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index f8ed22923..8721b4fd6 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -56,6 +56,7 @@ 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'; @@ -118,6 +119,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ contactsModuleConfig, chatModuleConfig, pictureModuleConfig, + cardsModuleConfig, quotesModuleConfig, musicModuleConfig, storageModuleConfig, diff --git a/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts b/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts index 523fc5318..257e6aeb2 100644 --- a/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts +++ b/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts @@ -211,6 +211,18 @@ 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', diff --git a/apps/mana/apps/web/src/lib/data/seed-registry.ts b/apps/mana/apps/web/src/lib/data/seed-registry.ts index 04c492345..f33ba1da5 100644 --- a/apps/mana/apps/web/src/lib/data/seed-registry.ts +++ b/apps/mana/apps/web/src/lib/data/seed-registry.ts @@ -22,6 +22,7 @@ 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'; @@ -62,6 +63,7 @@ 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); diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index 4ada90270..cf9dfaba5 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -16,6 +16,7 @@ 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'; @@ -67,6 +68,7 @@ export function initTools(): void { registerTools(bodyTools); registerTools(financeTools); registerTools(dreamsTools); + registerTools(cardsTools); registerTools(timesTools); registerTools(socialEventsTools); registerTools(musicTools); diff --git a/apps/mana/apps/web/src/lib/i18n/locales/cards/de.json b/apps/mana/apps/web/src/lib/i18n/locales/cards/de.json new file mode 100644 index 000000000..4a07a9990 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/cards/de.json @@ -0,0 +1,82 @@ +{ + "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}" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/cards/en.json b/apps/mana/apps/web/src/lib/i18n/locales/cards/en.json new file mode 100644 index 000000000..3c8fcfcf0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/cards/en.json @@ -0,0 +1,82 @@ +{ + "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}" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/cards/es.json b/apps/mana/apps/web/src/lib/i18n/locales/cards/es.json new file mode 100644 index 000000000..86bc42470 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/cards/es.json @@ -0,0 +1,82 @@ +{ + "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}" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/cards/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/cards/fr.json new file mode 100644 index 000000000..a567b2173 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/cards/fr.json @@ -0,0 +1,82 @@ +{ + "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}" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/cards/it.json b/apps/mana/apps/web/src/lib/i18n/locales/cards/it.json new file mode 100644 index 000000000..194bee77c --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/cards/it.json @@ -0,0 +1,82 @@ +{ + "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}" + } +} diff --git a/apps/mana/apps/web/src/lib/modules/cards/ListView.svelte b/apps/mana/apps/web/src/lib/modules/cards/ListView.svelte new file mode 100644 index 000000000..a866faf59 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/ListView.svelte @@ -0,0 +1,126 @@ + + + +{#if !standaloneHintDismissed} +
+ + Cardecky gibt es jetzt auch als eigenständige App auf + cardecky.mana.how — gleiche Daten, fokussierte UI. + + +
+{/if} + d.id} emptyTitle="Keine Decks"> + {#snippet header()} + {decks.length} Decks + {totalDue} fällig + {/snippet} + + {#snippet item(deck)} + {@const due = dueByDeck.get(deck.id) ?? 0} + + {/snippet} + diff --git a/apps/mana/apps/web/src/lib/modules/cards/card-reviews.ts b/apps/mana/apps/web/src/lib/modules/cards/card-reviews.ts new file mode 100644 index 000000000..15870809f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/card-reviews.ts @@ -0,0 +1,6 @@ +/** + * 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'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/cloze.ts b/apps/mana/apps/web/src/lib/modules/cards/cloze.ts new file mode 100644 index 000000000..a0f026aab --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/cloze.ts @@ -0,0 +1,13 @@ +/** + * 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'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/collections.ts b/apps/mana/apps/web/src/lib/modules/cards/collections.ts new file mode 100644 index 000000000..9a1bb029e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/collections.ts @@ -0,0 +1,60 @@ +/** + * 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('cardDecks'); +export const cardTable = db.table('cards'); +export const cardReviewTable = db.table('cardReviews'); +export const cardStudyBlockTable = db.table('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, + }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/components/CardFace.svelte b/apps/mana/apps/web/src/lib/modules/cards/components/CardFace.svelte new file mode 100644 index 000000000..3c3538628 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/components/CardFace.svelte @@ -0,0 +1,95 @@ + + +
+
+ {@html view.prompt} +
+ + {#if isTypeIn} + onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)} + disabled={showBack} + /> + {/if} + + {#if showBack} +
+ {@html view.answer} +
+ {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/cards/components/CreateDeckModal.svelte b/apps/mana/apps/web/src/lib/modules/cards/components/CreateDeckModal.svelte new file mode 100644 index 000000000..04331d7fe --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/components/CreateDeckModal.svelte @@ -0,0 +1,140 @@ + + +{#if open} +
e.key === 'Escape' && handleClose()} + tabindex="-1" + role="presentation" + > + + +
e.stopPropagation()} + > +

Neues Deck erstellen

+ +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ Tags + (selectedTagIds = ids)} + /> +
+ +
+ Farbe + (color = c)} + size="sm" + /> +
+ + {#if deckStore.error} +
+ {deckStore.error} +
+ {/if} + +
+ + +
+
+
+
+{/if} diff --git a/apps/mana/apps/web/src/lib/modules/cards/components/DeckCard.svelte b/apps/mana/apps/web/src/lib/modules/cards/components/DeckCard.svelte new file mode 100644 index 000000000..c3c488965 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/components/DeckCard.svelte @@ -0,0 +1,51 @@ + + + +
+ +
+ + +

{deck.title}

+ + + {#if deck.description} +

+ {deck.description} +

+ {/if} + + +
+
+ {deck.cardCount || 0} Karten + {#if deck.visibility === 'public'} + + Öffentlich + + {/if} +
+ {formatDate(deck.updatedAt)} +
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/cards/fsrs.ts b/apps/mana/apps/web/src/lib/modules/cards/fsrs.ts new file mode 100644 index 000000000..bf1264557 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/fsrs.ts @@ -0,0 +1,6 @@ +/** + * 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'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/index.ts b/apps/mana/apps/web/src/lib/modules/cards/index.ts new file mode 100644 index 000000000..f063df7aa --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/index.ts @@ -0,0 +1,28 @@ +/** + * 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'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/module.config.ts b/apps/mana/apps/web/src/lib/modules/cards/module.config.ts new file mode 100644 index 000000000..98818d4d5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/module.config.ts @@ -0,0 +1,12 @@ +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' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/queries.ts b/apps/mana/apps/web/src/lib/modules/cards/queries.ts new file mode 100644 index 000000000..166e18145 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/queries.ts @@ -0,0 +1,201 @@ +/** + * 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('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('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('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('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('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('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); +} diff --git a/apps/mana/apps/web/src/lib/modules/cards/render.ts b/apps/mana/apps/web/src/lib/modules/cards/render.ts new file mode 100644 index 000000000..d37a1fcb5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/render.ts @@ -0,0 +1,7 @@ +/** + * 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'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts new file mode 100644 index 000000000..bde4e6ff0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts @@ -0,0 +1,187 @@ +/** + * 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(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 { + 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 = { + 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; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts new file mode 100644 index 000000000..1f0ad27bb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts @@ -0,0 +1,167 @@ +/** + * 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(null); + +export const deckStore = { + get error() { + return error; + }, + + async createDeck(input: CreateDeckInput): Promise { + 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 = {}; + if (updates.title !== undefined) localUpdates.name = updates.title; + if (updates.description !== undefined) localUpdates.description = updates.description; + + const diff: Partial = { + ...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 { + 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 { + 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; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/reviews.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/reviews.svelte.ts new file mode 100644 index 000000000..a7b3e69d0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/reviews.svelte.ts @@ -0,0 +1,97 @@ +/** + * 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(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 { + 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 { + 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 { + 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; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/study-blocks.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/study-blocks.svelte.ts new file mode 100644 index 000000000..888d537b0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/study-blocks.svelte.ts @@ -0,0 +1,83 @@ +/** + * 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(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 { + 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 { + 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; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/tags.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/tags.svelte.ts new file mode 100644 index 000000000..d960a888c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/tags.svelte.ts @@ -0,0 +1,19 @@ +/** + * 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', +}); diff --git a/apps/mana/apps/web/src/lib/modules/cards/tools.ts b/apps/mana/apps/web/src/lib/modules/cards/tools.ts new file mode 100644 index 000000000..8133eb6da --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/tools.ts @@ -0,0 +1,25 @@ +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' }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/cards/types.ts b/apps/mana/apps/web/src/lib/modules/cards/types.ts new file mode 100644 index 000000000..263c01487 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/types.ts @@ -0,0 +1,24 @@ +/** + * 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'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte new file mode 100644 index 000000000..f3746c84b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte @@ -0,0 +1,147 @@ + + + + + detail.deleteWithUndo({ + label: $_('cards.detail.toast_deleted'), + delete: () => deckStore.deleteDeck(deckId), + goBack, + })} +> + {#snippet body(deck)} + + +
+
+ {$_('cards.detail.prop_color')} + +
+ +
+ {$_('cards.detail.prop_visibility')} + deckStore.setVisibility(deckId, next)} + disabledLevels={['unlisted']} + /> +
+ +
+ {$_('cards.detail.prop_cards')} + {cardCount} +
+ + {#if deck.lastStudied} +
+ {$_('cards.detail.prop_last_studied')} + {formatDate(new Date(deck.lastStudied))} +
+ {/if} +
+ +
+ + +
+ +
+ {$_('cards.detail.meta_created', { + values: { date: formatDate(new Date(deck.createdAt ?? '')) }, + })} + {#if deck.updatedAt} + {$_('cards.detail.meta_updated', { + values: { date: formatDate(new Date(deck.updatedAt)) }, + })} + {/if} +
+ {/snippet} +
diff --git a/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte index aaa00518e..70b5c6d5f 100644 --- a/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/feedback/views/DetailView.svelte @@ -9,7 +9,6 @@ import ItemCard from '../components/ItemCard.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import { feedbackService } from '$lib/api/feedback'; - import { portalHref } from '$lib/auth/portal-redirect'; interface Props { id: string; @@ -119,7 +118,7 @@
{:else} {/if} diff --git a/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte b/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte index 79e821a40..8da737e80 100644 --- a/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/feedback/views/ListView.svelte @@ -10,7 +10,6 @@ import { useCommunityFeed, toggleReactionOnItem } from '../queries.svelte'; import ItemCard from '../components/ItemCard.svelte'; import { authStore } from '$lib/stores/auth.svelte'; - import { portalHref } from '$lib/auth/portal-redirect'; interface Props { /** Optional initial moduleContext filter — passed by the @@ -83,7 +82,7 @@ Noch keine Stimmen — sei der erste, der was reinwirft. {#if !authStore.user}
- Login, um mitzumachen. + Login, um mitzumachen. {/if}
{:else} diff --git a/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte b/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte index bc91db851..05270e579 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ListView.svelte @@ -7,7 +7,6 @@ import { authStore } from '$lib/stores/auth.svelte'; import { toast } from '$lib/stores/toast.svelte'; import { goto } from '$app/navigation'; - import { redirectToPortal } from '$lib/auth/portal-redirect'; import { EditProfileModal, ChangePasswordModal, @@ -70,7 +69,7 @@ async function handleAccountDeleted() { toast.info($_('profile.hub.toast_account_deleting')); await authStore.signOut(); - redirectToPortal({ next: '/' }); + goto('/login'); } @@ -233,7 +232,7 @@ class="account-btn" onclick={async () => { await authStore.signOut(); - redirectToPortal({ next: '/' }); + goto('/login'); }} > {$_('profile.logout')} diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts index c13677eb4..6e9f52247 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -35,6 +35,7 @@ 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'; @@ -90,11 +91,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise { }); } -// 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. +/** + * 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 { + let decks = await db.table('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'}`, + }; + }); +} /** * Presi-decks: "talks I've given" teaser. Returns decks flipped to diff --git a/apps/mana/apps/web/src/lib/search/providers/cards.ts b/apps/mana/apps/web/src/lib/search/providers/cards.ts new file mode 100644 index 000000000..12ee0046e --- /dev/null +++ b/apps/mana/apps/web/src/lib/search/providers/cards.ts @@ -0,0 +1,75 @@ +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 { + 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); + }, +}; diff --git a/apps/mana/apps/web/src/lib/search/providers/index.ts b/apps/mana/apps/web/src/lib/search/providers/index.ts index 9b600e682..f97ca975d 100644 --- a/apps/mana/apps/web/src/lib/search/providers/index.ts +++ b/apps/mana/apps/web/src/lib/search/providers/index.ts @@ -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)); - // 'cards': dekommissioniert 2026-05-08 — Cards eigenständig auf cardecky.mana.how. + registry.registerLazy('cards', () => import('./cards').then((m) => m.cardsSearchProvider)); 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)); diff --git a/apps/mana/apps/web/src/lib/stores/auth.svelte.ts b/apps/mana/apps/web/src/lib/stores/auth.svelte.ts index 81664a5ad..5b3423b04 100644 --- a/apps/mana/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/auth.svelte.ts @@ -1,28 +1,7 @@ /** - * Auth Store — re-export der neuen Session-Klasse. - * - * Bis 2026-05-12 war das ein Wrapper über `createManaAuthStore()` aus - * `@mana/shared-auth-ui`, der 47 Methoden mitbrachte (Login, Register, - * Reset, Passkey-CRUD, 2FA-Setup, Sessions, Audit). Mit dem Wechsel - * auf den zentralen Auth-Portal sind die Login-/Register-/Reset-Flows - * komplett rausgeflogen — sie leben jetzt auf `auth.mana.how`. - * - * Was bleibt: - * - `authStore.user`, `authStore.isAuthenticated`, `authStore.initialized`, - * `authStore.loading` — der reaktive Session-State. - * - `authStore.initialize()` — Boot-Pass. - * - `authStore.signOut()` — Logout (kein Redirect; den macht der Caller). - * - `authStore.getValidToken()`, `authStore.getAccessToken()` — Token-Access. - * - * Was wegfällt (Settings → `$lib/auth/settings-client.ts`): - * - Passkey-CRUD → `import { passkeys } from '$lib/auth/settings-client'` - * - 2FA-Setup → `import { twoFactor } from '$lib/auth/settings-client'` - * - Sessions / Audit → `import { sessions, audit } from '$lib/auth/settings-client'` - * - * Was ganz wegfällt (passiert jetzt im Portal): - * - `signIn`, `signUp`, `resetPassword`, `resetPasswordWithToken`, - * `resendVerificationEmail`, `verifyTwoFactor`, `verifyBackupCode`, - * `sendMagicLink`, `signInWithPasskey`. + * Auth Store — uses centralized Mana auth factory. */ -export { session as authStore } from '$lib/auth/session.svelte'; +import { createManaAuthStore } from '@mana/shared-auth-ui'; + +export const authStore = createManaAuthStore(); diff --git a/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts index 6e0b709ae..2fd43fcec 100644 --- a/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts @@ -25,15 +25,12 @@ */ import type { BottomNotification } from '@mana/shared-ui'; -import { portalHref } from '$lib/auth/portal-redirect'; let prompts = $state([]); -/** Default action target — the central auth portal (auth.mana.how) handles - * both login and register flows in one UI. */ -function defaultLoginHref(): string { - return portalHref(); -} +/** Default action target — the login page already exposes both login + * and register flows so we point at one URL and let the user pick. */ +const DEFAULT_LOGIN_HREF = '/login'; /** Navigates the browser. Kept as a small wrapper so unit tests can * swap it out without pulling SvelteKit's `goto`. */ @@ -82,7 +79,7 @@ export const guestPrompt = { action: { label: actionLabel, onClick: () => { - navigate(defaultLoginHref()); + navigate(DEFAULT_LOGIN_HREF); guestPrompt.dismiss(id); }, }, diff --git a/apps/mana/apps/web/src/lib/types/dashboard.test.ts b/apps/mana/apps/web/src/lib/types/dashboard.test.ts index fe31962ed..8c3328aa8 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.test.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.test.ts @@ -75,6 +75,7 @@ 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'); diff --git a/apps/mana/apps/web/src/lib/types/dashboard.ts b/apps/mana/apps/web/src/lib/types/dashboard.ts index 820abc01a..3ae0f7402 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.ts @@ -19,6 +19,7 @@ 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 @@ -231,6 +232,15 @@ 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', diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 3fff16241..800fcb1d3 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -7,7 +7,6 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; - import { redirectToPortal, portalHref } from '$lib/auth/portal-redirect'; import type { Component, Snippet } from 'svelte'; import ToastContainer from '$lib/components/ToastContainer.svelte'; import FeedbackQuickModal from '$lib/components/feedback/FeedbackQuickModal.svelte'; @@ -498,7 +497,7 @@ guestMode?.destroy(); setErrorTrackingUser(null); await authStore.signOut(); - redirectToPortal({ next: '/' }); + goto('/login'); } // ── Guest Mode ────────────────────────────────────────── @@ -740,7 +739,7 @@ markAsGuest(); guestMode = createGuestMode('mana', { nudgeDelayMinutes: 3, - onRegister: () => redirectToPortal({ target: 'register' }), + onRegister: () => goto('/register'), }); } } @@ -1063,7 +1062,7 @@ {languageItems} {currentLanguageLabel} showLogout={authStore.isAuthenticated} - loginHref={portalHref()} + loginHref="/login" primaryColor="hsl(var(--color-primary))" showAppSwitcher={false} showAiTierSelector={true} @@ -1153,8 +1152,8 @@ appId="mana" visible={guestMode.showWelcome} onClose={() => guestMode?.dismissWelcome()} - onLogin={() => redirectToPortal()} - onRegister={() => redirectToPortal({ target: 'register' })} + onLogin={() => goto('/login')} + onRegister={() => goto('/register')} locale={($locale || 'de') === 'de' ? 'de' : 'en'} /> {/if} diff --git a/apps/mana/apps/web/src/routes/(app)/cards/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/cards/+layout.svelte new file mode 100644 index 000000000..7e0db5dc8 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/+layout.svelte @@ -0,0 +1,15 @@ + + +{@render children()} diff --git a/apps/mana/apps/web/src/routes/(app)/cards/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/+page.svelte new file mode 100644 index 000000000..ae85d9971 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/+page.svelte @@ -0,0 +1,78 @@ + + + + Cards - Mana + + + +
+
+

Cards

+

Karteikarten & Spaced Repetition

+
+ + +
+
+
+ +
+
+
Lerne effizienter
+
+ Karteikarten erstellen, organisieren und mit Spaced Repetition lernen +
+
+
+
+ + +
+ {#each quickLinks as link} + +
+
+ +
+
+
{link.label}
+
{link.description}
+
+
+
+ {/each} +
+
+
diff --git a/apps/mana/apps/web/src/routes/(app)/cards/decks/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/decks/+page.svelte new file mode 100644 index 000000000..dde873bdc --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/decks/+page.svelte @@ -0,0 +1,75 @@ + + + + Meine Decks - Cards - Mana + + + +
+ +
+
+

Meine Decks

+

Organisiere deine Lernmaterialien in Decks

+
+ +
+ + + {#if deckStore.error} +
+

{$_('common.error_loading')}

+

{deckStore.error}

+
+ {:else if (allDecks?.value ?? []).length === 0} + +
+
📚
+

Noch keine Decks

+

+ Erstelle dein erstes Deck, um mit dem Lernen zu beginnen. +

+ +
+ {:else} + +
+ {#each allDecks.value as deck (deck.id)} + handleDeckClick(deck.id)} /> + {/each} +
+ {/if} +
+ + + +
diff --git a/apps/mana/apps/web/src/routes/(app)/cards/decks/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/decks/[id]/+page.svelte new file mode 100644 index 000000000..fb1ca5de6 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/decks/[id]/+page.svelte @@ -0,0 +1,410 @@ + + + + {deck?.title || 'Deck'} — Cards — Mana + + + + {#if deck} +
+ + + +
+
+
+
+

{deck.title}

+
+ {#if deck.description} +

{deck.description}

+ {/if} +
+ +
+ {#if deck.visibility === 'public'} + Öffentlich + {/if} + + +
+
+ + +
+ + {#if dueCount === 0 && cards.length > 0} + + Heute alles gelernt — schau später wieder rein. + + {/if} +
+ + +
+
+
{cards.length}
+
Karten gesamt
+
+
+
{dueCount}
+
Fällig
+
+
+ + +
+ +
+ + + {#if showNewCardForm} +
+

Neue Karte

+ + +
+ {#each cardTypeOptions as opt} + + {/each} +
+ +
+ {#if newCardType === 'cloze'} +
+ + + +

+ Markiere mit + {{c1::Wort}} + — optional Hinweis: ::Hinweis. +

+
+ {:else} +
+ + + +
+
+ + +
+ {/if} +
+ + +
+
+
+ {/if} + + +
+

+ Karten ({cards.length}) +

+ {#if cards.length === 0} +
+
📝
+

Noch keine Karten. Erstelle deine erste Karte!

+ +
+ {:else} +
+ {#each cards as card, i (card.id)} + {@const preview = previewSummary(card)} +
+ {i + 1}. +
+
+ {@html renderMarkdown(preview.primary)} +
+ {#if preview.secondary} +
+ {@html renderMarkdown(preview.secondary)} +
+ {/if} +
+
+ + {typeBadge(card.type)} + + +
+
+ {/each} +
+ {/if} +
+ + + {#if showDeleteConfirm} +
(showDeleteConfirm = false)} + onkeydown={(e) => e.key === 'Escape' && (showDeleteConfirm = false)} + tabindex="-1" + role="presentation" + > + + +
e.stopPropagation()} + > +

Deck löschen?

+

+ 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. +

+
+ + +
+
+
+ {/if} +
+ {:else} +
+

Deck nicht gefunden

+ +
+ {/if} + + (showShare = false)} + url={shareUrl} + title={deck?.title ?? ''} + source="cards" + description={deck?.description ?? ''} + /> +
diff --git a/apps/mana/apps/web/src/routes/(app)/cards/explore/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/explore/+page.svelte new file mode 100644 index 000000000..147dd12f5 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/explore/+page.svelte @@ -0,0 +1,33 @@ + + + + Entdecken - Cards - Mana + + + +
+
+

Entdecken

+

+ Offentliche Decks aus der Community entdecken +

+
+ +
+
+
+ +
+

Entdecken-Feature

+

+ Offentliche Decks durchsuchen und entdecken — kommt bald! +

+
+
+
+
diff --git a/apps/mana/apps/web/src/routes/(app)/cards/learn/[deckId]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/learn/[deckId]/+page.svelte new file mode 100644 index 000000000..5a16ba8dc --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/learn/[deckId]/+page.svelte @@ -0,0 +1,175 @@ + + +
+
+
+ +

Lernen

+
+ {#if queue.length > 0 && !finished} +
+ {Math.min(currentIndex + 1, queue.length)} / {queue.length} +
+ {/if} +
+ + {#if empty} +
+
Alles gelernt
+

+ Komm später wieder — fällige Karten erscheinen automatisch. +

+ +
+ {:else if finished} +
+
Session abgeschlossen
+

+ {sessionCount} Karten in {Math.round((Date.now() - sessionStartedAt) / 1000)} s. +

+ +
+ {:else if current} + (typedAnswer = v)} + /> + + {#if !showBack} + + {:else} +
+ + + + +
+ {/if} + {:else} +
Lade…
+ {/if} +
diff --git a/apps/mana/apps/web/src/routes/(app)/cards/progress/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/progress/+page.svelte new file mode 100644 index 000000000..ccaa516df --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/progress/+page.svelte @@ -0,0 +1,84 @@ + + + + {$_('cards.progress.page_title_html')} + + + +
+
+

{$_('cards.progress.heading')}

+

{$_('cards.progress.subtitle')}

+
+ + +
+
+
{decks.length}
+
{$_('cards.progress.stat_decks')}
+
+
+
{totalCards}
+
{$_('cards.progress.stat_total_cards')}
+
+
+
0
+
{$_('cards.progress.stat_due')}
+
+
+ + +
+

+ + + {$_('cards.progress.section_overview')} + +

+ {#if decks.length === 0} +
+
🎯
+

{$_('cards.progress.empty_title')}

+

{$_('cards.progress.empty_hint')}

+
+ {:else} +
+ {#each decks as deck (deck.id)} +
+
+
+
+
{deck.title}
+
+ {$_('cards.progress.deck_cards', { values: { n: deck.cardCount || 0 } })} +
+
+
+
+
+ {formatDate(new Date(deck.updatedAt), { + day: '2-digit', + month: 'short', + })} +
+
+
+ {/each} +
+ {/if} +
+
+
diff --git a/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte b/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte index a63994607..95259e557 100644 --- a/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte @@ -2,7 +2,6 @@ import { Heart } from '@mana/shared-icons'; import { _ } from 'svelte-i18n'; import { authStore } from '$lib/stores/auth.svelte'; - import { portalHref } from '$lib/auth/portal-redirect'; import { RoutePage } from '$lib/components/shell'; import { favoritesStore, @@ -39,7 +38,7 @@

{$_('favorites.loginRequired')}

{$_('settings.login')} diff --git a/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte b/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte index fb8a7a2da..38b1021ce 100644 --- a/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte @@ -1,8 +1,8 @@ + + + + +{@render children()} diff --git a/apps/mana/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 000000000..3005fdda4 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,28 @@ + + + + {#snippet appSlider()} + + {/snippet} + diff --git a/apps/mana/apps/web/src/routes/(auth)/login/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/login/+page.svelte new file mode 100644 index 000000000..494d69c6f --- /dev/null +++ b/apps/mana/apps/web/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,62 @@ + + + authStore.signInWithPasskey()} + onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} + onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} + {goto} + successRedirect="/" + registerPath="/register" + forgotPasswordPath="/forgot-password" + lightBackground="#f3f4f6" + darkBackground="#121212" + {translations} + {verified} + {initialEmail} + {isDark} + version={APP_VERSION} + buildTime={BUILD_TIME} +> + {#snippet headerControls()} + + {/snippet} + {#snippet appSlider()} + + {/snippet} + diff --git a/apps/mana/apps/web/src/routes/(auth)/register/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/register/+page.svelte new file mode 100644 index 000000000..bd28dde7a --- /dev/null +++ b/apps/mana/apps/web/src/routes/(auth)/register/+page.svelte @@ -0,0 +1,55 @@ + + + + {translations.title} | Mana + + + + {#snippet appSlider()} + + {/snippet} + diff --git a/apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte b/apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte new file mode 100644 index 000000000..daf10c97c --- /dev/null +++ b/apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte @@ -0,0 +1,168 @@ + + +
+
+

Reset Password

+

+ {#if success} + Password reset successfully + {:else if hasToken} + Enter your new password + {:else} + Invalid or missing reset token + {/if} +

+
+ + {#if success} + +
+ + {:else if hasToken} + +
+ {#if error} +
+ {error} +
+ {/if} + +
+
+ + +

+ Must be at least 12 characters +

+
+ +
+ + +
+ +
+ +
+
+
+
+ {:else} + +
+
⚠️
+

+ This password reset link is invalid or has expired. +

+ + Request a new reset link + +
+
+ {/if} +
diff --git a/apps/mana/apps/web/src/routes/auth/callback/+page.svelte b/apps/mana/apps/web/src/routes/auth/callback/+page.svelte index d9aaa6c06..325345916 100644 --- a/apps/mana/apps/web/src/routes/auth/callback/+page.svelte +++ b/apps/mana/apps/web/src/routes/auth/callback/+page.svelte @@ -1,71 +1,88 @@ - Anmeldung wird abgeschlossen – Mana + Authenticating... - Mana
- {#if error} -
⚠️
-

- Authentifizierungsfehler -

-

- {error} -

-

- Du wirst zurück zur Anmeldung geleitet… -

- {:else} + {#if processing}
-

Anmeldung wird abgeschlossen…

-

Einen Moment bitte.

+

Authenticating...

+

Please wait while we complete your sign-in.

+ {:else if error} +
⚠️
+

Authentication Error

+

+ {error} +

+

Redirecting you back to login...

{/if}
diff --git a/apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte b/apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte new file mode 100644 index 000000000..ff15f6e4c --- /dev/null +++ b/apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte @@ -0,0 +1,23 @@ + + +
+
+
+

Redirecting...

+
+
diff --git a/apps/mana/apps/web/src/routes/email-verified/+page.svelte b/apps/mana/apps/web/src/routes/email-verified/+page.svelte index c937da4d4..c99361c89 100644 --- a/apps/mana/apps/web/src/routes/email-verified/+page.svelte +++ b/apps/mana/apps/web/src/routes/email-verified/+page.svelte @@ -1,6 +1,6 @@ @@ -26,7 +26,7 @@