mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
Compare commits
23 commits
cards-deco
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52bca1152c | ||
|
|
670036d56d | ||
|
|
97e285bc67 | ||
|
|
7f0e2ba10d | ||
|
|
b223247256 | ||
|
|
cec84c2a9d | ||
|
|
009a695345 | ||
|
|
dd2e4b6e9f | ||
|
|
b299a4acf1 | ||
|
|
5635598a58 | ||
|
|
d3d9271426 | ||
|
|
bf8353ea8a | ||
|
|
7b29dcc23c | ||
|
|
60f5c26507 | ||
|
|
2eb70c62da | ||
|
|
67963a4c0f | ||
|
|
3581ae0f21 | ||
|
|
0aec1d43c0 | ||
|
|
b1b9bbc269 | ||
|
|
ac15de280b | ||
|
|
dd1bab09d5 | ||
|
|
bc158cb0bc | ||
|
|
9cd8717494 |
321 changed files with 1568 additions and 21307 deletions
|
|
@ -23,6 +23,10 @@ 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 (mana-monorepo).
|
# name, which mismatches the current directory name (managarten).
|
||||||
# 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/mana-monorepo
|
PROJECT_DIR: /Users/mana/projects/managarten
|
||||||
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,12 +339,17 @@ 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 ==="
|
||||||
set -a
|
PG_PASSWORD=$(grep -E '^POSTGRES_PASSWORD=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed 's/^"\(.*\)"$/\1/; s/^'"'"'\(.*\)'"'"'$/\1/')
|
||||||
# shellcheck source=/dev/null
|
PG_PASSWORD="${PG_PASSWORD:-mana123}"
|
||||||
. "$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/mana-monorepo
|
cd /Users/mana/projects/managarten
|
||||||
|
|
||||||
# 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/mana-monorepo.git main 2>&1
|
git push ssh://git@localhost:2222/till/managarten.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` | React Native UI components |
|
| `@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-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 @@
|
||||||
# Mana Monorepo
|
# Managarten
|
||||||
|
|
||||||
Monorepo containing all Mana projects — a self-hosted multi-app ecosystem with shared packages and unified tooling.
|
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.
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ See [CLAUDE.md](./CLAUDE.md) for comprehensive development documentation.
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
mana-monorepo/
|
managarten/
|
||||||
├── 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 mana-monorepo.
|
Common issues and solutions for the managarten.
|
||||||
|
|
||||||
## 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/mana-monorepo/commit/d69cc607) - Fixed type-only ConfigService import in AiService
|
- [Commit d69cc607](https://github.com/Memo-2023/managarten/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/mana-monorepo/issues)
|
1. Check the [GitHub Issues](https://github.com/Memo-2023/managarten/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
|
||||||
|
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
# Cardecky — Konkurrenz-Analyse (Mai 2026)
|
|
||||||
|
|
||||||
> Stand: 2026-05-07. Quellen primär aus offiziellen Pricing-Seiten, G2/Trustpilot/Reddit/HN sowie Wikipedia/Crunchbase. Wo Daten fehlen oder nicht öffentlich sind, ist das explizit vermerkt. Preise schwanken regional/saisonal — die hier genannten Zahlen sind Listenpreise USD, sofern nicht anders angegeben.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Executive Summary
|
|
||||||
|
|
||||||
- **Anki bleibt der unschlagbare technische Gold-Standard**, aber UX-Schwächen (FSRS-„Difficulty Hell", Plugin-Hölle, kein natives Cloud-Sync mit Bildern) und der $25 iOS-Preis sind reale Lücken, in die wir stoßen können. Die Übergabe an AnkiHub im Februar 2026 könnte mittelfristig die Open-Source-Dynamik verändern — Beobachten lohnt.
|
|
||||||
- **Quizlet hat seine eigene Userbase verärgert**: Trustpilot 1.4/5, massive Beschwerden über Paywalls für Funktionen, die früher gratis waren. Genau dieses Vertrauensvakuum füllen Knowt und potenziell wir.
|
|
||||||
- **AI-Karten-Generierung ist Tischeinsatz, kein Differenzierer mehr.** Quizlet, Quizgecko, Knowt, RemNote, Wisdolia, sogar Memrise haben es. PDF-Import + KI ist erwartete Baseline.
|
|
||||||
- **Die „beautiful Anki"-Lücke ist umkämpft**: Mochi (5$/mo), RemNote (8$/mo), Noji (vormals AnkiPro). Cardecky mit _kostenlosem_ Sync sticht heraus — niemand sonst bietet die Kombination Markdown + FSRS + Cloud-Sync gratis. Das ist unsere wichtigste objektive Differenzierung.
|
|
||||||
- **Brand-Sniping ist real und schädlich**: AnkiPro (jetzt Noji) und AnkiApp (jetzt AlgoApp) haben sich einen Ruf als „Anki-Klone, die täuschen" erarbeitet — inkl. eines 10-tägigen Sync-Outages bei AnkiPro im Mai 2025. Lehre für uns: nie Anki im Namen führen, Kompatibilität sauber kommunizieren.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Vergleichstabelle
|
|
||||||
|
|
||||||
| Konkurrent | USP-Kurz | Lizenz | Free-Tier | Pro-Preis | Bedrohung |
|
|
||||||
| ------------------------------ | -------------------------------------- | --------------------------- | -------------------------------- | ----------------------------------------------- | -------------------------------- |
|
|
||||||
| **Anki (Desktop/Web/Android)** | Tech-Gold-Standard, FSRS, Add-ons | AGPL-3.0 | Voll-Funktional gratis | $0 (iOS: $24.99-29.99 lifetime) | **Hoch** |
|
|
||||||
| **AnkiHub** | Kollaborative Anki-Decks (USMLE-Fokus) | proprietär (auf Anki-Basis) | Trial | $5/mo | Mittel (Power-User) |
|
|
||||||
| **Quizlet** | Marktführer Volumen + Schule | proprietär | Sehr eingeschränkt, viele Ads | $35.99/Jahr (Plus), ~$45/Jahr (Unlimited) | **Hoch** (Reichweite) |
|
|
||||||
| **RemNote** | Notes + SR Hybrid | proprietär | Großzügig (3 PDFs, 5 Image-Occ.) | $8/mo annual (Pro) | Mittel |
|
|
||||||
| **Mochi** | Markdown, Local-First, schickes UI | proprietär | Single-Device | $5/mo (Sync) | **Hoch** (direkter Wettbewerber) |
|
|
||||||
| **Brainscape** | Confidence-Based-Repetition | proprietär | Limited Decks | ~$19.99/mo, $79.99 lifetime | Gering-Mittel |
|
|
||||||
| **Memrise** | Sprachen + AI-Buddies | proprietär | Eingeschränkt | $130.99/Jahr, $199.99 lifetime | Gering (Nische Sprachen) |
|
|
||||||
| **SuperMemo** | Algorithmus-Urvater (SM-20) | proprietär | Monatstrial Mobile | ~9.90$/mo Mobile, ~$66 Desktop perp. | Gering (Nische, sperrige UX) |
|
|
||||||
| **AnkiPro / Noji** | „Anki-Look" mit modernem UI | proprietär | mit Ads/Limits | nicht öffentlich klar (~$5-10/mo) | Mittel (Brand-Verwirrung) |
|
|
||||||
| **AnkiApp / AlgoApp** | Cloud-First Closed-Source | proprietär | Limited | Subscription (Details schwammig) | Gering (Reputation kaputt) |
|
|
||||||
| **Quizgecko** | AI-First (Quizzes, Podcasts) | proprietär | 1 AI-Lesson/Monat | $16/mo (Pro), $29 (Ultra) | Mittel (AI-Side) |
|
|
||||||
| **Knowt** | „Free Quizlet-Alternative" + AI | proprietär | Sehr großzügig | $9.99/mo (Ultra) | **Hoch** (gleiches Spielfeld) |
|
|
||||||
| **Wisdolia** | Browser-Ext: Karten aus Webcontent | proprietär | 50 Sets/Monat | $2.50/mo, $25/Jahr | Gering |
|
|
||||||
| **Mnemosyne** | Open-Source, Forschungs-Datasammlung | GPL | Voll gratis | — | Sehr gering |
|
|
||||||
| **Traverse** | Mind-Maps + SR (Mandarin Blueprint) | proprietär | Free-Plan | $15/mo Member, $35/User Enterprise | Gering |
|
|
||||||
| **Cerego** | Enterprise B2B Adaptive Learning | proprietär | — | ab $8.33/mo Indiv., Enterprise on req. | Sehr gering (B2B) |
|
|
||||||
| **NeuraCache** | Notion/Obsidian-Sync für SR | proprietär | Limited | 14d Trial → Pro (Preis nicht klar dokumentiert) | Gering |
|
|
||||||
|
|
||||||
> Threat-Ranking: nur **Anki, Quizlet, Mochi, Knowt** sind Top-Bedrohungen für Cardeckys Kernzielgruppe. RemNote, Quizgecko, AnkiPro/Noji sind Nebenfront.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Detail-Sektion pro Konkurrent
|
|
||||||
|
|
||||||
### 3.1 Anki (Desktop / AnkiWeb / AnkiDroid / AnkiMobile)
|
|
||||||
|
|
||||||
- **URL:** https://apps.ankiweb.net/
|
|
||||||
- **Plattformen:** Windows, macOS, Linux (Desktop), Web (AnkiWeb), Android (AnkiDroid), iOS (AnkiMobile)
|
|
||||||
- **USP:** Der etablierte technische Standard für Spaced Repetition; mächtig, erweiterbar (Add-ons), FSRS v6 nativ, riesiges Deck-Ökosystem (insbes. Medizin: AnKing).
|
|
||||||
- **Lizenz:** AGPL-3.0 (Desktop, AnkiDroid, Web). AnkiMobile iOS proprietär (finanziert die Open-Source-Arbeit).
|
|
||||||
- **Kosten:** Desktop / Web / Android **kostenlos**. AnkiMobile iOS: **$24.99-29.99 einmalig (Lifetime)**. AnkiHub-Cloud-Decks: $5/Monat (separat).
|
|
||||||
- **User loben:** Mächtig & flexibel; FSRS-Wirksamkeit; freie Decks (insbes. AnKing Step Deck mit 100k+ Studenten); Dauerhaftigkeit (seit 2006).
|
|
||||||
- **User kritisieren:** Steile UX-Lernkurve; FSRS-„Difficulty Hell" (Karten reifen langsam, Reviews explodieren); Plugin-Brüche zwischen Versionen; iOS-Preis abschreckend; Sync-Setup für Bilder/Audio umständlich.
|
|
||||||
- **Firma & Geschichte:** Damien Elmes (Australien), gestartet 5.10.2006 ursprünglich für Japanisch-Lernen. Im **Februar 2026** angekündigt, dass AnkiHub (Austin, TX) Business-Operations und Open-Source-Stewardship übernimmt — Anki bleibt Open Source, keine externen Investoren, Versprechen „no enshittification".
|
|
||||||
- **Bedrohungsgrad: Hoch.** Power-User-Standard, riesiges Decks-Ökosystem, kostenlos. Wir können sie nicht im technischen Spielfeld schlagen — wir müssen über UX, Onboarding und „Anki-Import-Bridge" gewinnen.
|
|
||||||
|
|
||||||
Quellen: [Anki Wikipedia](<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.
|
|
||||||
|
|
@ -1,367 +0,0 @@
|
||||||
# Cardecky — Projekt-Leitlinien
|
|
||||||
|
|
||||||
Verbindliche Regeln für den Spinoff. Ziel: in wenigen Wochen ein
|
|
||||||
ausspielbares Web-MVP, das ausschließlich seinen *Core Gameloop*
|
|
||||||
beherrscht und alles andere von zentralen Mana-Bausteinen erbt.
|
|
||||||
|
|
||||||
**Status:** Planungsphase, noch kein Code.
|
|
||||||
**Name:** Cardecky.
|
|
||||||
**App-Domain:** `cardecky.mana.how` (Subdomain unter `*.mana.how`, SSO über mana-auth).
|
|
||||||
**Marketing-Landing:** `cardecky.com` (eigene Domain, statisch, SEO/Akquise — keine Auth, leitet auf `cardecky.mana.how` für die App).
|
|
||||||
**Zugang:** offen für jeden eingeloggten Mana-User (`requiredTier: 'public'`, kein Beta-Gate).
|
|
||||||
|
|
||||||
## 1. Mission in einem Satz
|
|
||||||
|
|
||||||
Die schönste, einfachste Karteikarten-App mit Spaced Repetition —
|
|
||||||
zuerst nur Web, später Mobile, KI-Generierung als Phase 2.
|
|
||||||
|
|
||||||
## 2. Game-Dev-Prinzip: zuerst nur der Core Gameloop
|
|
||||||
|
|
||||||
Wie bei einem Spielprototyp gilt: alles, was nicht zum Loop gehört,
|
|
||||||
wird zurückgestellt. Erst wenn der Loop sich gut anfühlt und Nutzer ihn
|
|
||||||
freiwillig wiederholen, wird gebaut, was drumherum gehört.
|
|
||||||
|
|
||||||
### Der Core Gameloop von Cardecky
|
|
||||||
|
|
||||||
```
|
|
||||||
Start
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
"Du hast N Karten heute fällig" ─────► (wenn 0: "Alles gelernt — komm später wieder")
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Lernen starten]
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Vorderseite zeigen ──► User denkt ──► Tap/Space ──► Rückseite zeigen
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Selbst-Bewertung: 1=nochmal · 2=schwer · 3=gut · 4=leicht
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
FSRS rechnet next-due ──► nächste Karte (oder Session-Ende)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Session-Ende: "X Karten gelernt, nächste in Y Stunden"
|
|
||||||
│
|
|
||||||
└─► zurück zum Start
|
|
||||||
```
|
|
||||||
|
|
||||||
Sekundäre Loops (Karten erstellen, Decks verwalten) werden gebaut, sind
|
|
||||||
aber UI-arm. **Tertiäre Loops (KI-Generierung, Voice, Sharing) sind
|
|
||||||
Phase 2 und werden in Phase 1 nicht angefasst.**
|
|
||||||
|
|
||||||
### Was Phase 1 enthält
|
|
||||||
|
|
||||||
- Decks anlegen / löschen / umbenennen
|
|
||||||
- Karten manuell erstellen (Markdown-Inhalt)
|
|
||||||
- **Kartentypen:** Basic, Basic + Reverse, Cloze, Type-In (siehe §6)
|
|
||||||
- Lernsession mit FSRS v6, **inklusive per-User-Parameter-Tuning**
|
|
||||||
- "Heute fällig"-Übersicht + Streak-Zähler
|
|
||||||
- Tags auf Decks (das Modul hat sie ohnehin schon, raus wäre Mehrarbeit)
|
|
||||||
- PWA-installierbar, offline-fähig
|
|
||||||
- Auth via mana-auth, Sync via mana-sync
|
|
||||||
|
|
||||||
### Was Phase 1 absichtlich NICHT enthält
|
|
||||||
|
|
||||||
- KI-Generierung von Karten (kein PDF-Upload, keine Bild→Karte)
|
|
||||||
- Voice/TTS-Lernen
|
|
||||||
- Anki-Import / Export
|
|
||||||
- Statistik-Dashboards (nur Streak + Tagessumme)
|
|
||||||
- Public Decks / Marktplatz / Sharing
|
|
||||||
- Stripe / Bezahlung
|
|
||||||
- Mobile-App (PWA-tauglich aber kein Expo)
|
|
||||||
- Eigene Domain & Marketing-Landing
|
|
||||||
- Mehrsprachigkeit über Deutsch hinaus
|
|
||||||
- Bilder / Audio in Karten
|
|
||||||
- Image-Occlusion-Karten, Audio-Karten, Multiple-Choice
|
|
||||||
- Custom Card-Templates / WYSIWYG-Editor
|
|
||||||
- Erweiterte Suche
|
|
||||||
|
|
||||||
Jede dieser Features ist legitim — aber nur, wenn der Loop steht.
|
|
||||||
|
|
||||||
## 3. Goldene Regeln
|
|
||||||
|
|
||||||
1. **Simpel schlägt vollständig.** Wenn ein Feature nicht zum Core Gameloop gehört, kommt es in einen Phase-2-Backlog, nicht in den Code.
|
|
||||||
2. **Open Source only.** Jede Library, jedes Tool, jeder Dienst muss eine OSI-konforme Lizenz haben (MIT, Apache 2.0, BSD, MPL, AGPL akzeptabel). Keine Closed-Source-SDKs, keine proprietären APIs als Pflichtabhängigkeit.
|
|
||||||
3. **Bevorzugt was im Verein schon läuft.** Neue Technologie nur einführen, wenn ein konkreter Engpass es verlangt und kein vorhandenes Tool es löst.
|
|
||||||
4. **Zentrale Mana-Dienste statt Eigenbau.** Auth, Sync, Analytics, Notifications, Media usw. werden NICHT neu gebaut — siehe §5.
|
|
||||||
5. **Local-First wie der Rest des Verein-Stacks.** IndexedDB als Quelle der Wahrheit, Sync nach Postgres im Hintergrund.
|
|
||||||
6. **`cardecky.mana.how` als Subdomain unter `*.mana.how`.** Kein eigenes Auth-System, kein eigenes Hosting-Setup — Eintrag in `PRODUCTION_TRUSTED_ORIGINS` + Cloudflare-Tunnel-Route reichen.
|
|
||||||
7. **Eine UI-Schicht, ein Theme.** Wir verwenden `@mana/shared-theme(-ui)` und `@mana/shared-ui` so weit es geht — kein paralleles Design-System.
|
|
||||||
8. **Erweiterbare Daten, simples UI.** Das Datenmodell denkt zukünftige Kartentypen mit (siehe §6), das UI zeigt in Phase 1 nur die vier definierten Typen.
|
|
||||||
|
|
||||||
## 4. Tech-Stack (Phase 1)
|
|
||||||
|
|
||||||
Alles bereits im Verein verwendet, alles OSI-Open-Source.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
| Schicht | Wahl | Lizenz |
|
|
||||||
|---|---|---|
|
|
||||||
| Framework | SvelteKit 2 | MIT |
|
|
||||||
| UI-Sprache | Svelte 5 (Runes) | MIT |
|
|
||||||
| Sprache | TypeScript 5 | Apache-2.0 |
|
|
||||||
| Styling | Tailwind CSS 4 | MIT |
|
|
||||||
| Build/Dev | Vite | MIT |
|
|
||||||
| PWA | `@vite-pwa/sveltekit` (über `@mana/shared-pwa`) | MIT |
|
|
||||||
| Icons | über `@mana/shared-icons` | MIT |
|
|
||||||
| Markdown-Render | `marked` + `DOMPurify` | MIT |
|
|
||||||
|
|
||||||
### Datenhaltung (Client)
|
|
||||||
| Schicht | Wahl | Lizenz |
|
|
||||||
|---|---|---|
|
|
||||||
| Local Store | IndexedDB via Dexie | Apache-2.0 |
|
|
||||||
| Local-Store-Wrapper | `@mana/local-store` (intern) | — |
|
|
||||||
| Verschlüsselung | AES-GCM-256 via `@mana/shared-crypto` (Phase 2 — Hooks bereits an allen Schreib-/Lese-Pfaden, Wirkung deferred bis Vault-Server-Roundtrip steht; siehe `src/lib/data/crypto.ts`) | — |
|
|
||||||
|
|
||||||
### Spaced Repetition
|
|
||||||
| Schicht | Wahl | Lizenz |
|
|
||||||
|---|---|---|
|
|
||||||
| Algorithmus | FSRS v6 (Free Spaced Repetition Scheduler) | BSD-3 |
|
|
||||||
| TS-Implementation | `ts-fsrs` (offizielle Portierung, mit Optimizer) | MIT |
|
|
||||||
| Per-User-Tuning | `ts-fsrs`-Optimizer, läuft client-seitig nach ≥ 50 Reviews | MIT |
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
| Schicht | Wahl | Lizenz |
|
|
||||||
|---|---|---|
|
|
||||||
| Adapter | `@sveltejs/adapter-node` | MIT |
|
|
||||||
| Container | Docker, hinter Cloudflare Tunnel | Apache-2.0 |
|
|
||||||
| Host | Mac mini (siehe `docker-compose.macmini.yml`) | — |
|
|
||||||
|
|
||||||
### Tooling
|
|
||||||
| Schicht | Wahl | Lizenz |
|
|
||||||
|---|---|---|
|
|
||||||
| Paket-Manager | pnpm 9 | MIT |
|
|
||||||
| Monorepo-Orchestrierung | Turborepo (vorhanden) | MPL-2.0 |
|
|
||||||
| Linting | ESLint (`@mana/eslint-config`) | MIT |
|
|
||||||
| Formatierung | Prettier | MIT |
|
|
||||||
| Tests (Unit) | Vitest | MIT |
|
|
||||||
| Tests (E2E) | Playwright | Apache-2.0 |
|
|
||||||
| TS-Config | `@mana/test-config`, `@mana/shared-vite-config` | — |
|
|
||||||
|
|
||||||
### Backend in Phase 1: keiner
|
|
||||||
|
|
||||||
Phase 1 braucht **keinen eigenen Service**. Lese-/Schreibpfad geht
|
|
||||||
ausschließlich über IndexedDB → `mana-sync` (existiert) → Postgres.
|
|
||||||
|
|
||||||
Erst wenn KI-Generierung (Phase 2) dazukommt, entsteht
|
|
||||||
`services/cards-server` (Hono + Bun, analog zu allen anderen
|
|
||||||
Verein-Services).
|
|
||||||
|
|
||||||
## 5. Zentrale Mana-Bausteine (Pflicht in Phase 1)
|
|
||||||
|
|
||||||
### Services (laufen bereits, nur konsumieren)
|
|
||||||
| Service | Port | Wofür in Cardecky |
|
|
||||||
|---|---|---|
|
|
||||||
| `mana-auth` | 3001 | SSO, JWT, Sessions, Tier-Claims. Cardecky-Origin in `PRODUCTION_TRUSTED_ORIGINS` eintragen. |
|
|
||||||
| `mana-sync` | 3050 | Sync der `cards`-AppId-Daten (Decks, Karten, Reviews, StudyBlocks). |
|
|
||||||
| `mana-user` | 3062 | Profilinfos / Settings. |
|
|
||||||
| `mana-analytics` | 3064 | Page-Views, Loop-Events (siehe §11). |
|
|
||||||
| `mana-events` | 3115 | Domain-Events für Streak-Logik. |
|
|
||||||
| `mana-notify` | 3040 | "Du hast X Karten fällig"-Push (Phase 1.5). |
|
|
||||||
| `mana-credits` | 3061 | **Erst Phase 2** (KI-Generierung). |
|
|
||||||
| `mana-subscriptions` | 3063 | **Erst Phase 2** (Pro-Tier). |
|
|
||||||
| `mana-llm`, `mana-stt`, `mana-tts` | – | **Erst Phase 2.** |
|
|
||||||
| `mana-media` | 3015 | **Erst wenn Bilder in Karten erlaubt sind.** |
|
|
||||||
|
|
||||||
### Workspace-Pakete (`@mana/*`)
|
|
||||||
| Paket | Wofür in Cardecky |
|
|
||||||
|---|---|
|
|
||||||
| `@mana/shared-auth` | Client-seitiger Auth-Hook (SSO-Flow, JWT-Handling). |
|
|
||||||
| `@mana/shared-auth-ui` | Login/Logout-Komponenten. |
|
|
||||||
| `@mana/shared-hono` | (sobald cards-server existiert) Auth-/Health-/Error-Middleware. |
|
|
||||||
| `@mana/shared-branding` | App-Registry-Eintrag (Tier=`public`, Branding, Subdomain). |
|
|
||||||
| `@mana/shared-types` | Geteilte TS-Typen. |
|
|
||||||
| `@mana/shared-utils` | Utility-Funktionen. |
|
|
||||||
| `@mana/shared-ui` | UI-Komponenten. |
|
|
||||||
| `@mana/shared-theme`, `@mana/shared-theme-ui` | Theme-Tokens, Dark/Light. |
|
|
||||||
| `@mana/shared-tailwind` | Tailwind-Preset. |
|
|
||||||
| `@mana/shared-i18n` | Übersetzungsfundament (Phase 1: nur DE registriert). |
|
|
||||||
| `@mana/shared-icons` | Icon-Set. |
|
|
||||||
| `@mana/shared-privacy` | Visibility-Enum für Decks (Sharing erst Phase 2, aber Feld vorbereitet). |
|
|
||||||
| `@mana/shared-crypto` | AES-GCM-256 für sensible Felder. |
|
|
||||||
| `@mana/shared-pwa` | Manifest, Service-Worker, Install-Prompt. |
|
|
||||||
| `@mana/shared-vite-config` | Vite-Defaults. |
|
|
||||||
| `@mana/shared-error-tracking` | Error-Reporting. |
|
|
||||||
| `@mana/shared-logger` | Strukturiertes Logging (Server-Seite, sobald relevant). |
|
|
||||||
| `@mana/shared-stores` | Geteilte Local-Store-Helpers. |
|
|
||||||
| `@mana/shared-tags` | Tags auf Decks. |
|
|
||||||
| `@mana/local-store` | Dexie-Setup, Sync-Hooks. |
|
|
||||||
| `@mana/eslint-config` | Lint-Regeln. |
|
|
||||||
| `@mana/test-config` | Vitest-Defaults. |
|
|
||||||
| `@mana/feedback` | In-App-Feedback-Widget. |
|
|
||||||
| `@mana/help` | Hilfe-Overlay. |
|
|
||||||
|
|
||||||
**Erst Phase 2 oder später:** `@mana/shared-llm`, `@mana/shared-ai`,
|
|
||||||
`@mana/local-llm`, `@mana/local-stt`, `@mana/credits`, `@mana/qr-export`,
|
|
||||||
`@mana/wallpaper-generator`, `@mana/website-blocks`,
|
|
||||||
`@mana/shared-research`, `@mana/shared-uload`, `@mana/shared-storage`.
|
|
||||||
|
|
||||||
### Datenpfad
|
|
||||||
|
|
||||||
Cardecky übernimmt 1:1 das Mana-Datenpfad-Pattern:
|
|
||||||
|
|
||||||
```
|
|
||||||
User-Aktion → Store → encryptRecord → Dexie → Hooks (_pendingChanges)
|
|
||||||
→ mana-sync → Postgres (mana_platform.cards.*) → andere Clients
|
|
||||||
```
|
|
||||||
|
|
||||||
appId = `cards`. Tabellen: `cardDecks`, `cards`, `cardReviews`,
|
|
||||||
`cardStudyBlocks`, `deckTags`.
|
|
||||||
|
|
||||||
## 6. Datenmodell — erweiterbar gedacht
|
|
||||||
|
|
||||||
Heutiges Modul kennt nur `front`/`back`. Damit weitere Kartentypen
|
|
||||||
ohne Schema-Bruch dazukommen, wechseln wir auf ein **Felder-Map +
|
|
||||||
Typ-Diskriminator**:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type CardType =
|
|
||||||
| 'basic' // Phase 1: front/back
|
|
||||||
| 'basic-reverse' // Phase 1: erzeugt zwei Lernrichtungen aus einer Karte
|
|
||||||
| 'cloze' // Phase 1: Lückentext, eine Subkarte pro Cluster
|
|
||||||
| 'type-in' // Phase 1: User tippt Antwort, exact-match-Vergleich
|
|
||||||
| 'image-occlusion' // Phase 2
|
|
||||||
| 'audio' // Phase 2
|
|
||||||
| 'multiple-choice' // ggf. Phase 2
|
|
||||||
|
|
||||||
interface LocalCard extends BaseRecord {
|
|
||||||
deckId: string
|
|
||||||
type: CardType
|
|
||||||
fields: Record<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.
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Cardecky
|
|
||||||
|
|
||||||
Spaced-repetition flashcards on **cardecky.mana.how**.
|
|
||||||
|
|
||||||
Phase-1 standalone web app. The frontend lives here; data, auth, and
|
|
||||||
sync are shared with the rest of the Mana stack:
|
|
||||||
|
|
||||||
- **Auth:** mana-auth (SSO), `*.mana.how`
|
|
||||||
- **Sync:** mana-sync, app-id `cards`
|
|
||||||
- **Storage:** `mana_platform.cards.*` (Postgres, RLS)
|
|
||||||
|
|
||||||
The same `cards` data backs the **mana** built-in Cardecky module at
|
|
||||||
`mana.how/cards`. Schema changes ship to both frontends together — see
|
|
||||||
`apps/cards/GUIDELINES.md`.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/cards/
|
|
||||||
├── apps/
|
|
||||||
│ └── web/ # SvelteKit 2 + Svelte 5 — the Phase-1 surface
|
|
||||||
├── GUIDELINES.md # Project rules (read first)
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
`apps/cards/apps/mobile/` and any production `apps/cards/apps/landing/`
|
|
||||||
will land in Phase 2/3.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm --filter @cards/web dev # cardecky.mana.how on http://localhost:5180
|
|
||||||
```
|
|
||||||
7
apps/cards/apps/web/.gitignore
vendored
7
apps/cards/apps/web/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
.svelte-kit
|
|
||||||
build
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# syntax=docker/dockerfile:1
|
|
||||||
# Cardecky Standalone — cardecky.mana.how. Mirrors apps/manavoxel/apps/web/Dockerfile.
|
|
||||||
|
|
||||||
# ─── Stage 1: Build ──────────────────────────────────────────
|
|
||||||
FROM sveltekit-base:local AS builder
|
|
||||||
|
|
||||||
ARG PUBLIC_MANA_AUTH_URL=http://mana-auth:3001
|
|
||||||
ARG PUBLIC_SYNC_SERVER_URL=http://mana-sync:3050
|
|
||||||
ENV PUBLIC_MANA_AUTH_URL=$PUBLIC_MANA_AUTH_URL
|
|
||||||
ENV PUBLIC_SYNC_SERVER_URL=$PUBLIC_SYNC_SERVER_URL
|
|
||||||
|
|
||||||
# Cards-specific app sources. The shared @mana/* packages already live in
|
|
||||||
# the sveltekit-base image; we only copy what's unique to this app.
|
|
||||||
COPY apps/cards/apps/web ./apps/cards/apps/web
|
|
||||||
COPY packages/cards-core ./packages/cards-core
|
|
||||||
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
|
||||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
|
||||||
|
|
||||||
WORKDIR /app/apps/cards/apps/web
|
|
||||||
RUN pnpm exec svelte-kit sync
|
|
||||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
|
||||||
|
|
||||||
# ─── Stage 2: Production ─────────────────────────────────────
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
WORKDIR /app/apps/cards/apps/web
|
|
||||||
|
|
||||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
|
||||||
COPY --from=builder /app/apps/cards/apps/web/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/apps/cards/apps/web/build ./build
|
|
||||||
COPY --from=builder /app/apps/cards/apps/web/package.json ./
|
|
||||||
|
|
||||||
EXPOSE 5180
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=5180
|
|
||||||
ENV HOST=0.0.0.0
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5180/ || exit 1
|
|
||||||
|
|
||||||
CMD ["node", "build"]
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@cards/web",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev --port 5180",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview --port 5180",
|
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@mana/shared-vite-config": "workspace:*",
|
|
||||||
"@sveltejs/adapter-node": "^5.0.0",
|
|
||||||
"@sveltejs/kit": "^2.47.1",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.4",
|
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
|
||||||
"@types/node": "^22.10.5",
|
|
||||||
"@types/sql.js": "^1.4.11",
|
|
||||||
"@vite-pwa/sveltekit": "^1.1.0",
|
|
||||||
"svelte": "^5.41.0",
|
|
||||||
"svelte-check": "^4.3.3",
|
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"typescript": "^5.7.2",
|
|
||||||
"vite": "^6.0.7"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mana/cards-core": "workspace:*",
|
|
||||||
"@mana/local-store": "workspace:*",
|
|
||||||
"@mana/shared-auth": "workspace:*",
|
|
||||||
"@mana/shared-auth-ui": "workspace:*",
|
|
||||||
"@mana/shared-branding": "workspace:*",
|
|
||||||
"@mana/shared-icons": "workspace:*",
|
|
||||||
"@mana/shared-privacy": "workspace:*",
|
|
||||||
"@mana/shared-pwa": "workspace:*",
|
|
||||||
"@mana/shared-stores": "workspace:*",
|
|
||||||
"@mana/shared-tailwind": "workspace:*",
|
|
||||||
"@mana/shared-theme": "workspace:*",
|
|
||||||
"@mana/shared-theme-ui": "workspace:*",
|
|
||||||
"@mana/shared-types": "workspace:*",
|
|
||||||
"@mana/shared-utils": "workspace:*",
|
|
||||||
"dexie": "^4.4.1",
|
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"pdfjs-dist": "^5.7.284",
|
|
||||||
"sql.js": "^1.14.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
@import 'tailwindcss';
|
|
||||||
@import '@mana/shared-tailwind/themes.css';
|
|
||||||
@import '@mana/shared-tailwind/sources.css';
|
|
||||||
|
|
||||||
/* Phase A — Cards now lives on the unified @mana/shared-theme tokens.
|
|
||||||
The placeholder --color-cards-* palette is gone; everything goes
|
|
||||||
through `--color-{background,foreground,surface,muted,…}` from
|
|
||||||
shared-tailwind. The runtime `createThemeStore({ appId: 'cards' })`
|
|
||||||
in +layout.svelte writes the live variant + mode onto the
|
|
||||||
document. */
|
|
||||||
|
|
||||||
/* Cloze rendering — produced by @mana/cards-core/render. Uses the
|
|
||||||
active app accent so the highlight follows the Cards brand. */
|
|
||||||
.cloze-blank {
|
|
||||||
background: hsl(var(--color-app-accent) / 0.18);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
color: hsl(var(--color-app-accent));
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
mark.cloze-active {
|
|
||||||
background: hsl(var(--color-success) / 0.2);
|
|
||||||
color: hsl(var(--color-success));
|
|
||||||
padding: 0.05rem 0.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Minimal styling for HTML produced by marked() — Tailwind v4 ships
|
|
||||||
without typography plugin so we set the basics by hand. */
|
|
||||||
.card-content :where(p, ul, ol) {
|
|
||||||
margin-block: 0.5rem;
|
|
||||||
}
|
|
||||||
.card-content :where(ul) {
|
|
||||||
list-style: disc;
|
|
||||||
padding-inline-start: 1.25rem;
|
|
||||||
}
|
|
||||||
.card-content :where(ol) {
|
|
||||||
list-style: decimal;
|
|
||||||
padding-inline-start: 1.25rem;
|
|
||||||
}
|
|
||||||
.card-content :where(code) {
|
|
||||||
background: hsl(var(--color-muted) / 0.6);
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
.card-content :where(pre) {
|
|
||||||
background: hsl(var(--color-muted) / 0.4);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.card-content :where(a) {
|
|
||||||
color: hsl(var(--color-app-accent));
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.card-content :where(strong) {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.card-content :where(em) {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
16
apps/cards/apps/web/src/app.d.ts
vendored
16
apps/cards/apps/web/src/app.d.ts
vendored
|
|
@ -1,16 +0,0 @@
|
||||||
// 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 {};
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
/**
|
|
||||||
* AI card generation — text → list of basic cards via mana-llm.
|
|
||||||
*
|
|
||||||
* Uses mana-llm's OpenAI-compatible /v1/chat/completions endpoint with
|
|
||||||
* a system prompt that constrains the output to a JSON array. We strip
|
|
||||||
* Markdown code fences before parsing because most chat models wrap
|
|
||||||
* JSON output in ```json blocks even when explicitly told not to.
|
|
||||||
*
|
|
||||||
* No streaming — we need the full JSON before we can show anything.
|
|
||||||
* Phase-2 ideas: chunk long inputs, PDF parsing, image OCR.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Du bist ein Karteikarten-Generator. Aus dem vom Nutzer gegebenen Text erstellst du Lernkarten zum Auswendiglernen.
|
|
||||||
|
|
||||||
Regeln:
|
|
||||||
- Antworte AUSSCHLIESSLICH mit einem JSON-Array, ohne Erklärung, ohne Markdown-Code-Fences.
|
|
||||||
- Schema: [{"front": "Frage oder Begriff", "back": "Antwort"}, ...]
|
|
||||||
- 5–15 Karten je nach Textlänge.
|
|
||||||
- Front: kurze, präzise Frage oder ein Begriff. Back: prägnante Antwort, max. 2 Sätze.
|
|
||||||
- Eine Karte pro klar abgegrenzter Faktenerinnerung — nicht ganze Absätze umkopieren.
|
|
||||||
- Sprache: dieselbe wie der Quelltext.`;
|
|
||||||
|
|
||||||
export interface GeneratedCard {
|
|
||||||
front: string;
|
|
||||||
back: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function llmUrl(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const fromWindow = (window as unknown as { __PUBLIC_MANA_LLM_URL__?: string })
|
|
||||||
.__PUBLIC_MANA_LLM_URL__;
|
|
||||||
if (fromWindow) return fromWindow.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
return 'http://localhost:3025';
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCodeFences(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/^\s*```(?:json|javascript|js)?\s*/i, '')
|
|
||||||
.replace(/\s*```\s*$/i, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultModel(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const fromWindow = (window as unknown as { __PUBLIC_CARDS_AI_MODEL__?: string })
|
|
||||||
.__PUBLIC_CARDS_AI_MODEL__;
|
|
||||||
if (fromWindow) return fromWindow;
|
|
||||||
}
|
|
||||||
// mana-llm proxies many providers — this id matches what the
|
|
||||||
// playground module uses as a sensible default. Adjust per env via
|
|
||||||
// __PUBLIC_CARDS_AI_MODEL__ injection.
|
|
||||||
return 'gpt-4o-mini';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateCardsFromText(
|
|
||||||
source: string,
|
|
||||||
opts: { model?: string; signal?: AbortSignal } = {}
|
|
||||||
): Promise<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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
/**
|
|
||||||
* PDF text extraction using pdfjs-dist.
|
|
||||||
*
|
|
||||||
* Loads each page, walks the text layer, joins items with spaces and
|
|
||||||
* pages with double newlines so the LLM gets a structured input. We
|
|
||||||
* don't try to preserve columns / tables — the use case is "feed me
|
|
||||||
* the prose so I can make cards", not document fidelity.
|
|
||||||
*
|
|
||||||
* Worker is wired via Vite's `?worker` suffix so the heavy parsing
|
|
||||||
* happens off the main thread (PDF extraction is CPU-heavy).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as pdfjs from 'pdfjs-dist';
|
|
||||||
import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker';
|
|
||||||
|
|
||||||
let workerWired = false;
|
|
||||||
function ensureWorker() {
|
|
||||||
if (workerWired) return;
|
|
||||||
pdfjs.GlobalWorkerOptions.workerPort = new PdfjsWorker();
|
|
||||||
workerWired = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PdfExtractResult {
|
|
||||||
text: string;
|
|
||||||
pageCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function extractTextFromPdf(file: File | Blob): Promise<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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
/**
|
|
||||||
* Apply a `ParsedAnki` to the local DB.
|
|
||||||
*
|
|
||||||
* Strategy: every Anki deck becomes one of our decks (1:1, name-mapped).
|
|
||||||
* Card content is HTML-sanitized to plain Markdown / inline media tags
|
|
||||||
* before save. Reviews are auto-generated by reviewStore.ensureReviewsForCard
|
|
||||||
* — the imported cards become "new" in the FSRS sense, no inherited schedule.
|
|
||||||
*
|
|
||||||
* Media: every referenced file is uploaded to mana-media first; the
|
|
||||||
* resulting URL replaces the original Anki filename in the field text.
|
|
||||||
* Files referenced from no card are skipped — many Anki decks bundle
|
|
||||||
* orphaned media that bloats the upload time.
|
|
||||||
*
|
|
||||||
* No de-dupe: re-importing the same .apkg adds duplicate decks. The UI
|
|
||||||
* warns about this once we decide it matters.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { deckStore } from '../stores/decks.svelte';
|
|
||||||
import { cardStore } from '../stores/cards.svelte';
|
|
||||||
import { uploadCardMedia, MediaUploadError } from '../media/upload';
|
|
||||||
import { sanitizeAnkiHtml, type ParsedAnki } from './parse';
|
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
decksCreated: number;
|
|
||||||
cardsCreated: number;
|
|
||||||
mediaUploaded: number;
|
|
||||||
mediaFailed: number;
|
|
||||||
failed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaProgress {
|
|
||||||
uploaded: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEDIA_CONCURRENCY = 4;
|
|
||||||
// Anki's <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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
/**
|
|
||||||
* Parse an Anki .apkg / .colpkg file in the browser.
|
|
||||||
*
|
|
||||||
* .apkg = ZIP archive containing a SQLite collection (`collection.anki2`
|
|
||||||
* or `collection.anki21`) plus media files. We open the SQLite blob with
|
|
||||||
* sql.js (WASM-backed in-browser SQLite) and walk Anki's three core
|
|
||||||
* tables: `col` (collection meta with JSON-encoded models + decks),
|
|
||||||
* `notes` (the user-typed content), and `cards` (one row per learnable
|
|
||||||
* unit — basic = 1, basic-reverse = 2, cloze = N).
|
|
||||||
*
|
|
||||||
* MVP scope: basic + basic-reverse + cloze. Image/audio media is
|
|
||||||
* skipped (Phase 2). Review history is skipped — FSRS state will be
|
|
||||||
* regenerated on first sight.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import JSZip, { type JSZipObject } from 'jszip';
|
|
||||||
import initSqlJs, { type Database } from 'sql.js';
|
|
||||||
import type { CardType } from '@mana/cards-core';
|
|
||||||
|
|
||||||
export interface ParsedDeck {
|
|
||||||
ankiId: string; // Anki's numeric deck id, stringified
|
|
||||||
name: string; // "Studies::Spanish" — Anki uses :: as separator
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedCard {
|
|
||||||
ankiDeckId: string;
|
|
||||||
type: CardType;
|
|
||||||
fields: Record<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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,505 +0,0 @@
|
||||||
/**
|
|
||||||
* Thin client for cards-server (https://cardecky-api.mana.how / dev :3072).
|
|
||||||
*
|
|
||||||
* The auth-store provides the JWT; we never read tokens from storage
|
|
||||||
* here directly so there's only one place that knows about token
|
|
||||||
* lifecycle (refresh, expiry, vault).
|
|
||||||
*
|
|
||||||
* All endpoints under /v1 require auth; the wrapper just always
|
|
||||||
* sends `Authorization: Bearer …`. Errors come back as Hono's
|
|
||||||
* `{ statusCode, message, details? }` shape — we surface that to
|
|
||||||
* callers via the typed `CardsApiError` so UIs can branch on it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
|
||||||
|
|
||||||
function baseUrl(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const fromWindow = (window as unknown as { __PUBLIC_CARDS_API_URL__?: string })
|
|
||||||
.__PUBLIC_CARDS_API_URL__;
|
|
||||||
if (fromWindow) return fromWindow.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
return 'http://localhost:3072';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CardsApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public status: number,
|
|
||||||
message: string,
|
|
||||||
public details?: unknown
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'CardsApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequestOptions {
|
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
||||||
body?: unknown;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
/**
|
|
||||||
* - `true` (default): require an Authorization header — throws 401 if no token.
|
|
||||||
* - `'optional'`: include token if available, otherwise send anonymously.
|
|
||||||
* - `false`: never send a token.
|
|
||||||
*/
|
|
||||||
auth?: boolean | 'optional';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* CardFace — renders one learnable unit (a single subIndex of a card)
|
|
||||||
* for any Phase-1 card type. Stateless: the parent owns `showBack`,
|
|
||||||
* `typedAnswer`, and any timing.
|
|
||||||
*
|
|
||||||
* 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>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
/**
|
|
||||||
* Encryption wrapper — Phase-1 stub.
|
|
||||||
*
|
|
||||||
* The full Mana crypto stack (vault server roundtrip, KEK-wrapped
|
|
||||||
* master key, recovery codes, zero-knowledge mode) lives in the mana
|
|
||||||
* web app under `apps/mana/.../data/crypto/`. Lifting it intact into
|
|
||||||
* the standalone Cards app is a Phase-2 task — it requires a vault
|
|
||||||
* client, key provider, and boot-race handling that aren't worth
|
|
||||||
* dragging in until we have the deployment story for them.
|
|
||||||
*
|
|
||||||
* For Phase 1 these helpers are intentionally identity functions:
|
|
||||||
* data lands in IndexedDB and on `mana-sync` as plaintext. Everything
|
|
||||||
* is wired up at the right call sites (stores → write, queries → read,
|
|
||||||
* sync.applyServerChanges → apply) so flipping to real encryption is a
|
|
||||||
* single-file change here, not a sweep through every store.
|
|
||||||
*
|
|
||||||
* Allowlist is the contract with the future vault. It mirrors the
|
|
||||||
* mana-modul registry exactly so when sync converges, the same fields
|
|
||||||
* are protected on both ends.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ENCRYPTED_FIELDS: Record<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] ?? [];
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
/**
|
|
||||||
* Standalone Cards Dexie database.
|
|
||||||
*
|
|
||||||
* Phase-1 sync: every write to a sync-relevant table fires a Dexie hook
|
|
||||||
* that records a row into `_pendingChanges`. The sync engine drains
|
|
||||||
* that queue against `mana-sync` (POST /sync/cards). When server changes
|
|
||||||
* come back, they're applied with `beginApplying(table)` set so the
|
|
||||||
* hooks suppress queueing for those rows — otherwise client and server
|
|
||||||
* would ping-pong forever.
|
|
||||||
*
|
|
||||||
* Encryption is intentionally NOT wired here. Phase-1 ships plaintext;
|
|
||||||
* Etappe 3c.3 turns it on once the vault client is in place.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Dexie, { type Table } from 'dexie';
|
|
||||||
import type { LocalDeck, LocalCard, LocalCardReview, LocalCardStudyBlock } from '@mana/cards-core';
|
|
||||||
|
|
||||||
interface DeckTag {
|
|
||||||
id: string;
|
|
||||||
deckId: string;
|
|
||||||
tagId: string;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
deletedAt?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Server protocol expects this shape on push. */
|
|
||||||
export interface FieldChange {
|
|
||||||
value: unknown;
|
|
||||||
at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChangeOp = 'insert' | 'update' | 'delete';
|
|
||||||
|
|
||||||
export interface PendingChange {
|
|
||||||
/** Auto-increment PK (Dexie ++id). */
|
|
||||||
pk?: number;
|
|
||||||
table: string;
|
|
||||||
id: string;
|
|
||||||
op: ChangeOp;
|
|
||||||
fields?: Record<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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
/**
|
|
||||||
* Cards sync engine — talks to mana-sync (POST /sync/cards, GET /sync/cards/pull).
|
|
||||||
*
|
|
||||||
* Two loops, both polling-based for the Phase-1 MVP. WebSocket
|
|
||||||
* notifications can replace the pull poll later without changing
|
|
||||||
* anything outside this file.
|
|
||||||
*
|
|
||||||
* Push: drain `_pendingChanges` every 1s when there's anything queued.
|
|
||||||
* On success, delete drained rows and apply any server-changes
|
|
||||||
* the response carried back. Failures keep the rows queued —
|
|
||||||
* the next tick retries.
|
|
||||||
*
|
|
||||||
* Pull: every 5s, ask each sync table for changes since its cursor.
|
|
||||||
* Apply with suppression so the apply doesn't re-enqueue a push.
|
|
||||||
* Cursor lives in localStorage per table.
|
|
||||||
*
|
|
||||||
* Cursor format: ISO timestamp string. The server returns
|
|
||||||
* `syncedUntil` on push and we store that as a global push cursor; pull
|
|
||||||
* uses one cursor per collection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import {
|
|
||||||
beginApplying,
|
|
||||||
endApplying,
|
|
||||||
db,
|
|
||||||
pendingChangesTable,
|
|
||||||
type PendingChange,
|
|
||||||
} from './database';
|
|
||||||
import { encryptRecord } from './crypto';
|
|
||||||
|
|
||||||
const APP_ID = 'cards';
|
|
||||||
const PUSH_INTERVAL_MS = 1_000;
|
|
||||||
const PULL_INTERVAL_MS = 5_000;
|
|
||||||
const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags'];
|
|
||||||
|
|
||||||
// ─── URL + Auth wiring ─────────────────────────────────────
|
|
||||||
|
|
||||||
function getSyncUrl(): string {
|
|
||||||
if (browser && typeof window !== 'undefined') {
|
|
||||||
const injected = (window as unknown as { __PUBLIC_MANA_SYNC_URL__?: string })
|
|
||||||
.__PUBLIC_MANA_SYNC_URL__;
|
|
||||||
if (injected) return injected;
|
|
||||||
}
|
|
||||||
return import.meta.env.DEV ? 'http://localhost:3050' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthLike {
|
|
||||||
getValidToken?: () => Promise<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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
export {};
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/**
|
|
||||||
* Upload an image or audio file to mana-media and get back a media id
|
|
||||||
* + a public URL ready to drop into a card field.
|
|
||||||
*
|
|
||||||
* Resolves the media base URL from window.__PUBLIC_MANA_MEDIA_URL__
|
|
||||||
* (injected by hooks.server.ts) so the same code works in dev (when
|
|
||||||
* mana-media runs on localhost) and prod (https://media.mana.how).
|
|
||||||
*
|
|
||||||
* 25 MB hard-cap mirrors the website-upload pattern in mana-web.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MAX_BYTES = 25 * 1024 * 1024;
|
|
||||||
|
|
||||||
export class MediaUploadError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public status?: number
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MediaUploadError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mediaBaseUrl(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
|
|
||||||
.__PUBLIC_MANA_MEDIA_URL__;
|
|
||||||
if (fromWindow) return fromWindow.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
return 'http://localhost:3015';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadedMedia {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
kind: 'image' | 'audio' | 'video' | 'other';
|
|
||||||
}
|
|
||||||
|
|
||||||
function classify(mime: string): UploadedMedia['kind'] {
|
|
||||||
if (mime.startsWith('image/')) return 'image';
|
|
||||||
if (mime.startsWith('audio/')) return 'audio';
|
|
||||||
if (mime.startsWith('video/')) return 'video';
|
|
||||||
return 'other';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadCardMedia(file: File): Promise<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
/**
|
|
||||||
* Reactive queries — standalone.
|
|
||||||
*
|
|
||||||
* Wraps Dexie's liveQuery so Svelte components get auto-updates whenever
|
|
||||||
* the underlying tables change. Type converters mirror the mana-modul
|
|
||||||
* shape so component code stays portable.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { liveQuery } from 'dexie';
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
cardDeckTable,
|
|
||||||
cardTable,
|
|
||||||
cardReviewTable,
|
|
||||||
cardStudyBlockTable,
|
|
||||||
} from './data/database';
|
|
||||||
import { decryptRecord, decryptRecords } from './data/crypto';
|
|
||||||
import type {
|
|
||||||
CardFields,
|
|
||||||
CardType,
|
|
||||||
Card,
|
|
||||||
CardReview,
|
|
||||||
Deck,
|
|
||||||
LocalCard,
|
|
||||||
LocalCardReview,
|
|
||||||
LocalDeck,
|
|
||||||
} from '@mana/cards-core';
|
|
||||||
|
|
||||||
// ─── Type Converters ───────────────────────────────────────
|
|
||||||
|
|
||||||
export function toDeck(local: LocalDeck): Deck {
|
|
||||||
return {
|
|
||||||
id: local.id,
|
|
||||||
title: local.name,
|
|
||||||
description: local.description ?? undefined,
|
|
||||||
color: local.color,
|
|
||||||
visibility: local.visibility ?? 'private',
|
|
||||||
tags: [],
|
|
||||||
cardCount: local.cardCount,
|
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
|
||||||
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
|
|
||||||
subscribedFromSlug: local.subscribedFromSlug,
|
|
||||||
subscribedAtVersion: local.subscribedAtVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toLogicalCard(local: LocalCard): {
|
|
||||||
type: CardType;
|
|
||||||
fields: CardFields;
|
|
||||||
front: string;
|
|
||||||
back: string;
|
|
||||||
} {
|
|
||||||
const type: CardType = local.type ?? 'basic';
|
|
||||||
const fields: CardFields = local.fields ?? {
|
|
||||||
front: local.front ?? '',
|
|
||||||
back: local.back ?? '',
|
|
||||||
};
|
|
||||||
const front = fields.front ?? local.front ?? '';
|
|
||||||
const back = fields.back ?? local.back ?? '';
|
|
||||||
return { type, fields, front, back };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toCard(local: LocalCard): Card {
|
|
||||||
const { type, fields, front, back } = toLogicalCard(local);
|
|
||||||
return {
|
|
||||||
id: local.id,
|
|
||||||
deckId: local.deckId,
|
|
||||||
type,
|
|
||||||
fields,
|
|
||||||
front,
|
|
||||||
back,
|
|
||||||
order: local.order,
|
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
|
||||||
updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(),
|
|
||||||
serverContentHash: local.serverContentHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCardReview(r: LocalCardReview): CardReview {
|
|
||||||
return {
|
|
||||||
id: r.id,
|
|
||||||
cardId: r.cardId,
|
|
||||||
subIndex: r.subIndex,
|
|
||||||
state: r.state,
|
|
||||||
stability: r.stability,
|
|
||||||
difficulty: r.difficulty,
|
|
||||||
due: r.due,
|
|
||||||
reps: r.reps,
|
|
||||||
lapses: r.lapses,
|
|
||||||
lastReview: r.lastReview,
|
|
||||||
elapsedDays: r.elapsedDays,
|
|
||||||
scheduledDays: r.scheduledDays,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Live Queries ──────────────────────────────────────────
|
|
||||||
|
|
||||||
export function useAllDecks() {
|
|
||||||
return liveQuery(async () => {
|
|
||||||
const all = await cardDeckTable.toArray();
|
|
||||||
const visible = all.filter((d) => !d.deletedAt);
|
|
||||||
const decrypted = await decryptRecords('cardDecks', visible);
|
|
||||||
return decrypted.map(toDeck);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeck(deckId: string) {
|
|
||||||
return liveQuery(async () => {
|
|
||||||
const local = await cardDeckTable.get(deckId);
|
|
||||||
if (!local || local.deletedAt) return null;
|
|
||||||
const decrypted = await decryptRecord('cardDecks', { ...local });
|
|
||||||
return toDeck(decrypted);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCardsByDeck(deckId: string) {
|
|
||||||
return liveQuery(async () => {
|
|
||||||
const visible = (await cardTable.where('deckId').equals(deckId).sortBy('order')).filter(
|
|
||||||
(c) => !c.deletedAt
|
|
||||||
);
|
|
||||||
const decrypted = await decryptRecords('cards', visible);
|
|
||||||
return decrypted.map(toCard);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All reviews due now (or overdue) optionally filtered by deck. Joined
|
|
||||||
* with the parent card so the learn session can render immediately.
|
|
||||||
*/
|
|
||||||
export function useDueReviews(deckId?: string) {
|
|
||||||
return liveQuery(async () => {
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const due = await cardReviewTable.where('due').belowOrEqual(nowIso).toArray();
|
|
||||||
const live = due.filter((r) => !r.deletedAt);
|
|
||||||
if (live.length === 0) return [] as { review: CardReview; card: Card }[];
|
|
||||||
|
|
||||||
const cardIds = [...new Set(live.map((r) => r.cardId))];
|
|
||||||
const cardRows = await db.cards.where('id').anyOf(cardIds).toArray();
|
|
||||||
const decryptedCards = await decryptRecords(
|
|
||||||
'cards',
|
|
||||||
cardRows.filter((c) => !c.deletedAt)
|
|
||||||
);
|
|
||||||
const cardById = new Map(decryptedCards.map((c) => [c.id, toCard(c)] as const));
|
|
||||||
|
|
||||||
return live
|
|
||||||
.filter((r) => {
|
|
||||||
const c = cardById.get(r.cardId);
|
|
||||||
if (!c) return false;
|
|
||||||
if (deckId && c.deckId !== deckId) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0))
|
|
||||||
.map((r) => ({ review: toCardReview(r), card: cardById.get(r.cardId)! }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReview(reviewId: string) {
|
|
||||||
return liveQuery(async () => {
|
|
||||||
const r = await cardReviewTable.get(reviewId);
|
|
||||||
if (!r || r.deletedAt) return null;
|
|
||||||
return toCardReview(r);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of deckId → count of currently-due reviews. Used by the deck list
|
|
||||||
* so the user can see at a glance which deck wants attention without
|
|
||||||
* opening it.
|
|
||||||
*/
|
|
||||||
export function useDueCountByDeck() {
|
|
||||||
return liveQuery(async () => {
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const due = await cardReviewTable.where('due').belowOrEqual(nowIso).toArray();
|
|
||||||
const live = due.filter((r) => !r.deletedAt);
|
|
||||||
if (live.length === 0) return new Map<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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
/**
|
|
||||||
* Subscribe to a marketplace deck and pull its latest version into
|
|
||||||
* the local Dexie. Phase δ.2 — initial pull only; smart-merge of
|
|
||||||
* subsequent updates lands in δ.3 via `applySubscriptionUpdate`
|
|
||||||
* (placeholder export below).
|
|
||||||
*
|
|
||||||
* The subscribed deck shows up alongside own decks but is marked
|
|
||||||
* `subscribedFromSlug` + `subscribedAtVersion` so the UI can hide
|
|
||||||
* mutate controls and show an "Update available" indicator when
|
|
||||||
* cards-server reports a newer version.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cardsApi, CardsApiError } from '$lib/api/cards-api';
|
|
||||||
import type { ServerCard } from '$lib/api/cards-api';
|
|
||||||
import { cardDeckTable, cardTable } from '$lib/data/database';
|
|
||||||
import { reviewStore } from '$lib/stores/reviews.svelte';
|
|
||||||
import type { CardType, LocalCard, LocalDeck } from '@mana/cards-core';
|
|
||||||
|
|
||||||
const ALLOWED_TYPES: CardType[] = [
|
|
||||||
'basic',
|
|
||||||
'basic-reverse',
|
|
||||||
'cloze',
|
|
||||||
'type-in',
|
|
||||||
'image-occlusion',
|
|
||||||
'audio',
|
|
||||||
'multiple-choice',
|
|
||||||
];
|
|
||||||
|
|
||||||
function asCardType(t: string): CardType {
|
|
||||||
return (ALLOWED_TYPES as string[]).includes(t) ? (t as CardType) : 'basic';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscribeResult {
|
|
||||||
deckId: string;
|
|
||||||
cardCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subscribeAndPull(deckSlug: string): Promise<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 }));
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* Auth Store — uses the shared Mana auth factory.
|
|
||||||
*
|
|
||||||
* SSO: tokens land in the shared `*.mana.how` storage so a user already
|
|
||||||
* signed into mana.how / cardecky.mana.how lands directly in the app
|
|
||||||
* without re-typing credentials. The factory wires up the token
|
|
||||||
* manager + refresh + storage adapter for us.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createManaAuthStore } from '@mana/shared-auth-ui';
|
|
||||||
|
|
||||||
export const authStore = createManaAuthStore();
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
/**
|
|
||||||
* Author-state store.
|
|
||||||
*
|
|
||||||
* Lazily fetches the user's author row on first access. Runtime
|
|
||||||
* components never read the API directly — they go through this
|
|
||||||
* store, so refresh-on-mutation is centralised.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cardsApi, CardsApiError, type Author } from '$lib/api/cards-api';
|
|
||||||
|
|
||||||
let _author = $state<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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
/**
|
|
||||||
* Card Store — standalone.
|
|
||||||
*
|
|
||||||
* Writes the {type, fields} shape directly. Legacy mirror (front/back
|
|
||||||
* columns) kept on for cross-compat with the mana-modul data model
|
|
||||||
* once sync flips on. No encryption, no domain events — that's the
|
|
||||||
* deliberate Phase-1 simplification.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cardTable, cardDeckTable } from '../data/database';
|
|
||||||
import { encryptRecord, decryptRecord } from '../data/crypto';
|
|
||||||
import { reviewStore } from './reviews.svelte';
|
|
||||||
import {
|
|
||||||
type CardFields,
|
|
||||||
type CardType,
|
|
||||||
type LocalCard,
|
|
||||||
type CreateCardInput,
|
|
||||||
type UpdateCardInput,
|
|
||||||
} from '@mana/cards-core';
|
|
||||||
|
|
||||||
let error = $state<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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
/**
|
|
||||||
* Deck Store — standalone.
|
|
||||||
*
|
|
||||||
* Slim version of the mana-modul decks store: no time-blocks, no
|
|
||||||
* domain-events, no Mana-wide visibility hooks. Just CRUD against the
|
|
||||||
* standalone Dexie DB.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cardDeckTable, cardTable, db } from '../data/database';
|
|
||||||
import { encryptRecord } from '../data/crypto';
|
|
||||||
import type { CreateDeckInput, UpdateDeckInput, LocalDeck } from '@mana/cards-core';
|
|
||||||
|
|
||||||
let error = $state<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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/**
|
|
||||||
* Card-Review Store — standalone.
|
|
||||||
*
|
|
||||||
* Plaintext, no encryption hook (Phase 1). Fan-out logic comes from
|
|
||||||
* @mana/cards-core; the only standalone bit is which Dexie table to write to.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cardReviewTable } from '../data/database';
|
|
||||||
import {
|
|
||||||
newReview,
|
|
||||||
gradeReview as fsrsGrade,
|
|
||||||
subIndexesFor,
|
|
||||||
type CardFields,
|
|
||||||
type CardType,
|
|
||||||
type LocalCardReview,
|
|
||||||
type ReviewGrade,
|
|
||||||
} from '@mana/cards-core';
|
|
||||||
|
|
||||||
let error = $state<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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
/**
|
|
||||||
* Study-Block Store — standalone.
|
|
||||||
*
|
|
||||||
* Local daily-aggregate row for streak + per-day-stats.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cardStudyBlockTable } from '../data/database';
|
|
||||||
import type { LocalCardStudyBlock } from '@mana/cards-core';
|
|
||||||
|
|
||||||
let error = $state<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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
/**
|
|
||||||
* Cards Theme Store
|
|
||||||
*
|
|
||||||
* Uses the shared theme system. The Cards brand accent (#8b5cf6 from
|
|
||||||
* MANA_APPS) becomes `--color-app-accent` on document.documentElement
|
|
||||||
* so the existing `bg-app-accent` / `text-app-accent` utilities work
|
|
||||||
* everywhere — Lernen-CTA, cloze highlight, link colours, etc.
|
|
||||||
*
|
|
||||||
* The accent is theme-agnostic by design: it stays the same whether
|
|
||||||
* the user picks Lume / Nature / Stone / Ocean × Light / Dark, so the
|
|
||||||
* Cards identity reads consistently across variants.
|
|
||||||
*/
|
|
||||||
import { createThemeStore } from '@mana/shared-theme';
|
|
||||||
|
|
||||||
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@mana/shared-theme';
|
|
||||||
|
|
||||||
// Cards brand: #8b5cf6 (violet-500) → HSL channels.
|
|
||||||
const CARDS_ACCENT_HSL = '258 90% 66%';
|
|
||||||
|
|
||||||
export const theme = createThemeStore({
|
|
||||||
appId: 'cards',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write the Cards app accent onto documentElement once at boot. The
|
|
||||||
* shared theme store doesn't know about per-app accents — it only
|
|
||||||
* touches the variant tokens — so we set this independently and it
|
|
||||||
* survives every variant switch.
|
|
||||||
*/
|
|
||||||
export function applyCardsAccent(): void {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
document.documentElement.style.setProperty('--color-app-accent', CARDS_ACCENT_HSL);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Best-effort slug suggestion. Server-side validateSlug is the
|
|
||||||
* authoritative gate; this just gives the user a sensible default
|
|
||||||
* to edit.
|
|
||||||
*/
|
|
||||||
export function slugify(input: string): string {
|
|
||||||
return input
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[̀-ͯ]/g, '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 60);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,547 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
|
||||||
import { cardsApi, CardsApiError, type PublicAuthor, type DeckSummary } from '$lib/api/cards-api';
|
|
||||||
import DeckGrid from '$lib/components/DeckGrid.svelte';
|
|
||||||
|
|
||||||
const slug = $derived(page.params.slug as string);
|
|
||||||
|
|
||||||
let stage = $state<'loading' | 'ok' | 'not-found' | 'error'>('loading');
|
|
||||||
let author = $state<PublicAuthor | null>(null);
|
|
||||||
let decks = $state<DeckSummary[]>([]);
|
|
||||||
let following = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
let busy = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!slug) return;
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
stage = 'loading';
|
|
||||||
try {
|
|
||||||
const [a, d] = await Promise.all([
|
|
||||||
cardsApi.authors.bySlug(slug),
|
|
||||||
cardsApi.explore.browse({ author: slug, sort: 'recent', limit: 50 }),
|
|
||||||
]);
|
|
||||||
author = a;
|
|
||||||
decks = d.items;
|
|
||||||
stage = 'ok';
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof CardsApiError && e.status === 404) {
|
|
||||||
stage = 'not-found';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
error = (e as Error).message;
|
|
||||||
stage = 'error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFollow() {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
if (following) {
|
|
||||||
await cardsApi.follows.unfollow(slug);
|
|
||||||
following = false;
|
|
||||||
} else {
|
|
||||||
await cardsApi.follows.follow(slug);
|
|
||||||
following = true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{author?.displayName ?? '@' + slug} — Cards</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main class="mx-auto max-w-3xl px-6 py-8">
|
|
||||||
{#if stage === 'loading'}
|
|
||||||
<p class="py-12 text-center text-sm text-muted-foreground">Lade Profil…</p>
|
|
||||||
{:else if stage === 'not-found'}
|
|
||||||
<p
|
|
||||||
class="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
Profil <code class="rounded bg-muted px-1">@{slug}</code> existiert nicht.
|
|
||||||
</p>
|
|
||||||
{:else if stage === 'error'}
|
|
||||||
<p class="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
{:else if author}
|
|
||||||
<header class="mb-6 flex items-start gap-4">
|
|
||||||
{#if author.avatarUrl}
|
|
||||||
<img
|
|
||||||
src={author.avatarUrl}
|
|
||||||
alt=""
|
|
||||||
class="h-16 w-16 rounded-full border border-border object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-full border border-border bg-card text-xl font-semibold text-muted-foreground"
|
|
||||||
>
|
|
||||||
{author.displayName.slice(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 class="text-2xl font-semibold">{author.displayName}</h1>
|
|
||||||
{#if author.verifiedMana}
|
|
||||||
<span class="rounded-full bg-success/15 px-2 py-0.5 text-xs text-success">
|
|
||||||
🛡️ Mana
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if author.verifiedCommunity}
|
|
||||||
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-warning">
|
|
||||||
⭐ Community
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground/80">
|
|
||||||
@{author.slug} · seit {new Date(author.joinedAt).toLocaleDateString('de-DE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{#if author.bio}
|
|
||||||
<p class="mt-2 text-sm text-foreground/80">{author.bio}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if authStore.isAuthenticated}
|
|
||||||
<button
|
|
||||||
class="rounded-lg border border-app-accent/40 px-3 py-1.5 text-sm text-app-accent hover:bg-app-accent/10 disabled:opacity-50"
|
|
||||||
onclick={toggleFollow}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{following ? 'Entfolgen' : 'Folgen'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<h2 class="mb-3 text-sm font-medium text-foreground/80">
|
|
||||||
{decks.length}
|
|
||||||
{decks.length === 1 ? 'Deck' : 'Decks'}
|
|
||||||
</h2>
|
|
||||||
<DeckGrid {decks} emptyText="Dieser Author hat noch keine Decks veröffentlicht." />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p class="mt-12 text-center text-xs text-muted-foreground/60">
|
|
||||||
<a href="/explore" class="hover:text-foreground/80">← Marktplatz</a>
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 731 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 217 B |
Binary file not shown.
|
Before Width: | Height: | Size: 794 B |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -1,12 +0,0 @@
|
||||||
import adapter from '@sveltejs/adapter-node';
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
|
||||||
const config = {
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
kit: {
|
|
||||||
adapter: adapter(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
|
||||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
import { createPWAConfig } from '@mana/shared-pwa';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
tailwindcss(),
|
|
||||||
sveltekit(),
|
|
||||||
SvelteKitPWA(
|
|
||||||
createPWAConfig({
|
|
||||||
name: 'Cards',
|
|
||||||
shortName: 'Cards',
|
|
||||||
description: 'Karteikarten mit Spaced Repetition',
|
|
||||||
themeColor: '#0a0a0a',
|
|
||||||
})
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
@ -1,654 +0,0 @@
|
||||||
# Cardecky-Marktplatz — Plan
|
|
||||||
|
|
||||||
> **Status**: Plan, kein Code. Stand 2026-05-07.
|
|
||||||
> **Goal-Setting**: Vollvision, kein MVP-Druck. Wir bauen die optimale Lösung.
|
|
||||||
> **Alignment**: User hat folgende Eckpunkte gesetzt:
|
|
||||||
> - Versionierte Decks + Live-Updates + Pull-Requests = ja, volle Vision
|
|
||||||
> - mana-credits zentral, sowohl für User-Käufe als auch Author-Verdienst
|
|
||||||
> - „Verified" zweigleisig: Mana-Verein-Kuration UND Community-Schwellen, mit unterschiedlichen Badges
|
|
||||||
> - Co-Learn-Sessions explizit **nicht** für Phase 1 — auf Phase 2 verschoben
|
|
||||||
> - Mobile-App auch später
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Mission
|
|
||||||
|
|
||||||
**Die Karteikarten-Plattform mit der besten Lern-Community im Netz.** Wo qualitativ hochwertige Decks entstehen, gepflegt, geteilt und gelernt werden — und wo Lernende einander helfen.
|
|
||||||
|
|
||||||
## 2. Was wir gegen die Konkurrenz aufbieten
|
|
||||||
|
|
||||||
(verdichtet aus `apps/cards/COMPETITORS_2026-05.md`)
|
|
||||||
|
|
||||||
| Differenzierer | Wir | Wer noch |
|
|
||||||
|---|---|---|
|
|
||||||
| Free Cloud-Sync | ✓ | niemand |
|
|
||||||
| Versionierte Decks mit Live-Updates | ✓ | nur AnkiHub (paywalled, Medizin-only) |
|
|
||||||
| Pull-Requests auf Decks | ✓ | niemand |
|
|
||||||
| Card-Discussions (inline pro Karte) | ✓ | niemand |
|
|
||||||
| AI-Karten + AI-Moderation + AI-Tags | ✓ | fragmentiert bei anderen |
|
|
||||||
| Open Source PWA | ✓ | nur Anki/Mnemosyne (Desktop) |
|
|
||||||
| Anki-Migration mit Bildern/Audio | ✓ (vorhanden) | niemand vollständig |
|
|
||||||
| Author-Followings + Activity-Feed | ✓ | niemand |
|
|
||||||
| Bezahlte Decks mit Author-Erlös via mana-credits | ✓ | nur Brainscape (eigenes Closed-Pricing) |
|
|
||||||
| Pseudonym + verifiziert kombinierbar | ✓ | niemand klar |
|
|
||||||
|
|
||||||
## 3. Architektur-Prinzipien
|
|
||||||
|
|
||||||
1. **API ist `/v1` ab Tag 1** — OpenAPI-Spec als Quelle der Wahrheit, Versionierungs-Bewusstsein eingebaut.
|
|
||||||
2. **Public-Decks leben separat** vom Local-First-Sync-Pfad (eigene Postgres-Tabellen, eigene Service, eigene RLS-Policies). Kein Vermischen mit `mana_sync.sync_changes`.
|
|
||||||
3. **Subscribed Decks sind unidirektional**: Author → Subscribers. Updates fließen einseitig. Wer ändern will, forkt.
|
|
||||||
4. **Content-Hash überall.** Jede Karte und jede Version bekommt einen deterministischen SHA-256 → Trust + Cache + Diff kostenlos.
|
|
||||||
5. **Lizenzen sind explizit + maschinen-lesbar** (SPDX-IDs: `CC0-1.0`, `CC-BY-4.0`, `CC-BY-SA-4.0`, plus eigener `Cardecky-Personal-Use-1.0` für Default-Käufe und `Cardecky-Pro-Only-1.0` für paid Decks).
|
|
||||||
6. **AI ist Moderator, nicht Gatekeeper** — KI-First-Pass + Human-Review-Eskalation. Niemals KI-allein-Take-down.
|
|
||||||
7. **Search ist von der DB entkoppelt** — Read-Only-Index, asynchron befüllt. Bricht der Search-Service, läuft der Marktplatz weiter.
|
|
||||||
8. **mana-credits ist die einzige Geld-Schnittstelle** — niemals Stripe direkt im cards-server. Alles geht über `/api/v1/credits/use`, `/credits/grant`, `/credits/reservations/*`.
|
|
||||||
9. **Anonymisiertes Lern-Verhalten**: aggregierte Stats sichtbar (z.B. „1.200 Lernende"), individuelles Lernverhalten nie öffentlich ohne explizites Opt-in.
|
|
||||||
10. **Keine Drittanbieter-Tracker.** Telemetrie ausschließlich über mana-analytics, opt-out möglich.
|
|
||||||
|
|
||||||
## 4. Datenmodell
|
|
||||||
|
|
||||||
Neues Schema `cards` in `mana_platform`. Alle Tabellen über `pgSchema('cards').table(...)` (Mana-Konvention).
|
|
||||||
|
|
||||||
### 4.1 Authoren
|
|
||||||
|
|
||||||
```sql
|
|
||||||
public_authors (
|
|
||||||
user_id uuid PRIMARY KEY REFERENCES auth.users(id),
|
|
||||||
slug text UNIQUE NOT NULL, -- @anna-lang
|
|
||||||
display_name text NOT NULL,
|
|
||||||
bio text,
|
|
||||||
avatar_url text,
|
|
||||||
joined_at timestamptz DEFAULT now(),
|
|
||||||
pseudonym boolean DEFAULT false, -- true = klarname versteckt
|
|
||||||
verified_mana boolean DEFAULT false, -- vom Verein verliehen
|
|
||||||
verified_community boolean DEFAULT false, -- automatisch ab Schwelle
|
|
||||||
banned_at timestamptz, -- soft-ban
|
|
||||||
banned_reason text
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Drei Verifizierungs-Stufen mit unterschiedlichen Badges in der UI:
|
|
||||||
|
|
||||||
| Status | Badge | Wer / wie |
|
|
||||||
|---|---|---|
|
|
||||||
| `verified_mana = true` | 🛡️ **Mana Verifiziert** | Manuell vom Mana-Verein vergeben (Lehrer, Profis, Sprachschulen, Ärzte). Nicht erkaufbar. |
|
|
||||||
| `verified_community = true` | ⭐ **Community Verifiziert** | Automatisch bei: ≥ 500 Stars über alle Decks ODER ≥ 3 featured Decks ODER ≥ 200 aktive Subscribers über alle Decks. Periodisch neu evaluiert. |
|
|
||||||
| beides | 🛡️⭐ Beide Badges | Mana + Community zusammen. |
|
|
||||||
|
|
||||||
### 4.2 Decks + Versionen
|
|
||||||
|
|
||||||
```sql
|
|
||||||
public_decks (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
slug text UNIQUE NOT NULL, -- /decks/anna-lang/spanish-a2-vocab
|
|
||||||
title text NOT NULL,
|
|
||||||
description text,
|
|
||||||
language text, -- ISO-639-1
|
|
||||||
license text NOT NULL, -- SPDX
|
|
||||||
price_credits integer DEFAULT 0, -- 0 = kostenlos
|
|
||||||
owner_user_id uuid NOT NULL REFERENCES public_authors(user_id),
|
|
||||||
latest_version_id uuid, -- → public_deck_versions
|
|
||||||
is_featured boolean DEFAULT false,
|
|
||||||
is_takedown boolean DEFAULT false,
|
|
||||||
takedown_at timestamptz,
|
|
||||||
takedown_reason text,
|
|
||||||
created_at timestamptz DEFAULT now(),
|
|
||||||
CONSTRAINT price_requires_license CHECK (price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0')
|
|
||||||
)
|
|
||||||
|
|
||||||
public_deck_versions (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
deck_id uuid NOT NULL REFERENCES public_decks(id),
|
|
||||||
semver text NOT NULL, -- 1.0.0, 1.1.0, 2.0.0
|
|
||||||
changelog text,
|
|
||||||
content_hash text NOT NULL, -- SHA-256 of canonicalized cards
|
|
||||||
card_count integer NOT NULL,
|
|
||||||
published_at timestamptz DEFAULT now(),
|
|
||||||
deprecated_at timestamptz,
|
|
||||||
UNIQUE (deck_id, semver)
|
|
||||||
)
|
|
||||||
|
|
||||||
public_deck_cards (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
version_id uuid NOT NULL REFERENCES public_deck_versions(id),
|
|
||||||
type text NOT NULL, -- basic, basic-reverse, cloze, type-in
|
|
||||||
fields jsonb NOT NULL, -- {front, back} oder {text, extra}
|
|
||||||
ord integer NOT NULL,
|
|
||||||
content_hash text NOT NULL, -- per Karte: ermöglicht Smart-Merge
|
|
||||||
UNIQUE (version_id, ord)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 Tags + Discovery
|
|
||||||
|
|
||||||
```sql
|
|
||||||
tag_definitions (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
slug text UNIQUE NOT NULL,
|
|
||||||
name text NOT NULL,
|
|
||||||
parent_id uuid REFERENCES tag_definitions(id), -- Hierarchie
|
|
||||||
description text,
|
|
||||||
curated boolean DEFAULT false -- vom Mana-Verein gepflegt
|
|
||||||
)
|
|
||||||
|
|
||||||
deck_tags (
|
|
||||||
deck_id uuid REFERENCES public_decks(id),
|
|
||||||
tag_id uuid REFERENCES tag_definitions(id),
|
|
||||||
PRIMARY KEY (deck_id, tag_id)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Engagement (Stars, Subscribes, Forks)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
deck_stars (
|
|
||||||
user_id uuid REFERENCES auth.users(id),
|
|
||||||
deck_id uuid REFERENCES public_decks(id),
|
|
||||||
starred_at timestamptz DEFAULT now(),
|
|
||||||
PRIMARY KEY (user_id, deck_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
deck_subscriptions (
|
|
||||||
user_id uuid REFERENCES auth.users(id),
|
|
||||||
deck_id uuid REFERENCES public_decks(id),
|
|
||||||
current_version_id uuid REFERENCES public_deck_versions(id),
|
|
||||||
subscribed_at timestamptz DEFAULT now(),
|
|
||||||
notify_updates boolean DEFAULT true,
|
|
||||||
PRIMARY KEY (user_id, deck_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
deck_forks (
|
|
||||||
user_id uuid REFERENCES auth.users(id),
|
|
||||||
source_deck_id uuid REFERENCES public_decks(id),
|
|
||||||
source_version_id uuid REFERENCES public_deck_versions(id),
|
|
||||||
forked_at timestamptz DEFAULT now(),
|
|
||||||
PRIMARY KEY (user_id, source_deck_id, source_version_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
author_follows (
|
|
||||||
follower_user_id uuid REFERENCES auth.users(id),
|
|
||||||
author_user_id uuid REFERENCES public_authors(user_id),
|
|
||||||
since timestamptz DEFAULT now(),
|
|
||||||
PRIMARY KEY (follower_user_id, author_user_id)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 Pull-Requests + Discussions
|
|
||||||
|
|
||||||
```sql
|
|
||||||
deck_pull_requests (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
deck_id uuid REFERENCES public_decks(id),
|
|
||||||
author_user_id uuid REFERENCES auth.users(id),
|
|
||||||
status text NOT NULL, -- open, merged, closed, rejected
|
|
||||||
title text NOT NULL,
|
|
||||||
body text,
|
|
||||||
diff jsonb NOT NULL, -- {add: [...], modify: [...], remove: [...]}
|
|
||||||
merged_into_version uuid REFERENCES public_deck_versions(id),
|
|
||||||
created_at timestamptz DEFAULT now(),
|
|
||||||
resolved_at timestamptz
|
|
||||||
)
|
|
||||||
|
|
||||||
card_discussions (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
card_content_hash text NOT NULL, -- bindet sich an Karte, nicht an version
|
|
||||||
deck_id uuid REFERENCES public_decks(id),
|
|
||||||
author_user_id uuid REFERENCES auth.users(id),
|
|
||||||
parent_id uuid REFERENCES card_discussions(id),
|
|
||||||
body text NOT NULL,
|
|
||||||
hidden boolean DEFAULT false,
|
|
||||||
created_at timestamptz DEFAULT now()
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 Moderation
|
|
||||||
|
|
||||||
```sql
|
|
||||||
deck_reports (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
deck_id uuid REFERENCES public_decks(id),
|
|
||||||
version_id uuid REFERENCES public_deck_versions(id),
|
|
||||||
card_content_hash text, -- optional: Karte spezifisch
|
|
||||||
reporter_user_id uuid REFERENCES auth.users(id),
|
|
||||||
category text NOT NULL, -- spam, copyright, nsfw, misinformation, other
|
|
||||||
body text,
|
|
||||||
status text DEFAULT 'open', -- open, dismissed, actioned
|
|
||||||
resolved_by uuid,
|
|
||||||
resolved_at timestamptz,
|
|
||||||
resolution_notes text,
|
|
||||||
created_at timestamptz DEFAULT now()
|
|
||||||
)
|
|
||||||
|
|
||||||
ai_moderation_log (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
version_id uuid REFERENCES public_deck_versions(id),
|
|
||||||
verdict text NOT NULL, -- pass, flag, block
|
|
||||||
categories text[], -- spam, csam, hate, nsfw, ...
|
|
||||||
model text, -- "claude-3-5-sonnet" etc
|
|
||||||
rationale text,
|
|
||||||
human_reviewed boolean DEFAULT false,
|
|
||||||
human_overrode boolean DEFAULT false,
|
|
||||||
created_at timestamptz DEFAULT now()
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.7 mana-credits Integration
|
|
||||||
|
|
||||||
```sql
|
|
||||||
deck_purchases (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
buyer_user_id uuid REFERENCES auth.users(id),
|
|
||||||
deck_id uuid REFERENCES public_decks(id),
|
|
||||||
version_id uuid REFERENCES public_deck_versions(id),
|
|
||||||
price_credits integer NOT NULL, -- Snapshot zum Zeitpunkt des Kaufs
|
|
||||||
author_share integer NOT NULL, -- nach Verein-Cut
|
|
||||||
mana_share integer NOT NULL,
|
|
||||||
credits_transaction text, -- mana-credits ID
|
|
||||||
purchased_at timestamptz DEFAULT now(),
|
|
||||||
refunded_at timestamptz,
|
|
||||||
UNIQUE (buyer_user_id, deck_id) -- einmal Kauf reicht für Lifetime + alle Versionen
|
|
||||||
)
|
|
||||||
|
|
||||||
author_payouts (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
author_user_id uuid REFERENCES public_authors(user_id),
|
|
||||||
source_purchase_id uuid REFERENCES deck_purchases(id),
|
|
||||||
credits_granted integer NOT NULL,
|
|
||||||
credits_grant_id text, -- mana-credits grant ID
|
|
||||||
granted_at timestamptz DEFAULT now()
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. mana-credits Integration (Detail)
|
|
||||||
|
|
||||||
Zwei-seitiger Marktplatz. mana-credits ist Single-Source-of-Truth fürs Geld.
|
|
||||||
|
|
||||||
### 5.1 Kauf-Flow (Buyer)
|
|
||||||
|
|
||||||
1. User klickt „Kaufen" auf paid Deck (Preis: z.B. 50 Credits)
|
|
||||||
2. cards-server checkt: Hat User schon dieses Deck? (deck_purchases) → wenn ja, sofort Zugriff
|
|
||||||
3. cards-server reserviert Credits via `POST mana-credits/api/v1/credits/reservations` (2-phase)
|
|
||||||
4. cards-server erstellt deck_purchases-Row (committed)
|
|
||||||
5. cards-server commit-released die Reservation → Credits abgebucht
|
|
||||||
6. cards-server erstellt author_payouts-Row → ruft `POST mana-credits/api/v1/internal/credits/grant` für den Author-Anteil
|
|
||||||
7. User bekommt sofortigen Zugriff: Deck wird in private Liste verschoben (User hat eine eigene Lokal-Kopie als Author-Subscription)
|
|
||||||
|
|
||||||
**Was passiert wenn Author gebannt nach Kauf?** → Refund-Path (Phase γ Implementation): Admin kann Refund triggern → mana-credits → Reverse-Grant → User behält das Deck nicht mehr.
|
|
||||||
|
|
||||||
### 5.2 Author-Auszahlungs-Modell
|
|
||||||
|
|
||||||
- **Standard-Cut**: 80 % Author / 20 % Mana-Verein (Server-, Hosting-, Moderations-Kosten)
|
|
||||||
- **Verifizierte Authoren** (verified_mana): 90 % / 10 %
|
|
||||||
- **Mindestauszahlung**: keine — Credits werden direkt im mana-credits-Account gebucht, von dort kann der Author sie selbst nutzen oder per Stripe-Payout (mana-credits-Feature, falls vorhanden) abheben
|
|
||||||
- **Pricing-Range**: Free (0 Credits), oder 10–500 Credits (entspricht ungefähr 1–50 € — exakte Conversion siehe mana-credits packages)
|
|
||||||
|
|
||||||
### 5.3 Käufer-Lebenszyklus
|
|
||||||
|
|
||||||
- Einmal gekauft = Lifetime-Zugriff auf alle künftigen Versionen
|
|
||||||
- Bei major Version (e.g. 1.x → 2.0.0) **kein** zweiter Kauf nötig — Author behält die Verbesserungs-Pflicht
|
|
||||||
- Refund-Window: 30 Tage, automatisch verfügbar wenn ≤ 10 % der Karten gelernt wurden (Quizlet hat das, ist Best-Practice)
|
|
||||||
|
|
||||||
### 5.4 Buyer-Protection bei Take-Down
|
|
||||||
|
|
||||||
- Wenn Deck per Take-Down entfernt wird, behält Buyer Zugriff auf das letzte gesehene Snapshot (DSGVO-konform)
|
|
||||||
- Refund automatisch wenn Take-Down innerhalb 90 Tagen nach Kauf
|
|
||||||
|
|
||||||
## 6. Service-Architektur
|
|
||||||
|
|
||||||
### 6.1 `cards-server` (neu)
|
|
||||||
|
|
||||||
- **Stack**: Hono + Bun (Mana-Konvention)
|
|
||||||
- **Port**: 3072
|
|
||||||
- **Deps**: PostgreSQL (`mana_platform.cards.*`), Redis (Job-Queue für Indexing/Notifications)
|
|
||||||
- **Auth**: JWT via JWKS (mana-auth)
|
|
||||||
- **Routes**: siehe §7
|
|
||||||
|
|
||||||
### 6.2 `cards-search` (neu, später)
|
|
||||||
|
|
||||||
- Eigene PostgreSQL-Instance mit pg_trgm + tsvector + pgvector
|
|
||||||
- Async-Indexer hört auf cards-server-Events („deck-published", „deck-updated")
|
|
||||||
- Optional: Meilisearch wenn Postgres FTS nicht reicht
|
|
||||||
|
|
||||||
### 6.3 mana-llm (existierend, erweitert)
|
|
||||||
|
|
||||||
- Embeddings für semantic search (jeden Deck-Description + Karte → 1536-dim Vector)
|
|
||||||
- Moderation-First-Pass (Klassifikation in spam/csam/hate/nsfw/etc.)
|
|
||||||
- Auto-Tag-Suggestions
|
|
||||||
- Auto-Summary für Deck-Beschreibungen
|
|
||||||
|
|
||||||
### 6.4 mana-credits (existierend, erweitert)
|
|
||||||
|
|
||||||
- Bestehende `/credits/use` und `/credits/reservations/*` für Kauf
|
|
||||||
- Bestehender `/internal/credits/grant` für Author-Auszahlung
|
|
||||||
- Vermutlich keine API-Erweiterung nötig
|
|
||||||
|
|
||||||
### 6.5 mana-notify (existierend, erweitert)
|
|
||||||
|
|
||||||
- Push-Notifications für Subscribe-Updates, neue Subscribers, neue Discussions/Replies, neue Stars (vom User konfigurierbar)
|
|
||||||
|
|
||||||
### 6.6 mana-media (existierend)
|
|
||||||
|
|
||||||
- Bilder/Audio in published Decks landen wie heute auch
|
|
||||||
- Pro Author-Tier ein Soft-Quota: Free 100MB, Verified 1GB, Mana 5GB
|
|
||||||
|
|
||||||
## 7. API-Endpoints (Auswahl)
|
|
||||||
|
|
||||||
OpenAPI-Spec wird die Quelle der Wahrheit; hier die wichtigsten Routes:
|
|
||||||
|
|
||||||
### 7.1 Authoren
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/authors/me — Profil anlegen/updaten (slug, displayName, bio, avatar, pseudonym)
|
|
||||||
GET /v1/authors/:slug — Public Profile + Decks-Liste + Stats
|
|
||||||
GET /v1/authors/me/dashboard — Eigene Stats: Subscriber, Erlöse, Mod-Inbox
|
|
||||||
POST /v1/authors/:slug/follow — Folgen
|
|
||||||
DELETE /v1/authors/:slug/follow — Entfolgen
|
|
||||||
GET /v1/authors/me/feed — Personal Activity-Feed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Decks
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/decks — Deck als public registrieren (Init-Flow)
|
|
||||||
GET /v1/decks/:slug — Public Deck mit latest version
|
|
||||||
GET /v1/decks/:slug/versions — Versionsliste mit Changelogs
|
|
||||||
GET /v1/decks/:slug/versions/:semver — Specific Version + alle Karten
|
|
||||||
PATCH /v1/decks/:slug — Metadaten (title, description, license, price)
|
|
||||||
|
|
||||||
POST /v1/decks/:slug/publish — Neue Version publishen (body: cards[], semver, changelog)
|
|
||||||
→ triggert AI-Mod-Pass
|
|
||||||
→ setzt latest_version_id
|
|
||||||
|
|
||||||
POST /v1/decks/:slug/star — Star setzen
|
|
||||||
DELETE /v1/decks/:slug/star — Star entfernen
|
|
||||||
|
|
||||||
POST /v1/decks/:slug/subscribe — Subscribe (lädt + sync'd Karten in lokale DB)
|
|
||||||
DELETE /v1/decks/:slug/subscribe — Unsubscribe
|
|
||||||
|
|
||||||
POST /v1/decks/:slug/fork — Fork (lokale Kopie + Author-Lineage)
|
|
||||||
|
|
||||||
POST /v1/decks/:slug/buy — Paid Deck kaufen (mana-credits-Flow)
|
|
||||||
POST /v1/decks/:slug/refund — Refund anfragen
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 Pull-Requests
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /v1/decks/:slug/pull-requests — Liste
|
|
||||||
POST /v1/decks/:slug/pull-requests — Neuer PR (body: title, body, diff)
|
|
||||||
GET /v1/pull-requests/:id — Details
|
|
||||||
POST /v1/pull-requests/:id/merge — Author merged → erstellt neue Version
|
|
||||||
POST /v1/pull-requests/:id/close — Author schließt
|
|
||||||
POST /v1/pull-requests/:id/comments — Diskussion auf PR-Ebene
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 Discussions
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /v1/cards/:contentHash/discussions — Threads für eine Karte (über Versionen hinweg)
|
|
||||||
POST /v1/cards/:contentHash/discussions — Neuer Thread / Reply
|
|
||||||
POST /v1/discussions/:id/hide — Author/Mod versteckt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.5 Discovery + Search
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /v1/explore — Featured + Trending + Categories (curated)
|
|
||||||
GET /v1/search?q=…&tag=…&lang=…&sort=… — Volltextsuche (FTS + semantic)
|
|
||||||
GET /v1/tags — Tag-Hierarchie
|
|
||||||
GET /v1/decks?author=…&tag=…&sort=…&p=… — Filtered Browse
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.6 Reports + Moderation
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/decks/:slug/report — User reportet Deck
|
|
||||||
POST /v1/cards/:contentHash/report — User reportet Karte
|
|
||||||
GET /v1/admin/reports — Admin-Inbox (verifizierte Mana-Mods only)
|
|
||||||
POST /v1/admin/decks/:slug/takedown — Admin entfernt Deck
|
|
||||||
POST /v1/admin/authors/:slug/ban — Admin sperrt Author
|
|
||||||
POST /v1/admin/authors/:slug/verify-mana — Mana-Verein-Badge vergeben
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.7 Notifications
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /v1/notifications — Unread + recent
|
|
||||||
POST /v1/notifications/:id/read — Mark read
|
|
||||||
PATCH /v1/notifications/preferences — Settings (welche Events triggern Push)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. UI / Routes (Cardecky-Frontend)
|
|
||||||
|
|
||||||
```
|
|
||||||
/explore — Featured + Trending + Tag-Tree + Search-Bar
|
|
||||||
/explore/search?q=… — Search-Result-Page
|
|
||||||
/explore/tag/:slug — Tag-Page
|
|
||||||
|
|
||||||
/u/:slug — Author-Profil (Public)
|
|
||||||
/u/:slug/follow — Follow-Button im Header
|
|
||||||
|
|
||||||
/d/:slug — Public-Deck-Detail-View
|
|
||||||
(Description, Stats, Latest-Karten-Preview, Subscribe/Fork/Star/Buy, Discussions)
|
|
||||||
/d/:slug/v/:semver — spezifische Version
|
|
||||||
/d/:slug/discussions — Alle Discussions zum Deck
|
|
||||||
/d/:slug/pull-requests — PRs
|
|
||||||
/d/:slug/pull-requests/:id — PR-Detail mit Diff-View
|
|
||||||
|
|
||||||
/me/decks — Eigene private Decks (heute existiert)
|
|
||||||
/me/published — Eigene published Decks + Stats
|
|
||||||
/me/subscribed — Abonnierte Decks (mit Update-Indikator)
|
|
||||||
/me/forks — Geforkte Decks
|
|
||||||
/me/dashboard — Author-Dashboard (Erlöse, Subscriber-Wachstum)
|
|
||||||
|
|
||||||
/feed — Personal Activity-Feed (Following-Activity + Updates)
|
|
||||||
|
|
||||||
/admin/reports — Admin-Inbox (verified-mana-only)
|
|
||||||
/admin/decks — Take-Down-UI
|
|
||||||
/admin/authors — Verify + Ban
|
|
||||||
```
|
|
||||||
|
|
||||||
Zusätzlich: einige bestehende Komponenten erweitern (DeckDetail bekommt Subscribe-Button etc.).
|
|
||||||
|
|
||||||
## 9. Cold-Start-Strategie
|
|
||||||
|
|
||||||
Marktplatz ohne Decks ist nutzlos. Drei parallele Hebel:
|
|
||||||
|
|
||||||
1. **Verein-Seed-Decks**: 50 hochwertige Decks selbst erstellen — sprachen (Top-3000 Vokabeln pro Sprache), Geschichte (TimeLine-Karten), Allgemeinwissen, Programmierung. Vom Mana-Team published, alle mit `verified_mana`-Badge.
|
|
||||||
2. **Anki-Top-100-Import-Service**: Wir bieten an, populäre Anki-Web-Decks (mit korrekter CC-BY-Lizenz) zu importieren und mit Original-Author-Attribution als Public-Decks anzulegen. Original-Author bekommt das `verified_mana`-Badge wenn er sich registriert.
|
|
||||||
3. **Influencer-Outreach**: Direkte Ansprache von 10-20 Anki-Power-Authoren (AnKing, etc.) mit dem Angebot eines verified-Status + sehr Author-freundlichem Cut. Wenn 1-2 wechseln, kommt ein Lawineneffekt.
|
|
||||||
|
|
||||||
## 10. Risiken + Mitigationen
|
|
||||||
|
|
||||||
| Risiko | Mitigation |
|
|
||||||
|---|---|
|
|
||||||
| Cold-Start (Marktplatz leer) | Seed + Anki-Import + Influencer (siehe §9) |
|
|
||||||
| Spam / Junk-Decks | AI-Mod-First-Pass + Report-System + Author-Ban-Flow |
|
|
||||||
| Copyright-Klagen (Lehrbuch-Karten) | Lizenz-Pflichtangabe + DMCA-Process + Take-Down-Workflow |
|
|
||||||
| Server-Kosten (Storage von Bildern/Audio) | Soft-Quotas pro Author-Tier (§6.6) + lossy compression im mana-media |
|
|
||||||
| AnkiHub als Konkurrent (Live-Updates Medizin) | „Alle Fachgebiete + gratis" als Counter; Med-Decks aktiv akquirieren |
|
|
||||||
| Mana-Credits-Verein-Cut zu hoch oder zu niedrig | A/B-Test verschiedener Cut-Verhältnisse; Best-Practice: ~80/20 für Standard, ~90/10 für Verified |
|
|
||||||
| Author-Frustration über fehlende Mobile-App | Klarer Roadmap-Hinweis + Mobile-Push-Notifications via PWA (heute geht das schon) |
|
|
||||||
| Discussions werden Toxic | Author-Owns-Their-Discussions (kann hide); Community-Mod (Verified-User können flaggen); klar dokumentierte Community-Guidelines |
|
|
||||||
| Mining/Scraping der Decks | Rate-limit auf API + Auth-Required für full-content; offene Snippets aber paywall am Voll-Inhalt |
|
|
||||||
|
|
||||||
## 11. Phasenplan
|
|
||||||
|
|
||||||
> **Co-Learn explizit ausgeklammert.** Mobile-App auch.
|
|
||||||
|
|
||||||
### Phase α — Daten-Skelett (cards-server v0.1)
|
|
||||||
|
|
||||||
- `services/cards-server/` SvelteKit-style Service-Setup, Hono + Bun + Drizzle
|
|
||||||
- Alle Schema-Tabellen + Migrationen (§4)
|
|
||||||
- API-Routes (CRUD-Niveau): Authoren, Decks, Versionen, Stars, Subscriptions
|
|
||||||
- OpenAPI-Spec
|
|
||||||
- Integration-Tests (Drizzle + Vitest)
|
|
||||||
- mana-auth-JWT-Middleware (`@mana/shared-hono`)
|
|
||||||
- Container in `docker-compose.macmini.yml`
|
|
||||||
- Cloudflare-Tunnel-Route `cardecky-api.mana.how` → `:3072`
|
|
||||||
|
|
||||||
### Phase β — Author-Workflow ✅ shipped
|
|
||||||
|
|
||||||
- ✅ „Author werden"-Flow im Frontend (Profil anlegen, slug claimen)
|
|
||||||
- ✅ „Publish"-Aktion auf Deck-Detail-Seite
|
|
||||||
- ✅ Lizenz-Picker (SPDX-Auswahl)
|
|
||||||
- ✅ Optional: Preis in Credits
|
|
||||||
- ⏳ Tags: Picker fehlt im Publish-Flow; Server-Schema steht
|
|
||||||
- ✅ Versioning: semver-Eingabe (Auto-Suggest pre-fill folgt in θ)
|
|
||||||
- ✅ Changelog-Editor
|
|
||||||
- ✅ AI-First-Pass-Moderation (mana-llm classify, Verdict im Publish-Result)
|
|
||||||
- ⏳ Author-Dashboard mit Subscriber-Counts: Erlöse jetzt unter `/me/purchases`, restliche Stats fehlen
|
|
||||||
|
|
||||||
### Phase γ — Discovery-Frontend ✅ shipped (FTS minimal)
|
|
||||||
|
|
||||||
- ✅ `/explore`-Seite mit Featured + Trending
|
|
||||||
- 🟡 Volltext-Suche: einfaches `ILIKE` über Title/Description; tsvector-Upgrade in Phase ι
|
|
||||||
- 🟡 Tag-Hierarchie: flach implementiert; baumartige Eltern-Kind-Navigation offen
|
|
||||||
- ✅ Author-Profile (`/u/<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.*
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"name": "cards",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "Cardecky — Spaced-Repetition flashcards on cardecky.mana.how (Marketing-Landing: cardecky.com). Standalone Phase-1 frontend; data shared with the mana cards module via mana-sync.",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "pnpm run --filter=@cards/* --parallel dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,10 +16,10 @@ export default defineConfig({
|
||||||
replacesTitle: false,
|
replacesTitle: false,
|
||||||
},
|
},
|
||||||
social: {
|
social: {
|
||||||
github: 'https://github.com/mana/mana-monorepo',
|
github: 'https://github.com/Memo-2023/managarten',
|
||||||
},
|
},
|
||||||
editLink: {
|
editLink: {
|
||||||
baseUrl: 'https://github.com/mana/mana-monorepo/edit/main/apps/docs/',
|
baseUrl: 'https://github.com/Memo-2023/managarten/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/mana-monorepo/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/managarten/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/mana-monorepo/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/managarten/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/mana-monorepo/
|
~/projects/managarten/
|
||||||
├── 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/mana-monorepo
|
cd ~/projects/managarten
|
||||||
./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/mana-monorepo
|
cd ~/projects/managarten
|
||||||
./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/mana-monorepo
|
cd ~/projects/managarten
|
||||||
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/mana-monorepo
|
cd ~/projects/managarten
|
||||||
|
|
||||||
# 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/mana-monorepo.git
|
git clone https://github.com/mana/managarten.git
|
||||||
cd mana-monorepo
|
cd managarten
|
||||||
```
|
```
|
||||||
|
|
||||||
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/mana-monorepo.git
|
git clone https://github.com/mana/managarten.git
|
||||||
cd mana-monorepo
|
cd managarten
|
||||||
```
|
```
|
||||||
|
|
||||||
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/mana-monorepo
|
link: https://github.com/mana/managarten
|
||||||
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/mana-monorepo.git
|
git clone https://github.com/mana/managarten.git
|
||||||
cd mana-monorepo
|
cd managarten
|
||||||
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/mana-monorepo
|
cd ~/projects/managarten
|
||||||
git pull
|
git pull
|
||||||
./scripts/mac-mini/deploy.sh
|
./scripts/mac-mini/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ 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,6 +21,8 @@ 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,6 +33,8 @@ 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,6 +33,9 @@ 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,6 +34,8 @@ 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,6 +34,8 @@ 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,6 +37,8 @@ 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,6 +31,8 @@ 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,6 +33,8 @@ 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,6 +33,9 @@ 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,6 +33,8 @@ 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,6 +34,8 @@ 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,6 +33,8 @@ 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,6 +34,8 @@ 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,6 +35,8 @@ 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,6 +21,8 @@ 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,6 +32,8 @@ 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,6 +21,9 @@ 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,6 +21,8 @@ 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,6 +21,8 @@ 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,6 +21,8 @@ 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