mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 15:09:39 +02:00
Compare commits
No commits in common. "main" and "cards-decommission-base" have entirely different histories.
main
...
cards-deco
642 changed files with 21308 additions and 52768 deletions
|
|
@ -23,10 +23,6 @@ PUBLIC_GLITCHTIP_DSN=
|
||||||
|
|
||||||
# Mana Core Auth Service
|
# Mana Core Auth Service
|
||||||
MANA_AUTH_URL=http://localhost:3001
|
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 Service
|
||||||
MANA_CREDITS_URL=http://localhost:3061
|
MANA_CREDITS_URL=http://localhost:3061
|
||||||
# Mana Media Service (CAS, thumbnails, Photos gallery)
|
# Mana Media Service (CAS, thumbnails, Photos gallery)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
# Compose project name (pinned, do not change)
|
# Compose project name (pinned, do not change)
|
||||||
# ============================================
|
# ============================================
|
||||||
# All Mac Mini containers were originally created under this project
|
# 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 ...'
|
# Pinning the project name here means anyone running 'docker compose ...'
|
||||||
# from the repo root automatically lands in the same project as the
|
# from the repo root automatically lands in the same project as the
|
||||||
# already-running containers, instead of silently spawning a duplicate
|
# already-running containers, instead of silently spawning a duplicate
|
||||||
|
|
|
||||||
17
.github/workflows/cd-macmini.yml
vendored
17
.github/workflows/cd-macmini.yml
vendored
|
|
@ -55,7 +55,7 @@ concurrency:
|
||||||
cancel-in-progress: false # Don't cancel in-progress deploys
|
cancel-in-progress: false # Don't cancel in-progress deploys
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PROJECT_DIR: /Users/mana/projects/managarten
|
PROJECT_DIR: /Users/mana/projects/mana-monorepo
|
||||||
COMPOSE_FILE: docker-compose.macmini.yml
|
COMPOSE_FILE: docker-compose.macmini.yml
|
||||||
ENV_FILE: .env.macmini
|
ENV_FILE: .env.macmini
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
|
|
@ -339,17 +339,12 @@ jobs:
|
||||||
# If a service has no Drizzle config or no schema diff this is
|
# 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
|
# a fast no-op. We must source POSTGRES_PASSWORD from the env
|
||||||
# file because the workflow env doesn't carry it.
|
# 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 ==="
|
echo "=== Applying schema migrations ==="
|
||||||
PG_PASSWORD=$(grep -E '^POSTGRES_PASSWORD=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed 's/^"\(.*\)"$/\1/; s/^'"'"'\(.*\)'"'"'$/\1/')
|
set -a
|
||||||
PG_PASSWORD="${PG_PASSWORD:-mana123}"
|
# shellcheck source=/dev/null
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
PG_PASSWORD="${POSTGRES_PASSWORD:-mana123}"
|
||||||
|
|
||||||
# `drizzle-kit` reads `drizzle.config.ts`, which itself
|
# `drizzle-kit` reads `drizzle.config.ts`, which itself
|
||||||
# `import {defineConfig} from 'drizzle-kit'`. Node's resolver
|
# `import {defineConfig} from 'drizzle-kit'`. Node's resolver
|
||||||
|
|
|
||||||
4
.github/workflows/mirror-to-forgejo.yml
vendored
4
.github/workflows/mirror-to-forgejo.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Mirror to Forgejo via SSH
|
- name: Mirror to Forgejo via SSH
|
||||||
run: |
|
run: |
|
||||||
cd /Users/mana/projects/managarten
|
cd /Users/mana/projects/mana-monorepo
|
||||||
|
|
||||||
# Stash any local changes so pull never fails
|
# Stash any local changes so pull never fails
|
||||||
git stash --quiet 2>/dev/null || true
|
git stash --quiet 2>/dev/null || true
|
||||||
|
|
@ -25,5 +25,5 @@ jobs:
|
||||||
|
|
||||||
# Push to Forgejo via localhost SSH (runner is on the Mac Mini)
|
# 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_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"
|
echo "Mirrored to Forgejo"
|
||||||
|
|
|
||||||
|
|
@ -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-branding` | App registry, tiers, branding |
|
||||||
| `@mana/shared-types` | Common TS types |
|
| `@mana/shared-types` | Common TS types |
|
||||||
| `@mana/shared-utils` | Utility functions |
|
| `@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-theme` | Theme config |
|
||||||
| `@mana/shared-i18n` | i18n |
|
| `@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. |
|
| `@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. |
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Projects
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ See [CLAUDE.md](./CLAUDE.md) for comprehensive development documentation.
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
managarten/
|
mana-monorepo/
|
||||||
├── apps/ # Product applications
|
├── apps/ # Product applications
|
||||||
├── services/ # Microservices (auth, search, LLM, bots)
|
├── services/ # Microservices (auth, search, LLM, bots)
|
||||||
├── packages/ # Shared packages
|
├── packages/ # Shared packages
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Troubleshooting Guide
|
# Troubleshooting Guide
|
||||||
|
|
||||||
Common issues and solutions for the managarten.
|
Common issues and solutions for the mana-monorepo.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
|
@ -409,7 +409,7 @@ docker run --rm --entrypoint cat test /app/dist/ai/ai.service.js
|
||||||
|
|
||||||
### Related Issues
|
### 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
|
- TypeScript `import type` vs `import {}` - both erase at compile time
|
||||||
- Docker layer caching can hide fixes if source wasn't properly copied
|
- 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:
|
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
|
2. Review recent commits that may have introduced the issue
|
||||||
3. Run `pnpm clean` and `pnpm install` to reset
|
3. Run `pnpm clean` and `pnpm install` to reset
|
||||||
4. Create a new issue with full error logs
|
4. Create a new issue with full error logs
|
||||||
|
|
|
||||||
353
apps/cards/COMPETITORS_2026-05.md
Normal file
353
apps/cards/COMPETITORS_2026-05.md
Normal 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
367
apps/cards/GUIDELINES.md
Normal 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 (1–4), elapsedMs }`
|
||||||
|
- `cards_session_completed` — `{ deckId, cardCount, durationMs }`
|
||||||
|
- `cards_deck_created` — `{ deckId }`
|
||||||
|
- `cards_card_created` — `{ deckId, type }`
|
||||||
|
- `cards_fsrs_optimized` — `{ reviewCount, paramsHash }`
|
||||||
|
- `cards_pwa_installed` — Standard-PWA-Event
|
||||||
|
|
||||||
|
Reicht für die Core-Loop-Validierung. Mehr Events erst, wenn eine
|
||||||
|
konkrete Frage entsteht, die Daten beantworten sollen.
|
||||||
|
|
||||||
|
## 12. Hinweis im mana-Modul
|
||||||
|
|
||||||
|
Sobald `cardecky.mana.how` live ist, bekommt das mana-Modul einen
|
||||||
|
**dezenten** Hinweis (z.B. ein Banner oder Badge über der ListView):
|
||||||
|
"Cardecky gibt es jetzt auch als eigenständige App". Kein Pop-up, kein
|
||||||
|
forcierter Redirect — User entscheiden selbst.
|
||||||
34
apps/cards/README.md
Normal file
34
apps/cards/README.md
Normal 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
7
apps/cards/apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
43
apps/cards/apps/web/Dockerfile
Normal file
43
apps/cards/apps/web/Dockerfile
Normal 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"]
|
||||||
48
apps/cards/apps/web/package.json
Normal file
48
apps/cards/apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/cards/apps/web/src/app.css
Normal file
63
apps/cards/apps/web/src/app.css
Normal 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
16
apps/cards/apps/web/src/app.d.ts
vendored
Normal 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 {};
|
||||||
14
apps/cards/apps/web/src/app.html
Normal file
14
apps/cards/apps/web/src/app.html
Normal 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>
|
||||||
41
apps/cards/apps/web/src/hooks.server.ts
Normal file
41
apps/cards/apps/web/src/hooks.server.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
118
apps/cards/apps/web/src/lib/ai/generate.ts
Normal file
118
apps/cards/apps/web/src/lib/ai/generate.ts
Normal 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"}, ...]
|
||||||
|
- 5–15 Karten je nach Textlänge.
|
||||||
|
- Front: kurze, präzise Frage oder ein Begriff. Back: prägnante Antwort, max. 2 Sätze.
|
||||||
|
- Eine Karte pro klar abgegrenzter Faktenerinnerung — nicht ganze Absätze umkopieren.
|
||||||
|
- Sprache: dieselbe wie der Quelltext.`;
|
||||||
|
|
||||||
|
export interface GeneratedCard {
|
||||||
|
front: string;
|
||||||
|
back: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmUrl(): string {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const fromWindow = (window as unknown as { __PUBLIC_MANA_LLM_URL__?: string })
|
||||||
|
.__PUBLIC_MANA_LLM_URL__;
|
||||||
|
if (fromWindow) return fromWindow.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
return 'http://localhost:3025';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCodeFences(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/^\s*```(?:json|javascript|js)?\s*/i, '')
|
||||||
|
.replace(/\s*```\s*$/i, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultModel(): string {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const fromWindow = (window as unknown as { __PUBLIC_CARDS_AI_MODEL__?: string })
|
||||||
|
.__PUBLIC_CARDS_AI_MODEL__;
|
||||||
|
if (fromWindow) return fromWindow;
|
||||||
|
}
|
||||||
|
// mana-llm proxies many providers — this id matches what the
|
||||||
|
// playground module uses as a sensible default. Adjust per env via
|
||||||
|
// __PUBLIC_CARDS_AI_MODEL__ injection.
|
||||||
|
return 'gpt-4o-mini';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCardsFromText(
|
||||||
|
source: string,
|
||||||
|
opts: { model?: string; signal?: AbortSignal } = {}
|
||||||
|
): Promise<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;
|
||||||
|
}
|
||||||
56
apps/cards/apps/web/src/lib/ai/pdf.ts
Normal file
56
apps/cards/apps/web/src/lib/ai/pdf.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
212
apps/cards/apps/web/src/lib/anki/import.ts
Normal file
212
apps/cards/apps/web/src/lib/anki/import.ts
Normal 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;
|
||||||
|
}
|
||||||
247
apps/cards/apps/web/src/lib/anki/parse.ts
Normal file
247
apps/cards/apps/web/src/lib/anki/parse.ts
Normal 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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
505
apps/cards/apps/web/src/lib/api/cards-api.ts
Normal file
505
apps/cards/apps/web/src/lib/api/cards-api.ts
Normal 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;
|
||||||
|
}
|
||||||
209
apps/cards/apps/web/src/lib/components/AiCardGen.svelte
Normal file
209
apps/cards/apps/web/src/lib/components/AiCardGen.svelte
Normal 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>
|
||||||
187
apps/cards/apps/web/src/lib/components/AnkiImport.svelte
Normal file
187
apps/cards/apps/web/src/lib/components/AnkiImport.svelte
Normal 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>
|
||||||
134
apps/cards/apps/web/src/lib/components/CardDiscussions.svelte
Normal file
134
apps/cards/apps/web/src/lib/components/CardDiscussions.svelte
Normal 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>
|
||||||
194
apps/cards/apps/web/src/lib/components/CardFace.svelte
Normal file
194
apps/cards/apps/web/src/lib/components/CardFace.svelte
Normal 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>
|
||||||
13
apps/cards/apps/web/src/lib/components/CardsLogo.svelte
Normal file
13
apps/cards/apps/web/src/lib/components/CardsLogo.svelte
Normal 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>
|
||||||
104
apps/cards/apps/web/src/lib/components/DeckCardList.svelte
Normal file
104
apps/cards/apps/web/src/lib/components/DeckCardList.svelte
Normal 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>
|
||||||
62
apps/cards/apps/web/src/lib/components/DeckGrid.svelte
Normal file
62
apps/cards/apps/web/src/lib/components/DeckGrid.svelte
Normal 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}
|
||||||
353
apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte
Normal file
353
apps/cards/apps/web/src/lib/components/PublishDeckModal.svelte
Normal 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 (3–60 Zeichen, a–z, 0–9, -)
|
||||||
|
</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>
|
||||||
|
|
@ -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>
|
||||||
142
apps/cards/apps/web/src/lib/components/ReportButton.svelte
Normal file
142
apps/cards/apps/web/src/lib/components/ReportButton.svelte
Normal 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}
|
||||||
93
apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte
Normal file
93
apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte
Normal 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>
|
||||||
188
apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte
Normal file
188
apps/cards/apps/web/src/lib/components/SuggestEditModal.svelte
Normal 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}
|
||||||
56
apps/cards/apps/web/src/lib/data/crypto.ts
Normal file
56
apps/cards/apps/web/src/lib/data/crypto.ts
Normal 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] ?? [];
|
||||||
|
}
|
||||||
163
apps/cards/apps/web/src/lib/data/database.ts
Normal file
163
apps/cards/apps/web/src/lib/data/database.ts
Normal 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);
|
||||||
|
}
|
||||||
290
apps/cards/apps/web/src/lib/data/sync.ts
Normal file
290
apps/cards/apps/web/src/lib/data/sync.ts
Normal 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;
|
||||||
|
}
|
||||||
2
apps/cards/apps/web/src/lib/index.ts
Normal file
2
apps/cards/apps/web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
export {};
|
||||||
90
apps/cards/apps/web/src/lib/media/upload.ts
Normal file
90
apps/cards/apps/web/src/lib/media/upload.ts
Normal 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 ``;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
251
apps/cards/apps/web/src/lib/queries.ts
Normal file
251
apps/cards/apps/web/src/lib/queries.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
314
apps/cards/apps/web/src/lib/services/subscribe.ts
Normal file
314
apps/cards/apps/web/src/lib/services/subscribe.ts
Normal 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 }));
|
||||||
|
}
|
||||||
12
apps/cards/apps/web/src/lib/stores/auth.svelte.ts
Normal file
12
apps/cards/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||||
72
apps/cards/apps/web/src/lib/stores/author.svelte.ts
Normal file
72
apps/cards/apps/web/src/lib/stores/author.svelte.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
165
apps/cards/apps/web/src/lib/stores/cards.svelte.ts
Normal file
165
apps/cards/apps/web/src/lib/stores/cards.svelte.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
78
apps/cards/apps/web/src/lib/stores/decks.svelte.ts
Normal file
78
apps/cards/apps/web/src/lib/stores/decks.svelte.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
90
apps/cards/apps/web/src/lib/stores/reviews.svelte.ts
Normal file
90
apps/cards/apps/web/src/lib/stores/reviews.svelte.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
65
apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts
Normal file
65
apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
33
apps/cards/apps/web/src/lib/stores/theme.ts
Normal file
33
apps/cards/apps/web/src/lib/stores/theme.ts
Normal 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);
|
||||||
|
}
|
||||||
14
apps/cards/apps/web/src/lib/util/slug.ts
Normal file
14
apps/cards/apps/web/src/lib/util/slug.ts
Normal 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);
|
||||||
|
}
|
||||||
98
apps/cards/apps/web/src/routes/+layout.svelte
Normal file
98
apps/cards/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||||
156
apps/cards/apps/web/src/routes/+page.svelte
Normal file
156
apps/cards/apps/web/src/routes/+page.svelte
Normal 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>
|
||||||
170
apps/cards/apps/web/src/routes/admin/reports/+page.svelte
Normal file
170
apps/cards/apps/web/src/routes/admin/reports/+page.svelte
Normal 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>
|
||||||
267
apps/cards/apps/web/src/routes/d/[slug]/+page.svelte
Normal file
267
apps/cards/apps/web/src/routes/d/[slug]/+page.svelte
Normal 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>
|
||||||
547
apps/cards/apps/web/src/routes/decks/[id]/+page.svelte
Normal file
547
apps/cards/apps/web/src/routes/decks/[id]/+page.svelte
Normal 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 {{c1::Deutschland}}."
|
||||||
|
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">{{c1::Wort}}</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}
|
||||||
130
apps/cards/apps/web/src/routes/explore/+page.svelte
Normal file
130
apps/cards/apps/web/src/routes/explore/+page.svelte
Normal 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>
|
||||||
226
apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte
Normal file
226
apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte
Normal 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}
|
||||||
35
apps/cards/apps/web/src/routes/login/+page.svelte
Normal file
35
apps/cards/apps/web/src/routes/login/+page.svelte
Normal 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}
|
||||||
|
/>
|
||||||
130
apps/cards/apps/web/src/routes/me/purchases/+page.svelte
Normal file
130
apps/cards/apps/web/src/routes/me/purchases/+page.svelte
Normal 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>
|
||||||
27
apps/cards/apps/web/src/routes/register/+page.svelte
Normal file
27
apps/cards/apps/web/src/routes/register/+page.svelte
Normal 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"
|
||||||
|
/>
|
||||||
138
apps/cards/apps/web/src/routes/u/[slug]/+page.svelte
Normal file
138
apps/cards/apps/web/src/routes/u/[slug]/+page.svelte
Normal 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>
|
||||||
BIN
apps/cards/apps/web/static/apple-touch-icon.png
Normal file
BIN
apps/cards/apps/web/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 731 B |
4
apps/cards/apps/web/static/favicon.svg
Normal file
4
apps/cards/apps/web/static/favicon.svg
Normal 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 |
BIN
apps/cards/apps/web/static/pwa-192x192.png
Normal file
BIN
apps/cards/apps/web/static/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 794 B |
BIN
apps/cards/apps/web/static/pwa-512x512.png
Normal file
BIN
apps/cards/apps/web/static/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/cards/apps/web/static/sql-wasm-browser.wasm
Executable file
BIN
apps/cards/apps/web/static/sql-wasm-browser.wasm
Executable file
Binary file not shown.
BIN
apps/cards/apps/web/static/sql-wasm.wasm
Executable file
BIN
apps/cards/apps/web/static/sql-wasm.wasm
Executable file
Binary file not shown.
12
apps/cards/apps/web/svelte.config.js
Normal file
12
apps/cards/apps/web/svelte.config.js
Normal 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;
|
||||||
15
apps/cards/apps/web/tsconfig.json
Normal file
15
apps/cards/apps/web/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/cards/apps/web/vite.config.ts
Normal file
20
apps/cards/apps/web/vite.config.ts
Normal 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',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
654
apps/cards/docs/MARKETPLACE_PLAN.md
Normal file
654
apps/cards/docs/MARKETPLACE_PLAN.md
Normal 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 10–500 Credits (entspricht ungefähr 1–50 € — exakte Conversion siehe mana-credits packages)
|
||||||
|
|
||||||
|
### 5.3 Käufer-Lebenszyklus
|
||||||
|
|
||||||
|
- Einmal gekauft = Lifetime-Zugriff auf alle künftigen Versionen
|
||||||
|
- Bei major Version (e.g. 1.x → 2.0.0) **kein** zweiter Kauf nötig — Author behält die Verbesserungs-Pflicht
|
||||||
|
- Refund-Window: 30 Tage, automatisch verfügbar wenn ≤ 10 % der Karten gelernt wurden (Quizlet hat das, ist Best-Practice)
|
||||||
|
|
||||||
|
### 5.4 Buyer-Protection bei Take-Down
|
||||||
|
|
||||||
|
- Wenn Deck per Take-Down entfernt wird, behält Buyer Zugriff auf das letzte gesehene Snapshot (DSGVO-konform)
|
||||||
|
- Refund automatisch wenn Take-Down innerhalb 90 Tagen nach Kauf
|
||||||
|
|
||||||
|
## 6. Service-Architektur
|
||||||
|
|
||||||
|
### 6.1 `cards-server` (neu)
|
||||||
|
|
||||||
|
- **Stack**: Hono + Bun (Mana-Konvention)
|
||||||
|
- **Port**: 3072
|
||||||
|
- **Deps**: PostgreSQL (`mana_platform.cards.*`), Redis (Job-Queue für Indexing/Notifications)
|
||||||
|
- **Auth**: JWT via JWKS (mana-auth)
|
||||||
|
- **Routes**: siehe §7
|
||||||
|
|
||||||
|
### 6.2 `cards-search` (neu, später)
|
||||||
|
|
||||||
|
- Eigene PostgreSQL-Instance mit pg_trgm + tsvector + pgvector
|
||||||
|
- Async-Indexer hört auf cards-server-Events („deck-published", „deck-updated")
|
||||||
|
- Optional: Meilisearch wenn Postgres FTS nicht reicht
|
||||||
|
|
||||||
|
### 6.3 mana-llm (existierend, erweitert)
|
||||||
|
|
||||||
|
- Embeddings für semantic search (jeden Deck-Description + Karte → 1536-dim Vector)
|
||||||
|
- Moderation-First-Pass (Klassifikation in spam/csam/hate/nsfw/etc.)
|
||||||
|
- Auto-Tag-Suggestions
|
||||||
|
- Auto-Summary für Deck-Beschreibungen
|
||||||
|
|
||||||
|
### 6.4 mana-credits (existierend, erweitert)
|
||||||
|
|
||||||
|
- Bestehende `/credits/use` und `/credits/reservations/*` für Kauf
|
||||||
|
- Bestehender `/internal/credits/grant` für Author-Auszahlung
|
||||||
|
- Vermutlich keine API-Erweiterung nötig
|
||||||
|
|
||||||
|
### 6.5 mana-notify (existierend, erweitert)
|
||||||
|
|
||||||
|
- Push-Notifications für Subscribe-Updates, neue Subscribers, neue Discussions/Replies, neue Stars (vom User konfigurierbar)
|
||||||
|
|
||||||
|
### 6.6 mana-media (existierend)
|
||||||
|
|
||||||
|
- Bilder/Audio in published Decks landen wie heute auch
|
||||||
|
- Pro Author-Tier ein Soft-Quota: Free 100MB, Verified 1GB, Mana 5GB
|
||||||
|
|
||||||
|
## 7. API-Endpoints (Auswahl)
|
||||||
|
|
||||||
|
OpenAPI-Spec wird die Quelle der Wahrheit; hier die wichtigsten Routes:
|
||||||
|
|
||||||
|
### 7.1 Authoren
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/authors/me — Profil anlegen/updaten (slug, displayName, bio, avatar, pseudonym)
|
||||||
|
GET /v1/authors/:slug — Public Profile + Decks-Liste + Stats
|
||||||
|
GET /v1/authors/me/dashboard — Eigene Stats: Subscriber, Erlöse, Mod-Inbox
|
||||||
|
POST /v1/authors/:slug/follow — Folgen
|
||||||
|
DELETE /v1/authors/:slug/follow — Entfolgen
|
||||||
|
GET /v1/authors/me/feed — Personal Activity-Feed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Decks
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/decks — Deck als public registrieren (Init-Flow)
|
||||||
|
GET /v1/decks/:slug — Public Deck mit latest version
|
||||||
|
GET /v1/decks/:slug/versions — Versionsliste mit Changelogs
|
||||||
|
GET /v1/decks/:slug/versions/:semver — Specific Version + alle Karten
|
||||||
|
PATCH /v1/decks/:slug — Metadaten (title, description, license, price)
|
||||||
|
|
||||||
|
POST /v1/decks/:slug/publish — Neue Version publishen (body: cards[], semver, changelog)
|
||||||
|
→ triggert AI-Mod-Pass
|
||||||
|
→ setzt latest_version_id
|
||||||
|
|
||||||
|
POST /v1/decks/:slug/star — Star setzen
|
||||||
|
DELETE /v1/decks/:slug/star — Star entfernen
|
||||||
|
|
||||||
|
POST /v1/decks/:slug/subscribe — Subscribe (lädt + sync'd Karten in lokale DB)
|
||||||
|
DELETE /v1/decks/:slug/subscribe — Unsubscribe
|
||||||
|
|
||||||
|
POST /v1/decks/:slug/fork — Fork (lokale Kopie + Author-Lineage)
|
||||||
|
|
||||||
|
POST /v1/decks/:slug/buy — Paid Deck kaufen (mana-credits-Flow)
|
||||||
|
POST /v1/decks/:slug/refund — Refund anfragen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Pull-Requests
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/decks/:slug/pull-requests — Liste
|
||||||
|
POST /v1/decks/:slug/pull-requests — Neuer PR (body: title, body, diff)
|
||||||
|
GET /v1/pull-requests/:id — Details
|
||||||
|
POST /v1/pull-requests/:id/merge — Author merged → erstellt neue Version
|
||||||
|
POST /v1/pull-requests/:id/close — Author schließt
|
||||||
|
POST /v1/pull-requests/:id/comments — Diskussion auf PR-Ebene
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Discussions
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/cards/:contentHash/discussions — Threads für eine Karte (über Versionen hinweg)
|
||||||
|
POST /v1/cards/:contentHash/discussions — Neuer Thread / Reply
|
||||||
|
POST /v1/discussions/:id/hide — Author/Mod versteckt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 Discovery + Search
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/explore — Featured + Trending + Categories (curated)
|
||||||
|
GET /v1/search?q=…&tag=…&lang=…&sort=… — Volltextsuche (FTS + semantic)
|
||||||
|
GET /v1/tags — Tag-Hierarchie
|
||||||
|
GET /v1/decks?author=…&tag=…&sort=…&p=… — Filtered Browse
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 Reports + Moderation
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/decks/:slug/report — User reportet Deck
|
||||||
|
POST /v1/cards/:contentHash/report — User reportet Karte
|
||||||
|
GET /v1/admin/reports — Admin-Inbox (verifizierte Mana-Mods only)
|
||||||
|
POST /v1/admin/decks/:slug/takedown — Admin entfernt Deck
|
||||||
|
POST /v1/admin/authors/:slug/ban — Admin sperrt Author
|
||||||
|
POST /v1/admin/authors/:slug/verify-mana — Mana-Verein-Badge vergeben
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.7 Notifications
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/notifications — Unread + recent
|
||||||
|
POST /v1/notifications/:id/read — Mark read
|
||||||
|
PATCH /v1/notifications/preferences — Settings (welche Events triggern Push)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. UI / Routes (Cardecky-Frontend)
|
||||||
|
|
||||||
|
```
|
||||||
|
/explore — Featured + Trending + Tag-Tree + Search-Bar
|
||||||
|
/explore/search?q=… — Search-Result-Page
|
||||||
|
/explore/tag/:slug — Tag-Page
|
||||||
|
|
||||||
|
/u/:slug — Author-Profil (Public)
|
||||||
|
/u/:slug/follow — Follow-Button im Header
|
||||||
|
|
||||||
|
/d/:slug — Public-Deck-Detail-View
|
||||||
|
(Description, Stats, Latest-Karten-Preview, Subscribe/Fork/Star/Buy, Discussions)
|
||||||
|
/d/:slug/v/:semver — spezifische Version
|
||||||
|
/d/:slug/discussions — Alle Discussions zum Deck
|
||||||
|
/d/:slug/pull-requests — PRs
|
||||||
|
/d/:slug/pull-requests/:id — PR-Detail mit Diff-View
|
||||||
|
|
||||||
|
/me/decks — Eigene private Decks (heute existiert)
|
||||||
|
/me/published — Eigene published Decks + Stats
|
||||||
|
/me/subscribed — Abonnierte Decks (mit Update-Indikator)
|
||||||
|
/me/forks — Geforkte Decks
|
||||||
|
/me/dashboard — Author-Dashboard (Erlöse, Subscriber-Wachstum)
|
||||||
|
|
||||||
|
/feed — Personal Activity-Feed (Following-Activity + Updates)
|
||||||
|
|
||||||
|
/admin/reports — Admin-Inbox (verified-mana-only)
|
||||||
|
/admin/decks — Take-Down-UI
|
||||||
|
/admin/authors — Verify + Ban
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich: einige bestehende Komponenten erweitern (DeckDetail bekommt Subscribe-Button etc.).
|
||||||
|
|
||||||
|
## 9. Cold-Start-Strategie
|
||||||
|
|
||||||
|
Marktplatz ohne Decks ist nutzlos. Drei parallele Hebel:
|
||||||
|
|
||||||
|
1. **Verein-Seed-Decks**: 50 hochwertige Decks selbst erstellen — sprachen (Top-3000 Vokabeln pro Sprache), Geschichte (TimeLine-Karten), Allgemeinwissen, Programmierung. Vom Mana-Team published, alle mit `verified_mana`-Badge.
|
||||||
|
2. **Anki-Top-100-Import-Service**: Wir bieten an, populäre Anki-Web-Decks (mit korrekter CC-BY-Lizenz) zu importieren und mit Original-Author-Attribution als Public-Decks anzulegen. Original-Author bekommt das `verified_mana`-Badge wenn er sich registriert.
|
||||||
|
3. **Influencer-Outreach**: Direkte Ansprache von 10-20 Anki-Power-Authoren (AnKing, etc.) mit dem Angebot eines verified-Status + sehr Author-freundlichem Cut. Wenn 1-2 wechseln, kommt ein Lawineneffekt.
|
||||||
|
|
||||||
|
## 10. Risiken + Mitigationen
|
||||||
|
|
||||||
|
| Risiko | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Cold-Start (Marktplatz leer) | Seed + Anki-Import + Influencer (siehe §9) |
|
||||||
|
| Spam / Junk-Decks | AI-Mod-First-Pass + Report-System + Author-Ban-Flow |
|
||||||
|
| Copyright-Klagen (Lehrbuch-Karten) | Lizenz-Pflichtangabe + DMCA-Process + Take-Down-Workflow |
|
||||||
|
| Server-Kosten (Storage von Bildern/Audio) | Soft-Quotas pro Author-Tier (§6.6) + lossy compression im mana-media |
|
||||||
|
| AnkiHub als Konkurrent (Live-Updates Medizin) | „Alle Fachgebiete + gratis" als Counter; Med-Decks aktiv akquirieren |
|
||||||
|
| Mana-Credits-Verein-Cut zu hoch oder zu niedrig | A/B-Test verschiedener Cut-Verhältnisse; Best-Practice: ~80/20 für Standard, ~90/10 für Verified |
|
||||||
|
| Author-Frustration über fehlende Mobile-App | Klarer Roadmap-Hinweis + Mobile-Push-Notifications via PWA (heute geht das schon) |
|
||||||
|
| Discussions werden Toxic | Author-Owns-Their-Discussions (kann hide); Community-Mod (Verified-User können flaggen); klar dokumentierte Community-Guidelines |
|
||||||
|
| Mining/Scraping der Decks | Rate-limit auf API + Auth-Required für full-content; offene Snippets aber paywall am Voll-Inhalt |
|
||||||
|
|
||||||
|
## 11. Phasenplan
|
||||||
|
|
||||||
|
> **Co-Learn explizit ausgeklammert.** Mobile-App auch.
|
||||||
|
|
||||||
|
### Phase α — Daten-Skelett (cards-server v0.1)
|
||||||
|
|
||||||
|
- `services/cards-server/` SvelteKit-style Service-Setup, Hono + Bun + Drizzle
|
||||||
|
- Alle Schema-Tabellen + Migrationen (§4)
|
||||||
|
- API-Routes (CRUD-Niveau): Authoren, Decks, Versionen, Stars, Subscriptions
|
||||||
|
- OpenAPI-Spec
|
||||||
|
- Integration-Tests (Drizzle + Vitest)
|
||||||
|
- mana-auth-JWT-Middleware (`@mana/shared-hono`)
|
||||||
|
- Container in `docker-compose.macmini.yml`
|
||||||
|
- Cloudflare-Tunnel-Route `cardecky-api.mana.how` → `:3072`
|
||||||
|
|
||||||
|
### Phase β — Author-Workflow ✅ shipped
|
||||||
|
|
||||||
|
- ✅ „Author werden"-Flow im Frontend (Profil anlegen, slug claimen)
|
||||||
|
- ✅ „Publish"-Aktion auf Deck-Detail-Seite
|
||||||
|
- ✅ Lizenz-Picker (SPDX-Auswahl)
|
||||||
|
- ✅ Optional: Preis in Credits
|
||||||
|
- ⏳ Tags: Picker fehlt im Publish-Flow; Server-Schema steht
|
||||||
|
- ✅ Versioning: semver-Eingabe (Auto-Suggest pre-fill folgt in θ)
|
||||||
|
- ✅ Changelog-Editor
|
||||||
|
- ✅ AI-First-Pass-Moderation (mana-llm classify, Verdict im Publish-Result)
|
||||||
|
- ⏳ Author-Dashboard mit Subscriber-Counts: Erlöse jetzt unter `/me/purchases`, restliche Stats fehlen
|
||||||
|
|
||||||
|
### Phase γ — Discovery-Frontend ✅ shipped (FTS minimal)
|
||||||
|
|
||||||
|
- ✅ `/explore`-Seite mit Featured + Trending
|
||||||
|
- 🟡 Volltext-Suche: einfaches `ILIKE` über Title/Description; tsvector-Upgrade in Phase ι
|
||||||
|
- 🟡 Tag-Hierarchie: flach implementiert; baumartige Eltern-Kind-Navigation offen
|
||||||
|
- ✅ Author-Profile (`/u/<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
9
apps/cards/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,10 +16,10 @@ export default defineConfig({
|
||||||
replacesTitle: false,
|
replacesTitle: false,
|
||||||
},
|
},
|
||||||
social: {
|
social: {
|
||||||
github: 'https://github.com/Memo-2023/managarten',
|
github: 'https://github.com/mana/mana-monorepo',
|
||||||
},
|
},
|
||||||
editLink: {
|
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'],
|
customCss: ['./src/styles/custom.css'],
|
||||||
sidebar: [
|
sidebar: [
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Mana encrypts user-typed content with **AES-GCM-256** before it touches IndexedD
|
||||||
|
|
||||||
## What's encrypted
|
## 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 |
|
| Module | Fields |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
|
|
@ -238,7 +238,7 @@ the full standard / ZK guarantees.
|
||||||
|
|
||||||
## Implementation references
|
## Implementation references
|
||||||
|
|
||||||
For the architectural deep dive, code locations, and the complete rollout history (Phases 1–9 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/managarten/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md).
|
For the architectural deep dive, code locations, and the complete rollout history (Phases 1–9 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md).
|
||||||
|
|
||||||
Key files:
|
Key files:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ Requires `cloudflared` installed: `brew install cloudflare/cloudflare/cloudflare
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
~/projects/managarten/
|
~/projects/mana-monorepo/
|
||||||
├── docker-compose.macmini.yml # Production compose file
|
├── docker-compose.macmini.yml # Production compose file
|
||||||
├── .env.production # Production environment
|
├── .env.production # Production environment
|
||||||
├── scripts/mac-mini/ # Server management scripts
|
├── scripts/mac-mini/ # Server management scripts
|
||||||
|
|
@ -53,7 +53,7 @@ Requires `cloudflared` installed: `brew install cloudflare/cloudflare/cloudflare
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh mana-server
|
ssh mana-server
|
||||||
cd ~/projects/managarten
|
cd ~/projects/mana-monorepo
|
||||||
./scripts/mac-mini/status.sh
|
./scripts/mac-mini/status.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ chat-backend running (healthy)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh mana-server
|
ssh mana-server
|
||||||
cd ~/projects/managarten
|
cd ~/projects/mana-monorepo
|
||||||
./scripts/mac-mini/deploy.sh
|
./scripts/mac-mini/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ pnpm deploy:docs
|
||||||
ssh mana-server
|
ssh mana-server
|
||||||
|
|
||||||
# Pull latest changes
|
# Pull latest changes
|
||||||
cd ~/projects/managarten
|
cd ~/projects/mana-monorepo
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
|
|
@ -135,7 +135,7 @@ npx wrangler pages deployment tail <deployment-id> --project-name=chat-landing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh mana-server
|
ssh mana-server
|
||||||
cd ~/projects/managarten
|
cd ~/projects/mana-monorepo
|
||||||
|
|
||||||
# Revert to previous commit
|
# Revert to previous commit
|
||||||
git checkout HEAD~1
|
git checkout HEAD~1
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ Run your own Mana instance using Docker Compose.
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mana/managarten.git
|
git clone https://github.com/mana/mana-monorepo.git
|
||||||
cd managarten
|
cd mana-monorepo
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create environment file**
|
2. **Create environment file**
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ Before you begin, ensure you have:
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mana/managarten.git
|
git clone https://github.com/mana/mana-monorepo.git
|
||||||
cd managarten
|
cd mana-monorepo
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install dependencies**
|
2. **Install dependencies**
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ hero:
|
||||||
icon: right-arrow
|
icon: right-arrow
|
||||||
variant: primary
|
variant: primary
|
||||||
- text: View on GitHub
|
- text: View on GitHub
|
||||||
link: https://github.com/mana/managarten
|
link: https://github.com/mana/mana-monorepo
|
||||||
icon: external
|
icon: external
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -21,8 +21,8 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mana/managarten.git
|
git clone https://github.com/mana/mana-monorepo.git
|
||||||
cd managarten
|
cd mana-monorepo
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm docker:up
|
pnpm docker:up
|
||||||
pnpm dev:chat:full
|
pnpm dev:chat:full
|
||||||
|
|
|
||||||
|
|
@ -484,7 +484,7 @@ Nach Analyse aller Optionen ist die Empfehlung:
|
||||||
```bash
|
```bash
|
||||||
# Auf dem Server
|
# Auf dem Server
|
||||||
ssh mana-server
|
ssh mana-server
|
||||||
cd ~/projects/managarten
|
cd ~/projects/mana-monorepo
|
||||||
git pull
|
git pull
|
||||||
./scripts/mac-mini/deploy.sh
|
./scripts/mac-mini/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,6 @@ workingHours:
|
||||||
end: '2026-01-24T11:00'
|
end: '2026-01-24T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Heute war ein sehr produktiver Tag mit Fokus auf die **Produktivstellung der ManaCore Apps auf dem Mac Mini Server**. Die wichtigsten Errungenschaften:
|
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)
|
- **7 Apps live** auf https://mana.how (Auth, Dashboard, Chat, Todo, Calendar, Clock, Contacts)
|
||||||
|
|
@ -21,8 +21,6 @@ workingHours:
|
||||||
end: '2026-01-25T11:00'
|
end: '2026-01-25T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Fokussierter Tag mit **4 Commits** zur Verbesserung des Guest-Mode-Erlebnisses:
|
Fokussierter Tag mit **4 Commits** zur Verbesserung des Guest-Mode-Erlebnisses:
|
||||||
|
|
||||||
- **Clock Guest Mode** - Alarms/Timers ohne Auth-Redirect ladbar
|
- **Clock Guest Mode** - Alarms/Timers ohne Auth-Redirect ladbar
|
||||||
|
|
@ -33,8 +33,6 @@ workingHours:
|
||||||
end: '2026-01-26T11:00'
|
end: '2026-01-26T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Massiver Tag mit **38 Commits** und Fokus auf neue App, Monitoring und Infrastructure:
|
Massiver Tag mit **38 Commits** und Fokus auf neue App, Monitoring und Infrastructure:
|
||||||
|
|
||||||
- **Food** - AI-powered Nutrition Tracking App mit Gemini
|
- **Food** - AI-powered Nutrition Tracking App mit Gemini
|
||||||
|
|
@ -33,9 +33,6 @@ workingHours:
|
||||||
end: '2026-01-27T11:00'
|
end: '2026-01-27T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
|
|
||||||
Produktiver Tag mit Fokus auf **Monitoring-Infrastruktur**, **Email-Authentifizierung** und **lokale AI-Services**. Die wichtigsten Errungenschaften:
|
Produktiver Tag mit Fokus auf **Monitoring-Infrastruktur**, **Email-Authentifizierung** und **lokale AI-Services**. Die wichtigsten Errungenschaften:
|
||||||
|
|
||||||
- **Prometheus Metrics** für alle NestJS Backends
|
- **Prometheus Metrics** für alle NestJS Backends
|
||||||
|
|
@ -34,8 +34,6 @@ workingHours:
|
||||||
end: '2026-01-28T11:00'
|
end: '2026-01-28T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Strategischer Tag mit **11 Commits** und Fokus auf GDPR-Compliance und Developer Experience:
|
Strategischer Tag mit **11 Commits** und Fokus auf GDPR-Compliance und Developer Experience:
|
||||||
|
|
||||||
- **Matrix Self-Hosting** - Eigene Synapse-Instanz für GDPR
|
- **Matrix Self-Hosting** - Eigene Synapse-Instanz für GDPR
|
||||||
|
|
@ -34,8 +34,6 @@ workingHours:
|
||||||
end: '2026-01-29T11:00'
|
end: '2026-01-29T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Außergewöhnlich produktiver Tag (und Nacht!) mit **74 Commits** und mehreren großen neuen Features. Die wichtigsten Errungenschaften:
|
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)
|
- **SkillTree App** - Gamified Skill Tracking mit XP-System (MVP complete)
|
||||||
|
|
@ -37,8 +37,6 @@ workingHours:
|
||||||
end: '2026-01-30T11:00'
|
end: '2026-01-30T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Außergewöhnlich produktiver Tag mit **55 Commits** - aufgeteilt in drei große Bereiche:
|
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)
|
- **3 neue Microservices** - mana-llm (LLM Abstraction), mana-crawler (Web Crawler), mana-notify (Notifications)
|
||||||
|
|
@ -31,8 +31,6 @@ workingHours:
|
||||||
end: '2026-01-31T11:00'
|
end: '2026-01-31T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Produktiver Tag mit **41 Commits** und Fokus auf Matrix Bot Expansion und Developer Experience:
|
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
|
- **8 neue Matrix Bots** - Spezialisierte Bots für Planta, Cards, Contacts, Picture, Chat, SkillTree, Presi, Questions
|
||||||
|
|
@ -33,8 +33,6 @@ workingHours:
|
||||||
end: '2026-02-01T11:00'
|
end: '2026-02-01T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Intensiver Tag (und Nacht!) mit **52 Commits** - der Fokus lag auf der Konsolidierung der Matrix Bot Infrastruktur:
|
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
|
- **@manacore/matrix-bot-common** - Neues Shared Package für alle 19 Matrix Bots
|
||||||
|
|
@ -33,9 +33,6 @@ workingHours:
|
||||||
end: '2026-02-02T11:00'
|
end: '2026-02-02T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
|
|
||||||
Produktiver Tag mit **42 Commits** und Fokus auf Infrastructure und Production Readiness:
|
Produktiver Tag mit **42 Commits** und Fokus auf Infrastructure und Production Readiness:
|
||||||
|
|
||||||
- **SSD Migration** - PostgreSQL und MinIO auf externe SSD verschoben
|
- **SSD Migration** - PostgreSQL und MinIO auf externe SSD verschoben
|
||||||
|
|
@ -33,8 +33,6 @@ workingHours:
|
||||||
end: '2026-02-03T11:00'
|
end: '2026-02-03T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Produktiver Tag mit **40 Commits** und Fokus auf nahtlose Authentifizierung über alle Apps:
|
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
|
- **Cross-Domain SSO** - Single Sign-On für alle .mana.how Subdomains
|
||||||
|
|
@ -34,8 +34,6 @@ workingHours:
|
||||||
end: '2026-02-12T11:00'
|
end: '2026-02-12T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Nach einer Woche Pause: **28 Commits** mit Fokus auf neue Apps und API-Infrastruktur:
|
Nach einer Woche Pause: **28 Commits** mit Fokus auf neue Apps und API-Infrastruktur:
|
||||||
|
|
||||||
- **Photos App** - Neue App mit mana-media EXIF-Integration
|
- **Photos App** - Neue App mit mana-media EXIF-Integration
|
||||||
|
|
@ -33,8 +33,6 @@ workingHours:
|
||||||
end: '2026-02-13T11:00'
|
end: '2026-02-13T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
**22 Commits** mit Fokus auf DSGVO-Compliance, Mobile UX und Server-Stabilität:
|
**22 Commits** mit Fokus auf DSGVO-Compliance, Mobile UX und Server-Stabilität:
|
||||||
|
|
||||||
- **GDPR Self-Service** - Neue Endpoints für User Data Export
|
- **GDPR Self-Service** - Neue Endpoints für User Data Export
|
||||||
|
|
@ -34,8 +34,6 @@ workingHours:
|
||||||
end: '2026-02-14T11:00'
|
end: '2026-02-14T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Ein massiver Tag mit **55 Commits** und mehreren Major Features:
|
Ein massiver Tag mit **55 Commits** und mehreren Major Features:
|
||||||
|
|
||||||
- **Gift Codes** - Credit-Gutscheine mit Code-Einlösung
|
- **Gift Codes** - Credit-Gutscheine mit Code-Einlösung
|
||||||
|
|
@ -35,8 +35,6 @@ workingHours:
|
||||||
end: '2026-02-15T11:00'
|
end: '2026-02-15T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Produktiver Valentinstag mit **56 Commits** und vielen neuen Features:
|
Produktiver Valentinstag mit **56 Commits** und vielen neuen Features:
|
||||||
|
|
||||||
- **Matrix STT Bot** - Speech-to-Text direkt in Matrix
|
- **Matrix STT Bot** - Speech-to-Text direkt in Matrix
|
||||||
|
|
@ -21,8 +21,6 @@ workingHours:
|
||||||
end: '2026-02-16T11:00'
|
end: '2026-02-16T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Fokussierter Tag mit **7 Commits** für UI-Polish und kleinere Features:
|
Fokussierter Tag mit **7 Commits** für UI-Polish und kleinere Features:
|
||||||
|
|
||||||
- **Onboarding Modal** - Kompaktes Design statt Fullscreen
|
- **Onboarding Modal** - Kompaktes Design statt Fullscreen
|
||||||
|
|
@ -32,8 +32,6 @@ workingHours:
|
||||||
end: '2026-02-17T11:00'
|
end: '2026-02-17T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Massiver Tag mit **35 Commits** – neuer App-Launch, Monetarisierung und PWA-Rollout:
|
Massiver Tag mit **35 Commits** – neuer App-Launch, Monetarisierung und PWA-Rollout:
|
||||||
|
|
||||||
- **LightWrite** - Beat/Lyrics Editor als Full-Stack App gelauncht
|
- **LightWrite** - Beat/Lyrics Editor als Full-Stack App gelauncht
|
||||||
|
|
@ -21,9 +21,6 @@ workingHours:
|
||||||
end: '2026-02-18T11:00'
|
end: '2026-02-18T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
|
|
||||||
Produktiver Tag mit **26 Commits** – neue Packages, Bot-Features und Dokumentation:
|
Produktiver Tag mit **26 Commits** – neue Packages, Bot-Features und Dokumentation:
|
||||||
|
|
||||||
- **spiral-db** - Pixel-basierte Spiral-Datenbank-Visualisierung
|
- **spiral-db** - Pixel-basierte Spiral-Datenbank-Visualisierung
|
||||||
|
|
@ -21,8 +21,6 @@ workingHours:
|
||||||
end: '2026-03-07T11:00'
|
end: '2026-03-07T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Fokussierter Tag mit **7 Commits** für den neuen Matrix Mobile Client:
|
Fokussierter Tag mit **7 Commits** für den neuen Matrix Mobile Client:
|
||||||
|
|
||||||
- **Manalink** - Expo React Native App für Matrix Chat
|
- **Manalink** - Expo React Native App für Matrix Chat
|
||||||
|
|
@ -21,8 +21,6 @@ workingHours:
|
||||||
end: '2026-03-08T11:00'
|
end: '2026-03-08T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Bugfix-Tag mit **5 Commits** – Expo SDK Upgrade und kritische Fixes:
|
Bugfix-Tag mit **5 Commits** – Expo SDK Upgrade und kritische Fixes:
|
||||||
|
|
||||||
- **SDK 55** - Manalink auf Expo SDK 55 aktualisiert
|
- **SDK 55** - Manalink auf Expo SDK 55 aktualisiert
|
||||||
|
|
@ -21,8 +21,6 @@ workingHours:
|
||||||
end: '2026-03-12T11:00'
|
end: '2026-03-12T11:00'
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09). Bestand bleibt erhalten und unverändert; neue Einträge folgen der Tages-Konvention mit `spieler.md` + `macher.md` pro 06–06-Bucket. Spec: [`mana/docs/DEVLOG.md`](https://github.com/mana-ev/mana/blob/main/docs/DEVLOG.md).
|
|
||||||
|
|
||||||
Infrastruktur-Tag mit **6 Commits** für die Deployment-Pipeline:
|
Infrastruktur-Tag mit **6 Commits** für die Deployment-Pipeline:
|
||||||
|
|
||||||
- **CD Pipeline** - GitHub Actions mit self-hosted Runner
|
- **CD Pipeline** - GitHub Actions mit self-hosted Runner
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue