diff --git a/apps/cards/COMPETITORS_2026-05.md b/apps/cards/COMPETITORS_2026-05.md deleted file mode 100644 index 377f042ba..000000000 --- a/apps/cards/COMPETITORS_2026-05.md +++ /dev/null @@ -1,353 +0,0 @@ -# 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 deleted file mode 100644 index 38f8096b0..000000000 --- a/apps/cards/GUIDELINES.md +++ /dev/null @@ -1,367 +0,0 @@ -# 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 deleted file mode 100644 index 75891b4a9..000000000 --- a/apps/cards/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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 deleted file mode 100644 index 29d6acad1..000000000 --- a/apps/cards/apps/web/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 4f4174f67..000000000 --- a/apps/cards/apps/web/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 5869ae34a..000000000 --- a/apps/cards/apps/web/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "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 deleted file mode 100644 index 94506ec9d..000000000 --- a/apps/cards/apps/web/src/app.css +++ /dev/null @@ -1,63 +0,0 @@ -@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 deleted file mode 100644 index 3b4b2bb75..000000000 --- a/apps/cards/apps/web/src/app.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 deleted file mode 100644 index 470d4ca25..000000000 --- a/apps/cards/apps/web/src/app.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - diff --git a/apps/cards/apps/web/src/hooks.server.ts b/apps/cards/apps/web/src/hooks.server.ts deleted file mode 100644 index 846ce98e2..000000000 --- a/apps/cards/apps/web/src/hooks.server.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 265741222..000000000 --- a/apps/cards/apps/web/src/lib/ai/generate.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * 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 deleted file mode 100644 index 9cb5655a8..000000000 --- a/apps/cards/apps/web/src/lib/ai/pdf.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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 deleted file mode 100644 index b01176c17..000000000 --- a/apps/cards/apps/web/src/lib/anki/import.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * 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 deleted file mode 100644 index afccaadc7..000000000 --- a/apps/cards/apps/web/src/lib/anki/parse.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6ed102197..000000000 --- a/apps/cards/apps/web/src/lib/api/cards-api.ts +++ /dev/null @@ -1,505 +0,0 @@ -/** - * 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 deleted file mode 100644 index dedcee6e7..000000000 --- a/apps/cards/apps/web/src/lib/components/AiCardGen.svelte +++ /dev/null @@ -1,209 +0,0 @@ - - -
-
- ✨ 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 deleted file mode 100644 index 981074a5f..000000000 --- a/apps/cards/apps/web/src/lib/components/AnkiImport.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - -
-
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 deleted file mode 100644 index 38723a5d2..000000000 --- a/apps/cards/apps/web/src/lib/components/CardDiscussions.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - - diff --git a/apps/cards/apps/web/src/lib/components/CardFace.svelte b/apps/cards/apps/web/src/lib/components/CardFace.svelte deleted file mode 100644 index 19d09cf1f..000000000 --- a/apps/cards/apps/web/src/lib/components/CardFace.svelte +++ /dev/null @@ -1,194 +0,0 @@ - - -{#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 deleted file mode 100644 index 556e51700..000000000 --- a/apps/cards/apps/web/src/lib/components/CardsLogo.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/apps/cards/apps/web/src/lib/components/DeckCardList.svelte b/apps/cards/apps/web/src/lib/components/DeckCardList.svelte deleted file mode 100644 index 351216754..000000000 --- a/apps/cards/apps/web/src/lib/components/DeckCardList.svelte +++ /dev/null @@ -1,104 +0,0 @@ - - -
-
-

- 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 deleted file mode 100644 index def2e7c11..000000000 --- a/apps/cards/apps/web/src/lib/components/DeckGrid.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -{#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 deleted file mode 100644 index 97145ffed..000000000 --- a/apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte +++ /dev/null @@ -1,353 +0,0 @@ - - -
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 deleted file mode 100644 index f52564aff..000000000 --- a/apps/cards/apps/web/src/lib/components/PullRequestsSection.svelte +++ /dev/null @@ -1,233 +0,0 @@ - - -
-
-

- 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 deleted file mode 100644 index 1a7dd0b86..000000000 --- a/apps/cards/apps/web/src/lib/components/ReportButton.svelte +++ /dev/null @@ -1,142 +0,0 @@ - - -{#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 deleted file mode 100644 index d2c8b6795..000000000 --- a/apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte +++ /dev/null @@ -1,93 +0,0 @@ - - -
-
- 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 deleted file mode 100644 index f8077a514..000000000 --- a/apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte +++ /dev/null @@ -1,188 +0,0 @@ - - -{#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 deleted file mode 100644 index 07df39beb..000000000 --- a/apps/cards/apps/web/src/lib/data/crypto.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7088f15e1..000000000 --- a/apps/cards/apps/web/src/lib/data/database.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 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 deleted file mode 100644 index 8889d1f54..000000000 --- a/apps/cards/apps/web/src/lib/data/sync.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * 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 deleted file mode 100644 index 648b5d03a..000000000 --- a/apps/cards/apps/web/src/lib/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// 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 deleted file mode 100644 index 2a28d01e1..000000000 --- a/apps/cards/apps/web/src/lib/media/upload.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 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 deleted file mode 100644 index 869f7ae45..000000000 --- a/apps/cards/apps/web/src/lib/queries.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * 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 deleted file mode 100644 index 24c179fb8..000000000 --- a/apps/cards/apps/web/src/lib/services/subscribe.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * 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 deleted file mode 100644 index ce4e9f88c..000000000 --- a/apps/cards/apps/web/src/lib/stores/auth.svelte.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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 deleted file mode 100644 index 84b94f289..000000000 --- a/apps/cards/apps/web/src/lib/stores/author.svelte.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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 deleted file mode 100644 index 079ae5c62..000000000 --- a/apps/cards/apps/web/src/lib/stores/cards.svelte.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * 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 deleted file mode 100644 index 79fcd0f01..000000000 --- a/apps/cards/apps/web/src/lib/stores/decks.svelte.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7caa295d1..000000000 --- a/apps/cards/apps/web/src/lib/stores/reviews.svelte.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 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 deleted file mode 100644 index 91fb0b517..000000000 --- a/apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 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 deleted file mode 100644 index 1260137f3..000000000 --- a/apps/cards/apps/web/src/lib/stores/theme.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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 deleted file mode 100644 index 677d94af7..000000000 --- a/apps/cards/apps/web/src/lib/util/slug.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 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 deleted file mode 100644 index 0c6207d26..000000000 --- a/apps/cards/apps/web/src/routes/+layout.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - - - {@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 deleted file mode 100644 index f59253390..000000000 --- a/apps/cards/apps/web/src/routes/+page.svelte +++ /dev/null @@ -1,156 +0,0 @@ - - - - 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 deleted file mode 100644 index 70d766f0c..000000000 --- a/apps/cards/apps/web/src/routes/admin/reports/+page.svelte +++ /dev/null @@ -1,170 +0,0 @@ - - - - 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 deleted file mode 100644 index 692635ddd..000000000 --- a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte +++ /dev/null @@ -1,267 +0,0 @@ - - - - {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 deleted file mode 100644 index 79387ec37..000000000 --- a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte +++ /dev/null @@ -1,547 +0,0 @@ - - - - {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 deleted file mode 100644 index a0f60ce9a..000000000 --- a/apps/cards/apps/web/src/routes/explore/+page.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - - - 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 deleted file mode 100644 index 6588788b0..000000000 --- a/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte +++ /dev/null @@ -1,226 +0,0 @@ - - - - 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 deleted file mode 100644 index b09129a41..000000000 --- a/apps/cards/apps/web/src/routes/login/+page.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/apps/cards/apps/web/src/routes/me/purchases/+page.svelte b/apps/cards/apps/web/src/routes/me/purchases/+page.svelte deleted file mode 100644 index 5f0d29f0f..000000000 --- a/apps/cards/apps/web/src/routes/me/purchases/+page.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - - - 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 deleted file mode 100644 index 098ca4162..000000000 --- a/apps/cards/apps/web/src/routes/register/+page.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte deleted file mode 100644 index da381fdc1..000000000 --- a/apps/cards/apps/web/src/routes/u/[slug]/+page.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - - - {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 deleted file mode 100644 index d09ef49e5..000000000 Binary files a/apps/cards/apps/web/static/apple-touch-icon.png and /dev/null differ diff --git a/apps/cards/apps/web/static/favicon.svg b/apps/cards/apps/web/static/favicon.svg deleted file mode 100644 index 1f160f709..000000000 --- a/apps/cards/apps/web/static/favicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/cards/apps/web/static/pwa-192x192.png b/apps/cards/apps/web/static/pwa-192x192.png deleted file mode 100644 index 7cd2f8d33..000000000 Binary files a/apps/cards/apps/web/static/pwa-192x192.png and /dev/null differ diff --git a/apps/cards/apps/web/static/pwa-512x512.png b/apps/cards/apps/web/static/pwa-512x512.png deleted file mode 100644 index 2ab569c8b..000000000 Binary files a/apps/cards/apps/web/static/pwa-512x512.png and /dev/null differ diff --git a/apps/cards/apps/web/static/sql-wasm-browser.wasm b/apps/cards/apps/web/static/sql-wasm-browser.wasm deleted file mode 100755 index b32b66473..000000000 Binary files a/apps/cards/apps/web/static/sql-wasm-browser.wasm and /dev/null differ diff --git a/apps/cards/apps/web/static/sql-wasm.wasm b/apps/cards/apps/web/static/sql-wasm.wasm deleted file mode 100755 index b32b66473..000000000 Binary files a/apps/cards/apps/web/static/sql-wasm.wasm and /dev/null differ diff --git a/apps/cards/apps/web/svelte.config.js b/apps/cards/apps/web/svelte.config.js deleted file mode 100644 index fc92816a8..000000000 --- a/apps/cards/apps/web/svelte.config.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 9637d322e..000000000 --- a/apps/cards/apps/web/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 89272593f..000000000 --- a/apps/cards/apps/web/vite.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index d753bc7a4..000000000 --- a/apps/cards/docs/MARKETPLACE_PLAN.md +++ /dev/null @@ -1,654 +0,0 @@ -# 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 deleted file mode 100644 index b5f750efe..000000000 --- a/apps/cards/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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" - } -}