Compare commits

..

No commits in common. "main" and "cards-decommission-base" have entirely different histories.

321 changed files with 21308 additions and 1569 deletions

View file

@ -23,10 +23,6 @@ PUBLIC_GLITCHTIP_DSN=
# Mana Core Auth Service
MANA_AUTH_URL=http://localhost:3001
# Auth-Portal-UI (Login/Register/Reset, getrennt vom Auth-API-Service).
# In Prod identisch mit MANA_AUTH_URL (nginx splittet /api/* zu mana-auth,
# Rest zu mana-auth-web), lokal aber eigener Port (mana-auth-web :3002).
MANA_AUTH_WEB_URL=http://localhost:3002
# Mana Credits Service
MANA_CREDITS_URL=http://localhost:3061
# Mana Media Service (CAS, thumbnails, Photos gallery)

View file

@ -21,7 +21,7 @@
# Compose project name (pinned, do not change)
# ============================================
# All Mac Mini containers were originally created under this project
# name, which mismatches the current directory name (managarten).
# name, which mismatches the current directory name (mana-monorepo).
# Pinning the project name here means anyone running 'docker compose ...'
# from the repo root automatically lands in the same project as the
# already-running containers, instead of silently spawning a duplicate

View file

@ -55,7 +55,7 @@ concurrency:
cancel-in-progress: false # Don't cancel in-progress deploys
env:
PROJECT_DIR: /Users/mana/projects/managarten
PROJECT_DIR: /Users/mana/projects/mana-monorepo
COMPOSE_FILE: docker-compose.macmini.yml
ENV_FILE: .env.macmini
DOCKER_BUILDKIT: 1
@ -339,17 +339,12 @@ jobs:
# If a service has no Drizzle config or no schema diff this is
# a fast no-op. We must source POSTGRES_PASSWORD from the env
# file because the workflow env doesn't carry it.
#
# `. "$ENV_FILE"` (bash source) breaks on DOTENV-Werte, die
# ungequotete Leerzeichen enthalten — z.B. `MANA_AI_PUBLIC_KEY_PEM`
# mit `-----BEGIN PUBLIC KEY-----…`: bash interpretiert "PUBLIC"
# als nächstes Command und scheitert mit `PUBLIC: command not
# found`. Backup-Script hat dasselbe Problem (Commit 97e285bc6).
# Wir lesen daher gezielt nur die benötigten Variablen via grep
# statt die ganze Datei als Shell-Script zu sourcen.
echo "=== Applying schema migrations ==="
PG_PASSWORD=$(grep -E '^POSTGRES_PASSWORD=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed 's/^"\(.*\)"$/\1/; s/^'"'"'\(.*\)'"'"'$/\1/')
PG_PASSWORD="${PG_PASSWORD:-mana123}"
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
PG_PASSWORD="${POSTGRES_PASSWORD:-mana123}"
# `drizzle-kit` reads `drizzle.config.ts`, which itself
# `import {defineConfig} from 'drizzle-kit'`. Node's resolver

View file

@ -17,7 +17,7 @@ jobs:
steps:
- name: Mirror to Forgejo via SSH
run: |
cd /Users/mana/projects/managarten
cd /Users/mana/projects/mana-monorepo
# Stash any local changes so pull never fails
git stash --quiet 2>/dev/null || true
@ -25,5 +25,5 @@ jobs:
# Push to Forgejo via localhost SSH (runner is on the Mac Mini)
GIT_SSH_COMMAND='ssh -p 2222 -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no' \
git push ssh://git@localhost:2222/till/managarten.git main 2>&1
git push ssh://git@localhost:2222/till/mana-monorepo.git main 2>&1
echo "Mirrored to Forgejo"

View file

@ -160,7 +160,7 @@ Enforced by `pnpm run validate:turbo` (`scripts/validate-no-recursive-turbo.mjs`
| `@mana/shared-branding` | App registry, tiers, branding |
| `@mana/shared-types` | Common TS types |
| `@mana/shared-utils` | Utility functions |
| `@mana/shared-ui` | **Svelte-5-Komponenten-Bibliothek** (Pills, Modals, Toast, Quick-Input, Skeletons …). **Heimat seit 2026-05-09: `mana/packages/shared-ui` und `npm.mana.how`** — die Kopie hier ist eingefroren bis zum Rückbau dieses Repos. Bei Änderungen in mana/ zuerst, dann erst hierher. |
| `@mana/shared-ui` | React Native UI components |
| `@mana/shared-theme` | Theme config |
| `@mana/shared-i18n` | i18n |
| `@mana/shared-privacy` | Unified visibility/privacy system: `VisibilityLevel` enum + zod schema + `<VisibilityPicker>` + predicates (`canEmbedOnWebsite`, …). Plan: [`docs/plans/visibility-system.md`](docs/plans/visibility-system.md). Rollout per-module, not yet adopted anywhere. |

View file

@ -1,6 +1,6 @@
# Managarten
# Mana Monorepo
Der Garten der mana-Apps — ein selbst-gehostetes Multi-App-Ökosystem unter `mana.how` mit geteilten Packages und einheitlichem Tooling. Teil der mana-e.V.-Plattform.
Monorepo containing all Mana projects — a self-hosted multi-app ecosystem with shared packages and unified tooling.
## Projects
@ -62,7 +62,7 @@ See [CLAUDE.md](./CLAUDE.md) for comprehensive development documentation.
## Architecture
```
managarten/
mana-monorepo/
├── apps/ # Product applications
├── services/ # Microservices (auth, search, LLM, bots)
├── packages/ # Shared packages

View file

@ -1,6 +1,6 @@
# Troubleshooting Guide
Common issues and solutions for the managarten.
Common issues and solutions for the mana-monorepo.
## Table of Contents
@ -409,7 +409,7 @@ docker run --rm --entrypoint cat test /app/dist/ai/ai.service.js
### Related Issues
- [Commit d69cc607](https://github.com/Memo-2023/managarten/commit/d69cc607) - Fixed type-only ConfigService import in AiService
- [Commit d69cc607](https://github.com/Memo-2023/mana-monorepo/commit/d69cc607) - Fixed type-only ConfigService import in AiService
- TypeScript `import type` vs `import {}` - both erase at compile time
- Docker layer caching can hide fixes if source wasn't properly copied
@ -425,7 +425,7 @@ docker run --rm --entrypoint cat test /app/dist/ai/ai.service.js
If you encounter an issue not covered here:
1. Check the [GitHub Issues](https://github.com/Memo-2023/managarten/issues)
1. Check the [GitHub Issues](https://github.com/Memo-2023/mana-monorepo/issues)
2. Review recent commits that may have introduced the issue
3. Run `pnpm clean` and `pnpm install` to reset
4. Create a new issue with full error logs

View file

@ -0,0 +1,353 @@
# Cardecky — Konkurrenz-Analyse (Mai 2026)
> Stand: 2026-05-07. Quellen primär aus offiziellen Pricing-Seiten, G2/Trustpilot/Reddit/HN sowie Wikipedia/Crunchbase. Wo Daten fehlen oder nicht öffentlich sind, ist das explizit vermerkt. Preise schwanken regional/saisonal — die hier genannten Zahlen sind Listenpreise USD, sofern nicht anders angegeben.
---
## 1. Executive Summary
- **Anki bleibt der unschlagbare technische Gold-Standard**, aber UX-Schwächen (FSRS-„Difficulty Hell", Plugin-Hölle, kein natives Cloud-Sync mit Bildern) und der $25 iOS-Preis sind reale Lücken, in die wir stoßen können. Die Übergabe an AnkiHub im Februar 2026 könnte mittelfristig die Open-Source-Dynamik verändern — Beobachten lohnt.
- **Quizlet hat seine eigene Userbase verärgert**: Trustpilot 1.4/5, massive Beschwerden über Paywalls für Funktionen, die früher gratis waren. Genau dieses Vertrauensvakuum füllen Knowt und potenziell wir.
- **AI-Karten-Generierung ist Tischeinsatz, kein Differenzierer mehr.** Quizlet, Quizgecko, Knowt, RemNote, Wisdolia, sogar Memrise haben es. PDF-Import + KI ist erwartete Baseline.
- **Die „beautiful Anki"-Lücke ist umkämpft**: Mochi (5$/mo), RemNote (8$/mo), Noji (vormals AnkiPro). Cardecky mit _kostenlosem_ Sync sticht heraus — niemand sonst bietet die Kombination Markdown + FSRS + Cloud-Sync gratis. Das ist unsere wichtigste objektive Differenzierung.
- **Brand-Sniping ist real und schädlich**: AnkiPro (jetzt Noji) und AnkiApp (jetzt AlgoApp) haben sich einen Ruf als „Anki-Klone, die täuschen" erarbeitet — inkl. eines 10-tägigen Sync-Outages bei AnkiPro im Mai 2025. Lehre für uns: nie Anki im Namen führen, Kompatibilität sauber kommunizieren.
---
## 2. Vergleichstabelle
| Konkurrent | USP-Kurz | Lizenz | Free-Tier | Pro-Preis | Bedrohung |
| ------------------------------ | -------------------------------------- | --------------------------- | -------------------------------- | ----------------------------------------------- | -------------------------------- |
| **Anki (Desktop/Web/Android)** | Tech-Gold-Standard, FSRS, Add-ons | AGPL-3.0 | Voll-Funktional gratis | $0 (iOS: $24.99-29.99 lifetime) | **Hoch** |
| **AnkiHub** | Kollaborative Anki-Decks (USMLE-Fokus) | proprietär (auf Anki-Basis) | Trial | $5/mo | Mittel (Power-User) |
| **Quizlet** | Marktführer Volumen + Schule | proprietär | Sehr eingeschränkt, viele Ads | $35.99/Jahr (Plus), ~$45/Jahr (Unlimited) | **Hoch** (Reichweite) |
| **RemNote** | Notes + SR Hybrid | proprietär | Großzügig (3 PDFs, 5 Image-Occ.) | $8/mo annual (Pro) | Mittel |
| **Mochi** | Markdown, Local-First, schickes UI | proprietär | Single-Device | $5/mo (Sync) | **Hoch** (direkter Wettbewerber) |
| **Brainscape** | Confidence-Based-Repetition | proprietär | Limited Decks | ~$19.99/mo, $79.99 lifetime | Gering-Mittel |
| **Memrise** | Sprachen + AI-Buddies | proprietär | Eingeschränkt | $130.99/Jahr, $199.99 lifetime | Gering (Nische Sprachen) |
| **SuperMemo** | Algorithmus-Urvater (SM-20) | proprietär | Monatstrial Mobile | ~9.90$/mo Mobile, ~$66 Desktop perp. | Gering (Nische, sperrige UX) |
| **AnkiPro / Noji** | „Anki-Look" mit modernem UI | proprietär | mit Ads/Limits | nicht öffentlich klar (~$5-10/mo) | Mittel (Brand-Verwirrung) |
| **AnkiApp / AlgoApp** | Cloud-First Closed-Source | proprietär | Limited | Subscription (Details schwammig) | Gering (Reputation kaputt) |
| **Quizgecko** | AI-First (Quizzes, Podcasts) | proprietär | 1 AI-Lesson/Monat | $16/mo (Pro), $29 (Ultra) | Mittel (AI-Side) |
| **Knowt** | „Free Quizlet-Alternative" + AI | proprietär | Sehr großzügig | $9.99/mo (Ultra) | **Hoch** (gleiches Spielfeld) |
| **Wisdolia** | Browser-Ext: Karten aus Webcontent | proprietär | 50 Sets/Monat | $2.50/mo, $25/Jahr | Gering |
| **Mnemosyne** | Open-Source, Forschungs-Datasammlung | GPL | Voll gratis | — | Sehr gering |
| **Traverse** | Mind-Maps + SR (Mandarin Blueprint) | proprietär | Free-Plan | $15/mo Member, $35/User Enterprise | Gering |
| **Cerego** | Enterprise B2B Adaptive Learning | proprietär | — | ab $8.33/mo Indiv., Enterprise on req. | Sehr gering (B2B) |
| **NeuraCache** | Notion/Obsidian-Sync für SR | proprietär | Limited | 14d Trial → Pro (Preis nicht klar dokumentiert) | Gering |
> Threat-Ranking: nur **Anki, Quizlet, Mochi, Knowt** sind Top-Bedrohungen für Cardeckys Kernzielgruppe. RemNote, Quizgecko, AnkiPro/Noji sind Nebenfront.
---
## 3. Detail-Sektion pro Konkurrent
### 3.1 Anki (Desktop / AnkiWeb / AnkiDroid / AnkiMobile)
- **URL:** https://apps.ankiweb.net/
- **Plattformen:** Windows, macOS, Linux (Desktop), Web (AnkiWeb), Android (AnkiDroid), iOS (AnkiMobile)
- **USP:** Der etablierte technische Standard für Spaced Repetition; mächtig, erweiterbar (Add-ons), FSRS v6 nativ, riesiges Deck-Ökosystem (insbes. Medizin: AnKing).
- **Lizenz:** AGPL-3.0 (Desktop, AnkiDroid, Web). AnkiMobile iOS proprietär (finanziert die Open-Source-Arbeit).
- **Kosten:** Desktop / Web / Android **kostenlos**. AnkiMobile iOS: **$24.99-29.99 einmalig (Lifetime)**. AnkiHub-Cloud-Decks: $5/Monat (separat).
- **User loben:** Mächtig & flexibel; FSRS-Wirksamkeit; freie Decks (insbes. AnKing Step Deck mit 100k+ Studenten); Dauerhaftigkeit (seit 2006).
- **User kritisieren:** Steile UX-Lernkurve; FSRS-„Difficulty Hell" (Karten reifen langsam, Reviews explodieren); Plugin-Brüche zwischen Versionen; iOS-Preis abschreckend; Sync-Setup für Bilder/Audio umständlich.
- **Firma & Geschichte:** Damien Elmes (Australien), gestartet 5.10.2006 ursprünglich für Japanisch-Lernen. Im **Februar 2026** angekündigt, dass AnkiHub (Austin, TX) Business-Operations und Open-Source-Stewardship übernimmt — Anki bleibt Open Source, keine externen Investoren, Versprechen „no enshittification".
- **Bedrohungsgrad: Hoch.** Power-User-Standard, riesiges Decks-Ökosystem, kostenlos. Wir können sie nicht im technischen Spielfeld schlagen — wir müssen über UX, Onboarding und „Anki-Import-Bridge" gewinnen.
Quellen: [Anki Wikipedia](<https://en.wikipedia.org/wiki/Anki_(software)>) · [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](<https://en.wikipedia.org/wiki/Mnemosyne_(software)>) · [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.

367
apps/cards/GUIDELINES.md Normal file
View file

@ -0,0 +1,367 @@
# Cardecky — Projekt-Leitlinien
Verbindliche Regeln für den Spinoff. Ziel: in wenigen Wochen ein
ausspielbares Web-MVP, das ausschließlich seinen *Core Gameloop*
beherrscht und alles andere von zentralen Mana-Bausteinen erbt.
**Status:** Planungsphase, noch kein Code.
**Name:** Cardecky.
**App-Domain:** `cardecky.mana.how` (Subdomain unter `*.mana.how`, SSO über mana-auth).
**Marketing-Landing:** `cardecky.com` (eigene Domain, statisch, SEO/Akquise — keine Auth, leitet auf `cardecky.mana.how` für die App).
**Zugang:** offen für jeden eingeloggten Mana-User (`requiredTier: 'public'`, kein Beta-Gate).
## 1. Mission in einem Satz
Die schönste, einfachste Karteikarten-App mit Spaced Repetition —
zuerst nur Web, später Mobile, KI-Generierung als Phase 2.
## 2. Game-Dev-Prinzip: zuerst nur der Core Gameloop
Wie bei einem Spielprototyp gilt: alles, was nicht zum Loop gehört,
wird zurückgestellt. Erst wenn der Loop sich gut anfühlt und Nutzer ihn
freiwillig wiederholen, wird gebaut, was drumherum gehört.
### Der Core Gameloop von Cardecky
```
Start
"Du hast N Karten heute fällig" ─────► (wenn 0: "Alles gelernt — komm später wieder")
[Lernen starten]
Vorderseite zeigen ──► User denkt ──► Tap/Space ──► Rückseite zeigen
Selbst-Bewertung: 1=nochmal · 2=schwer · 3=gut · 4=leicht
FSRS rechnet next-due ──► nächste Karte (oder Session-Ende)
Session-Ende: "X Karten gelernt, nächste in Y Stunden"
└─► zurück zum Start
```
Sekundäre Loops (Karten erstellen, Decks verwalten) werden gebaut, sind
aber UI-arm. **Tertiäre Loops (KI-Generierung, Voice, Sharing) sind
Phase 2 und werden in Phase 1 nicht angefasst.**
### Was Phase 1 enthält
- Decks anlegen / löschen / umbenennen
- Karten manuell erstellen (Markdown-Inhalt)
- **Kartentypen:** Basic, Basic + Reverse, Cloze, Type-In (siehe §6)
- Lernsession mit FSRS v6, **inklusive per-User-Parameter-Tuning**
- "Heute fällig"-Übersicht + Streak-Zähler
- Tags auf Decks (das Modul hat sie ohnehin schon, raus wäre Mehrarbeit)
- PWA-installierbar, offline-fähig
- Auth via mana-auth, Sync via mana-sync
### Was Phase 1 absichtlich NICHT enthält
- KI-Generierung von Karten (kein PDF-Upload, keine Bild→Karte)
- Voice/TTS-Lernen
- Anki-Import / Export
- Statistik-Dashboards (nur Streak + Tagessumme)
- Public Decks / Marktplatz / Sharing
- Stripe / Bezahlung
- Mobile-App (PWA-tauglich aber kein Expo)
- Eigene Domain & Marketing-Landing
- Mehrsprachigkeit über Deutsch hinaus
- Bilder / Audio in Karten
- Image-Occlusion-Karten, Audio-Karten, Multiple-Choice
- Custom Card-Templates / WYSIWYG-Editor
- Erweiterte Suche
Jede dieser Features ist legitim — aber nur, wenn der Loop steht.
## 3. Goldene Regeln
1. **Simpel schlägt vollständig.** Wenn ein Feature nicht zum Core Gameloop gehört, kommt es in einen Phase-2-Backlog, nicht in den Code.
2. **Open Source only.** Jede Library, jedes Tool, jeder Dienst muss eine OSI-konforme Lizenz haben (MIT, Apache 2.0, BSD, MPL, AGPL akzeptabel). Keine Closed-Source-SDKs, keine proprietären APIs als Pflichtabhängigkeit.
3. **Bevorzugt was im Verein schon läuft.** Neue Technologie nur einführen, wenn ein konkreter Engpass es verlangt und kein vorhandenes Tool es löst.
4. **Zentrale Mana-Dienste statt Eigenbau.** Auth, Sync, Analytics, Notifications, Media usw. werden NICHT neu gebaut — siehe §5.
5. **Local-First wie der Rest des Verein-Stacks.** IndexedDB als Quelle der Wahrheit, Sync nach Postgres im Hintergrund.
6. **`cardecky.mana.how` als Subdomain unter `*.mana.how`.** Kein eigenes Auth-System, kein eigenes Hosting-Setup — Eintrag in `PRODUCTION_TRUSTED_ORIGINS` + Cloudflare-Tunnel-Route reichen.
7. **Eine UI-Schicht, ein Theme.** Wir verwenden `@mana/shared-theme(-ui)` und `@mana/shared-ui` so weit es geht — kein paralleles Design-System.
8. **Erweiterbare Daten, simples UI.** Das Datenmodell denkt zukünftige Kartentypen mit (siehe §6), das UI zeigt in Phase 1 nur die vier definierten Typen.
## 4. Tech-Stack (Phase 1)
Alles bereits im Verein verwendet, alles OSI-Open-Source.
### Frontend
| Schicht | Wahl | Lizenz |
|---|---|---|
| Framework | SvelteKit 2 | MIT |
| UI-Sprache | Svelte 5 (Runes) | MIT |
| Sprache | TypeScript 5 | Apache-2.0 |
| Styling | Tailwind CSS 4 | MIT |
| Build/Dev | Vite | MIT |
| PWA | `@vite-pwa/sveltekit` (über `@mana/shared-pwa`) | MIT |
| Icons | über `@mana/shared-icons` | MIT |
| Markdown-Render | `marked` + `DOMPurify` | MIT |
### Datenhaltung (Client)
| Schicht | Wahl | Lizenz |
|---|---|---|
| Local Store | IndexedDB via Dexie | Apache-2.0 |
| Local-Store-Wrapper | `@mana/local-store` (intern) | — |
| Verschlüsselung | AES-GCM-256 via `@mana/shared-crypto` (Phase 2 — Hooks bereits an allen Schreib-/Lese-Pfaden, Wirkung deferred bis Vault-Server-Roundtrip steht; siehe `src/lib/data/crypto.ts`) | — |
### Spaced Repetition
| Schicht | Wahl | Lizenz |
|---|---|---|
| Algorithmus | FSRS v6 (Free Spaced Repetition Scheduler) | BSD-3 |
| TS-Implementation | `ts-fsrs` (offizielle Portierung, mit Optimizer) | MIT |
| Per-User-Tuning | `ts-fsrs`-Optimizer, läuft client-seitig nach ≥ 50 Reviews | MIT |
### Deployment
| Schicht | Wahl | Lizenz |
|---|---|---|
| Adapter | `@sveltejs/adapter-node` | MIT |
| Container | Docker, hinter Cloudflare Tunnel | Apache-2.0 |
| Host | Mac mini (siehe `docker-compose.macmini.yml`) | — |
### Tooling
| Schicht | Wahl | Lizenz |
|---|---|---|
| Paket-Manager | pnpm 9 | MIT |
| Monorepo-Orchestrierung | Turborepo (vorhanden) | MPL-2.0 |
| Linting | ESLint (`@mana/eslint-config`) | MIT |
| Formatierung | Prettier | MIT |
| Tests (Unit) | Vitest | MIT |
| Tests (E2E) | Playwright | Apache-2.0 |
| TS-Config | `@mana/test-config`, `@mana/shared-vite-config` | — |
### Backend in Phase 1: keiner
Phase 1 braucht **keinen eigenen Service**. Lese-/Schreibpfad geht
ausschließlich über IndexedDB → `mana-sync` (existiert) → Postgres.
Erst wenn KI-Generierung (Phase 2) dazukommt, entsteht
`services/cards-server` (Hono + Bun, analog zu allen anderen
Verein-Services).
## 5. Zentrale Mana-Bausteine (Pflicht in Phase 1)
### Services (laufen bereits, nur konsumieren)
| Service | Port | Wofür in Cardecky |
|---|---|---|
| `mana-auth` | 3001 | SSO, JWT, Sessions, Tier-Claims. Cardecky-Origin in `PRODUCTION_TRUSTED_ORIGINS` eintragen. |
| `mana-sync` | 3050 | Sync der `cards`-AppId-Daten (Decks, Karten, Reviews, StudyBlocks). |
| `mana-user` | 3062 | Profilinfos / Settings. |
| `mana-analytics` | 3064 | Page-Views, Loop-Events (siehe §11). |
| `mana-events` | 3115 | Domain-Events für Streak-Logik. |
| `mana-notify` | 3040 | "Du hast X Karten fällig"-Push (Phase 1.5). |
| `mana-credits` | 3061 | **Erst Phase 2** (KI-Generierung). |
| `mana-subscriptions` | 3063 | **Erst Phase 2** (Pro-Tier). |
| `mana-llm`, `mana-stt`, `mana-tts` | | **Erst Phase 2.** |
| `mana-media` | 3015 | **Erst wenn Bilder in Karten erlaubt sind.** |
### Workspace-Pakete (`@mana/*`)
| Paket | Wofür in Cardecky |
|---|---|
| `@mana/shared-auth` | Client-seitiger Auth-Hook (SSO-Flow, JWT-Handling). |
| `@mana/shared-auth-ui` | Login/Logout-Komponenten. |
| `@mana/shared-hono` | (sobald cards-server existiert) Auth-/Health-/Error-Middleware. |
| `@mana/shared-branding` | App-Registry-Eintrag (Tier=`public`, Branding, Subdomain). |
| `@mana/shared-types` | Geteilte TS-Typen. |
| `@mana/shared-utils` | Utility-Funktionen. |
| `@mana/shared-ui` | UI-Komponenten. |
| `@mana/shared-theme`, `@mana/shared-theme-ui` | Theme-Tokens, Dark/Light. |
| `@mana/shared-tailwind` | Tailwind-Preset. |
| `@mana/shared-i18n` | Übersetzungsfundament (Phase 1: nur DE registriert). |
| `@mana/shared-icons` | Icon-Set. |
| `@mana/shared-privacy` | Visibility-Enum für Decks (Sharing erst Phase 2, aber Feld vorbereitet). |
| `@mana/shared-crypto` | AES-GCM-256 für sensible Felder. |
| `@mana/shared-pwa` | Manifest, Service-Worker, Install-Prompt. |
| `@mana/shared-vite-config` | Vite-Defaults. |
| `@mana/shared-error-tracking` | Error-Reporting. |
| `@mana/shared-logger` | Strukturiertes Logging (Server-Seite, sobald relevant). |
| `@mana/shared-stores` | Geteilte Local-Store-Helpers. |
| `@mana/shared-tags` | Tags auf Decks. |
| `@mana/local-store` | Dexie-Setup, Sync-Hooks. |
| `@mana/eslint-config` | Lint-Regeln. |
| `@mana/test-config` | Vitest-Defaults. |
| `@mana/feedback` | In-App-Feedback-Widget. |
| `@mana/help` | Hilfe-Overlay. |
**Erst Phase 2 oder später:** `@mana/shared-llm`, `@mana/shared-ai`,
`@mana/local-llm`, `@mana/local-stt`, `@mana/credits`, `@mana/qr-export`,
`@mana/wallpaper-generator`, `@mana/website-blocks`,
`@mana/shared-research`, `@mana/shared-uload`, `@mana/shared-storage`.
### Datenpfad
Cardecky übernimmt 1:1 das Mana-Datenpfad-Pattern:
```
User-Aktion → Store → encryptRecord → Dexie → Hooks (_pendingChanges)
→ mana-sync → Postgres (mana_platform.cards.*) → andere Clients
```
appId = `cards`. Tabellen: `cardDecks`, `cards`, `cardReviews`,
`cardStudyBlocks`, `deckTags`.
## 6. Datenmodell — erweiterbar gedacht
Heutiges Modul kennt nur `front`/`back`. Damit weitere Kartentypen
ohne Schema-Bruch dazukommen, wechseln wir auf ein **Felder-Map +
Typ-Diskriminator**:
```ts
type CardType =
| 'basic' // Phase 1: front/back
| 'basic-reverse' // Phase 1: erzeugt zwei Lernrichtungen aus einer Karte
| 'cloze' // Phase 1: Lückentext, eine Subkarte pro Cluster
| 'type-in' // Phase 1: User tippt Antwort, exact-match-Vergleich
| 'image-occlusion' // Phase 2
| 'audio' // Phase 2
| 'multiple-choice' // ggf. Phase 2
interface LocalCard extends BaseRecord {
deckId: string
type: CardType
fields: Record<string, string> // 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 (14), 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.

34
apps/cards/README.md Normal file
View file

@ -0,0 +1,34 @@
# Cardecky
Spaced-repetition flashcards on **cardecky.mana.how**.
Phase-1 standalone web app. The frontend lives here; data, auth, and
sync are shared with the rest of the Mana stack:
- **Auth:** mana-auth (SSO), `*.mana.how`
- **Sync:** mana-sync, app-id `cards`
- **Storage:** `mana_platform.cards.*` (Postgres, RLS)
The same `cards` data backs the **mana** built-in Cardecky module at
`mana.how/cards`. Schema changes ship to both frontends together — see
`apps/cards/GUIDELINES.md`.
## Layout
```
apps/cards/
├── apps/
│ └── web/ # SvelteKit 2 + Svelte 5 — the Phase-1 surface
├── GUIDELINES.md # Project rules (read first)
└── README.md
```
`apps/cards/apps/mobile/` and any production `apps/cards/apps/landing/`
will land in Phase 2/3.
## Quick start
```bash
pnpm install
pnpm --filter @cards/web dev # cardecky.mana.how on http://localhost:5180
```

7
apps/cards/apps/web/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules
.DS_Store
.svelte-kit
build
.env
.env.*
!.env.example

View file

@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
# Cardecky Standalone — cardecky.mana.how. Mirrors apps/manavoxel/apps/web/Dockerfile.
# ─── Stage 1: Build ──────────────────────────────────────────
FROM sveltekit-base:local AS builder
ARG PUBLIC_MANA_AUTH_URL=http://mana-auth:3001
ARG PUBLIC_SYNC_SERVER_URL=http://mana-sync:3050
ENV PUBLIC_MANA_AUTH_URL=$PUBLIC_MANA_AUTH_URL
ENV PUBLIC_SYNC_SERVER_URL=$PUBLIC_SYNC_SERVER_URL
# Cards-specific app sources. The shared @mana/* packages already live in
# the sveltekit-base image; we only copy what's unique to this app.
COPY apps/cards/apps/web ./apps/cards/apps/web
COPY packages/cards-core ./packages/cards-core
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --no-frozen-lockfile --ignore-scripts
WORKDIR /app/apps/cards/apps/web
RUN pnpm exec svelte-kit sync
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
# ─── Stage 2: Production ─────────────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app/apps/cards/apps/web
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
COPY --from=builder /app/apps/cards/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/cards/apps/web/build ./build
COPY --from=builder /app/apps/cards/apps/web/package.json ./
EXPOSE 5180
ENV NODE_ENV=production
ENV PORT=5180
ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5180/ || exit 1
CMD ["node", "build"]

View file

@ -0,0 +1,48 @@
{
"name": "@cards/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 5180",
"build": "vite build",
"preview": "vite preview --port 5180",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
},
"devDependencies": {
"@mana/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^22.10.5",
"@types/sql.js": "^1.4.11",
"@vite-pwa/sveltekit": "^1.1.0",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.17",
"typescript": "^5.7.2",
"vite": "^6.0.7"
},
"dependencies": {
"@mana/cards-core": "workspace:*",
"@mana/local-store": "workspace:*",
"@mana/shared-auth": "workspace:*",
"@mana/shared-auth-ui": "workspace:*",
"@mana/shared-branding": "workspace:*",
"@mana/shared-icons": "workspace:*",
"@mana/shared-privacy": "workspace:*",
"@mana/shared-pwa": "workspace:*",
"@mana/shared-stores": "workspace:*",
"@mana/shared-tailwind": "workspace:*",
"@mana/shared-theme": "workspace:*",
"@mana/shared-theme-ui": "workspace:*",
"@mana/shared-types": "workspace:*",
"@mana/shared-utils": "workspace:*",
"dexie": "^4.4.1",
"jszip": "^3.10.1",
"pdfjs-dist": "^5.7.284",
"sql.js": "^1.14.1"
}
}

View file

@ -0,0 +1,63 @@
@import 'tailwindcss';
@import '@mana/shared-tailwind/themes.css';
@import '@mana/shared-tailwind/sources.css';
/* Phase A Cards now lives on the unified @mana/shared-theme tokens.
The placeholder --color-cards-* palette is gone; everything goes
through `--color-{background,foreground,surface,muted,}` from
shared-tailwind. The runtime `createThemeStore({ appId: 'cards' })`
in +layout.svelte writes the live variant + mode onto the
document. */
/* Cloze rendering produced by @mana/cards-core/render. Uses the
active app accent so the highlight follows the Cards brand. */
.cloze-blank {
background: hsl(var(--color-app-accent) / 0.18);
border-radius: 0.25rem;
padding: 0.05rem 0.4rem;
color: hsl(var(--color-app-accent));
font-style: italic;
}
mark.cloze-active {
background: hsl(var(--color-success) / 0.2);
color: hsl(var(--color-success));
padding: 0.05rem 0.25rem;
border-radius: 0.25rem;
}
/* Minimal styling for HTML produced by marked() Tailwind v4 ships
without typography plugin so we set the basics by hand. */
.card-content :where(p, ul, ol) {
margin-block: 0.5rem;
}
.card-content :where(ul) {
list-style: disc;
padding-inline-start: 1.25rem;
}
.card-content :where(ol) {
list-style: decimal;
padding-inline-start: 1.25rem;
}
.card-content :where(code) {
background: hsl(var(--color-muted) / 0.6);
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.95em;
}
.card-content :where(pre) {
background: hsl(var(--color-muted) / 0.4);
padding: 0.75rem;
border-radius: 0.5rem;
overflow-x: auto;
}
.card-content :where(a) {
color: hsl(var(--color-app-accent));
text-decoration: underline;
}
.card-content :where(strong) {
font-weight: 600;
}
.card-content :where(em) {
font-style: italic;
}

16
apps/cards/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
// Virtual modules provided by vite-plugin-pwa (wrapped by @vite-pwa/sveltekit):
// - virtual:pwa-info → pwaInfo.webManifest.linkTag for <svelte:head>
// - virtual:pwa-register/svelte → useRegisterSW() Svelte-store hook
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-pwa/svelte" />
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 {};

View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0a0a" />
<meta name="description" content="Cards — Karteikarten mit Spaced Repetition." />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="min-h-screen bg-background text-foreground antialiased">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,41 @@
import type { Handle } from '@sveltejs/kit';
/**
* Inject the runtime client URLs into <head> 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 =
`<script>` +
`window.__PUBLIC_MANA_AUTH_URL__=${JSON.stringify(PUBLIC_MANA_AUTH_URL_CLIENT)};` +
`window.__PUBLIC_MANA_SYNC_URL__=${JSON.stringify(PUBLIC_MANA_SYNC_URL_CLIENT)};` +
`window.__PUBLIC_MANA_LLM_URL__=${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};` +
`window.__PUBLIC_MANA_MEDIA_URL__=${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};` +
`window.__PUBLIC_CARDS_API_URL__=${JSON.stringify(PUBLIC_CARDS_API_URL_CLIENT)};` +
`</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -0,0 +1,118 @@
/**
* AI card generation text list of basic cards via mana-llm.
*
* Uses mana-llm's OpenAI-compatible /v1/chat/completions endpoint with
* a system prompt that constrains the output to a JSON array. We strip
* Markdown code fences before parsing because most chat models wrap
* JSON output in ```json blocks even when explicitly told not to.
*
* No streaming we need the full JSON before we can show anything.
* Phase-2 ideas: chunk long inputs, PDF parsing, image OCR.
*/
const SYSTEM_PROMPT = `Du bist ein Karteikarten-Generator. Aus dem vom Nutzer gegebenen Text erstellst du Lernkarten zum Auswendiglernen.
Regeln:
- Antworte AUSSCHLIESSLICH mit einem JSON-Array, ohne Erklärung, ohne Markdown-Code-Fences.
- Schema: [{"front": "Frage oder Begriff", "back": "Antwort"}, ...]
- 515 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<GeneratedCard[]> {
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;
}

View file

@ -0,0 +1,56 @@
/**
* PDF text extraction using pdfjs-dist.
*
* Loads each page, walks the text layer, joins items with spaces and
* pages with double newlines so the LLM gets a structured input. We
* don't try to preserve columns / tables the use case is "feed me
* the prose so I can make cards", not document fidelity.
*
* Worker is wired via Vite's `?worker` suffix so the heavy parsing
* happens off the main thread (PDF extraction is CPU-heavy).
*/
import * as pdfjs from 'pdfjs-dist';
import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker';
let workerWired = false;
function ensureWorker() {
if (workerWired) return;
pdfjs.GlobalWorkerOptions.workerPort = new PdfjsWorker();
workerWired = true;
}
export interface PdfExtractResult {
text: string;
pageCount: number;
}
export async function extractTextFromPdf(file: File | Blob): Promise<PdfExtractResult> {
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,
};
}

View file

@ -0,0 +1,212 @@
/**
* Apply a `ParsedAnki` to the local DB.
*
* Strategy: every Anki deck becomes one of our decks (1:1, name-mapped).
* Card content is HTML-sanitized to plain Markdown / inline media tags
* before save. Reviews are auto-generated by reviewStore.ensureReviewsForCard
* the imported cards become "new" in the FSRS sense, no inherited schedule.
*
* Media: every referenced file is uploaded to mana-media first; the
* resulting URL replaces the original Anki filename in the field text.
* Files referenced from no card are skipped many Anki decks bundle
* orphaned media that bloats the upload time.
*
* No de-dupe: re-importing the same .apkg adds duplicate decks. The UI
* warns about this once we decide it matters.
*/
import { deckStore } from '../stores/decks.svelte';
import { cardStore } from '../stores/cards.svelte';
import { uploadCardMedia, MediaUploadError } from '../media/upload';
import { sanitizeAnkiHtml, type ParsedAnki } from './parse';
export interface ImportResult {
decksCreated: number;
cardsCreated: number;
mediaUploaded: number;
mediaFailed: number;
failed: number;
}
export interface MediaProgress {
uploaded: number;
total: number;
}
const MEDIA_CONCURRENCY = 4;
// Anki's <img src="..."> always quotes; we also catch [sound:foo.mp3].
const IMG_RE = /<img\b[^>]*\bsrc=["']([^"']+)["']/gi;
const SOUND_RE = /\[sound:([^\]]+)\]/g;
function collectMediaRefs(parsed: ParsedAnki): Set<string> {
const refs = new Set<string>();
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<string, string> = {
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<string, string>; uploaded: number; failed: number }> {
const referenced = [...collectMediaRefs(parsed)].filter((f) => parsed.mediaByFilename.has(f));
const urlByFilename = new Map<string, string>();
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<ImportResult> {
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<string, string>();
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<string, number>();
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<string, string> = {};
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;
}

View file

@ -0,0 +1,247 @@
/**
* Parse an Anki .apkg / .colpkg file in the browser.
*
* .apkg = ZIP archive containing a SQLite collection (`collection.anki2`
* or `collection.anki21`) plus media files. We open the SQLite blob with
* sql.js (WASM-backed in-browser SQLite) and walk Anki's three core
* tables: `col` (collection meta with JSON-encoded models + decks),
* `notes` (the user-typed content), and `cards` (one row per learnable
* unit basic = 1, basic-reverse = 2, cloze = N).
*
* MVP scope: basic + basic-reverse + cloze. Image/audio media is
* skipped (Phase 2). Review history is skipped FSRS state will be
* regenerated on first sight.
*/
import JSZip, { type JSZipObject } from 'jszip';
import initSqlJs, { type Database } from 'sql.js';
import type { CardType } from '@mana/cards-core';
export interface ParsedDeck {
ankiId: string; // Anki's numeric deck id, stringified
name: string; // "Studies::Spanish" — Anki uses :: as separator
}
export interface ParsedCard {
ankiDeckId: string;
type: CardType;
fields: Record<string, string>;
}
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<string, JSZipObject>;
}
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<ReturnType<typeof initSqlJs>> | 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<ParsedAnki> {
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<Map<string, JSZipObject>> {
const out = new Map<string, JSZipObject>();
const manifestEntry = zip.file('media');
if (!manifestEntry) return out;
let manifest: Record<string, string>;
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<ParsedAnki, 'mediaByFilename'>;
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<string, AnkiModel> = JSON.parse(modelsJson);
const decksMap: Record<string, AnkiDeckJson> = 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<string, NoteRow>();
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<string>();
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<string, string> } | 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 `<img src="paris.jpg">` 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<string, string> = new Map()
): string {
const imgReplaced = html.replace(
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
(_, src: string) => {
const url = mediaUrlByFilename.get(src);
return url ? `<img src="${url}" alt="" />` : '';
}
);
const soundReplaced = imgReplaced.replace(/\[sound:([^\]]+)\]/g, (_, name: string) => {
const url = mediaUrlByFilename.get(name);
return url ? `<audio controls preload="metadata" src="${url}"></audio>` : '';
});
return (
soundReplaced
.replace(/<br\s*\/?>/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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim()
);
}

View file

@ -0,0 +1,505 @@
/**
* Thin client for cards-server (https://cardecky-api.mana.how / dev :3072).
*
* The auth-store provides the JWT; we never read tokens from storage
* here directly so there's only one place that knows about token
* lifecycle (refresh, expiry, vault).
*
* All endpoints under /v1 require auth; the wrapper just always
* sends `Authorization: Bearer …`. Errors come back as Hono's
* `{ statusCode, message, details? }` shape we surface that to
* callers via the typed `CardsApiError` so UIs can branch on it.
*/
import { authStore } from '$lib/stores/auth.svelte';
function baseUrl(): string {
if (typeof window !== 'undefined') {
const fromWindow = (window as unknown as { __PUBLIC_CARDS_API_URL__?: string })
.__PUBLIC_CARDS_API_URL__;
if (fromWindow) return fromWindow.replace(/\/$/, '');
}
return 'http://localhost:3072';
}
export class CardsApiError extends Error {
constructor(
public status: number,
message: string,
public details?: unknown
) {
super(message);
this.name = 'CardsApiError';
}
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
/**
* - `true` (default): require an Authorization header throws 401 if no token.
* - `'optional'`: include token if available, otherwise send anonymously.
* - `false`: never send a token.
*/
auth?: boolean | 'optional';
}
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {};
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<Author | null>('/v1/authors/me'),
upsertMe: (input: {
slug: string;
displayName: string;
bio?: string;
avatarUrl?: string;
pseudonym?: boolean;
}) => request<Author>('/v1/authors/me', { method: 'POST', body: input }),
bySlug: (slug: string) => request<PublicAuthor>(`/v1/authors/${encodeURIComponent(slug)}`),
},
decks: {
init: (input: {
slug: string;
title: string;
description?: string;
language?: string;
license?: string;
priceCredits?: number;
}) => request<PublicDeck>('/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<string, string> }[];
}
) =>
request<PublishResult>(`/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<TagDefinition[]>('/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<SubscriptionInfo[]>('/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<DeckVersionPayload>(
`/v1/decks/${encodeURIComponent(deckSlug)}/versions/${encodeURIComponent(semver)}`,
{ auth: 'optional' }
),
diff: (deckSlug: string, fromSemver: string) =>
request<DiffPayload>(
`/v1/decks/${encodeURIComponent(deckSlug)}/diff?from=${encodeURIComponent(fromSemver)}`,
{ auth: 'optional' }
),
},
pullRequests: {
create: (
deckSlug: string,
input: {
title: string;
body?: string;
diff: PullRequestDiffInput;
}
) =>
request<PullRequest>(`/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<PullRequest[]>(
`/v1/decks/${encodeURIComponent(deckSlug)}/pull-requests${qs}`,
{ auth: 'optional' }
);
},
get: (id: string) => request<PullRequest>(`/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<DeckReport>('/v1/reports', { method: 'POST', body: input }),
},
admin: {
listReports: () => request<DeckReportItem[]>('/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<PurchaseResult>(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, {
method: 'POST',
body: {},
}),
listMine: () => request<BuyerPurchase[]>('/v1/me/purchases'),
},
payouts: {
listMine: () => request<AuthorPayout[]>('/v1/authors/me/payouts'),
},
discussions: {
countsForDeck: (deckSlug: string) =>
request<Record<string, number>>(
`/v1/decks/${encodeURIComponent(deckSlug)}/discussion-counts`,
{ auth: 'optional' }
),
listForCard: (contentHash: string) =>
request<CardDiscussion[]>(`/v1/cards/${encodeURIComponent(contentHash)}/discussions`, {
auth: 'optional',
}),
post: (contentHash: string, input: { deckSlug: string; body: string; parentId?: string }) =>
request<CardDiscussion>(`/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<PublicAuthor>(`/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<string, string>;
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<string, string> }[];
modify: { previousContentHash: string; type: string; fields: Record<string, string> }[];
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<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
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;
}

View file

@ -0,0 +1,209 @@
<script lang="ts">
import { generateCardsFromText, type GeneratedCard } from '$lib/ai/generate';
import { extractTextFromPdf } from '$lib/ai/pdf';
import { cardStore } from '$lib/stores/cards.svelte';
interface Props {
deckId: string;
currentCardCount: number;
onCreated?: () => void;
}
let { deckId, currentCardCount, onCreated }: Props = $props();
let stage = $state<
'idle' | 'reading-pdf' | 'generating' | 'preview' | 'creating' | 'done' | 'error'
>('idle');
let source = $state('');
let pdfPicker = $state<HTMLInputElement | null>(null);
let pdfStatus = $state<string | null>(null);
let generated = $state<GeneratedCard[]>([]);
let selected = $state<boolean[]>([]);
let error = $state<string | null>(null);
let createdCount = $state(0);
let abortController: AbortController | null = null;
async function handleGenerate() {
if (!source.trim()) return;
error = null;
stage = 'generating';
abortController = new AbortController();
try {
const cards = await generateCardsFromText(source, { signal: abortController.signal });
generated = cards;
selected = cards.map(() => true);
stage = 'preview';
} catch (e: any) {
if (e?.name === 'AbortError') {
stage = 'idle';
return;
}
error = e?.message ?? 'Generierung fehlgeschlagen.';
stage = 'error';
} finally {
abortController = null;
}
}
function cancelGenerate() {
abortController?.abort();
}
async function handleConfirm() {
stage = 'creating';
let order = currentCardCount;
let count = 0;
for (let i = 0; i < generated.length; i++) {
if (!selected[i]) continue;
const c = generated[i];
const created = await cardStore.createCard(
{ deckId, type: 'basic', front: c.front, back: c.back },
order
);
if (created) {
count++;
order++;
}
}
createdCount = count;
stage = 'done';
onCreated?.();
}
function reset() {
stage = 'idle';
generated = [];
selected = [];
source = '';
error = null;
createdCount = 0;
pdfStatus = null;
}
async function handlePdfPick(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
error = null;
stage = 'reading-pdf';
pdfStatus = `Lese ${file.name}…`;
try {
const result = await extractTextFromPdf(file);
source = result.text;
pdfStatus = `${file.name} · ${result.pageCount} Seiten · ${result.text.length} Zeichen`;
stage = 'idle';
} catch (e: any) {
error = e?.message ?? 'PDF konnte nicht gelesen werden.';
stage = 'error';
pdfStatus = null;
}
}
</script>
<div class="rounded-xl border border-indigo-500/30 bg-card p-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium">✨ Karten aus Text generieren</span>
{#if stage !== 'idle'}
<button
class="text-xs text-muted-foreground/80 hover:text-foreground/80"
onclick={stage === 'generating' ? cancelGenerate : reset}
>
{stage === 'generating' ? 'Abbrechen' : 'Zurücksetzen'}
</button>
{/if}
</div>
{#if stage === 'idle' || stage === 'error'}
<textarea
bind:value={source}
placeholder="Text einfügen — Notizen, Lehrbuch-Absatz, Definition…"
class="min-h-[120px] w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
></textarea>
{#if stage === 'error' && error}
<p class="mt-2 text-sm text-error">{error}</p>
{/if}
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted-foreground/80">
<div class="flex items-center gap-3">
<span>{source.length} Zeichen</span>
{#if pdfStatus}<span class="text-app-accent">📄 {pdfStatus}</span>{/if}
</div>
<div class="flex items-center gap-2">
<button
class="rounded-lg border border-border-strong px-3 py-1.5 text-foreground/80 hover:bg-muted"
onclick={() => pdfPicker?.click()}
>
📄 PDF laden
</button>
<button
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={handleGenerate}
disabled={!source.trim()}
>
Generieren
</button>
</div>
</div>
<input
bind:this={pdfPicker}
type="file"
accept="application/pdf,.pdf"
class="hidden"
onchange={handlePdfPick}
/>
{:else if stage === 'reading-pdf'}
<div class="py-6 text-center text-sm text-muted-foreground">{pdfStatus ?? 'Lese PDF…'}</div>
{:else if stage === 'generating'}
<div class="py-6 text-center text-sm text-muted-foreground">Modell denkt nach…</div>
{:else if stage === 'preview'}
<div class="space-y-2 text-sm">
<div class="text-foreground/80">
{generated.length} Karten generiert. Wähle aus, was übernommen werden soll:
</div>
<ul class="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{#each generated as card, i (i)}
<li class="flex items-start gap-2 rounded-md p-1 hover:bg-muted/50">
<input
type="checkbox"
bind:checked={selected[i]}
class="mt-1 shrink-0"
id="ai-card-{i}"
/>
<label for="ai-card-{i}" class="min-w-0 flex-1 cursor-pointer">
<div class="font-medium text-foreground">{card.front}</div>
<div class="text-xs text-muted-foreground">{card.back}</div>
</label>
</li>
{/each}
</ul>
<div class="flex justify-end gap-2 pt-1">
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={() => (selected = selected.map(() => true))}
>
Alle
</button>
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={() => (selected = selected.map(() => false))}
>
Keine
</button>
<button
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={handleConfirm}
disabled={!selected.some(Boolean)}
>
{selected.filter(Boolean).length} übernehmen
</button>
</div>
</div>
{:else if stage === 'creating'}
<div class="py-6 text-center text-sm text-muted-foreground">Lege Karten an…</div>
{:else if stage === 'done'}
<div class="text-sm text-green-400">{createdCount} Karten angelegt.</div>
<button class="mt-2 text-xs text-muted-foreground/80 hover:text-foreground/80" onclick={reset}>
Weiteren Text generieren
</button>
{/if}
</div>

View file

@ -0,0 +1,187 @@
<script lang="ts">
import { parseApkg, type ParsedAnki } from '$lib/anki/parse';
import { importParsedAnki, type ImportResult } from '$lib/anki/import';
let fileInput = $state<HTMLInputElement | null>(null);
let stage = $state<
'idle' | 'parsing' | 'preview' | 'uploading-media' | 'importing' | 'done' | 'error'
>('idle');
let parsed = $state<ParsedAnki | null>(null);
let result = $state<ImportResult | null>(null);
let error = $state<string | null>(null);
let fileName = $state<string>('');
let mediaProgress = $state<{ uploaded: number; total: number }>({ uploaded: 0, total: 0 });
const mediaCount = $derived(parsed?.mediaByFilename.size ?? 0);
async function handleFile(file: File) {
error = null;
fileName = file.name;
stage = 'parsing';
try {
parsed = await parseApkg(file);
stage = 'preview';
} catch (e: any) {
error = e?.message ?? 'Datei konnte nicht gelesen werden.';
stage = 'error';
}
}
function onPick(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const f = input.files?.[0];
if (f) handleFile(f);
input.value = '';
}
function onDrop(e: DragEvent) {
e.preventDefault();
const f = e.dataTransfer?.files?.[0];
if (f) handleFile(f);
}
async function confirmImport() {
if (!parsed) return;
mediaProgress = { uploaded: 0, total: mediaCount };
stage = mediaCount > 0 ? 'uploading-media' : 'importing';
try {
result = await importParsedAnki(parsed, {
onMediaProgress: (p) => {
mediaProgress = p;
if (p.uploaded >= p.total && stage === 'uploading-media') {
stage = 'importing';
}
},
});
stage = 'done';
} catch (e: any) {
error = e?.message ?? 'Import fehlgeschlagen.';
stage = 'error';
}
}
function reset() {
stage = 'idle';
parsed = null;
result = null;
error = null;
fileName = '';
}
</script>
<div class="rounded-xl border border-border bg-card p-4">
<div class="mb-2 text-sm font-medium">Aus Anki importieren</div>
{#if stage === 'idle'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="rounded-lg border-2 border-dashed border-border-strong px-4 py-6 text-center text-sm text-muted-foreground transition-colors hover:border-indigo-400 hover:text-foreground/90"
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
onclick={() => fileInput?.click()}
>
<div class="mb-1">📦 .apkg-Datei hier ablegen oder klicken</div>
<div class="text-xs text-muted-foreground/80">
Basic, Basic + Reverse, Cloze · Bilder + Audio werden mit übernommen.
</div>
</div>
<input
bind:this={fileInput}
type="file"
accept=".apkg,.colpkg"
class="hidden"
onchange={onPick}
/>
{:else if stage === 'parsing'}
<div class="py-6 text-center text-sm text-muted-foreground">Lese {fileName}</div>
{:else if stage === 'preview' && parsed}
<div class="space-y-2 text-sm">
<div>
<span class="text-muted-foreground">Gefunden in</span>
<code class="rounded bg-muted px-1 text-xs">{fileName}</code>:
</div>
<ul class="ml-4 list-disc text-foreground/80">
<li>{parsed.decks.length} {parsed.decks.length === 1 ? 'Deck' : 'Decks'}</li>
<li>{parsed.cards.length} {parsed.cards.length === 1 ? 'Karte' : 'Karten'}</li>
{#if mediaCount > 0}
<li>{mediaCount} Medien (Bilder/Audio)</li>
{/if}
{#if parsed.skipped > 0}
<li class="text-warning">{parsed.skipped} übersprungen (unbekannter Typ)</li>
{/if}
</ul>
{#if parsed.warnings.length > 0}
<details class="text-xs text-muted-foreground/80">
<summary class="cursor-pointer">Hinweise ({parsed.warnings.length})</summary>
<ul class="mt-1 list-disc pl-4">
{#each parsed.warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
</ul>
</details>
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={reset}
>
Abbrechen
</button>
<button
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90"
onclick={confirmImport}
>
Importieren
</button>
</div>
</div>
{:else if stage === 'uploading-media'}
<div class="py-6 text-center text-sm text-muted-foreground">
<div>Lade Medien hoch · {mediaProgress.uploaded} / {mediaProgress.total}</div>
<div class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-app-accent transition-all"
style="width: {mediaProgress.total === 0
? 0
: (mediaProgress.uploaded / mediaProgress.total) * 100}%"
></div>
</div>
</div>
{:else if stage === 'importing'}
<div class="py-6 text-center text-sm text-muted-foreground">
Importiere {parsed?.cards.length ?? 0} Karten…
</div>
{:else if stage === 'done' && result}
<div class="space-y-2 text-sm">
<div class="text-green-400">
{result.cardsCreated} Karten in {result.decksCreated}
{result.decksCreated === 1 ? 'Deck' : 'Decks'} angelegt.
</div>
{#if result.mediaUploaded > 0 || result.mediaFailed > 0}
<div class="text-muted-foreground">
{result.mediaUploaded} Medien übernommen{#if result.mediaFailed > 0}
<span class="text-warning">· {result.mediaFailed} fehlgeschlagen</span>
{/if}
</div>
{/if}
{#if result.failed > 0}
<div class="text-warning">{result.failed} Karten konnten nicht angelegt werden.</div>
{/if}
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={reset}
>
Weitere Datei
</button>
</div>
{:else if stage === 'error'}
<div class="space-y-2 text-sm">
<div class="text-error">Fehler: {error}</div>
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={reset}
>
Erneut versuchen
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import { cardsApi, CardsApiError, type CardDiscussion } from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
import ReportButton from './ReportButton.svelte';
interface Props {
contentHash: string;
deckSlug: string;
}
let { contentHash, deckSlug }: Props = $props();
let comments = $state<CardDiscussion[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let draft = $state('');
let posting = $state(false);
$effect(() => {
// Re-load whenever the card under review changes.
void contentHash;
comments = [];
load();
});
async function load() {
loading = true;
error = null;
try {
comments = await cardsApi.discussions.listForCard(contentHash);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
}
async function post() {
const body = draft.trim();
if (!body) return;
posting = true;
error = null;
try {
const row = await cardsApi.discussions.post(contentHash, { deckSlug, body });
comments = [...comments, row];
draft = '';
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
posting = false;
}
}
async function hide(c: CardDiscussion) {
if (!confirm('Kommentar ausblenden?')) return;
try {
await cardsApi.discussions.hide(c.id);
comments = comments.filter((x) => x.id !== c.id);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
}
}
</script>
<aside class="mt-4 rounded-xl border border-border bg-background p-4">
<header class="mb-2 flex items-center justify-between">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
Diskussion {comments.length > 0 ? `(${comments.length})` : ''}
</h3>
{#if loading}
<span class="text-xs text-muted-foreground/60">Lädt…</span>
{/if}
</header>
{#if error}
<p class="mb-2 text-xs text-error">{error}</p>
{/if}
{#if comments.length === 0 && !loading}
<p class="text-xs text-muted-foreground/60">Noch keine Kommentare zu dieser Karte.</p>
{:else}
<ul class="space-y-2">
{#each comments as c (c.id)}
<li class="rounded-lg border border-border bg-card p-2 text-sm">
<div class="flex items-start justify-between gap-2">
<p class="whitespace-pre-line text-foreground/90">{c.body}</p>
<div class="flex shrink-0 items-center gap-2">
{#if authStore.user?.id !== c.authorUserId}
<ReportButton {deckSlug} cardContentHash={c.cardContentHash} variant="icon" />
{/if}
{#if authStore.user?.id === c.authorUserId}
<button
class="text-xs text-muted-foreground/60 hover:text-error"
onclick={() => hide(c)}
title="Ausblenden"
aria-label="Ausblenden"
>
</button>
{/if}
</div>
</div>
<p class="mt-1 text-xs text-muted-foreground/60">
{new Date(c.createdAt).toLocaleString('de-DE')}
</p>
</li>
{/each}
</ul>
{/if}
{#if authStore.isAuthenticated}
<form
class="mt-3 flex gap-2"
onsubmit={(e) => {
e.preventDefault();
post();
}}
>
<input
class="flex-1 rounded-lg border border-border bg-card px-3 py-1.5 text-sm"
placeholder="Kommentar zur Karte…"
bind:value={draft}
disabled={posting}
/>
<button
class="rounded-lg bg-app-accent px-3 py-1.5 text-xs text-white hover:bg-app-accent/90 disabled:opacity-50"
type="submit"
disabled={posting || !draft.trim()}
>
{posting ? 'Sende…' : 'Senden'}
</button>
</form>
{/if}
</aside>

View file

@ -0,0 +1,194 @@
<script lang="ts">
/**
* CardFace — renders one learnable unit (a single subIndex of a card)
* for any Phase-1 card type. Stateless: the parent owns `showBack`,
* `typedAnswer`, and any timing.
*
* Card-feel design (Phase A polish):
* - Single surface that physically flips on Y axis when revealed.
* Both faces share a CSS-grid cell so the parent height is the
* max of front/back, no jumpy reflow on flip.
* - Tap anywhere on the surface reveals (only while `showBack` is
* false). The /learn page keeps the keyboard space/enter shortcut.
* - `prefers-reduced-motion: reduce` collapses the rotateY into an
* instant cross-fade — same affordance, no vestibular trigger.
*
* Type-in cards skip the flip: the input field doesn't make sense on
* a flippable face, so we keep the historical "input + answer below"
* layout for that single card type.
*/
import { renderCloze, renderMarkdown, type Card } from '@mana/cards-core';
interface Props {
card: Card;
subIndex: number;
showBack: boolean;
typedAnswer?: string;
onTypedAnswer?: (value: string) => void;
onReveal?: () => void;
}
let { card, subIndex, showBack, typedAnswer = '', onTypedAnswer, onReveal }: Props = $props();
const view = $derived.by(() => {
switch (card.type) {
case 'basic':
case 'type-in':
return {
prompt: renderMarkdown(card.fields.front ?? ''),
answer: renderMarkdown(card.fields.back ?? ''),
expected: card.fields.back ?? '',
};
case 'basic-reverse':
return subIndex === 0
? {
prompt: renderMarkdown(card.fields.front ?? ''),
answer: renderMarkdown(card.fields.back ?? ''),
expected: card.fields.back ?? '',
}
: {
prompt: renderMarkdown(card.fields.back ?? ''),
answer: renderMarkdown(card.fields.front ?? ''),
expected: card.fields.front ?? '',
};
case 'cloze': {
const r = renderCloze(card.fields.text ?? '', subIndex);
const extra = card.fields.extra
? `<div class="mt-3 text-sm text-muted-foreground">${renderMarkdown(card.fields.extra)}</div>`
: '';
return { prompt: r.front + extra, answer: r.back + extra, expected: r.answer };
}
default:
return { prompt: '', answer: '', expected: '' };
}
});
const isTypeIn = $derived(card.type === 'type-in');
const matched = $derived(
isTypeIn && typedAnswer.trim().toLowerCase() === view.expected.trim().toLowerCase()
);
function tryReveal() {
if (!showBack && !isTypeIn) onReveal?.();
}
</script>
{#if isTypeIn}
<!-- Type-in keeps the classic two-block layout: the input is part of the
question affordance, so flipping the whole thing would hide it. -->
<article class="space-y-4">
<div
class="card-content rounded-2xl border border-border bg-card p-6 text-lg leading-relaxed shadow-md"
>
{@html view.prompt}
</div>
<input
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-base outline-none focus:border-app-accent"
type="text"
placeholder="Antwort eingeben…"
value={typedAnswer}
oninput={(e) => onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)}
disabled={showBack}
/>
{#if showBack}
<div
class="card-content rounded-2xl border-2 p-6 text-lg leading-relaxed
{matched ? 'border-success bg-success/5' : 'border-error bg-error/5'}"
>
{@html view.answer}
</div>
{/if}
</article>
{:else}
<article class="card-stage">
<button
type="button"
class="card-flip"
class:flipped={showBack}
onclick={tryReveal}
aria-label={showBack ? 'Karte aufgedeckt' : 'Karte aufdecken'}
>
<div
class="card-face card-content card-front rounded-2xl border border-border bg-card p-6 text-lg leading-relaxed shadow-md"
>
{@html view.prompt}
{#if !showBack}
<p class="card-hint mt-4 text-xs text-muted-foreground/70">
Tippe auf die Karte oder drücke Leertaste
</p>
{/if}
</div>
<div
class="card-face card-content card-back rounded-2xl border-2 border-app-accent bg-app-accent/5 p-6 text-lg leading-relaxed shadow-md"
>
{@html view.answer}
</div>
</button>
</article>
{/if}
<style>
.card-stage {
perspective: 1500px;
}
.card-flip {
position: relative;
display: grid;
width: 100%;
min-height: 280px;
padding: 0;
border: 0;
background: transparent;
text-align: left;
font: inherit;
color: inherit;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-flip.flipped {
transform: rotateY(180deg);
cursor: default;
}
.card-flip:focus-visible {
outline: 2px solid hsl(var(--color-app-accent));
outline-offset: 4px;
border-radius: 1rem;
}
.card-face {
grid-area: 1 / 1;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.card-back {
transform: rotateY(180deg);
}
@media (prefers-reduced-motion: reduce) {
.card-flip,
.card-flip.flipped {
transition: none;
transform: none;
}
.card-back {
transform: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.card-flip.flipped .card-back {
opacity: 1;
}
.card-flip.flipped .card-front {
opacity: 0;
}
}
</style>

View file

@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
size?: number;
color?: string;
}
let { size = 64, color = '#6366f1' }: Props = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 64 64">
<rect x="6" y="10" width="42" height="50" rx="6" fill={color} opacity="0.85" />
<rect x="16" y="4" width="42" height="50" rx="6" fill={color} />
</svg>

View file

@ -0,0 +1,104 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cardsApi, CardsApiError, type ServerCard } from '$lib/api/cards-api';
import CardDiscussions from './CardDiscussions.svelte';
interface Props {
deckSlug: string;
semver: string;
}
let { deckSlug, semver }: Props = $props();
let cards = $state<ServerCard[]>([]);
let counts = $state<Record<string, number>>({});
let loading = $state(true);
let error = $state<string | null>(null);
let openHash = $state<string | null>(null);
onMount(load);
async function load() {
loading = true;
error = null;
try {
const [version, c] = await Promise.all([
cardsApi.subscriptions.version(deckSlug, semver),
cardsApi.discussions.countsForDeck(deckSlug),
]);
cards = version.cards;
counts = c;
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
}
function preview(card: ServerCard): string {
// Best-effort one-liner: prefer "front" field, then any first non-empty.
const front = card.fields.front ?? card.fields.text ?? '';
if (front) return stripTags(front).slice(0, 140);
const first = Object.values(card.fields).find((v) => v && v.trim());
return first ? stripTags(first).slice(0, 140) : `(${card.type})`;
}
function stripTags(s: string): string {
return s
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
</script>
<section class="mt-10">
<header class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Karten {cards.length > 0 ? `(${cards.length})` : ''}
</h2>
{#if loading}
<span class="text-xs text-muted-foreground/60">Lädt…</span>
{/if}
</header>
{#if error}
<p class="mb-3 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
{error}
</p>
{:else if cards.length === 0 && !loading}
<p class="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground/80">
Diese Version enthält keine Karten.
</p>
{:else}
<ul class="space-y-2">
{#each cards as c (c.contentHash)}
{@const n = counts[c.contentHash] ?? 0}
{@const isOpen = openHash === c.contentHash}
<li class="rounded-xl border border-border bg-card p-3">
<button
class="flex w-full items-center justify-between gap-3 text-left"
onclick={() => (openHash = isOpen ? null : c.contentHash)}
>
<div class="min-w-0 flex-1">
<div class="text-xs uppercase tracking-wide text-muted-foreground/80">
#{c.ord + 1} · {c.type}
</div>
<div class="mt-1 truncate text-sm text-foreground/90">{preview(c)}</div>
</div>
<div class="shrink-0 text-xs text-muted-foreground/80">
{#if n > 0}
💬 {n}
{:else}
💬
{/if}
</div>
</button>
{#if isOpen}
<CardDiscussions contentHash={c.contentHash} {deckSlug} />
{/if}
</li>
{/each}
</ul>
{/if}
</section>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { DeckSummary } from '$lib/api/cards-api';
interface Props {
decks: DeckSummary[];
emptyText?: string;
}
let { decks, emptyText = 'Noch keine Decks.' }: Props = $props();
function badgeClass(d: DeckSummary): string {
if (d.owner.verifiedMana) return 'bg-success/15 text-success';
if (d.owner.verifiedCommunity) return 'bg-amber-500/15 text-warning';
return '';
}
function badgeText(d: DeckSummary): string {
if (d.owner.verifiedMana) return '🛡️';
if (d.owner.verifiedCommunity) return '⭐';
return '';
}
</script>
{#if decks.length === 0}
<p class="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground">
{emptyText}
</p>
{:else}
<ul class="grid gap-3 sm:grid-cols-2">
{#each decks as deck (deck.slug)}
<li>
<a
href={`/d/${deck.slug}`}
class="block rounded-xl border border-border bg-card p-4 transition-colors hover:border-border-strong hover:bg-muted"
>
<div class="mb-1 flex items-start justify-between gap-3">
<h3 class="font-semibold leading-tight">{deck.title}</h3>
{#if deck.priceCredits > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-warning">
{deck.priceCredits} 💎
</span>
{/if}
</div>
{#if deck.description}
<p class="mb-2 line-clamp-2 text-xs text-muted-foreground">{deck.description}</p>
{/if}
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground/80">
<!-- Author shows as text inside the deck-link; the deck card
navigates to the deck page, the author profile is one
hop further from there. Keeps HTML valid (no nested <a>). -->
<span class="text-foreground/80">{deck.owner.displayName}</span>
{#if badgeText(deck)}
<span class="rounded-full px-1.5 py-0.5 {badgeClass(deck)}">{badgeText(deck)}</span>
{/if}
<span>· {deck.cardCount} Karten</span>
<span>· ⭐ {deck.starCount}</span>
{#if deck.language}<span>· {deck.language.toUpperCase()}</span>{/if}
</div>
</a>
</li>
{/each}
</ul>
{/if}

View file

@ -0,0 +1,353 @@
<script lang="ts">
/**
* Publish-flow modal — three-stage:
*
* 1. become-author — only if the user has no author row yet.
* Asks for slug + displayName + pseudonym.
* 2. deck-meta — title (prefilled), description, language,
* license, optional price.
* 3. publishing — posts to cards-api, shows result + link.
*
* Bestehende Karten aus der lokalen Dexie werden direkt gelesen —
* der Server bekommt eine flache Karte-Liste mit type + fields.
*/
import type { Card, Deck } from '@mana/cards-core';
import { authorStore } from '$lib/stores/author.svelte';
import { CardsApiError, type PublishResult } from '$lib/api/cards-api';
import { cardsApi } from '$lib/api/cards-api';
import { slugify } from '$lib/util/slug';
interface Props {
deck: Deck;
cards: Card[];
onClose: () => void;
onPublished?: (result: PublishResult) => void;
}
let { deck, cards, onClose, onPublished }: Props = $props();
let stage = $state<'loading' | 'become-author' | 'meta' | 'publishing' | 'done' | 'error'>(
'loading'
);
let error = $state<string | null>(null);
let result = $state<PublishResult | null>(null);
// Author form
let authorSlug = $state('');
let authorName = $state('');
let authorPseudonym = $state(false);
// Deck meta form — initial values come from the deck prop. Wrapped
// in a $derived initializer so svelte-check stops complaining
// about state-from-props initialisation; user edits then live in
// the locally-bound $state.
// svelte-ignore state_referenced_locally
let deckSlug = $state(slugify(deck.title));
// svelte-ignore state_referenced_locally
let deckTitle = $state(deck.title);
// svelte-ignore state_referenced_locally
let deckDescription = $state(deck.description ?? '');
let deckLanguage = $state('de');
let deckLicense = $state<'CC0-1.0' | 'CC-BY-4.0' | 'CC-BY-SA-4.0' | 'Cardecky-Personal-Use-1.0'>(
'CC-BY-4.0'
);
let deckSemver = $state('1.0.0');
let deckChangelog = $state('');
$effect(() => {
if (stage !== 'loading') return;
(async () => {
await authorStore.load();
if (authorStore.isAuthor) {
stage = 'meta';
} else {
stage = 'become-author';
authorName = ''; // user fills in
}
})();
});
async function submitAuthor() {
if (!authorSlug.trim() || !authorName.trim()) return;
const created = await authorStore.upsert({
slug: authorSlug.trim(),
displayName: authorName.trim(),
pseudonym: authorPseudonym,
});
if (created) stage = 'meta';
else error = authorStore.error;
}
function buildPublishCards() {
// Map our local CardType + fields straight to the server shape.
// Cloze fields ship as { text, extra? }; basic + basic-reverse +
// type-in ship as { front, back }.
return cards
.filter((c) => Object.keys(c.fields ?? {}).length > 0)
.map((c) => ({ type: c.type, fields: c.fields }));
}
async function submitPublish() {
if (!deckSlug.trim() || !deckTitle.trim()) return;
stage = 'publishing';
error = null;
try {
// 1. Init the deck (idempotent on slug — re-init throws 409,
// in which case we just continue to publish).
try {
await cardsApi.decks.init({
slug: deckSlug.trim(),
title: deckTitle.trim(),
description: deckDescription.trim() || undefined,
language: deckLanguage,
license: deckLicense,
priceCredits: 0,
});
} catch (e) {
if (!(e instanceof CardsApiError && e.status === 409)) throw e;
}
// 2. Publish a version with the local cards.
const publishCards = buildPublishCards();
if (publishCards.length === 0) {
throw new Error('Das Deck enthält keine Karten zum Veröffentlichen.');
}
result = await cardsApi.decks.publish(deckSlug.trim(), {
semver: deckSemver.trim(),
changelog: deckChangelog.trim() || undefined,
cards: publishCards,
});
stage = 'done';
onPublished?.(result);
} catch (e) {
error = e instanceof Error ? e.message : 'Veröffentlichung fehlgeschlagen';
stage = 'error';
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="presentation"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="w-full max-w-lg rounded-xl border border-border bg-card p-6"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-start justify-between">
<h2 class="text-xl font-semibold">Deck veröffentlichen</h2>
<button
onclick={onClose}
class="text-muted-foreground/80 hover:text-foreground/90"
aria-label="Schließen">✕</button
>
</div>
{#if stage === 'loading'}
<div class="py-8 text-center text-sm text-muted-foreground">Lade Author-Profil…</div>
{:else if stage === 'become-author'}
<div class="space-y-4 text-sm">
<p class="text-foreground/80">
Erstelle ein Author-Profil — andere User finden deine Decks unter
<code class="rounded bg-muted px-1 text-xs">cardecky.mana.how/u/dein-slug</code>.
</p>
<div>
<label for="author-slug" class="mb-1 block text-xs text-muted-foreground">
Slug (360 Zeichen, az, 09, -)
</label>
<input
id="author-slug"
type="text"
bind:value={authorSlug}
placeholder="anna-lang"
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="author-name" class="mb-1 block text-xs text-muted-foreground"
>Anzeigename</label
>
<input
id="author-name"
type="text"
bind:value={authorName}
placeholder="Anna Lang"
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<label class="flex items-start gap-2 text-xs text-muted-foreground">
<input type="checkbox" bind:checked={authorPseudonym} class="mt-0.5" />
<span>Pseudonym — Anzeigename ist nicht mein Klarname</span>
</label>
{#if authorStore.error}
<p class="text-error">{authorStore.error}</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={onClose}
>
Abbrechen
</button>
<button
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={submitAuthor}
disabled={!authorSlug.trim() || !authorName.trim() || authorStore.loading}
>
{authorStore.loading ? 'Speichere…' : 'Author werden'}
</button>
</div>
</div>
{:else if stage === 'meta'}
<div class="space-y-4 text-sm">
<p class="text-muted-foreground">
Veröffentlicht als <code class="rounded bg-muted px-1 text-xs"
>cardecky.mana.how/d/{deckSlug || '...'}</code
>
</p>
<div>
<label for="d-slug" class="mb-1 block text-xs text-muted-foreground">Slug</label>
<input
id="d-slug"
type="text"
bind:value={deckSlug}
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="d-title" class="mb-1 block text-xs text-muted-foreground">Titel</label>
<input
id="d-title"
type="text"
bind:value={deckTitle}
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="d-desc" class="mb-1 block text-xs text-muted-foreground">Beschreibung</label>
<textarea
id="d-desc"
bind:value={deckDescription}
placeholder="Worum geht es in diesem Deck?"
class="min-h-[80px] w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="d-lang" class="mb-1 block text-xs text-muted-foreground">Sprache</label>
<select
id="d-lang"
bind:value={deckLanguage}
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ja">日本語</option>
</select>
</div>
<div>
<label for="d-license" class="mb-1 block text-xs text-muted-foreground">Lizenz</label>
<select
id="d-license"
bind:value={deckLicense}
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
>
<option value="CC-BY-4.0">CC-BY 4.0 — frei mit Namensnennung</option>
<option value="CC-BY-SA-4.0">CC-BY-SA 4.0 — share-alike</option>
<option value="CC0-1.0">CC0 — gemeinfrei</option>
<option value="Cardecky-Personal-Use-1.0">Personal Use — nur lernen</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="d-semver" class="mb-1 block text-xs text-muted-foreground">Version</label>
<input
id="d-semver"
type="text"
bind:value={deckSemver}
placeholder="1.0.0"
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
<div>
<label for="d-changelog" class="mb-1 block text-xs text-muted-foreground">
Changelog (optional)
</label>
<input
id="d-changelog"
type="text"
bind:value={deckChangelog}
placeholder="Erste Version"
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
</div>
</div>
<p class="text-xs text-muted-foreground/80">
{cards.length}
{cards.length === 1 ? 'Karte' : 'Karten'} werden veröffentlicht. Das Deck durchläuft eine KI-Inhaltsprüfung
— offensichtlich harmloses Material geht direkt durch.
</p>
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={onClose}
>
Abbrechen
</button>
<button
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={submitPublish}
disabled={!deckSlug.trim() || !deckTitle.trim() || cards.length === 0}
>
Veröffentlichen
</button>
</div>
</div>
{:else if stage === 'publishing'}
<div class="py-8 text-center text-sm text-muted-foreground">
Lade {cards.length} Karten hoch und prüfe Inhalt…
</div>
{:else if stage === 'done' && result}
<div class="space-y-3 text-sm">
<div class="text-green-400">
✓ Veröffentlicht als Version {result.version.semver}
</div>
<div class="text-foreground/80">
{result.version.cardCount} Karten · Lizenz: {result.deck.license}
</div>
{#if result.moderation.verdict === 'flag'}
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-warning">
Inhalt wurde zur Moderations-Prüfung markiert ({result.moderation.categories.join(
', '
)}). Das Deck ist veröffentlicht; ein Mensch schaut bei Gelegenheit drüber.
</div>
{/if}
<button
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90"
onclick={onClose}
>
Fertig
</button>
</div>
{:else if stage === 'error'}
<div class="space-y-3 text-sm">
<div class="text-error">Fehler: {error}</div>
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={() => (stage = 'meta')}
>
Erneut versuchen
</button>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,233 @@
<script lang="ts">
import { cardsApi, CardsApiError, type PullRequest } from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
deckSlug: string;
ownerUserId: string;
onMerged?: () => void;
}
let { deckSlug, ownerUserId, onMerged }: Props = $props();
let prs = $state<PullRequest[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let actionBusy = $state<string | null>(null);
let expanded = $state<Record<string, boolean>>({});
const viewerIsOwner = $derived(authStore.user?.id === ownerUserId);
$effect(() => {
void deckSlug;
load();
});
async function load() {
loading = true;
error = null;
try {
prs = await cardsApi.pullRequests.list(deckSlug);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
}
async function merge(pr: PullRequest) {
if (!confirm(`PR „${pr.title}" mergen? Erstellt eine neue Version.`)) return;
actionBusy = pr.id;
error = null;
try {
await cardsApi.pullRequests.merge(pr.id);
await load();
onMerged?.();
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
actionBusy = null;
}
}
async function close(pr: PullRequest) {
actionBusy = pr.id;
error = null;
try {
await cardsApi.pullRequests.close(pr.id);
await load();
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
actionBusy = null;
}
}
async function reject(pr: PullRequest) {
actionBusy = pr.id;
error = null;
try {
await cardsApi.pullRequests.reject(pr.id);
await load();
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
actionBusy = null;
}
}
function statusBadgeClass(s: PullRequest['status']) {
if (s === 'open') return 'bg-success/15 text-success';
if (s === 'merged') return 'bg-violet-500/15 text-violet-300';
if (s === 'rejected') return 'bg-error/15 text-error';
return 'bg-muted text-muted-foreground';
}
function diffSummary(pr: PullRequest) {
return `+${pr.diff.add.length} · ~${pr.diff.modify.length} · ${pr.diff.remove.length}`;
}
</script>
<section class="mt-10">
<header class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Pull Requests {prs.length > 0 ? `(${prs.length})` : ''}
</h2>
<button
class="text-xs text-muted-foreground/80 hover:text-foreground/80"
onclick={load}
disabled={loading}
>
{loading ? 'Lädt…' : 'Aktualisieren'}
</button>
</header>
{#if error}
<p class="mb-3 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
{error}
</p>
{/if}
{#if loading && prs.length === 0}
<p class="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground/80">
Lädt…
</p>
{:else if prs.length === 0}
<p class="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground/80">
Noch keine Pull Requests. Abonnenten können Verbesserungen vorschlagen.
</p>
{:else}
<ul class="space-y-2">
{#each prs as pr (pr.id)}
<li class="rounded-xl border border-border bg-card p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs {statusBadgeClass(pr.status)}">
{pr.status}
</span>
<h3 class="truncate font-medium text-foreground">{pr.title}</h3>
</div>
<p class="mt-1 text-xs text-muted-foreground/80">
{diffSummary(pr)} · {new Date(pr.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
<button
class="shrink-0 text-xs text-muted-foreground/80 hover:text-foreground/80"
onclick={() => (expanded[pr.id] = !expanded[pr.id])}
>
{expanded[pr.id] ? 'Einklappen' : 'Details'}
</button>
</div>
{#if expanded[pr.id]}
{#if pr.body}
<p class="mt-3 whitespace-pre-line text-sm text-foreground/80">{pr.body}</p>
{/if}
{#if pr.diff.modify.length > 0}
<div class="mt-3">
<div class="mb-1 text-xs uppercase text-muted-foreground/80">Geändert</div>
<ul class="space-y-2">
{#each pr.diff.modify as m (m.contentHash)}
<li class="rounded-lg border border-border bg-background p-2 text-xs">
<div class="text-muted-foreground/80">
{m.contentHash.slice(0, 12)}
</div>
{#each Object.entries(m.fields) as [k, v]}
<div class="mt-1">
<span class="text-muted-foreground/80">{k}:</span>
<span class="text-foreground/90">{v}</span>
</div>
{/each}
</li>
{/each}
</ul>
</div>
{/if}
{#if pr.diff.add.length > 0}
<div class="mt-3">
<div class="mb-1 text-xs uppercase text-muted-foreground/80">
Neu (+{pr.diff.add.length})
</div>
<ul class="space-y-2">
{#each pr.diff.add as a, i (i)}
<li class="rounded-lg border border-border bg-background p-2 text-xs">
<div class="text-muted-foreground/80">{a.type}</div>
{#each Object.entries(a.fields) as [k, v]}
<div class="mt-1">
<span class="text-muted-foreground/80">{k}:</span>
<span class="text-foreground/90">{v}</span>
</div>
{/each}
</li>
{/each}
</ul>
</div>
{/if}
{#if pr.diff.remove.length > 0}
<div class="mt-3">
<div class="mb-1 text-xs uppercase text-muted-foreground/80">
Entfernt ({pr.diff.remove.length})
</div>
<ul class="space-y-1 text-xs text-muted-foreground">
{#each pr.diff.remove as r (r.contentHash)}
<li>· {r.contentHash.slice(0, 12)}</li>
{/each}
</ul>
</div>
{/if}
{#if pr.status === 'open' && viewerIsOwner}
<div class="mt-4 flex gap-2">
<button
class="rounded-lg bg-violet-500 px-3 py-1.5 text-xs text-white hover:bg-violet-400 disabled:opacity-50"
onclick={() => merge(pr)}
disabled={actionBusy === pr.id}
>
{actionBusy === pr.id ? 'Mergt…' : 'Mergen'}
</button>
<button
class="rounded-lg border border-error/40 px-3 py-1.5 text-xs text-error hover:bg-error/10 disabled:opacity-50"
onclick={() => reject(pr)}
disabled={actionBusy === pr.id}
>
Ablehnen
</button>
<button
class="rounded-lg border border-border-strong px-3 py-1.5 text-xs hover:bg-muted disabled:opacity-50"
onclick={() => close(pr)}
disabled={actionBusy === pr.id}
>
Schließen
</button>
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
</section>

View file

@ -0,0 +1,142 @@
<script lang="ts">
import { cardsApi, CardsApiError, type ReportCategory } from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
deckSlug: string;
cardContentHash?: string;
variant?: 'inline' | 'icon';
}
let { deckSlug, cardContentHash, variant = 'inline' }: Props = $props();
let open = $state(false);
let category = $state<ReportCategory>('spam');
let body = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
let done = $state(false);
const CATEGORIES: { value: ReportCategory; label: string }[] = [
{ value: 'spam', label: 'Spam' },
{ value: 'copyright', label: 'Urheberrecht' },
{ value: 'nsfw', label: 'NSFW' },
{ value: 'misinformation', label: 'Falschinformation' },
{ value: 'hate', label: 'Hass' },
{ value: 'other', label: 'Sonstiges' },
];
function close() {
open = false;
error = null;
body = '';
done = false;
}
async function submit() {
busy = true;
error = null;
try {
await cardsApi.moderation.report({
deckSlug,
cardContentHash,
category,
body: body.trim() || undefined,
});
done = true;
setTimeout(close, 1500);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
busy = false;
}
}
</script>
{#if authStore.isAuthenticated}
{#if variant === 'icon'}
<button
class="text-xs text-muted-foreground/60 hover:text-warning"
onclick={() => (open = true)}
title="Melden"
aria-label="Melden"
>
🚩
</button>
{:else}
<button
class="text-xs text-muted-foreground/80 hover:text-warning"
onclick={() => (open = true)}
>
🚩 Melden
</button>
{/if}
{/if}
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-md rounded-xl border border-border bg-background p-5">
<header class="mb-4 flex items-center justify-between">
<h2 class="text-base font-semibold">
{cardContentHash ? 'Karte melden' : 'Deck melden'}
</h2>
<button
class="text-muted-foreground hover:text-foreground"
onclick={close}
aria-label="Schließen">✕</button
>
</header>
{#if done}
<p class="rounded-lg border border-success/30 bg-success/10 p-3 text-sm text-success">
Danke — die Moderation prüft den Bericht.
</p>
{:else}
<label class="mb-3 block">
<span class="mb-1 block text-xs text-muted-foreground">Kategorie</span>
<select
class="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm"
bind:value={category}
>
{#each CATEGORIES as c (c.value)}
<option value={c.value}>{c.label}</option>
{/each}
</select>
</label>
<label class="mb-4 block">
<span class="mb-1 block text-xs text-muted-foreground">Begründung (optional)</span>
<textarea
class="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm"
rows="3"
bind:value={body}
placeholder="Was stimmt nicht?"
></textarea>
</label>
{#if error}
<p class="mb-3 text-sm text-error">{error}</p>
{/if}
<div class="flex justify-end gap-2">
<button
class="rounded-lg border border-border px-4 py-2 text-sm hover:border-border-strong"
onclick={close}
disabled={busy}>Abbrechen</button
>
<button
class="rounded-lg bg-amber-500 px-4 py-2 text-sm text-amber-950 hover:bg-amber-400 disabled:opacity-50"
onclick={submit}
disabled={busy}
>
{busy ? 'Sende…' : 'Melden'}
</button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,93 @@
<script lang="ts">
/**
* GitHub-style activity grid: 7 rows (weekdays) × N columns (weeks).
* Each cell encodes one day's review count via 5 color steps.
* Tooltips on hover show the date + count.
*
* Week-start convention: we group by ISO week starting Monday so the
* top row is always Mondays — matches the European calendar convention.
*/
import { useStudyHeatmap } from '$lib/queries';
interface Props {
weeks?: number;
}
let { weeks = 12 }: Props = $props();
const dataQuery = $derived(useStudyHeatmap(weeks));
const rawDays = $derived(($dataQuery as { date: string; count: number }[] | undefined) ?? []);
// Pad to align the first day to a Monday so columns are full weeks.
const grid = $derived.by(() => {
if (rawDays.length === 0) return [] as { date: string | null; count: number }[];
const first = new Date(rawDays[0].date);
const dow = (first.getDay() + 6) % 7; // 0=Mon, 6=Sun
const padded: { date: string | null; count: number }[] = [];
for (let i = 0; i < dow; i++) padded.push({ date: null, count: 0 });
padded.push(...rawDays);
return padded;
});
const columns = $derived.by(() => {
const cols: { date: string | null; count: number }[][] = [];
for (let i = 0; i < grid.length; i += 7) cols.push(grid.slice(i, i + 7));
return cols;
});
const max = $derived(rawDays.reduce((m, d) => Math.max(m, d.count), 0));
function bucket(count: number): string {
if (count === 0) return 'bg-muted';
if (count <= Math.max(1, max * 0.25)) return 'bg-emerald-900';
if (count <= max * 0.5) return 'bg-emerald-700';
if (count <= max * 0.75) return 'bg-emerald-500';
return 'bg-emerald-300';
}
function fmt(date: string): string {
const d = new Date(date);
return d.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
}
const total = $derived(rawDays.reduce((sum, d) => sum + d.count, 0));
const activeDays = $derived(rawDays.filter((d) => d.count > 0).length);
</script>
<div class="rounded-xl border border-border bg-card p-4">
<div class="mb-3 flex items-center justify-between text-sm">
<span class="font-medium">Lernaktivität</span>
<span class="text-xs text-muted-foreground/80">
{total} Karten · {activeDays} aktive {activeDays === 1 ? 'Tag' : 'Tage'} · letzte {weeks} Wochen
</span>
</div>
<div class="flex gap-1 overflow-x-auto">
{#each columns as col, ci (ci)}
<div class="flex flex-col gap-1">
{#each col as cell, ri (ri)}
{#if cell.date === null}
<div class="h-3 w-3"></div>
{:else}
<div
class="h-3 w-3 rounded-sm {bucket(cell.count)}"
title="{fmt(cell.date)}: {cell.count} {cell.count === 1 ? 'Karte' : 'Karten'}"
></div>
{/if}
{/each}
</div>
{/each}
</div>
<div class="mt-3 flex items-center gap-1 text-xs text-muted-foreground/80">
<span>weniger</span>
<span class="ml-1 h-3 w-3 rounded-sm bg-muted"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-900"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-700"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-500"></span>
<span class="h-3 w-3 rounded-sm bg-emerald-300"></span>
<span class="ml-1">mehr</span>
</div>
</div>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import { cardsApi, CardsApiError } from '$lib/api/cards-api';
import type { Card } from '@mana/cards-core';
type Mode = 'modify' | 'remove';
interface Props {
card: Card;
deckSlug: string;
open: boolean;
onClose: () => void;
onSubmitted?: () => void;
}
let { card, deckSlug, open, onClose, onSubmitted }: Props = $props();
let mode = $state<Mode>('modify');
let title = $state('');
let body = $state('');
let editedFields = $state<Record<string, string>>({});
let busy = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
$effect(() => {
if (open) {
editedFields = { ...card.fields };
title = `Verbesserung: Karte ${card.order + 1}`;
body = '';
mode = 'modify';
error = null;
success = false;
}
});
const fieldKeys = $derived(Object.keys(editedFields));
const hasChanges = $derived.by(() => {
if (mode === 'remove') return true;
return fieldKeys.some((k) => editedFields[k] !== card.fields[k]);
});
async function submit() {
if (!card.serverContentHash) {
error = 'Diese Karte stammt nicht aus einem abonnierten Deck.';
return;
}
if (!hasChanges) {
error = 'Keine Änderungen zu vorschlagen.';
return;
}
if (!title.trim()) {
error = 'Titel fehlt.';
return;
}
busy = true;
error = null;
try {
const diff =
mode === 'remove'
? {
add: [],
modify: [],
remove: [{ contentHash: card.serverContentHash }],
}
: {
add: [],
modify: [
{
previousContentHash: card.serverContentHash,
type: card.type,
fields: editedFields,
},
],
remove: [],
};
await cardsApi.pullRequests.create(deckSlug, {
title: title.trim(),
body: body.trim() || undefined,
diff,
});
success = true;
onSubmitted?.();
setTimeout(() => onClose(), 1200);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
busy = false;
}
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-xl rounded-xl border border-border bg-background p-6">
<header class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Verbesserung vorschlagen</h2>
<button
class="text-muted-foreground hover:text-foreground"
onclick={onClose}
aria-label="Schließen">✕</button
>
</header>
{#if success}
<p class="rounded-lg border border-success/30 bg-success/10 p-3 text-sm text-success">
Pull Request gesendet — der Author wird benachrichtigt.
</p>
{:else}
<div class="mb-4 inline-flex rounded-lg border border-border p-1">
<button
class="rounded px-3 py-1 text-xs"
class:bg-muted={mode === 'modify'}
onclick={() => (mode = 'modify')}>Inhalt ändern</button
>
<button
class="rounded px-3 py-1 text-xs"
class:bg-muted={mode === 'remove'}
onclick={() => (mode = 'remove')}>Karte entfernen</button
>
</div>
<label class="mb-3 block">
<span class="mb-1 block text-xs text-muted-foreground">Titel</span>
<input
class="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm"
bind:value={title}
placeholder="Kurzbeschreibung der Verbesserung"
/>
</label>
{#if mode === 'modify'}
<div class="mb-3 space-y-2">
{#each fieldKeys as key (key)}
<label class="block">
<span class="mb-1 block text-xs text-muted-foreground">{key}</span>
<textarea
class="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm"
rows="2"
bind:value={editedFields[key]}
></textarea>
</label>
{/each}
</div>
{:else}
<p
class="mb-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-warning"
>
Diese Karte wird beim Merge aus dem Deck entfernt.
</p>
{/if}
<label class="mb-4 block">
<span class="mb-1 block text-xs text-muted-foreground">Begründung (optional)</span>
<textarea
class="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm"
rows="3"
bind:value={body}
placeholder="Warum ist diese Änderung sinnvoll?"
></textarea>
</label>
{#if error}
<p class="mb-3 text-sm text-error">{error}</p>
{/if}
<div class="flex items-center justify-end gap-2">
<button
class="rounded-lg border border-border px-4 py-2 text-sm hover:border-border-strong"
onclick={onClose}
disabled={busy}>Abbrechen</button
>
<button
class="rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={submit}
disabled={busy || !hasChanges}
>
{busy ? 'Sende…' : 'PR senden'}
</button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,56 @@
/**
* Encryption wrapper Phase-1 stub.
*
* The full Mana crypto stack (vault server roundtrip, KEK-wrapped
* master key, recovery codes, zero-knowledge mode) lives in the mana
* web app under `apps/mana/.../data/crypto/`. Lifting it intact into
* the standalone Cards app is a Phase-2 task it requires a vault
* client, key provider, and boot-race handling that aren't worth
* dragging in until we have the deployment story for them.
*
* For Phase 1 these helpers are intentionally identity functions:
* data lands in IndexedDB and on `mana-sync` as plaintext. Everything
* is wired up at the right call sites (stores write, queries read,
* sync.applyServerChanges apply) so flipping to real encryption is a
* single-file change here, not a sweep through every store.
*
* Allowlist is the contract with the future vault. It mirrors the
* mana-modul registry exactly so when sync converges, the same fields
* are protected on both ends.
*/
const ENCRYPTED_FIELDS: Record<string, readonly string[]> = {
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<T extends object>(tableName: string, record: T): Promise<T> {
void ENCRYPTED_FIELDS[tableName];
return record;
}
export async function decryptRecord<T extends object>(_tableName: string, record: T): Promise<T> {
return record;
}
export async function decryptRecords<T extends object>(
tableName: string,
records: T[]
): Promise<T[]> {
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] ?? [];
}

View file

@ -0,0 +1,163 @@
/**
* Standalone Cards Dexie database.
*
* Phase-1 sync: every write to a sync-relevant table fires a Dexie hook
* that records a row into `_pendingChanges`. The sync engine drains
* that queue against `mana-sync` (POST /sync/cards). When server changes
* come back, they're applied with `beginApplying(table)` set so the
* hooks suppress queueing for those rows otherwise client and server
* would ping-pong forever.
*
* Encryption is intentionally NOT wired here. Phase-1 ships plaintext;
* Etappe 3c.3 turns it on once the vault client is in place.
*/
import Dexie, { type Table } from 'dexie';
import type { LocalDeck, LocalCard, LocalCardReview, LocalCardStudyBlock } from '@mana/cards-core';
interface DeckTag {
id: string;
deckId: string;
tagId: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
}
/** Server protocol expects this shape on push. */
export interface FieldChange {
value: unknown;
at: string;
}
export type ChangeOp = 'insert' | 'update' | 'delete';
export interface PendingChange {
/** Auto-increment PK (Dexie ++id). */
pk?: number;
table: string;
id: string;
op: ChangeOp;
fields?: Record<string, FieldChange>;
data?: Record<string, unknown>;
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<LocalDeck, string>;
cards!: Table<LocalCard, string>;
cardReviews!: Table<LocalCardReview, string>;
cardStudyBlocks!: Table<LocalCardStudyBlock, string>;
deckTags!: Table<DeckTag, string>;
_pendingChanges!: Table<PendingChange, number>;
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<string>();
/** 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<string, unknown>,
next: Record<string, unknown>
): Record<string, FieldChange> {
const at = new Date().toISOString();
const out: Record<string, FieldChange> = {};
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(row)) {
if (key.startsWith('_')) continue;
out[key] = row[key];
}
return out;
}
// ─── Hook installation ─────────────────────────────────────
function installSyncHooks(table: Table<any, any>, 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);
}

View file

@ -0,0 +1,290 @@
/**
* Cards sync engine talks to mana-sync (POST /sync/cards, GET /sync/cards/pull).
*
* Two loops, both polling-based for the Phase-1 MVP. WebSocket
* notifications can replace the pull poll later without changing
* anything outside this file.
*
* Push: drain `_pendingChanges` every 1s when there's anything queued.
* On success, delete drained rows and apply any server-changes
* the response carried back. Failures keep the rows queued
* the next tick retries.
*
* Pull: every 5s, ask each sync table for changes since its cursor.
* Apply with suppression so the apply doesn't re-enqueue a push.
* Cursor lives in localStorage per table.
*
* Cursor format: ISO timestamp string. The server returns
* `syncedUntil` on push and we store that as a global push cursor; pull
* uses one cursor per collection.
*/
import { browser } from '$app/environment';
import {
beginApplying,
endApplying,
db,
pendingChangesTable,
type PendingChange,
} from './database';
import { encryptRecord } from './crypto';
const APP_ID = 'cards';
const PUSH_INTERVAL_MS = 1_000;
const PULL_INTERVAL_MS = 5_000;
const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags'];
// ─── URL + Auth wiring ─────────────────────────────────────
function getSyncUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_SYNC_URL__?: string })
.__PUBLIC_MANA_SYNC_URL__;
if (injected) return injected;
}
return import.meta.env.DEV ? 'http://localhost:3050' : '';
}
interface AuthLike {
getValidToken?: () => Promise<string | null>;
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<string, { value: unknown; at: string }>;
data?: Record<string, unknown>;
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<string, ServerChange[]>();
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<string, unknown> = { ...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<void> {
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<void> {
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<typeof setInterval> | null = null;
let pullTimer: ReturnType<typeof setInterval> | 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;
}

View file

@ -0,0 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
export {};

View file

@ -0,0 +1,90 @@
/**
* Upload an image or audio file to mana-media and get back a media id
* + a public URL ready to drop into a card field.
*
* Resolves the media base URL from window.__PUBLIC_MANA_MEDIA_URL__
* (injected by hooks.server.ts) so the same code works in dev (when
* mana-media runs on localhost) and prod (https://media.mana.how).
*
* 25 MB hard-cap mirrors the website-upload pattern in mana-web.
*/
const MAX_BYTES = 25 * 1024 * 1024;
export class MediaUploadError extends Error {
constructor(
message: string,
public status?: number
) {
super(message);
this.name = 'MediaUploadError';
}
}
function mediaBaseUrl(): string {
if (typeof window !== 'undefined') {
const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
.__PUBLIC_MANA_MEDIA_URL__;
if (fromWindow) return fromWindow.replace(/\/$/, '');
}
return 'http://localhost:3015';
}
export interface UploadedMedia {
id: string;
url: string;
kind: 'image' | 'audio' | 'video' | 'other';
}
function classify(mime: string): UploadedMedia['kind'] {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('video/')) return 'video';
return 'other';
}
export async function uploadCardMedia(file: File): Promise<UploadedMedia> {
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 `<audio controls preload="metadata" src="${media.url}"></audio>`;
case 'video':
return `<video controls preload="metadata" src="${media.url}"></video>`;
default:
return media.url;
}
}

View file

@ -0,0 +1,251 @@
/**
* Reactive queries standalone.
*
* Wraps Dexie's liveQuery so Svelte components get auto-updates whenever
* the underlying tables change. Type converters mirror the mana-modul
* shape so component code stays portable.
*/
import { liveQuery } from 'dexie';
import {
db,
cardDeckTable,
cardTable,
cardReviewTable,
cardStudyBlockTable,
} from './data/database';
import { decryptRecord, decryptRecords } from './data/crypto';
import type {
CardFields,
CardType,
Card,
CardReview,
Deck,
LocalCard,
LocalCardReview,
LocalDeck,
} from '@mana/cards-core';
// ─── Type Converters ───────────────────────────────────────
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
title: local.name,
description: local.description ?? undefined,
color: local.color,
visibility: local.visibility ?? 'private',
tags: [],
cardCount: local.cardCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
subscribedFromSlug: local.subscribedFromSlug,
subscribedAtVersion: local.subscribedAtVersion,
};
}
export function toLogicalCard(local: LocalCard): {
type: CardType;
fields: CardFields;
front: string;
back: string;
} {
const type: CardType = local.type ?? 'basic';
const fields: CardFields = local.fields ?? {
front: local.front ?? '',
back: local.back ?? '',
};
const front = fields.front ?? local.front ?? '';
const back = fields.back ?? local.back ?? '';
return { type, fields, front, back };
}
export function toCard(local: LocalCard): Card {
const { type, fields, front, back } = toLogicalCard(local);
return {
id: local.id,
deckId: local.deckId,
type,
fields,
front,
back,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
serverContentHash: local.serverContentHash,
};
}
function toCardReview(r: LocalCardReview): CardReview {
return {
id: r.id,
cardId: r.cardId,
subIndex: r.subIndex,
state: r.state,
stability: r.stability,
difficulty: r.difficulty,
due: r.due,
reps: r.reps,
lapses: r.lapses,
lastReview: r.lastReview,
elapsedDays: r.elapsedDays,
scheduledDays: r.scheduledDays,
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllDecks() {
return liveQuery(async () => {
const all = await cardDeckTable.toArray();
const visible = all.filter((d) => !d.deletedAt);
const decrypted = await decryptRecords('cardDecks', visible);
return decrypted.map(toDeck);
});
}
export function useDeck(deckId: string) {
return liveQuery(async () => {
const local = await cardDeckTable.get(deckId);
if (!local || local.deletedAt) return null;
const decrypted = await decryptRecord('cardDecks', { ...local });
return toDeck(decrypted);
});
}
export function useCardsByDeck(deckId: string) {
return liveQuery(async () => {
const visible = (await cardTable.where('deckId').equals(deckId).sortBy('order')).filter(
(c) => !c.deletedAt
);
const decrypted = await decryptRecords('cards', visible);
return decrypted.map(toCard);
});
}
/**
* All reviews due now (or overdue) optionally filtered by deck. Joined
* with the parent card so the learn session can render immediately.
*/
export function useDueReviews(deckId?: string) {
return liveQuery(async () => {
const nowIso = new Date().toISOString();
const due = await cardReviewTable.where('due').belowOrEqual(nowIso).toArray();
const live = due.filter((r) => !r.deletedAt);
if (live.length === 0) return [] as { review: CardReview; card: Card }[];
const cardIds = [...new Set(live.map((r) => r.cardId))];
const cardRows = await db.cards.where('id').anyOf(cardIds).toArray();
const decryptedCards = await decryptRecords(
'cards',
cardRows.filter((c) => !c.deletedAt)
);
const cardById = new Map(decryptedCards.map((c) => [c.id, toCard(c)] as const));
return live
.filter((r) => {
const c = cardById.get(r.cardId);
if (!c) return false;
if (deckId && c.deckId !== deckId) return false;
return true;
})
.sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0))
.map((r) => ({ review: toCardReview(r), card: cardById.get(r.cardId)! }));
});
}
export function useReview(reviewId: string) {
return liveQuery(async () => {
const r = await cardReviewTable.get(reviewId);
if (!r || r.deletedAt) return null;
return toCardReview(r);
});
}
/**
* Map of deckId count of currently-due reviews. Used by the deck list
* so the user can see at a glance which deck wants attention without
* opening it.
*/
export function useDueCountByDeck() {
return liveQuery(async () => {
const nowIso = new Date().toISOString();
const due = await cardReviewTable.where('due').belowOrEqual(nowIso).toArray();
const live = due.filter((r) => !r.deletedAt);
if (live.length === 0) return new Map<string, number>();
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<string, number>();
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<string, number>();
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;
});
}

View file

@ -0,0 +1,314 @@
/**
* Subscribe to a marketplace deck and pull its latest version into
* the local Dexie. Phase δ.2 initial pull only; smart-merge of
* subsequent updates lands in δ.3 via `applySubscriptionUpdate`
* (placeholder export below).
*
* The subscribed deck shows up alongside own decks but is marked
* `subscribedFromSlug` + `subscribedAtVersion` so the UI can hide
* mutate controls and show an "Update available" indicator when
* cards-server reports a newer version.
*/
import { cardsApi, CardsApiError } from '$lib/api/cards-api';
import type { ServerCard } from '$lib/api/cards-api';
import { cardDeckTable, cardTable } from '$lib/data/database';
import { reviewStore } from '$lib/stores/reviews.svelte';
import type { CardType, LocalCard, LocalDeck } from '@mana/cards-core';
const ALLOWED_TYPES: CardType[] = [
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
];
function asCardType(t: string): CardType {
return (ALLOWED_TYPES as string[]).includes(t) ? (t as CardType) : 'basic';
}
export interface SubscribeResult {
deckId: string;
cardCount: number;
}
export async function subscribeAndPull(deckSlug: string): Promise<SubscribeResult> {
// 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<void> {
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<boolean> {
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<UpdatePreview | null> {
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<UpdatePreview | null> {
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<void> {
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 }));
}

View file

@ -0,0 +1,12 @@
/**
* Auth Store uses the shared Mana auth factory.
*
* SSO: tokens land in the shared `*.mana.how` storage so a user already
* signed into mana.how / cardecky.mana.how lands directly in the app
* without re-typing credentials. The factory wires up the token
* manager + refresh + storage adapter for us.
*/
import { createManaAuthStore } from '@mana/shared-auth-ui';
export const authStore = createManaAuthStore();

View file

@ -0,0 +1,72 @@
/**
* Author-state store.
*
* Lazily fetches the user's author row on first access. Runtime
* components never read the API directly they go through this
* store, so refresh-on-mutation is centralised.
*/
import { cardsApi, CardsApiError, type Author } from '$lib/api/cards-api';
let _author = $state<Author | null>(null);
let _loaded = $state(false);
let _loading = $state(false);
let _error = $state<string | null>(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<Author | null> {
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<typeof cardsApi.authors.upsertMe>[0]): Promise<Author | null> {
_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;
},
};

View file

@ -0,0 +1,165 @@
/**
* Card Store standalone.
*
* Writes the {type, fields} shape directly. Legacy mirror (front/back
* columns) kept on for cross-compat with the mana-modul data model
* once sync flips on. No encryption, no domain events that's the
* deliberate Phase-1 simplification.
*/
import { cardTable, cardDeckTable } from '../data/database';
import { encryptRecord, decryptRecord } from '../data/crypto';
import { reviewStore } from './reviews.svelte';
import {
type CardFields,
type CardType,
type LocalCard,
type CreateCardInput,
type UpdateCardInput,
} from '@mana/cards-core';
let error = $state<string | null>(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<LocalCard | null> {
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<LocalCard> = {
type: nextType,
fields: nextFields,
updatedAt: new Date().toISOString(),
...legacy,
};
if (updates.order !== undefined) diff.order = updates.order;
await encryptRecord('cards', diff as Record<string, unknown>);
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;
},
};

View file

@ -0,0 +1,78 @@
/**
* Deck Store standalone.
*
* Slim version of the mana-modul decks store: no time-blocks, no
* domain-events, no Mana-wide visibility hooks. Just CRUD against the
* standalone Dexie DB.
*/
import { cardDeckTable, cardTable, db } from '../data/database';
import { encryptRecord } from '../data/crypto';
import type { CreateDeckInput, UpdateDeckInput, LocalDeck } from '@mana/cards-core';
let error = $state<string | null>(null);
export const deckStore = {
get error() {
return error;
},
async createDeck(input: CreateDeckInput): Promise<LocalDeck | null> {
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<LocalDeck> = { 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<string, unknown>);
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;
},
};

View file

@ -0,0 +1,90 @@
/**
* Card-Review Store standalone.
*
* Plaintext, no encryption hook (Phase 1). Fan-out logic comes from
* @mana/cards-core; the only standalone bit is which Dexie table to write to.
*/
import { cardReviewTable } from '../data/database';
import {
newReview,
gradeReview as fsrsGrade,
subIndexesFor,
type CardFields,
type CardType,
type LocalCardReview,
type ReviewGrade,
} from '@mana/cards-core';
let error = $state<string | null>(null);
export const reviewStore = {
get error() {
return error;
},
async ensureReviewsForCard(card: {
id: string;
type: CardType;
fields: CardFields;
}): Promise<LocalCardReview[]> {
error = null;
try {
const existing = await cardReviewTable.where('cardId').equals(card.id).toArray();
const live = existing.filter((r) => !r.deletedAt);
const liveByIdx = new Map(live.map((r) => [r.subIndex, r]));
const wanted = subIndexesFor(card);
const wantedSet = new Set(wanted);
const nowIso = new Date().toISOString();
for (const subIndex of wanted) {
if (!liveByIdx.has(subIndex)) {
const r = newReview({ cardId: card.id, subIndex });
await cardReviewTable.add(r);
liveByIdx.set(subIndex, r);
}
}
for (const r of live) {
if (!wantedSet.has(r.subIndex)) {
await cardReviewTable.update(r.id, { deletedAt: nowIso });
liveByIdx.delete(r.subIndex);
}
}
return [...liveByIdx.values()].sort((a, b) => a.subIndex - b.subIndex);
} catch (err: any) {
error = err.message || 'Failed to ensure reviews';
console.error('Ensure reviews error:', err);
return [];
}
},
async grade(reviewId: string, grade: ReviewGrade): Promise<LocalCardReview | null> {
error = null;
try {
const existing = await cardReviewTable.get(reviewId);
if (!existing) return null;
const next = fsrsGrade(existing, grade);
await cardReviewTable.put(next);
return next;
} catch (err: any) {
error = err.message || 'Failed to grade review';
console.error('Grade review error:', err);
return null;
}
},
async softDeleteForCard(cardId: string): Promise<void> {
const reviews = await cardReviewTable.where('cardId').equals(cardId).toArray();
const now = new Date().toISOString();
for (const r of reviews) {
if (!r.deletedAt) await cardReviewTable.update(r.id, { deletedAt: now });
}
},
clearError() {
error = null;
},
};

View file

@ -0,0 +1,65 @@
/**
* Study-Block Store standalone.
*
* Local daily-aggregate row for streak + per-day-stats.
*/
import { cardStudyBlockTable } from '../data/database';
import type { LocalCardStudyBlock } from '@mana/cards-core';
let error = $state<string | null>(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<void> {
error = null;
try {
const date = localDateKey();
const existing = await cardStudyBlockTable.where('date').equals(date).first();
if (existing && !existing.deletedAt) {
await cardStudyBlockTable.update(existing.id, {
cardsReviewed: existing.cardsReviewed + count,
durationMs: existing.durationMs + durationMs,
});
} else {
const row: LocalCardStudyBlock = {
id: crypto.randomUUID(),
date,
cardsReviewed: count,
durationMs,
};
await cardStudyBlockTable.add(row);
}
} catch (err: any) {
error = err.message || 'Failed to record review';
console.error('Record review error:', err);
}
},
async getRecentStreak(): Promise<number> {
const today = new Date();
let streak = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const row = await cardStudyBlockTable.where('date').equals(localDateKey(d)).first();
if (!row || row.deletedAt || row.cardsReviewed <= 0) break;
streak++;
}
return streak;
},
clearError() {
error = null;
},
};

View file

@ -0,0 +1,33 @@
/**
* Cards Theme Store
*
* Uses the shared theme system. The Cards brand accent (#8b5cf6 from
* MANA_APPS) becomes `--color-app-accent` on document.documentElement
* so the existing `bg-app-accent` / `text-app-accent` utilities work
* everywhere Lernen-CTA, cloze highlight, link colours, etc.
*
* The accent is theme-agnostic by design: it stays the same whether
* the user picks Lume / Nature / Stone / Ocean × Light / Dark, so the
* Cards identity reads consistently across variants.
*/
import { createThemeStore } from '@mana/shared-theme';
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@mana/shared-theme';
// Cards brand: #8b5cf6 (violet-500) → HSL channels.
const CARDS_ACCENT_HSL = '258 90% 66%';
export const theme = createThemeStore({
appId: 'cards',
});
/**
* Write the Cards app accent onto documentElement once at boot. The
* shared theme store doesn't know about per-app accents it only
* touches the variant tokens so we set this independently and it
* survives every variant switch.
*/
export function applyCardsAccent(): void {
if (typeof document === 'undefined') return;
document.documentElement.style.setProperty('--color-app-accent', CARDS_ACCENT_HSL);
}

View file

@ -0,0 +1,14 @@
/**
* Best-effort slug suggestion. Server-side validateSlug is the
* authoritative gate; this just gives the user a sensible default
* to edit.
*/
export function slugify(input: string): string {
return input
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}

View file

@ -0,0 +1,98 @@
<script lang="ts">
import '../app.css';
import type { Snippet } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { AuthGate } from '@mana/shared-auth-ui';
import ThemeToggle from '@mana/shared-theme-ui/ThemeToggle.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { theme, applyCardsAccent } from '$lib/stores/theme';
import { startSync, stopSync } from '$lib/data/sync';
import { useStreak } from '$lib/queries';
import { pwaInfo } from 'virtual:pwa-info';
let { children }: { children: Snippet } = $props();
// Auth/marketing pages render outside the gate so first-time visitors
// can actually reach them. Everything else is gated.
// Public marketplace surface — anyone can browse decks/profiles/explore
// without signing in. AuthGate kicks in once the user opens their own
// decks/learn pages.
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/explore', '/u/', '/d/'];
const isPublic = $derived(PUBLIC_PATHS.some((p) => page.url.pathname.startsWith(p)));
function handleAuthReady() {
// AuthGate guarantees authStore.isAuthenticated by the time this fires.
startSync(authStore);
}
// Live streak — recomputed whenever cardStudyBlocks changes. Lives at
// the layout level so the count is visible from every gated page.
const streakQuery = $derived(useStreak());
const streak = $derived(($streakQuery as number | undefined) ?? 0);
// vite-plugin-pwa exposes the hashed manifest filename via this
// virtual module. Without inlining its <link> Chrome can't read the
// manifest → no install icon, no A2HS on mobile.
const webManifestLink = $derived(pwaInfo?.webManifest.linkTag ?? '');
onMount(() => {
// Apply the Cards brand accent once at boot. The shared theme
// store handles light/dark + variant via createThemeStore above
// (ran during module init); this just sets --color-app-accent
// so `bg-app-accent` etc. resolve to Cards' violet.
applyCardsAccent();
});
onDestroy(() => stopSync());
</script>
<svelte:head>
{@html webManifestLink}
</svelte:head>
{#if isPublic}
{@render children()}
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<header class="border-b border-border">
<div class="mx-auto flex max-w-3xl items-center justify-between px-6 py-3">
<a href="/" class="flex items-center gap-2 text-sm font-semibold tracking-tight">
<span class="text-base">🃏</span> Cards
</a>
<nav class="flex items-center gap-4 text-xs text-muted-foreground">
<a href="/" class="hover:text-foreground">Meine Decks</a>
<a href="/explore" class="hover:text-foreground">Entdecken</a>
<a href="/me/purchases" class="hover:text-foreground">Käufe</a>
</nav>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
{#if streak > 0}
<span
class="inline-flex items-center gap-1 rounded-full bg-warning/15 px-2 py-0.5 text-warning"
title="{streak} {streak === 1 ? 'Tag' : 'Tage'} in Folge gelernt"
>
🔥 {streak}
</span>
{/if}
<ThemeToggle {theme} size={16} />
{#if authStore.user?.email}
<span class="hidden sm:inline">{authStore.user.email}</span>
{/if}
<button
onclick={async () => {
stopSync();
await authStore.signOut();
goto('/login');
}}
class="rounded-md border border-border px-2 py-1 hover:border-border-strong hover:text-foreground"
>
Abmelden
</button>
</div>
</div>
</header>
{@render children()}
</AuthGate>
{/if}

View file

@ -0,0 +1,156 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllDecks, useDueCountByDeck } from '$lib/queries';
import { deckStore } from '$lib/stores/decks.svelte';
import AnkiImport from '$lib/components/AnkiImport.svelte';
import StudyHeatmap from '$lib/components/StudyHeatmap.svelte';
import type { Deck } from '@mana/cards-core';
const decksQuery = $derived(useAllDecks());
const decks = $derived(($decksQuery as Deck[] | undefined) ?? []);
const dueByDeckQuery = $derived(useDueCountByDeck());
const dueByDeck = $derived(($dueByDeckQuery as Map<string, number> | undefined) ?? new Map());
const totalDue = $derived(
[...(dueByDeck as Map<string, number>).values()].reduce((a, b) => a + b, 0)
);
let showNew = $state(false);
let newTitle = $state('');
let newDesc = $state('');
let creating = $state(false);
async function handleCreate() {
if (!newTitle.trim() || creating) return;
creating = true;
const deck = await deckStore.createDeck({
title: newTitle.trim(),
description: newDesc.trim() || undefined,
});
creating = false;
newTitle = '';
newDesc = '';
showNew = false;
if (deck) goto(`/decks/${deck.id}`);
}
</script>
<svelte:head>
<title>Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-10">
<header class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-semibold tracking-tight">Cards</h1>
<p class="text-sm text-muted-foreground">
{decks.length}
{decks.length === 1 ? 'Deck' : 'Decks'}{#if totalDue > 0}
· <span class="text-warning">{totalDue} fällig</span>
{/if}
</p>
</div>
<button
class="rounded-lg bg-app-accent px-4 py-2 text-sm font-medium text-white hover:bg-app-accent/90"
onclick={() => (showNew = true)}
>
Neues Deck
</button>
</header>
{#if showNew}
<form
class="mb-6 space-y-3 rounded-xl border border-border bg-card p-4"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newTitle}
placeholder="Titel (z.B. Spanisch Vokabeln)"
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
autofocus
required
/>
<textarea
bind:value={newDesc}
placeholder="Beschreibung (optional)"
class="min-h-[60px] w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
></textarea>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={() => {
showNew = false;
newTitle = '';
newDesc = '';
}}
>
Abbrechen
</button>
<button
type="submit"
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
disabled={!newTitle.trim() || creating}
>
{creating ? 'Lege an…' : 'Anlegen'}
</button>
</div>
</form>
{/if}
{#if decks.length === 0 && !showNew}
<div class="rounded-xl border border-border bg-card p-10 text-center">
<div class="mb-3 text-4xl">🃏</div>
<p class="text-muted-foreground">Noch keine Decks. Leg dein erstes an.</p>
<button
class="mt-4 rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90"
onclick={() => (showNew = true)}
>
Erstes Deck anlegen
</button>
</div>
{:else}
<ul class="space-y-2">
{#each decks as deck (deck.id)}
{@const due = (dueByDeck as Map<string, number>).get(deck.id) ?? 0}
<li>
<a
href={`/decks/${deck.id}`}
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-colors hover:border-border-strong hover:bg-muted"
>
<span class="h-3 w-3 shrink-0 rounded-full" style="background: {deck.color}"></span>
<span class="flex-1 truncate">
<span class="block font-medium">{deck.title}</span>
{#if deck.description}
<span class="block truncate text-xs text-muted-foreground">{deck.description}</span>
{/if}
</span>
{#if due > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-warning">
{due} fällig
</span>
{/if}
<span class="text-xs text-muted-foreground/80">{deck.cardCount}</span>
</a>
</li>
{/each}
</ul>
{/if}
<div class="mt-10">
<StudyHeatmap />
</div>
<div class="mt-6">
<AnkiImport />
</div>
<p class="mt-12 text-center text-xs text-muted-foreground/60">
Phase 1 · synct mit mana.how/cards
</p>
</main>

View file

@ -0,0 +1,170 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
cardsApi,
CardsApiError,
type DeckReportItem,
type ResolveAction,
} from '$lib/api/cards-api';
import { authStore } from '$lib/stores/auth.svelte';
let stage = $state<'loading' | 'forbidden' | 'ok' | 'error'>('loading');
let reports = $state<DeckReportItem[]>([]);
let error = $state<string | null>(null);
let busy = $state<string | null>(null);
const isAdmin = $derived((authStore.user as { role?: string } | undefined)?.role === 'admin');
onMount(load);
async function load() {
stage = 'loading';
try {
reports = await cardsApi.admin.listReports();
stage = 'ok';
} catch (e) {
if (e instanceof CardsApiError && e.status === 403) {
stage = 'forbidden';
return;
}
error = e instanceof CardsApiError ? e.message : (e as Error).message;
stage = 'error';
}
}
async function resolve(report: DeckReportItem, action: ResolveAction) {
const messages: Record<ResolveAction, string> = {
dismiss: 'Diesen Bericht als unbegründet abweisen?',
takedown: `Deck „${report.deckTitle}" entfernen?`,
'ban-author': `Author dieses Decks bannen? (Alle ihre Decks werden entfernt.)`,
};
if (!confirm(messages[action])) return;
const notes =
action === 'dismiss'
? undefined
: (prompt('Notiz für interne Doku (optional):') ?? undefined);
busy = report.id;
try {
await cardsApi.admin.resolveReport(report.id, { action, notes });
reports = reports.filter((r) => r.id !== report.id);
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
busy = null;
}
}
function badgeClass(c: DeckReportItem['category']) {
const map: Record<DeckReportItem['category'], string> = {
spam: 'bg-amber-500/15 text-warning',
copyright: 'bg-blue-500/15 text-blue-300',
nsfw: 'bg-pink-500/15 text-pink-300',
misinformation: 'bg-violet-500/15 text-violet-300',
hate: 'bg-error/15 text-error',
other: 'bg-muted text-foreground/80',
};
return map[c];
}
</script>
<svelte:head>
<title>Moderation — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
<header class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Moderation-Inbox</h1>
{#if stage === 'ok'}
<button class="text-xs text-muted-foreground/80 hover:text-foreground/90" onclick={load}>
Aktualisieren
</button>
{/if}
</header>
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-muted-foreground">Lädt…</p>
{:else if stage === 'forbidden' || !isAdmin}
<p
class="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground"
>
Nur Admins haben Zugang zur Moderation-Inbox.
</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error">
{error}
</p>
{:else if reports.length === 0}
<p
class="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground/80"
>
Keine offenen Reports.
</p>
{:else}
<ul class="space-y-3">
{#each reports as r (r.id)}
<li class="rounded-xl border border-border bg-card p-4">
<header class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs {badgeClass(r.category)}">
{r.category}
</span>
<a
href="/d/{r.deckSlug}"
class="truncate text-sm font-medium hover:text-app-accent"
>
{r.deckTitle}
</a>
{#if r.cardContentHash}
<span class="text-xs text-muted-foreground/80"
>· Karte {r.cardContentHash.slice(0, 8)}</span
>
{/if}
</div>
<p class="mt-1 text-xs text-muted-foreground/80">
{new Date(r.createdAt).toLocaleString('de-DE')}
</p>
</div>
</header>
{#if r.body}
<p
class="mb-3 whitespace-pre-line rounded-lg bg-background p-2 text-sm text-foreground/80"
>
{r.body}
</p>
{/if}
{#if error}
<p class="mb-2 text-xs text-error">{error}</p>
{/if}
<div class="flex flex-wrap gap-2">
<button
class="rounded-lg border border-border-strong px-3 py-1.5 text-xs hover:bg-muted disabled:opacity-50"
onclick={() => resolve(r, 'dismiss')}
disabled={busy === r.id}
>
Abweisen
</button>
<button
class="rounded-lg bg-amber-500 px-3 py-1.5 text-xs text-amber-950 hover:bg-amber-400 disabled:opacity-50"
onclick={() => resolve(r, 'takedown')}
disabled={busy === r.id}
>
Deck entfernen
</button>
<button
class="rounded-lg bg-error px-3 py-1.5 text-xs text-white hover:bg-error/90 disabled:opacity-50"
onclick={() => resolve(r, 'ban-author')}
disabled={busy === r.id}
>
Author bannen
</button>
</div>
</li>
{/each}
</ul>
{/if}
</main>

View file

@ -0,0 +1,267 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import {
cardsApi,
CardsApiError,
type PublicDeck,
type PublicDeckVersion,
} from '$lib/api/cards-api';
import { isSubscribedLocally, subscribeAndPull, unsubscribe } from '$lib/services/subscribe';
import { cardDeckTable } from '$lib/data/database';
import PullRequestsSection from '$lib/components/PullRequestsSection.svelte';
import DeckCardList from '$lib/components/DeckCardList.svelte';
import ReportButton from '$lib/components/ReportButton.svelte';
const slug = $derived(page.params.slug as string);
let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading');
let deck = $state<PublicDeck | null>(null);
let version = $state<PublicDeckVersion | null>(null);
let starred = $state(false);
let starBusy = $state(false);
let subscribed = $state(false);
let subscribeBusy = $state(false);
let subscribedDeckId = $state<string | null>(null);
let hasPurchased = $state<boolean | null>(null);
let purchaseBusy = $state(false);
let error = $state<string | null>(null);
const isPaid = $derived(!!deck && deck.priceCredits > 0);
const canSubscribeNow = $derived(!isPaid || hasPurchased === true);
const isOwner = $derived(!!deck && authStore.user?.id === deck.ownerUserId);
$effect(() => {
if (!slug) return;
load();
});
async function load() {
stage = 'loading';
try {
const r = await cardsApi.decks.bySlug(slug);
deck = r.deck;
version = r.latestVersion;
hasPurchased = r.hasPurchased;
subscribed = await isSubscribedLocally(slug);
if (subscribed) {
const local = await cardDeckTable
.where('subscribedFromSlug')
.equals(slug)
.first()
.catch(() => undefined);
subscribedDeckId = local?.id ?? null;
}
stage = 'ok';
} catch (e) {
if (e instanceof CardsApiError && e.status === 404) {
stage = 'not-found';
return;
}
error = (e as Error).message;
stage = 'error';
}
}
async function toggleStar() {
if (!deck || starBusy) return;
starBusy = true;
error = null;
try {
if (starred) {
await cardsApi.decks.unstar(deck.slug);
starred = false;
} else {
await cardsApi.decks.star(deck.slug);
starred = true;
}
} catch (e) {
error = (e as Error).message;
} finally {
starBusy = false;
}
}
async function buy() {
if (!deck || purchaseBusy) return;
if (!confirm(`Deck „${deck.title}" für ${deck.priceCredits} Credits kaufen?`)) return;
purchaseBusy = true;
error = null;
try {
await cardsApi.purchases.buy(deck.slug);
hasPurchased = true;
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
purchaseBusy = false;
}
}
async function toggleSubscribe() {
if (!deck || subscribeBusy) return;
subscribeBusy = true;
error = null;
try {
if (subscribed) {
await unsubscribe(deck.slug);
subscribed = false;
subscribedDeckId = null;
} else {
const result = await subscribeAndPull(deck.slug);
subscribed = true;
subscribedDeckId = result.deckId;
}
} catch (e) {
error = (e as Error).message;
} finally {
subscribeBusy = false;
}
}
</script>
<svelte:head>
<title>{deck?.title ?? slug} — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-muted-foreground">Lade Deck…</p>
{:else if stage === 'not-found'}
<p
class="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground"
>
Deck <code class="rounded bg-muted px-1">{slug}</code> existiert nicht.
</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error">
{error}
</p>
{:else if deck}
<article>
<header class="mb-6">
<h1 class="text-3xl font-semibold tracking-tight">{deck.title}</h1>
{#if deck.description}
<p class="mt-2 text-sm text-muted-foreground">{deck.description}</p>
{/if}
</header>
<div class="mb-6 flex flex-wrap items-center gap-3 text-sm">
{#if version}
<span class="rounded-full bg-muted px-2 py-0.5 text-xs text-foreground/80">
v{version.semver}
</span>
<span class="text-muted-foreground">{version.cardCount} Karten</span>
{/if}
<span class="text-muted-foreground">{deck.license}</span>
{#if deck.language}
<span class="text-muted-foreground">{deck.language.toUpperCase()}</span>
{/if}
{#if deck.priceCredits > 0}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-warning">
{deck.priceCredits} 💎
</span>
{/if}
</div>
{#if version?.changelog}
<section class="mb-6 rounded-xl border border-border bg-card p-4">
<h2 class="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/80">
Changelog v{version.semver}
</h2>
<p class="whitespace-pre-line text-sm text-foreground/80">{version.changelog}</p>
</section>
{/if}
<div class="flex flex-wrap items-center gap-2">
{#if authStore.isAuthenticated}
<button
class="rounded-lg border border-app-accent/40 px-4 py-2 text-sm text-app-accent hover:bg-app-accent/10 disabled:opacity-50"
onclick={toggleStar}
disabled={starBusy}
>
{starred ? '★ Markiert' : '☆ Merken'}
</button>
{#if subscribed}
<button
class="rounded-lg border border-success/40 px-4 py-2 text-sm text-success hover:bg-success/10 disabled:opacity-50"
onclick={toggleSubscribe}
disabled={subscribeBusy}
title="Abo entfernen"
>
{subscribeBusy ? 'Lädt…' : '✓ Abonniert'}
</button>
{#if subscribedDeckId}
<button
class="rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90"
onclick={() => goto(`/learn/${subscribedDeckId}`)}
>
Lernen
</button>
{/if}
{:else if isPaid && !canSubscribeNow && !isOwner}
<button
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950 hover:bg-amber-400 disabled:opacity-50"
onclick={buy}
disabled={purchaseBusy || !version}
>
{purchaseBusy ? 'Verarbeite…' : `Kaufen für ${deck.priceCredits} 💎`}
</button>
{:else}
<button
class="rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={toggleSubscribe}
disabled={subscribeBusy || !version}
title={version ? 'In meine Decks ziehen' : 'Deck hat noch keine Version'}
>
{subscribeBusy ? 'Abonniere…' : 'Abonnieren'}
</button>
{#if isPaid && hasPurchased}
<span
class="rounded-full bg-success/15 px-2 py-1 text-xs text-success"
title="Du besitzt dieses Deck"
>
✓ Gekauft
</span>
{/if}
{/if}
{:else}
<a
href="/login"
class="rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90"
>
Anmelden um zu abonnieren
</a>
{/if}
</div>
{#if error}
<p class="mt-3 text-sm text-error">{error}</p>
{/if}
<div class="mt-10 flex items-center justify-between text-xs text-muted-foreground/80">
<span>Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}</span>
{#if !isOwner}
<ReportButton deckSlug={deck.slug} />
{/if}
</div>
{#if deck.isTakedown}
<p class="mt-3 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
Dieses Deck wurde von der Moderation entfernt.
</p>
{/if}
{#if version}
<DeckCardList deckSlug={deck.slug} semver={version.semver} />
{/if}
<PullRequestsSection deckSlug={deck.slug} ownerUserId={deck.ownerUserId} onMerged={load} />
</article>
{/if}
<p class="mt-12 text-center text-xs text-muted-foreground/60">
<a href="/explore" class="hover:text-foreground/80">← Marktplatz</a>
</p>
</main>

View file

@ -0,0 +1,547 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { useDeck, useCardsByDeck, useDueReviews } from '$lib/queries';
import { deckStore } from '$lib/stores/decks.svelte';
import { cardStore } from '$lib/stores/cards.svelte';
import { renderMarkdown, type Card, type CardType, type Deck } from '@mana/cards-core';
import AiCardGen from '$lib/components/AiCardGen.svelte';
import PublishDeckModal from '$lib/components/PublishDeckModal.svelte';
import { uploadCardMedia, mediaToFieldSnippet } from '$lib/media/upload';
import { cardDeckTable } from '$lib/data/database';
import { previewUpdate, applyUpdate, type UpdatePreview } from '$lib/services/subscribe';
const deckId = $derived(page.params.id as string);
const deckQuery = $derived(useDeck(deckId));
const cardsQuery = $derived(useCardsByDeck(deckId));
const dueQuery = $derived(useDueReviews(deckId));
const deck = $derived(($deckQuery as Deck | null | undefined) ?? null);
const cards = $derived(($cardsQuery as Card[] | undefined) ?? []);
const dueCount = $derived(($dueQuery as { card: Card }[] | undefined)?.length ?? 0);
let showNew = $state(false);
let showAi = $state(false);
let showPublish = $state(false);
// Subscription state — populated on mount + after each change so
// the read-only gating + update-banner stays in sync without
// hooking another live-query.
let subscribedFromSlug = $state<string | null>(null);
let subscribedAtVersion = $state<string | null>(null);
let updatePreview = $state<UpdatePreview | null>(null);
let updateBusy = $state(false);
let updateError = $state<string | null>(null);
const isSubscribed = $derived(subscribedFromSlug !== null);
$effect(() => {
if (!deckId) return;
void refreshSubscriptionState();
});
async function refreshSubscriptionState() {
const local = await cardDeckTable.get(deckId).catch(() => undefined);
subscribedFromSlug = local?.subscribedFromSlug ?? null;
subscribedAtVersion = local?.subscribedAtVersion ?? null;
if (!subscribedFromSlug) {
updatePreview = null;
return;
}
try {
updatePreview = await previewUpdate(subscribedFromSlug);
} catch {
updatePreview = null;
}
}
async function handleApplyUpdate() {
if (!subscribedFromSlug || updateBusy) return;
updateBusy = true;
updateError = null;
try {
await applyUpdate(subscribedFromSlug);
await refreshSubscriptionState();
} catch (e) {
updateError = e instanceof Error ? e.message : 'Update fehlgeschlagen';
} finally {
updateBusy = false;
}
}
let attachBusy = $state<'front' | 'back' | 'cloze' | null>(null);
let attachError = $state<string | null>(null);
let attachInputs = $state<Record<string, HTMLInputElement | null>>({
front: null,
back: null,
cloze: null,
});
async function handleAttach(target: 'front' | 'back' | 'cloze', file: File) {
attachError = null;
attachBusy = target;
try {
const media = await uploadCardMedia(file);
const snippet = mediaToFieldSnippet(media, file.name.replace(/\.[^.]+$/, ''));
if (target === 'front') {
newFront = newFront ? `${newFront}\n${snippet}` : snippet;
} else if (target === 'back') {
newBack = newBack ? `${newBack}\n${snippet}` : snippet;
} else {
newCloze = newCloze ? `${newCloze}\n${snippet}` : snippet;
}
} catch (e: any) {
attachError = e?.message ?? 'Upload fehlgeschlagen.';
} finally {
attachBusy = null;
}
}
function pickAttachment(target: 'front' | 'back' | 'cloze') {
attachInputs[target]?.click();
}
function onAttachChange(target: 'front' | 'back' | 'cloze') {
return (e: Event) => {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) handleAttach(target, file);
};
}
let newType = $state<CardType>('basic');
let newFront = $state('');
let newBack = $state('');
let newCloze = $state('');
let confirmDelete = $state(false);
const cardTypeOptions: { value: CardType; label: string; hint: string }[] = [
{ value: 'basic', label: 'Standard', hint: 'Vorderseite → Rückseite' },
{ value: 'basic-reverse', label: 'Beidseitig', hint: 'Lernt in beide Richtungen' },
{ value: 'cloze', label: 'Lückentext', hint: 'Markiere mit {{c1::Wort}}' },
{ value: 'type-in', label: 'Eintippen', hint: 'Antwort wird verglichen' },
];
function canSubmit(): boolean {
if (newType === 'cloze') return newCloze.trim().length > 0;
return newFront.trim().length > 0 && newBack.trim().length > 0;
}
async function handleCreateCard() {
if (!canSubmit()) return;
if (newType === 'cloze') {
await cardStore.createCard(
{ deckId, type: 'cloze', fields: { text: newCloze.trim() } },
cards.length
);
} else {
await cardStore.createCard(
{ deckId, type: newType, front: newFront.trim(), back: newBack.trim() },
cards.length
);
}
newFront = '';
newBack = '';
newCloze = '';
showNew = false;
}
async function handleDeleteCard(cardId: string) {
if (!confirm('Karte wirklich löschen?')) return;
await cardStore.deleteCard(cardId, deckId);
}
async function handleDeleteDeck() {
await deckStore.deleteDeck(deckId);
goto('/');
}
function typeBadge(type: CardType): string {
switch (type) {
case 'basic':
return 'Standard';
case 'basic-reverse':
return 'Beidseitig';
case 'cloze':
return 'Lückentext';
case 'type-in':
return 'Eintippen';
default:
return type;
}
}
function preview(card: Card): { primary: string; secondary: string } {
if (card.type === 'cloze') {
return { primary: (card.fields.text ?? '').slice(0, 140), secondary: '' };
}
return {
primary: card.fields.front ?? card.front ?? '',
secondary: card.fields.back ?? card.back ?? '',
};
}
</script>
<svelte:head>
<title>{deck?.title ?? 'Deck'} — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-10">
<a href="/" class="mb-6 inline-block text-sm text-muted-foreground hover:text-foreground"
>← Decks</a
>
{#if deck}
<header class="mb-6 flex items-start justify-between gap-4">
<div class="flex-1">
<div class="mb-2 flex items-center gap-3">
<span class="h-3 w-3 rounded-full" style="background: {deck.color}"></span>
<h1 class="text-2xl font-semibold">{deck.title}</h1>
</div>
{#if deck.description}
<p class="text-sm text-muted-foreground">{deck.description}</p>
{/if}
</div>
<button
class="rounded-lg border border-error/30 px-3 py-1.5 text-sm text-error hover:bg-error/10"
onclick={() => (confirmDelete = true)}
>
Löschen
</button>
</header>
{#if isSubscribed}
<div class="mb-6 rounded-xl border border-success/30 bg-emerald-500/5 p-4 text-sm">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium text-success">
📥 Abonniert · v{subscribedAtVersion}
</div>
<p class="mt-1 text-xs text-muted-foreground">
Aus dem Marktplatz von <a
href={`/d/${subscribedFromSlug}`}
class="text-success hover:underline">{subscribedFromSlug}</a
>. Karten sind read-only — Author entscheidet über Inhalte. Forken um eigene Variante
zu machen (Phase ε).
</p>
</div>
</div>
{#if updatePreview}
<div class="mt-3 flex flex-wrap items-center gap-2 rounded-lg bg-success/10 p-2">
<span class="text-xs font-medium text-emerald-200">
Update auf v{updatePreview.to} verfügbar
</span>
<span class="text-xs text-muted-foreground">
+{updatePreview.added} neu · ~{updatePreview.changed} geändert · {updatePreview.removed}
entfernt
</span>
<button
class="ml-auto rounded-lg bg-emerald-500 px-3 py-1 text-xs text-white hover:bg-emerald-400 disabled:opacity-50"
onclick={handleApplyUpdate}
disabled={updateBusy}
>
{updateBusy ? 'Wende an…' : 'Update anwenden'}
</button>
</div>
{/if}
{#if updateError}
<p class="mt-2 text-xs text-error">{updateError}</p>
{/if}
</div>
{/if}
<div class="mb-6 flex flex-wrap items-center gap-3">
<button
class="rounded-lg bg-app-accent px-5 py-2.5 text-sm font-medium text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={() => goto(`/learn/${deckId}`)}
disabled={dueCount === 0}
>
Lernen
{#if dueCount > 0}
<span class="ml-2 rounded-full bg-background/20 px-2 py-0.5 text-xs">
{dueCount} fällig
</span>
{/if}
</button>
{#if !isSubscribed}
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-app-accent hover:bg-app-accent/10 disabled:opacity-50"
onclick={() => (showPublish = true)}
disabled={cards.length === 0}
title={cards.length === 0
? 'Erstelle zuerst Karten'
: 'Im Cards-Marktplatz veröffentlichen'}
>
🌍 Veröffentlichen
</button>
{/if}
{#if dueCount === 0 && cards.length > 0}
<span class="text-sm text-muted-foreground"
>Heute alles gelernt — schau später wieder rein.</span
>
{/if}
</div>
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-2xl font-semibold">{cards.length}</div>
<div class="text-xs text-muted-foreground">Karten</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-2xl font-semibold text-warning">{dueCount}</div>
<div class="text-xs text-muted-foreground">Fällig</div>
</div>
</div>
{#if !isSubscribed}
<div class="mb-6 flex flex-wrap items-center gap-3">
<button
class="rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90"
onclick={() => (showNew = true)}
>
Neue Karte
</button>
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-app-accent hover:bg-app-accent/10"
onclick={() => (showAi = !showAi)}
>
✨ Aus Text generieren
</button>
</div>
{/if}
{#if showAi}
<div class="mb-6">
<AiCardGen {deckId} currentCardCount={cards.length} onCreated={() => (showAi = false)} />
</div>
{/if}
{#if showNew}
<div class="mb-6 rounded-xl border border-indigo-500/30 bg-card p-4">
<h3 class="mb-3 font-medium">Neue Karte</h3>
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
{#each cardTypeOptions as opt (opt.value)}
<button
type="button"
onclick={() => (newType = opt.value)}
class="rounded-lg border p-2 text-left text-sm transition-colors {newType ===
opt.value
? 'border-indigo-400 bg-app-accent/10 text-app-accent'
: 'border-border-strong hover:bg-muted'}"
>
<div class="font-medium">{opt.label}</div>
<div class="text-xs text-muted-foreground">{opt.hint}</div>
</button>
{/each}
</div>
<div class="space-y-3">
{#if newType === 'cloze'}
<div>
<div class="mb-1 flex items-center justify-between">
<label for="card-cloze" class="text-sm text-muted-foreground">Text mit Lücken</label
>
<button
type="button"
class="text-xs text-app-accent hover:text-indigo-200 disabled:opacity-50"
onclick={() => pickAttachment('cloze')}
disabled={attachBusy !== null}
>
{attachBusy === 'cloze' ? '⏳ lade…' : '📎 Anhang'}
</button>
<input
bind:this={attachInputs.cloze}
type="file"
accept="image/*,audio/*,video/*"
class="hidden"
onchange={onAttachChange('cloze')}
/>
</div>
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="card-cloze"
bind:value={newCloze}
placeholder="Berlin ist die Hauptstadt von &#123;&#123;c1::Deutschland&#125;&#125;."
class="min-h-[100px] w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
autofocus
></textarea>
<p class="mt-1 text-xs text-muted-foreground/80">
Markiere mit
<code class="rounded bg-muted px-1">&#123;&#123;c1::Wort&#125;&#125;</code>
— optional Hinweis: <code class="rounded bg-muted px-1">::Hinweis</code>.
</p>
</div>
{:else}
<div>
<div class="mb-1 flex items-center justify-between">
<label for="card-front" class="text-sm text-muted-foreground">Vorderseite</label>
<button
type="button"
class="text-xs text-app-accent hover:text-indigo-200 disabled:opacity-50"
onclick={() => pickAttachment('front')}
disabled={attachBusy !== null}
>
{attachBusy === 'front' ? '⏳ lade…' : '📎 Anhang'}
</button>
<input
bind:this={attachInputs.front}
type="file"
accept="image/*,audio/*,video/*"
class="hidden"
onchange={onAttachChange('front')}
/>
</div>
<!-- svelte-ignore a11y_autofocus -->
<input
id="card-front"
type="text"
bind:value={newFront}
placeholder="Frage oder Begriff…"
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
autofocus
/>
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<label for="card-back" class="text-sm text-muted-foreground">Rückseite</label>
<button
type="button"
class="text-xs text-app-accent hover:text-indigo-200 disabled:opacity-50"
onclick={() => pickAttachment('back')}
disabled={attachBusy !== null}
>
{attachBusy === 'back' ? '⏳ lade…' : '📎 Anhang'}
</button>
<input
bind:this={attachInputs.back}
type="file"
accept="image/*,audio/*,video/*"
class="hidden"
onchange={onAttachChange('back')}
/>
</div>
<textarea
id="card-back"
bind:value={newBack}
placeholder="Antwort oder Erklärung…"
class="min-h-[80px] w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
></textarea>
</div>
{/if}
{#if attachError}
<p class="text-xs text-error">{attachError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={() => {
showNew = false;
newFront = '';
newBack = '';
newCloze = '';
}}
>
Abbrechen
</button>
<button
class="rounded-lg bg-app-accent px-4 py-1.5 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
onclick={handleCreateCard}
disabled={!canSubmit()}
>
Karte erstellen
</button>
</div>
</div>
</div>
{/if}
<div class="rounded-xl border border-border bg-card">
<h2 class="border-b border-border p-4 text-lg font-semibold">
Karten ({cards.length})
</h2>
{#if cards.length === 0}
<div class="p-10 text-center text-muted-foreground">
Noch keine Karten. Erstelle deine erste!
</div>
{:else}
<ul class="divide-y divide-neutral-800">
{#each cards as card, i (card.id)}
{@const p = preview(card)}
<li class="flex items-start gap-4 p-4">
<span class="mt-1 text-xs text-muted-foreground/80">{i + 1}.</span>
<div class="min-w-0 flex-1 space-y-1">
<div class="card-content">
{@html renderMarkdown(p.primary)}
</div>
{#if p.secondary}
<div class="card-content text-sm text-muted-foreground">
{@html renderMarkdown(p.secondary)}
</div>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{typeBadge(card.type)}
</span>
{#if !isSubscribed}
<button
class="rounded p-1 text-muted-foreground/80 hover:text-error"
onclick={() => handleDeleteCard(card.id)}
aria-label="Karte löschen"
>
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
{#if confirmDelete}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onclick={() => (confirmDelete = false)}
onkeydown={(e) => e.key === 'Escape' && (confirmDelete = false)}
role="presentation"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="mx-4 w-full max-w-md rounded-xl border border-border bg-card p-6"
onclick={(e) => e.stopPropagation()}
>
<h3 class="mb-2 text-xl font-semibold">Deck löschen?</h3>
<p class="mb-6 text-muted-foreground">
"{deck.title}" wird mit allen Karten gelöscht.
</p>
<div class="flex justify-end gap-3">
<button
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
onclick={() => (confirmDelete = false)}
>
Abbrechen
</button>
<button
class="rounded-lg bg-error px-4 py-2 text-sm text-white hover:bg-error/90"
onclick={handleDeleteDeck}
>
Löschen
</button>
</div>
</div>
</div>
{/if}
{:else}
<div class="py-16 text-center text-muted-foreground">
Deck nicht gefunden.
<a href="/" class="ml-2 text-app-accent hover:underline">zurück</a>
</div>
{/if}
</main>
{#if showPublish && deck}
<PublishDeckModal {deck} {cards} onClose={() => (showPublish = false)} />
{/if}

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { cardsApi, type DeckSummary } from '$lib/api/cards-api';
import DeckGrid from '$lib/components/DeckGrid.svelte';
let stage = $state<'loading' | 'landing' | 'search' | 'error'>('loading');
let featured = $state<DeckSummary[]>([]);
let trending = $state<DeckSummary[]>([]);
let searchQuery = $state('');
let searchResults = $state<DeckSummary[]>([]);
let searchTotal = $state(0);
let searchBusy = $state(false);
let error = $state<string | null>(null);
onMount(loadLanding);
async function loadLanding() {
stage = 'loading';
try {
const r = await cardsApi.explore.landing();
featured = r.featured;
trending = r.trending;
stage = 'landing';
} catch (e) {
error = (e as Error).message;
stage = 'error';
}
}
async function runSearch() {
const q = searchQuery.trim();
if (!q) {
loadLanding();
return;
}
searchBusy = true;
try {
const r = await cardsApi.explore.browse({ q, sort: 'popular', limit: 30 });
searchResults = r.items;
searchTotal = r.total;
stage = 'search';
} catch (e) {
error = (e as Error).message;
stage = 'error';
} finally {
searchBusy = false;
}
}
</script>
<svelte:head>
<title>Entdecken — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
<header class="mb-6">
<h1 class="text-3xl font-semibold tracking-tight">Entdecken</h1>
<p class="text-sm text-muted-foreground">
Decks aus dem Cards-Marktplatz — kostenlos lernen oder eigene veröffentlichen.
</p>
</header>
<form
class="mb-6 flex gap-2"
onsubmit={(e) => {
e.preventDefault();
runSearch();
}}
>
<input
type="search"
bind:value={searchQuery}
placeholder="Suche nach Titel oder Beschreibung…"
class="flex-1 rounded-lg border border-border-strong bg-background px-3 py-2 text-sm outline-none focus:border-indigo-400"
/>
<button
type="submit"
class="rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90 disabled:opacity-50"
disabled={searchBusy}
>
{searchBusy ? 'Suche…' : 'Suchen'}
</button>
</form>
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-muted-foreground">Lade Marktplatz…</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error">
{error}
<button class="ml-2 underline" onclick={loadLanding}>Erneut versuchen</button>
</p>
{:else if stage === 'search'}
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-medium text-foreground/80">
{searchTotal} Treffer für „{searchQuery}"
</h2>
<button
class="text-xs text-muted-foreground/80 hover:text-foreground/90"
onclick={loadLanding}
>
Zurück
</button>
</div>
<DeckGrid decks={searchResults} emptyText="Keine Decks gefunden." />
</section>
{:else if stage === 'landing'}
{#if featured.length > 0}
<section class="mb-8">
<h2 class="mb-3 text-sm font-medium text-foreground/80">
🛡️ Featured · vom Mana-Verein empfohlen
</h2>
<DeckGrid decks={featured} />
</section>
{/if}
<section>
<h2 class="mb-3 text-sm font-medium text-foreground/80">📈 Trending · letzte 7 Tage</h2>
<DeckGrid
decks={trending}
emptyText="Noch keine Trends — sei der/die Erste mit einem Public-Deck."
/>
</section>
{/if}
<p class="mt-12 text-center text-xs text-muted-foreground/60">
<a href="/" class="hover:text-foreground/80">← Eigene Decks</a>
</p>
</main>

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { useDueReviews, useDeck } from '$lib/queries';
import { reviewStore } from '$lib/stores/reviews.svelte';
import { studyBlockStore } from '$lib/stores/study-blocks.svelte';
import CardFace from '$lib/components/CardFace.svelte';
import SuggestEditModal from '$lib/components/SuggestEditModal.svelte';
import CardDiscussions from '$lib/components/CardDiscussions.svelte';
import type { Card, CardReview, ReviewGrade } from '@mana/cards-core';
const deckId = $derived(page.params.deckId as string);
const dueQuery = $derived(useDueReviews(deckId));
const deckQuery = $derived(useDeck(deckId));
let queue = $state<{ review: CardReview; card: Card }[]>([]);
let currentIndex = $state(0);
let showBack = $state(false);
let typedAnswer = $state('');
let sessionCount = $state(0);
let sessionStartedAt = $state(Date.now());
let cardShownAt = $state(Date.now());
const current = $derived(queue[currentIndex]);
const deckTitle = $derived($deckQuery?.title ?? 'Deck');
const subscribedSlug = $derived($deckQuery?.subscribedFromSlug);
const canSuggest = $derived(!!subscribedSlug && !!current?.card.serverContentHash);
let suggestOpen = $state(false);
let discussionsOpen = $state(false);
$effect(() => {
// Collapse the discussion panel whenever the card changes so the
// learner isn't visually overloaded between cards.
void current?.card.id;
discussionsOpen = false;
});
$effect(() => {
const snap = $dueQuery;
if (snap && queue.length === 0 && snap.length > 0) {
queue = snap;
}
});
function reveal() {
if (!showBack && current) showBack = true;
}
async function grade(g: ReviewGrade) {
if (!current || !showBack) return;
const elapsedMs = Date.now() - cardShownAt;
await reviewStore.grade(current.review.id, g);
await studyBlockStore.recordReview(elapsedMs);
sessionCount++;
nextCard();
}
function nextCard() {
showBack = false;
typedAnswer = '';
cardShownAt = Date.now();
if (currentIndex < queue.length - 1) {
currentIndex++;
} else {
currentIndex = queue.length;
}
}
function handleKey(e: KeyboardEvent) {
if (e.target && (e.target as HTMLElement).tagName === 'INPUT') return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (!showBack) reveal();
return;
}
if (showBack && (e.key === '1' || e.key === '2' || e.key === '3' || e.key === '4')) {
e.preventDefault();
grade(Number(e.key) as ReviewGrade);
}
}
onMount(() => {
window.addEventListener('keydown', handleKey);
sessionStartedAt = Date.now();
cardShownAt = Date.now();
});
onDestroy(() => window.removeEventListener('keydown', handleKey));
const finished = $derived(queue.length > 0 && currentIndex >= queue.length);
const empty = $derived(queue.length === 0 && $dueQuery?.length === 0);
</script>
<svelte:head>
<title>Lernen — {deckTitle} — Cards</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-6 py-10">
<header class="mb-6 flex items-center justify-between">
<div>
<button
class="text-sm text-muted-foreground hover:text-foreground"
onclick={() => goto(`/decks/${deckId}`)}
>
{deckTitle}
</button>
<h1 class="mt-1 text-xl font-semibold">Lernen</h1>
</div>
{#if queue.length > 0 && !finished}
<div class="text-sm text-muted-foreground">
{Math.min(currentIndex + 1, queue.length)} / {queue.length}
</div>
{/if}
</header>
{#if empty}
<div class="rounded-xl border border-border bg-card p-10 text-center">
<div class="text-2xl">Alles gelernt</div>
<p class="mt-2 text-sm text-muted-foreground">
Komm später wieder — fällige Karten erscheinen automatisch.
</p>
<button
class="mt-4 rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90"
onclick={() => goto(`/decks/${deckId}`)}
>
Zurück zum Deck
</button>
</div>
{:else if finished}
<div class="rounded-xl border border-border bg-card p-10 text-center">
<div class="text-2xl">Session abgeschlossen</div>
<p class="mt-2 text-sm text-muted-foreground">
{sessionCount} Karten in {Math.round((Date.now() - sessionStartedAt) / 1000)} s.
</p>
<button
class="mt-4 rounded-lg bg-app-accent px-4 py-2 text-sm text-white hover:bg-app-accent/90"
onclick={() => goto(`/decks/${deckId}`)}
>
Fertig
</button>
</div>
{:else if current}
<CardFace
card={current.card}
subIndex={current.review.subIndex}
{showBack}
{typedAnswer}
onTypedAnswer={(v) => (typedAnswer = v)}
onReveal={reveal}
/>
{#if canSuggest}
<div class="mt-3 flex justify-end gap-3">
<button
class="text-xs text-muted-foreground/80 hover:text-foreground/90"
onclick={() => (discussionsOpen = !discussionsOpen)}
title="Kommentare zur Karte"
>
💬 {discussionsOpen ? 'Diskussion ausblenden' : 'Diskussion'}
</button>
<button
class="text-xs text-muted-foreground/80 hover:text-app-accent"
onclick={() => (suggestOpen = true)}
title="Verbesserung dieser Karte vorschlagen"
>
✏️ Verbessern
</button>
</div>
{#if discussionsOpen && subscribedSlug && current?.card.serverContentHash}
<CardDiscussions contentHash={current.card.serverContentHash} deckSlug={subscribedSlug} />
{/if}
{/if}
{#if !showBack && current.card.type === 'type-in'}
<button
class="mt-6 w-full rounded-lg bg-app-accent py-3 text-base text-white hover:bg-app-accent/90"
onclick={reveal}
>
Aufdecken <span class="ml-2 text-xs opacity-70">(Leertaste)</span>
</button>
{:else if showBack}
<div class="mt-6 grid grid-cols-4 gap-2">
<button
class="rounded-lg bg-error py-3 text-sm text-white hover:bg-error/90"
onclick={() => grade(1)}
>
Nochmal
<div class="text-xs opacity-70">1</div>
</button>
<button
class="rounded-lg bg-orange-500 py-3 text-sm text-white hover:bg-orange-400"
onclick={() => grade(2)}
>
Schwer
<div class="text-xs opacity-70">2</div>
</button>
<button
class="rounded-lg bg-green-500 py-3 text-sm text-white hover:bg-green-400"
onclick={() => grade(3)}
>
Gut
<div class="text-xs opacity-70">3</div>
</button>
<button
class="rounded-lg bg-blue-500 py-3 text-sm text-white hover:bg-blue-400"
onclick={() => grade(4)}
>
Leicht
<div class="text-xs opacity-70">4</div>
</button>
</div>
{/if}
{:else}
<div class="text-center text-sm text-muted-foreground">Lade…</div>
{/if}
</div>
{#if subscribedSlug && current}
<SuggestEditModal
card={current.card}
deckSlug={subscribedSlug}
open={suggestOpen}
onClose={() => (suggestOpen = false)}
/>
{/if}

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { LoginPage } from '@mana/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import CardsLogo from '$lib/components/CardsLogo.svelte';
const verified = $derived(page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived(page.url.searchParams.get('email') || '');
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<LoginPage
appName="Cards"
logo={CardsLogo}
primaryColor="#6366f1"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
successRedirect="/"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f5f5f5"
darkBackground="#0a0a0a"
isDark
{verified}
{initialEmail}
/>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
cardsApi,
CardsApiError,
type BuyerPurchase,
type AuthorPayout,
} from '$lib/api/cards-api';
let purchases = $state<BuyerPurchase[]>([]);
let payouts = $state<AuthorPayout[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const totalSpent = $derived(
purchases.filter((p) => !p.refundedAt).reduce((acc, p) => acc + p.priceCredits, 0)
);
const totalEarned = $derived(payouts.reduce((acc, p) => acc + p.creditsGranted, 0));
onMount(async () => {
try {
const [p, py] = await Promise.all([
cardsApi.purchases.listMine(),
cardsApi.payouts.listMine().catch(() => [] as AuthorPayout[]),
]);
purchases = p;
payouts = py;
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>Meine Käufe — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
<h1 class="mb-6 text-2xl font-semibold tracking-tight">Käufe & Auszahlungen</h1>
{#if error}
<p class="mb-4 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
{error}
</p>
{/if}
<section class="mb-10">
<header class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Käufe</h2>
<span class="text-xs text-muted-foreground/80">Ausgegeben: {totalSpent} 💎</span>
</header>
{#if loading}
<p class="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground/80">
Lädt…
</p>
{:else if purchases.length === 0}
<p class="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground/80">
Du hast noch keine Decks gekauft.
</p>
{:else}
<ul class="space-y-2">
{#each purchases as p (p.id)}
<li class="flex items-center justify-between rounded-xl border border-border bg-card p-4">
<div class="min-w-0 flex-1">
<a
href="/d/{p.deckSlug}"
class="truncate font-medium text-foreground hover:text-app-accent"
>
{p.deckTitle}
</a>
<p class="mt-1 text-xs text-muted-foreground/80">
v{p.versionSemver} · {new Date(p.purchasedAt).toLocaleDateString('de-DE')}
{#if p.refundedAt}
<span class="ml-2 rounded bg-amber-500/15 px-1.5 py-0.5 text-warning"
>Erstattet</span
>
{/if}
</p>
</div>
<span class="shrink-0 text-sm text-foreground/80">{p.priceCredits} 💎</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if payouts.length > 0 || (!loading && payouts.length === 0)}
<section>
<header class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Author-Auszahlungen
</h2>
<span class="text-xs text-muted-foreground/80">Erhalten: {totalEarned} 💎</span>
</header>
{#if payouts.length === 0}
<p class="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground/80">
Noch keine Auszahlungen — sobald jemand eines deiner kostenpflichtigen Decks kauft, landet
die Author-Beteiligung hier.
</p>
{:else}
<ul class="space-y-2">
{#each payouts as p (p.id)}
<li
class="flex items-center justify-between rounded-xl border border-border bg-card p-4"
>
<div class="min-w-0 flex-1">
<a
href="/d/{p.deckSlug}"
class="truncate font-medium text-foreground hover:text-app-accent"
>
{p.deckTitle}
</a>
<p class="mt-1 text-xs text-muted-foreground/80">
Verkauf {p.priceCredits} 💎 · gutgeschrieben {new Date(
p.grantedAt
).toLocaleDateString('de-DE')}
</p>
</div>
<span class="shrink-0 text-sm text-success">+{p.creditsGranted} 💎</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</main>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@mana/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import CardsLogo from '$lib/components/CardsLogo.svelte';
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<RegisterPage
appName="Cards"
logo={CardsLogo}
primaryColor="#6366f1"
onSignUp={handleSignUp}
onResendVerification={handleResendVerification}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#f5f5f5"
darkBackground="#0a0a0a"
/>

View file

@ -0,0 +1,138 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { authStore } from '$lib/stores/auth.svelte';
import { cardsApi, CardsApiError, type PublicAuthor, type DeckSummary } from '$lib/api/cards-api';
import DeckGrid from '$lib/components/DeckGrid.svelte';
const slug = $derived(page.params.slug as string);
let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading');
let author = $state<PublicAuthor | null>(null);
let decks = $state<DeckSummary[]>([]);
let following = $state(false);
let error = $state<string | null>(null);
let busy = $state(false);
$effect(() => {
if (!slug) return;
load();
});
async function load() {
stage = 'loading';
try {
const [a, d] = await Promise.all([
cardsApi.authors.bySlug(slug),
cardsApi.explore.browse({ author: slug, sort: 'recent', limit: 50 }),
]);
author = a;
decks = d.items;
stage = 'ok';
} catch (e) {
if (e instanceof CardsApiError && e.status === 404) {
stage = 'not-found';
return;
}
error = (e as Error).message;
stage = 'error';
}
}
async function toggleFollow() {
if (busy) return;
busy = true;
try {
if (following) {
await cardsApi.follows.unfollow(slug);
following = false;
} else {
await cardsApi.follows.follow(slug);
following = true;
}
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>{author?.displayName ?? '@' + slug} — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
{#if stage === 'loading'}
<p class="py-12 text-center text-sm text-muted-foreground">Lade Profil…</p>
{:else if stage === 'not-found'}
<p
class="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground"
>
Profil <code class="rounded bg-muted px-1">@{slug}</code> existiert nicht.
</p>
{:else if stage === 'error'}
<p class="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error">
{error}
</p>
{:else if author}
<header class="mb-6 flex items-start gap-4">
{#if author.avatarUrl}
<img
src={author.avatarUrl}
alt=""
class="h-16 w-16 rounded-full border border-border object-cover"
/>
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-full border border-border bg-card text-xl font-semibold text-muted-foreground"
>
{author.displayName.slice(0, 1).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-2xl font-semibold">{author.displayName}</h1>
{#if author.verifiedMana}
<span class="rounded-full bg-success/15 px-2 py-0.5 text-xs text-success">
🛡️ Mana
</span>
{/if}
{#if author.verifiedCommunity}
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-warning">
⭐ Community
</span>
{/if}
</div>
<p class="text-xs text-muted-foreground/80">
@{author.slug} · seit {new Date(author.joinedAt).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
})}
</p>
{#if author.bio}
<p class="mt-2 text-sm text-foreground/80">{author.bio}</p>
{/if}
</div>
{#if authStore.isAuthenticated}
<button
class="rounded-lg border border-app-accent/40 px-3 py-1.5 text-sm text-app-accent hover:bg-app-accent/10 disabled:opacity-50"
onclick={toggleFollow}
disabled={busy}
>
{following ? 'Entfolgen' : 'Folgen'}
</button>
{/if}
</header>
<h2 class="mb-3 text-sm font-medium text-foreground/80">
{decks.length}
{decks.length === 1 ? 'Deck' : 'Decks'}
</h2>
<DeckGrid {decks} emptyText="Dieser Author hat noch keine Decks veröffentlicht." />
{/if}
<p class="mt-12 text-center text-xs text-muted-foreground/60">
<a href="/explore" class="hover:text-foreground/80">← Marktplatz</a>
</p>
</main>

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="6" y="10" width="42" height="50" rx="6" fill="#6366f1"/>
<rect x="16" y="4" width="42" height="50" rx="6" fill="#a855f7" opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true
}
}

View file

@ -0,0 +1,20 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import { createPWAConfig } from '@mana/shared-pwa';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA(
createPWAConfig({
name: 'Cards',
shortName: 'Cards',
description: 'Karteikarten mit Spaced Repetition',
themeColor: '#0a0a0a',
})
),
],
});

View file

@ -0,0 +1,654 @@
# Cardecky-Marktplatz — Plan
> **Status**: Plan, kein Code. Stand 2026-05-07.
> **Goal-Setting**: Vollvision, kein MVP-Druck. Wir bauen die optimale Lösung.
> **Alignment**: User hat folgende Eckpunkte gesetzt:
> - Versionierte Decks + Live-Updates + Pull-Requests = ja, volle Vision
> - mana-credits zentral, sowohl für User-Käufe als auch Author-Verdienst
> - „Verified" zweigleisig: Mana-Verein-Kuration UND Community-Schwellen, mit unterschiedlichen Badges
> - Co-Learn-Sessions explizit **nicht** für Phase 1 — auf Phase 2 verschoben
> - Mobile-App auch später
---
## 1. Mission
**Die Karteikarten-Plattform mit der besten Lern-Community im Netz.** Wo qualitativ hochwertige Decks entstehen, gepflegt, geteilt und gelernt werden — und wo Lernende einander helfen.
## 2. Was wir gegen die Konkurrenz aufbieten
(verdichtet aus `apps/cards/COMPETITORS_2026-05.md`)
| Differenzierer | Wir | Wer noch |
|---|---|---|
| Free Cloud-Sync | ✓ | niemand |
| Versionierte Decks mit Live-Updates | ✓ | nur AnkiHub (paywalled, Medizin-only) |
| Pull-Requests auf Decks | ✓ | niemand |
| Card-Discussions (inline pro Karte) | ✓ | niemand |
| AI-Karten + AI-Moderation + AI-Tags | ✓ | fragmentiert bei anderen |
| Open Source PWA | ✓ | nur Anki/Mnemosyne (Desktop) |
| Anki-Migration mit Bildern/Audio | ✓ (vorhanden) | niemand vollständig |
| Author-Followings + Activity-Feed | ✓ | niemand |
| Bezahlte Decks mit Author-Erlös via mana-credits | ✓ | nur Brainscape (eigenes Closed-Pricing) |
| Pseudonym + verifiziert kombinierbar | ✓ | niemand klar |
## 3. Architektur-Prinzipien
1. **API ist `/v1` ab Tag 1** — OpenAPI-Spec als Quelle der Wahrheit, Versionierungs-Bewusstsein eingebaut.
2. **Public-Decks leben separat** vom Local-First-Sync-Pfad (eigene Postgres-Tabellen, eigene Service, eigene RLS-Policies). Kein Vermischen mit `mana_sync.sync_changes`.
3. **Subscribed Decks sind unidirektional**: Author → Subscribers. Updates fließen einseitig. Wer ändern will, forkt.
4. **Content-Hash überall.** Jede Karte und jede Version bekommt einen deterministischen SHA-256 → Trust + Cache + Diff kostenlos.
5. **Lizenzen sind explizit + maschinen-lesbar** (SPDX-IDs: `CC0-1.0`, `CC-BY-4.0`, `CC-BY-SA-4.0`, plus eigener `Cardecky-Personal-Use-1.0` für Default-Käufe und `Cardecky-Pro-Only-1.0` für paid Decks).
6. **AI ist Moderator, nicht Gatekeeper** — KI-First-Pass + Human-Review-Eskalation. Niemals KI-allein-Take-down.
7. **Search ist von der DB entkoppelt** — Read-Only-Index, asynchron befüllt. Bricht der Search-Service, läuft der Marktplatz weiter.
8. **mana-credits ist die einzige Geld-Schnittstelle** — niemals Stripe direkt im cards-server. Alles geht über `/api/v1/credits/use`, `/credits/grant`, `/credits/reservations/*`.
9. **Anonymisiertes Lern-Verhalten**: aggregierte Stats sichtbar (z.B. „1.200 Lernende"), individuelles Lernverhalten nie öffentlich ohne explizites Opt-in.
10. **Keine Drittanbieter-Tracker.** Telemetrie ausschließlich über mana-analytics, opt-out möglich.
## 4. Datenmodell
Neues Schema `cards` in `mana_platform`. Alle Tabellen über `pgSchema('cards').table(...)` (Mana-Konvention).
### 4.1 Authoren
```sql
public_authors (
user_id uuid PRIMARY KEY REFERENCES auth.users(id),
slug text UNIQUE NOT NULL, -- @anna-lang
display_name text NOT NULL,
bio text,
avatar_url text,
joined_at timestamptz DEFAULT now(),
pseudonym boolean DEFAULT false, -- true = klarname versteckt
verified_mana boolean DEFAULT false, -- vom Verein verliehen
verified_community boolean DEFAULT false, -- automatisch ab Schwelle
banned_at timestamptz, -- soft-ban
banned_reason text
)
```
Drei Verifizierungs-Stufen mit unterschiedlichen Badges in der UI:
| Status | Badge | Wer / wie |
|---|---|---|
| `verified_mana = true` | 🛡️ **Mana Verifiziert** | Manuell vom Mana-Verein vergeben (Lehrer, Profis, Sprachschulen, Ärzte). Nicht erkaufbar. |
| `verified_community = true` | ⭐ **Community Verifiziert** | Automatisch bei: ≥ 500 Stars über alle Decks ODER ≥ 3 featured Decks ODER ≥ 200 aktive Subscribers über alle Decks. Periodisch neu evaluiert. |
| beides | 🛡️⭐ Beide Badges | Mana + Community zusammen. |
### 4.2 Decks + Versionen
```sql
public_decks (
id uuid PRIMARY KEY,
slug text UNIQUE NOT NULL, -- /decks/anna-lang/spanish-a2-vocab
title text NOT NULL,
description text,
language text, -- ISO-639-1
license text NOT NULL, -- SPDX
price_credits integer DEFAULT 0, -- 0 = kostenlos
owner_user_id uuid NOT NULL REFERENCES public_authors(user_id),
latest_version_id uuid, -- → public_deck_versions
is_featured boolean DEFAULT false,
is_takedown boolean DEFAULT false,
takedown_at timestamptz,
takedown_reason text,
created_at timestamptz DEFAULT now(),
CONSTRAINT price_requires_license CHECK (price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0')
)
public_deck_versions (
id uuid PRIMARY KEY,
deck_id uuid NOT NULL REFERENCES public_decks(id),
semver text NOT NULL, -- 1.0.0, 1.1.0, 2.0.0
changelog text,
content_hash text NOT NULL, -- SHA-256 of canonicalized cards
card_count integer NOT NULL,
published_at timestamptz DEFAULT now(),
deprecated_at timestamptz,
UNIQUE (deck_id, semver)
)
public_deck_cards (
id uuid PRIMARY KEY,
version_id uuid NOT NULL REFERENCES public_deck_versions(id),
type text NOT NULL, -- basic, basic-reverse, cloze, type-in
fields jsonb NOT NULL, -- {front, back} oder {text, extra}
ord integer NOT NULL,
content_hash text NOT NULL, -- per Karte: ermöglicht Smart-Merge
UNIQUE (version_id, ord)
)
```
### 4.3 Tags + Discovery
```sql
tag_definitions (
id uuid PRIMARY KEY,
slug text UNIQUE NOT NULL,
name text NOT NULL,
parent_id uuid REFERENCES tag_definitions(id), -- Hierarchie
description text,
curated boolean DEFAULT false -- vom Mana-Verein gepflegt
)
deck_tags (
deck_id uuid REFERENCES public_decks(id),
tag_id uuid REFERENCES tag_definitions(id),
PRIMARY KEY (deck_id, tag_id)
)
```
### 4.4 Engagement (Stars, Subscribes, Forks)
```sql
deck_stars (
user_id uuid REFERENCES auth.users(id),
deck_id uuid REFERENCES public_decks(id),
starred_at timestamptz DEFAULT now(),
PRIMARY KEY (user_id, deck_id)
)
deck_subscriptions (
user_id uuid REFERENCES auth.users(id),
deck_id uuid REFERENCES public_decks(id),
current_version_id uuid REFERENCES public_deck_versions(id),
subscribed_at timestamptz DEFAULT now(),
notify_updates boolean DEFAULT true,
PRIMARY KEY (user_id, deck_id)
)
deck_forks (
user_id uuid REFERENCES auth.users(id),
source_deck_id uuid REFERENCES public_decks(id),
source_version_id uuid REFERENCES public_deck_versions(id),
forked_at timestamptz DEFAULT now(),
PRIMARY KEY (user_id, source_deck_id, source_version_id)
)
author_follows (
follower_user_id uuid REFERENCES auth.users(id),
author_user_id uuid REFERENCES public_authors(user_id),
since timestamptz DEFAULT now(),
PRIMARY KEY (follower_user_id, author_user_id)
)
```
### 4.5 Pull-Requests + Discussions
```sql
deck_pull_requests (
id uuid PRIMARY KEY,
deck_id uuid REFERENCES public_decks(id),
author_user_id uuid REFERENCES auth.users(id),
status text NOT NULL, -- open, merged, closed, rejected
title text NOT NULL,
body text,
diff jsonb NOT NULL, -- {add: [...], modify: [...], remove: [...]}
merged_into_version uuid REFERENCES public_deck_versions(id),
created_at timestamptz DEFAULT now(),
resolved_at timestamptz
)
card_discussions (
id uuid PRIMARY KEY,
card_content_hash text NOT NULL, -- bindet sich an Karte, nicht an version
deck_id uuid REFERENCES public_decks(id),
author_user_id uuid REFERENCES auth.users(id),
parent_id uuid REFERENCES card_discussions(id),
body text NOT NULL,
hidden boolean DEFAULT false,
created_at timestamptz DEFAULT now()
)
```
### 4.6 Moderation
```sql
deck_reports (
id uuid PRIMARY KEY,
deck_id uuid REFERENCES public_decks(id),
version_id uuid REFERENCES public_deck_versions(id),
card_content_hash text, -- optional: Karte spezifisch
reporter_user_id uuid REFERENCES auth.users(id),
category text NOT NULL, -- spam, copyright, nsfw, misinformation, other
body text,
status text DEFAULT 'open', -- open, dismissed, actioned
resolved_by uuid,
resolved_at timestamptz,
resolution_notes text,
created_at timestamptz DEFAULT now()
)
ai_moderation_log (
id uuid PRIMARY KEY,
version_id uuid REFERENCES public_deck_versions(id),
verdict text NOT NULL, -- pass, flag, block
categories text[], -- spam, csam, hate, nsfw, ...
model text, -- "claude-3-5-sonnet" etc
rationale text,
human_reviewed boolean DEFAULT false,
human_overrode boolean DEFAULT false,
created_at timestamptz DEFAULT now()
)
```
### 4.7 mana-credits Integration
```sql
deck_purchases (
id uuid PRIMARY KEY,
buyer_user_id uuid REFERENCES auth.users(id),
deck_id uuid REFERENCES public_decks(id),
version_id uuid REFERENCES public_deck_versions(id),
price_credits integer NOT NULL, -- Snapshot zum Zeitpunkt des Kaufs
author_share integer NOT NULL, -- nach Verein-Cut
mana_share integer NOT NULL,
credits_transaction text, -- mana-credits ID
purchased_at timestamptz DEFAULT now(),
refunded_at timestamptz,
UNIQUE (buyer_user_id, deck_id) -- einmal Kauf reicht für Lifetime + alle Versionen
)
author_payouts (
id uuid PRIMARY KEY,
author_user_id uuid REFERENCES public_authors(user_id),
source_purchase_id uuid REFERENCES deck_purchases(id),
credits_granted integer NOT NULL,
credits_grant_id text, -- mana-credits grant ID
granted_at timestamptz DEFAULT now()
)
```
## 5. mana-credits Integration (Detail)
Zwei-seitiger Marktplatz. mana-credits ist Single-Source-of-Truth fürs Geld.
### 5.1 Kauf-Flow (Buyer)
1. User klickt „Kaufen" auf paid Deck (Preis: z.B. 50 Credits)
2. cards-server checkt: Hat User schon dieses Deck? (deck_purchases) → wenn ja, sofort Zugriff
3. cards-server reserviert Credits via `POST mana-credits/api/v1/credits/reservations` (2-phase)
4. cards-server erstellt deck_purchases-Row (committed)
5. cards-server commit-released die Reservation → Credits abgebucht
6. cards-server erstellt author_payouts-Row → ruft `POST mana-credits/api/v1/internal/credits/grant` für den Author-Anteil
7. User bekommt sofortigen Zugriff: Deck wird in private Liste verschoben (User hat eine eigene Lokal-Kopie als Author-Subscription)
**Was passiert wenn Author gebannt nach Kauf?** → Refund-Path (Phase γ Implementation): Admin kann Refund triggern → mana-credits → Reverse-Grant → User behält das Deck nicht mehr.
### 5.2 Author-Auszahlungs-Modell
- **Standard-Cut**: 80 % Author / 20 % Mana-Verein (Server-, Hosting-, Moderations-Kosten)
- **Verifizierte Authoren** (verified_mana): 90 % / 10 %
- **Mindestauszahlung**: keine — Credits werden direkt im mana-credits-Account gebucht, von dort kann der Author sie selbst nutzen oder per Stripe-Payout (mana-credits-Feature, falls vorhanden) abheben
- **Pricing-Range**: Free (0 Credits), oder 10500 Credits (entspricht ungefähr 150 € — 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/<slug>`) + 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/<slug>` (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/<slug>`) + 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**: `<DeckCardList>` 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/<slug>`, 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.*

9
apps/cards/package.json Normal file
View file

@ -0,0 +1,9 @@
{
"name": "cards",
"version": "0.1.0",
"private": true,
"description": "Cardecky — Spaced-Repetition flashcards on cardecky.mana.how (Marketing-Landing: cardecky.com). Standalone Phase-1 frontend; data shared with the mana cards module via mana-sync.",
"scripts": {
"dev": "pnpm run --filter=@cards/* --parallel dev"
}
}

View file

@ -16,10 +16,10 @@ export default defineConfig({
replacesTitle: false,
},
social: {
github: 'https://github.com/Memo-2023/managarten',
github: 'https://github.com/mana/mana-monorepo',
},
editLink: {
baseUrl: 'https://github.com/Memo-2023/managarten/edit/main/apps/docs/',
baseUrl: 'https://github.com/mana/mana-monorepo/edit/main/apps/docs/',
},
customCss: ['./src/styles/custom.css'],
sidebar: [

View file

@ -16,7 +16,7 @@ Mana encrypts user-typed content with **AES-GCM-256** before it touches IndexedD
## What's encrypted
**27 tables** ship with at-rest encryption enabled. The full list is in [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/managarten/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md), but the highlights:
**27 tables** ship with at-rest encryption enabled. The full list is in [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md), but the highlights:
| Module | Fields |
|--------|--------|
@ -238,7 +238,7 @@ the full standard / ZK guarantees.
## Implementation references
For the architectural deep dive, code locations, and the complete rollout history (Phases 19 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/managarten/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md).
For the architectural deep dive, code locations, and the complete rollout history (Phases 19 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md).
Key files:

View file

@ -37,7 +37,7 @@ Requires `cloudflared` installed: `brew install cloudflare/cloudflare/cloudflare
## Directory Structure
```
~/projects/managarten/
~/projects/mana-monorepo/
├── docker-compose.macmini.yml # Production compose file
├── .env.production # Production environment
├── scripts/mac-mini/ # Server management scripts
@ -53,7 +53,7 @@ Requires `cloudflared` installed: `brew install cloudflare/cloudflare/cloudflare
```bash
ssh mana-server
cd ~/projects/managarten
cd ~/projects/mana-monorepo
./scripts/mac-mini/status.sh
```
@ -71,7 +71,7 @@ chat-backend running (healthy)
```bash
ssh mana-server
cd ~/projects/managarten
cd ~/projects/mana-monorepo
./scripts/mac-mini/deploy.sh
```

View file

@ -92,7 +92,7 @@ pnpm deploy:docs
ssh mana-server
# Pull latest changes
cd ~/projects/managarten
cd ~/projects/mana-monorepo
git pull
# Restart services
@ -135,7 +135,7 @@ npx wrangler pages deployment tail <deployment-id> --project-name=chat-landing
```bash
ssh mana-server
cd ~/projects/managarten
cd ~/projects/mana-monorepo
# Revert to previous commit
git checkout HEAD~1

View file

@ -23,8 +23,8 @@ Run your own Mana instance using Docker Compose.
1. **Clone the repository**
```bash
git clone https://github.com/mana/managarten.git
cd managarten
git clone https://github.com/mana/mana-monorepo.git
cd mana-monorepo
```
2. **Create environment file**

View file

@ -24,8 +24,8 @@ Before you begin, ensure you have:
1. **Clone the repository**
```bash
git clone https://github.com/mana/managarten.git
cd managarten
git clone https://github.com/mana/mana-monorepo.git
cd mana-monorepo
```
2. **Install dependencies**

View file

@ -12,7 +12,7 @@ hero:
icon: right-arrow
variant: primary
- text: View on GitHub
link: https://github.com/mana/managarten
link: https://github.com/mana/mana-monorepo
icon: external
---
@ -21,8 +21,8 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
## Quick Start
```bash
git clone https://github.com/mana/managarten.git
cd managarten
git clone https://github.com/mana/mana-monorepo.git
cd mana-monorepo
pnpm install
pnpm docker:up
pnpm dev:chat:full

View file

@ -484,7 +484,7 @@ Nach Analyse aller Optionen ist die Empfehlung:
```bash
# Auf dem Server
ssh mana-server
cd ~/projects/managarten
cd ~/projects/mana-monorepo
git pull
./scripts/mac-mini/deploy.sh
```

View file

@ -31,8 +31,6 @@ workingHours:
end: '2026-01-24T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Heute war ein sehr produktiver Tag mit Fokus auf die **Produktivstellung der ManaCore Apps auf dem Mac Mini Server**. Die wichtigsten Errungenschaften:
- **7 Apps live** auf https://mana.how (Auth, Dashboard, Chat, Todo, Calendar, Clock, Contacts)

View file

@ -21,8 +21,6 @@ workingHours:
end: '2026-01-25T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Fokussierter Tag mit **4 Commits** zur Verbesserung des Guest-Mode-Erlebnisses:
- **Clock Guest Mode** - Alarms/Timers ohne Auth-Redirect ladbar

View file

@ -33,8 +33,6 @@ workingHours:
end: '2026-01-26T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Massiver Tag mit **38 Commits** und Fokus auf neue App, Monitoring und Infrastructure:
- **Food** - AI-powered Nutrition Tracking App mit Gemini

View file

@ -33,9 +33,6 @@ workingHours:
end: '2026-01-27T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Produktiver Tag mit Fokus auf **Monitoring-Infrastruktur**, **Email-Authentifizierung** und **lokale AI-Services**. Die wichtigsten Errungenschaften:
- **Prometheus Metrics** für alle NestJS Backends

View file

@ -34,8 +34,6 @@ workingHours:
end: '2026-01-28T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Strategischer Tag mit **11 Commits** und Fokus auf GDPR-Compliance und Developer Experience:
- **Matrix Self-Hosting** - Eigene Synapse-Instanz für GDPR

View file

@ -34,8 +34,6 @@ workingHours:
end: '2026-01-29T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Außergewöhnlich produktiver Tag (und Nacht!) mit **74 Commits** und mehreren großen neuen Features. Die wichtigsten Errungenschaften:
- **SkillTree App** - Gamified Skill Tracking mit XP-System (MVP complete)

View file

@ -37,8 +37,6 @@ workingHours:
end: '2026-01-30T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Außergewöhnlich produktiver Tag mit **55 Commits** - aufgeteilt in drei große Bereiche:
- **3 neue Microservices** - mana-llm (LLM Abstraction), mana-crawler (Web Crawler), mana-notify (Notifications)

View file

@ -31,8 +31,6 @@ workingHours:
end: '2026-01-31T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Produktiver Tag mit **41 Commits** und Fokus auf Matrix Bot Expansion und Developer Experience:
- **8 neue Matrix Bots** - Spezialisierte Bots für Planta, Cards, Contacts, Picture, Chat, SkillTree, Presi, Questions

View file

@ -33,8 +33,6 @@ workingHours:
end: '2026-02-01T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Intensiver Tag (und Nacht!) mit **52 Commits** - der Fokus lag auf der Konsolidierung der Matrix Bot Infrastruktur:
- **@manacore/matrix-bot-common** - Neues Shared Package für alle 19 Matrix Bots

View file

@ -33,9 +33,6 @@ workingHours:
end: '2026-02-02T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Produktiver Tag mit **42 Commits** und Fokus auf Infrastructure und Production Readiness:
- **SSD Migration** - PostgreSQL und MinIO auf externe SSD verschoben

View file

@ -33,8 +33,6 @@ workingHours:
end: '2026-02-03T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Produktiver Tag mit **40 Commits** und Fokus auf nahtlose Authentifizierung über alle Apps:
- **Cross-Domain SSO** - Single Sign-On für alle .mana.how Subdomains

View file

@ -34,8 +34,6 @@ workingHours:
end: '2026-02-12T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Nach einer Woche Pause: **28 Commits** mit Fokus auf neue Apps und API-Infrastruktur:
- **Photos App** - Neue App mit mana-media EXIF-Integration

View file

@ -33,8 +33,6 @@ workingHours:
end: '2026-02-13T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
**22 Commits** mit Fokus auf DSGVO-Compliance, Mobile UX und Server-Stabilität:
- **GDPR Self-Service** - Neue Endpoints für User Data Export

View file

@ -34,8 +34,6 @@ workingHours:
end: '2026-02-14T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Ein massiver Tag mit **55 Commits** und mehreren Major Features:
- **Gift Codes** - Credit-Gutscheine mit Code-Einlösung

View file

@ -35,8 +35,6 @@ workingHours:
end: '2026-02-15T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Produktiver Valentinstag mit **56 Commits** und vielen neuen Features:
- **Matrix STT Bot** - Speech-to-Text direkt in Matrix

View file

@ -21,8 +21,6 @@ workingHours:
end: '2026-02-16T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Fokussierter Tag mit **7 Commits** für UI-Polish und kleinere Features:
- **Onboarding Modal** - Kompaktes Design statt Fullscreen

View file

@ -32,8 +32,6 @@ workingHours:
end: '2026-02-17T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Massiver Tag mit **35 Commits** neuer App-Launch, Monetarisierung und PWA-Rollout:
- **LightWrite** - Beat/Lyrics Editor als Full-Stack App gelauncht

View file

@ -21,9 +21,6 @@ workingHours:
end: '2026-02-18T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Produktiver Tag mit **26 Commits** neue Packages, Bot-Features und Dokumentation:
- **spiral-db** - Pixel-basierte Spiral-Datenbank-Visualisierung

View file

@ -21,8 +21,6 @@ workingHours:
end: '2026-03-07T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Fokussierter Tag mit **7 Commits** für den neuen Matrix Mobile Client:
- **Manalink** - Expo React Native App für Matrix Chat

View file

@ -21,8 +21,6 @@ workingHours:
end: '2026-03-08T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Bugfix-Tag mit **5 Commits** Expo SDK Upgrade und kritische Fixes:
- **SDK 55** - Manalink auf Expo SDK 55 aktualisiert

View file

@ -21,8 +21,6 @@ workingHours:
end: '2026-03-12T11:00'
---
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 0606-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
Infrastruktur-Tag mit **6 Commits** für die Deployment-Pipeline:
- **CD Pipeline** - GitHub Actions mit self-hosted Runner

Some files were not shown because too many files have changed in this diff Show more