mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(cards): Phase-1 Spinoff — standalone cards.mana.how + cards-core extraction
Builds out the Cards spinoff end-to-end so the standalone app at
cards.mana.how shares its data layer with the in-mana cards module
through a single pure-utility package.
Why a spinoff and not just a deeper module: per the GUIDELINES, Cards
gets its own brand + URL while reusing mana-auth, mana-sync, and the
mana-credits/billing stack. The in-mana module under mana.how/cards
stays untouched as the integrated experience.
Phase 0 — mana-modul foundation
• New tables cardReviews + cardStudyBlocks (Dexie v61) + plaintext
classification in the crypto registry.
• LocalCard learns a {type, fields} shape; legacy front/back columns
kept as a back-compat mirror so older builds keep rendering.
• FSRS v6 scheduler + Cloze parser + Markdown render pipeline.
• UI in apps/mana/.../routes/(app)/cards/ gets a learn session
(learn/[deckId]), 4-type card editor, due-counter, markdown lists.
Phase 1 — standalone (apps/cards/apps/web)
• SvelteKit 2 + Svelte 5 + Tailwind 4, port 5180.
• Own Dexie 'cards' DB with a slim 5-table schema.
• Own sync engine: pending-changes hooks, 1 s push / 5 s pull against
POST /sync/cards, server-apply with suppression to avoid ping-pong.
• Auth-Gate via @mana/shared-auth-ui (LoginPage / RegisterPage).
• Encryption hooks at every write/read/apply path, currently no-op
stubs — flipping to real vault-backed AES-GCM is a single-file
change in src/lib/data/crypto.ts.
Shared package — @mana/cards-core
• Pulls types, cloze, card-reviews, FSRS wrapper, and Markdown
renderer out of the mana module so both frontends import from one
source. mana-modul keeps thin re-export shims so consumers don't
need to change imports.
• 19 vitest tests carried over from the mana module.
Server-side wiring
• cards.mana.how added to mana-auth PRODUCTION_TRUSTED_ORIGINS and
its CORS_ORIGINS env (sso-config.spec.ts stays green).
• New cards-web container in docker-compose.macmini.yml (mirrors
manavoxel-web pattern, 128m, depends on mana-auth healthy).
• cloudflared-config.yml repoints cards.mana.how from :5000 (the
unified mana-web container) to :5180. mana.how/cards is unchanged.
Cleanup
• Removed an unrelated 2026-03/04 NestJS+Supabase+Expo experiment
that was lingering under apps/cards/ (apps/landing, supabase/,
.github/workflows, MANA_CORE_*.md, etc.). It predated this plan
and would have confused future readers.
Validation
• svelte-check on mana-web: 0 errors over 7697 files
• svelte-check on cards-web: 0 errors over 3481 files
• vitest on cards-core: 19/19 pass
• pnpm check:crypto: 214 tables classified
• bun test sso-config.spec.ts: 8/8 pass
• vite build on cards-web: green
Not done in this commit (deliberate)
• Real encryption (vault roundtrip) — Phase 2.
• WebSocket-driven pull (5 s polling for now).
• Mobile/landing standalone surfaces — Phase 2/3.
• The actual production cutover on the Mac mini (build, deploy,
cloudflared sync) — config is staged, deploy is a user action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
950b822070
commit
0a544ac410
94 changed files with 4090 additions and 7466 deletions
342
apps/cards/.github/workflows/deploy-backend.yml
vendored
342
apps/cards/.github/workflows/deploy-backend.yml
vendored
|
|
@ -1,342 +0,0 @@
|
|||
name: Deploy Backend to Cloud Run
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'production'
|
||||
options:
|
||||
- production
|
||||
- staging
|
||||
|
||||
env:
|
||||
PROJECT_ID: mana-core-453821
|
||||
REGION: europe-west3
|
||||
ARTIFACT_REGISTRY: europe-west3-docker.pkg.dev
|
||||
SERVICE_NAME: cards-backend
|
||||
REPOSITORY_NAME: cards-backend
|
||||
WORKING_DIR: backend
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Build Verification
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ env.WORKING_DIR }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure git for private packages
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
run: |
|
||||
git config --global url."https://${GH_TOKEN}@github.com/".insteadOf ssh://git@github.com/
|
||||
git config --global url."https://${GH_TOKEN}@github.com/".insteadOf git@github.com:
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ${{ env.WORKING_DIR }}/package-lock.json
|
||||
|
||||
- name: Patch package-lock.json with authenticated URLs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
working-directory: ${{ env.WORKING_DIR }}
|
||||
run: |
|
||||
# Handle both SSH and HTTPS URLs
|
||||
if grep -q "git+ssh://git@github.com" package-lock.json; then
|
||||
echo "⚠️ SSH URLs found - patching to HTTPS with token..."
|
||||
sed -i "s|git+ssh://git@github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json
|
||||
echo "✓ Lockfile patched successfully"
|
||||
else
|
||||
echo "⚠️ HTTPS URLs found - injecting token..."
|
||||
sed -i "s|git+https://github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json
|
||||
echo "✓ Token injected successfully"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.WORKING_DIR }}
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Type check & build
|
||||
working-directory: ${{ env.WORKING_DIR }}
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ${{ env.WORKING_DIR }}
|
||||
run: npm test
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ env.WORKING_DIR }}/dist
|
||||
retention-days: 1
|
||||
|
||||
build-and-deploy:
|
||||
name: Build & Deploy to Cloud Run
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Google Cloud Auth
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY_PROD }}
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Configure Docker for Artifact Registry
|
||||
run: |
|
||||
gcloud auth configure-docker ${{ env.ARTIFACT_REGISTRY }}
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
VERSION="v${TIMESTAMP}-${SHORT_SHA}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
working-directory: ${{ env.WORKING_DIR }}
|
||||
run: |
|
||||
IMAGE_NAME="${{ env.ARTIFACT_REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY_NAME }}/${{ env.SERVICE_NAME }}"
|
||||
IMAGE_TAG="${IMAGE_NAME}:${{ steps.version.outputs.version }}"
|
||||
LATEST_TAG="${IMAGE_NAME}:latest"
|
||||
SHA_TAG="${IMAGE_NAME}:${{ steps.version.outputs.short_sha }}"
|
||||
|
||||
echo "Building image: ${IMAGE_TAG}"
|
||||
|
||||
docker build \
|
||||
-t ${IMAGE_TAG} \
|
||||
-t ${LATEST_TAG} \
|
||||
-t ${SHA_TAG} \
|
||||
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
|
||||
--build-arg VCS_REF=${{ github.sha }} \
|
||||
--build-arg VERSION=${{ steps.version.outputs.version }} \
|
||||
--secret id=github_token,env=GH_TOKEN \
|
||||
.
|
||||
|
||||
echo "Pushing images..."
|
||||
docker push ${IMAGE_TAG}
|
||||
docker push ${LATEST_TAG}
|
||||
docker push ${SHA_TAG}
|
||||
|
||||
echo "image_tag=${IMAGE_TAG}" >> $GITHUB_ENV
|
||||
echo "version=${{ steps.version.outputs.version }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Deploy to Cloud Run
|
||||
id: deploy
|
||||
run: |
|
||||
echo "Deploying to Cloud Run..."
|
||||
echo "Service account: ${{ secrets.CLOUD_RUN_SERVICE_ACCOUNT }}"
|
||||
gcloud run deploy ${{ env.SERVICE_NAME }} \
|
||||
--image="${{ env.image_tag }}" \
|
||||
--project=${{ env.PROJECT_ID }} \
|
||||
--region=${{ env.REGION }} \
|
||||
--platform=managed \
|
||||
--allow-unauthenticated \
|
||||
--min-instances=0 \
|
||||
--max-instances=10 \
|
||||
--memory=512Mi \
|
||||
--cpu=1 \
|
||||
--timeout=300 \
|
||||
--concurrency=80 \
|
||||
--port=8080 \
|
||||
--service-account=${{ secrets.CLOUD_RUN_SERVICE_ACCOUNT }} \
|
||||
--set-env-vars="NODE_ENV=production" \
|
||||
--set-secrets="MANA_SERVICE_URL=MANA_SERVICE_URL:latest,APP_ID=CARDS_APP_ID:latest,MANA_SUPABASE_SECRET_KEY=MANA_SUPABASE_SECRET_KEY:latest,SUPABASE_URL=CARDS_SUPABASE_URL:latest,SUPABASE_ANON_KEY=CARDS_SUPABASE_ANON_KEY:latest,SUPABASE_SERVICE_KEY=CARDS_SUPABASE_SERVICE_KEY:latest" \
|
||||
--labels="environment=production,commit=${{ steps.version.outputs.short_sha }},version=${{ env.version }}"
|
||||
|
||||
# Ensure 100% traffic goes to the new revision
|
||||
echo "Routing traffic to latest revision..."
|
||||
gcloud run services update-traffic ${{ env.SERVICE_NAME }} \
|
||||
--to-latest \
|
||||
--project=${{ env.PROJECT_ID }} \
|
||||
--region=${{ env.REGION }} \
|
||||
--platform=managed
|
||||
|
||||
echo "✅ Traffic routed to latest revision"
|
||||
|
||||
- name: Get Service URL
|
||||
id: service-url
|
||||
run: |
|
||||
SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE_NAME }} \
|
||||
--project=${{ env.PROJECT_ID }} \
|
||||
--region=${{ env.REGION }} \
|
||||
--format='value(status.url)')
|
||||
|
||||
echo "service_url=${SERVICE_URL}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "## 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Service**: \`${{ env.SERVICE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **URL**: ${SERVICE_URL}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Version**: \`${{ env.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Image**: \`${{ env.image_tag }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Region**: \`${{ env.REGION }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit**: \`${{ steps.version.outputs.short_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Wait for deployment
|
||||
run: sleep 15
|
||||
|
||||
- name: Health Check
|
||||
id: health-check
|
||||
run: |
|
||||
SERVICE_URL="${{ steps.service-url.outputs.service_url }}"
|
||||
|
||||
echo "Testing health endpoint: ${SERVICE_URL}/health/live"
|
||||
|
||||
MAX_RETRIES=5
|
||||
RETRY_COUNT=0
|
||||
SUCCESS=false
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" ${SERVICE_URL}/health/live || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ Health check passed (HTTP $HTTP_CODE)"
|
||||
SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "⚠️ Health check attempt $((RETRY_COUNT + 1))/$MAX_RETRIES failed (HTTP $HTTP_CODE)"
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
echo "Retrying in 10 seconds..."
|
||||
sleep 10
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$SUCCESS" = false ]; then
|
||||
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Liveness Check
|
||||
run: |
|
||||
SERVICE_URL="${{ steps.service-url.outputs.service_url }}"
|
||||
|
||||
echo "Testing liveness endpoint: ${SERVICE_URL}/health/live"
|
||||
RESPONSE=$(curl -s ${SERVICE_URL}/health/live)
|
||||
echo "Response: $RESPONSE"
|
||||
|
||||
STATUS=$(echo $RESPONSE | jq -r '.status')
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
echo "✅ Liveness check passed"
|
||||
else
|
||||
echo "❌ Liveness check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deployment Notification
|
||||
if: success()
|
||||
run: |
|
||||
echo "## ✅ Deployment Successful" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Service is healthy and ready to receive traffic." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Quick Links" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Service URL](${{ steps.service-url.outputs.service_url }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Health Check](${{ steps.service-url.outputs.service_url }}/health)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Cloud Run Console](https://console.cloud.google.com/run/detail/${{ env.REGION }}/${{ env.SERVICE_NAME }}/metrics?project=${{ env.PROJECT_ID }})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
rollback:
|
||||
name: Rollback on Failure
|
||||
runs-on: ubuntu-latest
|
||||
if: failure() && needs.build-and-deploy.result == 'failure'
|
||||
needs: build-and-deploy
|
||||
|
||||
steps:
|
||||
- name: Google Cloud Auth
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY_PROD }}
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Get Previous Revision
|
||||
id: get-revision
|
||||
run: |
|
||||
REVISIONS=$(gcloud run revisions list \
|
||||
--service=${{ env.SERVICE_NAME }} \
|
||||
--project=${{ env.PROJECT_ID }} \
|
||||
--region=${{ env.REGION }} \
|
||||
--format="value(name)" \
|
||||
--limit=2)
|
||||
|
||||
PREV_REVISION=$(echo "$REVISIONS" | tail -n 1)
|
||||
|
||||
if [ -z "$PREV_REVISION" ]; then
|
||||
echo "❌ No previous revision found for rollback"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "prev_revision=${PREV_REVISION}" >> $GITHUB_OUTPUT
|
||||
echo "Found previous revision: ${PREV_REVISION}"
|
||||
|
||||
- name: Rollback to Previous Revision
|
||||
run: |
|
||||
echo "Rolling back to revision: ${{ steps.get-revision.outputs.prev_revision }}"
|
||||
|
||||
gcloud run services update-traffic ${{ env.SERVICE_NAME }} \
|
||||
--to-revisions=${{ steps.get-revision.outputs.prev_revision }}=100 \
|
||||
--project=${{ env.PROJECT_ID }} \
|
||||
--region=${{ env.REGION }}
|
||||
|
||||
echo "## ⚠️ Deployment Failed - Rollback Executed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Service has been rolled back to revision: \`${{ steps.get-revision.outputs.prev_revision }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Please check the logs and fix the issues before redeploying." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Verify Rollback
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE_NAME }} \
|
||||
--project=${{ env.PROJECT_ID }} \
|
||||
--region=${{ env.REGION }} \
|
||||
--format='value(status.url)')
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" ${SERVICE_URL}/health/live)
|
||||
|
||||
if [ $HTTP_CODE -eq 200 ]; then
|
||||
echo "✅ Rollback successful - service is healthy"
|
||||
else
|
||||
echo "❌ Rollback verification failed - service may be unhealthy"
|
||||
exit 1
|
||||
fi
|
||||
31
apps/cards/.gitignore
vendored
31
apps/cards/.gitignore
vendored
|
|
@ -1,31 +0,0 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# firebase/supabase/vexo
|
||||
.env
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
|
||||
|
||||
backend/node_modules
|
||||
apps/mobile/node_modules
|
||||
apps/web/node_modules
|
||||
apps/landing/node_modules
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,506 +0,0 @@
|
|||
# Cards Credit System
|
||||
|
||||
This document explains how the Mana credit system is integrated into Cards.
|
||||
|
||||
## Overview
|
||||
|
||||
Cards uses **Mana** as its credit currency to charge for operations like deck creation, card generation, and AI features. The credit system is powered by [Mana](https://github.com/Memo-2023/mana-core-nestjs-package), which provides:
|
||||
|
||||
- Credit validation before operations
|
||||
- Credit consumption after successful operations
|
||||
- Real-time balance tracking
|
||||
- Transaction history
|
||||
- App-level usage tracking
|
||||
|
||||
## Credit Costs
|
||||
|
||||
| Operation | Cost (Mana) | Description |
|
||||
|-----------|-------------|-------------|
|
||||
| Deck Creation | 10 | Create a new deck |
|
||||
| Card Creation | 2 | Add a single card to a deck |
|
||||
| AI Card Generation | 5 | Generate a card using AI |
|
||||
| Deck Export | 3 | Export a deck to various formats |
|
||||
|
||||
These costs are defined in `backend/src/config/credit-operations.ts`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Frontend │
|
||||
│ (React Native)
|
||||
└─────┬───────┘
|
||||
│ 1. Create Deck Request
|
||||
│ POST /api/decks
|
||||
│ { name, description }
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Backend │
|
||||
│ (NestJS) │
|
||||
├─────────────┤
|
||||
│ API │ 2. Validate Credits (10 mana)
|
||||
│ Controller │ ├─ Has credits? → Continue
|
||||
│ │ └─ No credits? → Return 400
|
||||
│ │
|
||||
│ │ 3. Create Deck (business logic)
|
||||
│ │
|
||||
│ │ 4. Consume Credits (10 mana)
|
||||
│ │
|
||||
│ │ 5. Return Success + Credits Used
|
||||
└─────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Mana │ - Validate balance
|
||||
│ Service │ - Deduct credits
|
||||
│ │ - Record transaction
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Backend Integration
|
||||
|
||||
### 1. Service Key Configuration
|
||||
|
||||
The backend needs a service key from Mana to perform credit operations.
|
||||
|
||||
**backend/.env**:
|
||||
```env
|
||||
SERVICE_KEY=your-service-key-from-mana-core
|
||||
```
|
||||
|
||||
**backend/src/app.module.ts**:
|
||||
```typescript
|
||||
ManaModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
serviceKey: configService.get<string>('SERVICE_KEY', ''),
|
||||
// ... other config
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Credit Operations
|
||||
|
||||
**backend/src/config/credit-operations.ts**:
|
||||
```typescript
|
||||
export enum CreditOperationType {
|
||||
DECK_CREATION = 'deck_creation',
|
||||
CARD_CREATION = 'card_creation',
|
||||
// Add more as needed
|
||||
}
|
||||
|
||||
export const CREDIT_COSTS: Record<CreditOperationType, number> = {
|
||||
[CreditOperationType.DECK_CREATION]: 10,
|
||||
[CreditOperationType.CARD_CREATION]: 2,
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Controller Implementation
|
||||
|
||||
**backend/src/controllers/api.controller.ts**:
|
||||
```typescript
|
||||
import { CreditClientService } from '@mana-core/nestjs-integration/services';
|
||||
import { CreditOperationType, getCreditCost } from '../config/credit-operations';
|
||||
|
||||
@Controller('api')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ApiController {
|
||||
constructor(private readonly creditClient: CreditClientService) {}
|
||||
|
||||
@Post('decks')
|
||||
async createDeck(@CurrentUser() user: any, @Body() deckData: any) {
|
||||
const operationType = CreditOperationType.DECK_CREATION;
|
||||
const creditCost = getCreditCost(operationType);
|
||||
|
||||
// 1. Validate credits BEFORE operation
|
||||
const validation = await this.creditClient.validateCredits(
|
||||
user.id,
|
||||
operationType,
|
||||
creditCost,
|
||||
);
|
||||
|
||||
if (!validation.hasCredits) {
|
||||
throw new BadRequestException({
|
||||
error: 'insufficient_credits',
|
||||
message: `Insufficient mana. Required: ${creditCost}, Available: ${validation.availableCredits}`,
|
||||
requiredCredits: creditCost,
|
||||
availableCredits: validation.availableCredits,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Perform the operation
|
||||
const newDeck = await this.createDeckInDatabase(deckData);
|
||||
|
||||
// 3. Consume credits AFTER success
|
||||
await this.creditClient.consumeCredits(
|
||||
user.id,
|
||||
operationType,
|
||||
creditCost,
|
||||
`Created deck: ${deckData.name}`,
|
||||
{ deckId: newDeck.id },
|
||||
);
|
||||
|
||||
return { success: true, deck: newDeck, creditsUsed: creditCost };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Credit Balance Endpoint
|
||||
|
||||
Get user's current credit balance:
|
||||
|
||||
```typescript
|
||||
@Get('credits/balance')
|
||||
async getCreditBalance(@CurrentUser() user: any) {
|
||||
const balance = await this.creditClient.getCreditBalance(user.id);
|
||||
return {
|
||||
userId: user.id,
|
||||
balance: balance.balance || 0,
|
||||
currency: 'mana',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### 1. Types
|
||||
|
||||
**apps/mobile/types/credits.ts**:
|
||||
```typescript
|
||||
export interface InsufficientCreditsError {
|
||||
error: 'insufficient_credits';
|
||||
message: string;
|
||||
requiredCredits: number;
|
||||
availableCredits: number;
|
||||
operation?: string;
|
||||
}
|
||||
|
||||
export function isInsufficientCreditsError(error: any): boolean {
|
||||
return error && error.error === 'insufficient_credits';
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Credit Service
|
||||
|
||||
**apps/mobile/services/creditService.ts**:
|
||||
```typescript
|
||||
export const creditService = {
|
||||
async getBalance(): Promise<number> {
|
||||
const response = await get(`${API_URL}/api/credits/balance`);
|
||||
return response.balance || 0;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Insufficient Credits Modal
|
||||
|
||||
**apps/mobile/components/InsufficientCreditsModal.tsx**:
|
||||
|
||||
A pre-built modal component that displays:
|
||||
- Required vs available credits
|
||||
- Shortfall amount
|
||||
- "Get More Mana" button (optional)
|
||||
- Cancel button
|
||||
|
||||
### 4. Hook for Easy Integration
|
||||
|
||||
**apps/mobile/hooks/useInsufficientCredits.ts**:
|
||||
```typescript
|
||||
export function useInsufficientCredits() {
|
||||
// ... state management
|
||||
|
||||
return {
|
||||
visible,
|
||||
requiredCredits,
|
||||
availableCredits,
|
||||
operation,
|
||||
handleCreditError, // Automatically shows modal for credit errors
|
||||
hideInsufficientCredits,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Usage Example
|
||||
|
||||
**In any screen that creates a deck**:
|
||||
```typescript
|
||||
import { useInsufficientCredits } from '../hooks/useInsufficientCredits';
|
||||
import { InsufficientCreditsModal } from '../components/InsufficientCreditsModal';
|
||||
import { creditService } from '../services/creditService';
|
||||
|
||||
function DeckCreationScreen() {
|
||||
const [creditBalance, setCreditBalance] = useState(0);
|
||||
const insufficientCredits = useInsufficientCredits();
|
||||
|
||||
// Load balance
|
||||
useEffect(() => {
|
||||
creditService.getBalance().then(setCreditBalance);
|
||||
}, []);
|
||||
|
||||
const handleCreateDeck = async () => {
|
||||
try {
|
||||
const response = await post('/api/decks', deckData);
|
||||
Alert.alert('Success', `Deck created! ${response.creditsUsed} mana used.`);
|
||||
|
||||
// Refresh balance
|
||||
const newBalance = await creditService.getBalance();
|
||||
setCreditBalance(newBalance);
|
||||
} catch (error) {
|
||||
// Automatically shows modal if it's a credit error
|
||||
if (!insufficientCredits.handleCreditError(error)) {
|
||||
// Handle other errors
|
||||
Alert.alert('Error', error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* Show balance */}
|
||||
<Text>Your Mana: {creditBalance} ⚡</Text>
|
||||
|
||||
{/* Create deck button */}
|
||||
<Button title="Create Deck (10 mana)" onPress={handleCreateDeck} />
|
||||
|
||||
{/* Insufficient credits modal */}
|
||||
<InsufficientCreditsModal
|
||||
visible={insufficientCredits.visible}
|
||||
requiredCredits={insufficientCredits.requiredCredits}
|
||||
availableCredits={insufficientCredits.availableCredits}
|
||||
operation={insufficientCredits.operation}
|
||||
onClose={insufficientCredits.hideInsufficientCredits}
|
||||
onPurchase={() => {/* Navigate to purchase */}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend Error Response
|
||||
|
||||
When a user has insufficient credits:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "insufficient_credits",
|
||||
"message": "Insufficient mana. Required: 10, Available: 5",
|
||||
"requiredCredits": 10,
|
||||
"availableCredits": 5,
|
||||
"operation": "Create a new deck"
|
||||
}
|
||||
```
|
||||
|
||||
Status code: **400 Bad Request**
|
||||
|
||||
### Frontend Error Handling
|
||||
|
||||
The `useInsufficientCredits` hook automatically detects credit errors:
|
||||
|
||||
```typescript
|
||||
const insufficientCredits = useInsufficientCredits();
|
||||
|
||||
try {
|
||||
await createDeck();
|
||||
} catch (error) {
|
||||
// Returns true if it was a credit error (modal shown automatically)
|
||||
const wasCreditError = insufficientCredits.handleCreditError(error);
|
||||
|
||||
if (!wasCreditError) {
|
||||
// Handle other errors
|
||||
Alert.alert('Error', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Steps
|
||||
|
||||
1. **Check credit balance**:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://cards-backend-111768794939.europe-west3.run.app/api/credits/balance
|
||||
```
|
||||
|
||||
2. **Create deck with sufficient credits**:
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Test Deck","description":"Testing"}' \
|
||||
https://cards-backend-111768794939.europe-west3.run.app/api/decks
|
||||
```
|
||||
|
||||
3. **Create deck with insufficient credits**:
|
||||
- Repeat step 2 until balance is < 10
|
||||
- Should return 400 with `insufficient_credits` error
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Mock the `CreditClientService` in tests:
|
||||
|
||||
```typescript
|
||||
{
|
||||
provide: CreditClientService,
|
||||
useValue: {
|
||||
validateCredits: jest.fn().mockResolvedValue({
|
||||
hasCredits: true,
|
||||
availableCredits: 100,
|
||||
}),
|
||||
consumeCredits: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
transactionId: 'txn_123',
|
||||
}),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Always validate BEFORE the operation**
|
||||
```typescript
|
||||
const validation = await creditClient.validateCredits(...);
|
||||
if (!validation.hasCredits) throw error;
|
||||
|
||||
// Then do the operation
|
||||
const result = await performOperation();
|
||||
|
||||
// Then consume
|
||||
await creditClient.consumeCredits(...);
|
||||
```
|
||||
|
||||
2. **Consume AFTER success**
|
||||
- Only consume credits if the operation succeeds
|
||||
- This prevents charging users for failed operations
|
||||
|
||||
3. **Include metadata in consumption**
|
||||
```typescript
|
||||
await creditClient.consumeCredits(
|
||||
userId,
|
||||
operationType,
|
||||
cost,
|
||||
'Human-readable description',
|
||||
{ deckId, deckName } // Useful for audit logs
|
||||
);
|
||||
```
|
||||
|
||||
4. **Refresh balance after operations**
|
||||
- Show users their updated balance
|
||||
- Prevents confusion about remaining credits
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **Don't consume without validation**
|
||||
```typescript
|
||||
// BAD: What if they don't have credits?
|
||||
await performOperation();
|
||||
await creditClient.consumeCredits(...);
|
||||
```
|
||||
|
||||
2. **Don't consume before the operation**
|
||||
```typescript
|
||||
// BAD: They'll be charged even if operation fails
|
||||
await creditClient.consumeCredits(...);
|
||||
await performOperation();
|
||||
```
|
||||
|
||||
3. **Don't hardcode credit costs**
|
||||
```typescript
|
||||
// BAD
|
||||
const cost = 10;
|
||||
|
||||
// GOOD
|
||||
const cost = getCreditCost(CreditOperationType.DECK_CREATION);
|
||||
```
|
||||
|
||||
## Adding New Billable Operations
|
||||
|
||||
To add a new operation (e.g., "AI Card Description"):
|
||||
|
||||
### 1. Backend
|
||||
|
||||
**backend/src/config/credit-operations.ts**:
|
||||
```typescript
|
||||
export enum CreditOperationType {
|
||||
// ... existing
|
||||
AI_CARD_DESCRIPTION = 'ai_card_description',
|
||||
}
|
||||
|
||||
export const CREDIT_COSTS: Record<CreditOperationType, number> = {
|
||||
// ... existing
|
||||
[CreditOperationType.AI_CARD_DESCRIPTION]: 3,
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Controller
|
||||
|
||||
```typescript
|
||||
@Post('cards/:id/generate-description')
|
||||
async generateDescription(@CurrentUser() user: any, @Param('id') cardId: string) {
|
||||
const operationType = CreditOperationType.AI_CARD_DESCRIPTION;
|
||||
const cost = getCreditCost(operationType);
|
||||
|
||||
// Validate
|
||||
const validation = await this.creditClient.validateCredits(user.id, operationType, cost);
|
||||
if (!validation.hasCredits) throw insufficientCreditsError;
|
||||
|
||||
// Perform
|
||||
const description = await this.aiService.generateDescription(cardId);
|
||||
|
||||
// Consume
|
||||
await this.creditClient.consumeCredits(
|
||||
user.id,
|
||||
operationType,
|
||||
cost,
|
||||
`Generated description for card ${cardId}`,
|
||||
{ cardId, description: description.substring(0, 50) }
|
||||
);
|
||||
|
||||
return { description, creditsUsed: cost };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Frontend
|
||||
|
||||
Use the same pattern as deck creation - the modal and hooks work for all operations automatically!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Service key not configured" error
|
||||
|
||||
**Problem**: Credits operations fail with authentication error.
|
||||
|
||||
**Solution**: Add `SERVICE_KEY` to your `.env` file:
|
||||
```env
|
||||
SERVICE_KEY=your-actual-service-key-from-mana-core
|
||||
```
|
||||
|
||||
### Credits not being deducted
|
||||
|
||||
**Problem**: Operations succeed but credits don't decrease.
|
||||
|
||||
**Check**:
|
||||
1. Is `consumeCredits()` being called?
|
||||
2. Is it being called AFTER the operation succeeds?
|
||||
3. Check backend logs for errors
|
||||
|
||||
### Frontend not showing insufficient credits modal
|
||||
|
||||
**Problem**: Users get an error but modal doesn't appear.
|
||||
|
||||
**Check**:
|
||||
1. Is `useInsufficientCredits` hook being used?
|
||||
2. Is `handleCreditError()` being called in the catch block?
|
||||
3. Is `InsufficientCreditsModal` component rendered with hook props?
|
||||
|
||||
## Resources
|
||||
|
||||
- [Mana Documentation](https://docs.mana-core.com)
|
||||
- [Mana NestJS Package](https://github.com/Memo-2023/mana-core-nestjs-package)
|
||||
- [Integration Guide](./MANA_CORE_INTEGRATION_GUIDE.md)
|
||||
- [Example Implementation](./apps/mobile/examples/DeckCreationExample.tsx)
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
# Cards Backend Deployment Checklist
|
||||
|
||||
This checklist ensures you have everything configured for automated deployment.
|
||||
|
||||
## ✅ Prerequisites
|
||||
|
||||
### 1. GitHub Secrets (Required)
|
||||
|
||||
Go to `https://github.com/Memo-2023/cards` → Settings → Secrets and variables → Actions
|
||||
|
||||
Add these secrets:
|
||||
|
||||
| Secret Name | Description | How to Get |
|
||||
| --------------------------- | ------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `GCP_SA_KEY_PROD` | Service account JSON key for Cloud Run deployment | See "Create Service Account" below |
|
||||
| `CLOUD_RUN_SERVICE_ACCOUNT` | Service account email | `cards-backend-sa@memo-2c4c4.iam.gserviceaccount.com` |
|
||||
| `GH_PERSONAL_TOKEN` | GitHub Personal Access Token for private packages | See "Create GitHub PAT" below |
|
||||
|
||||
#### Create Service Account
|
||||
|
||||
```bash
|
||||
# 1. Create service account
|
||||
gcloud iam service-accounts create cards-backend-sa \
|
||||
--display-name="Cards Backend Service Account" \
|
||||
--project=memo-2c4c4
|
||||
|
||||
# 2. Grant permissions
|
||||
SA_EMAIL="cards-backend-sa@memo-2c4c4.iam.gserviceaccount.com"
|
||||
|
||||
gcloud projects add-iam-policy-binding memo-2c4c4 \
|
||||
--member="serviceAccount:${SA_EMAIL}" \
|
||||
--role="roles/run.admin"
|
||||
|
||||
gcloud projects add-iam-policy-binding memo-2c4c4 \
|
||||
--member="serviceAccount:${SA_EMAIL}" \
|
||||
--role="roles/iam.serviceAccountUser"
|
||||
|
||||
gcloud projects add-iam-policy-binding memo-2c4c4 \
|
||||
--member="serviceAccount:${SA_EMAIL}" \
|
||||
--role="roles/artifactregistry.writer"
|
||||
|
||||
# 3. Create and download key
|
||||
gcloud iam service-accounts keys create cards-sa-key.json \
|
||||
--iam-account=${SA_EMAIL} \
|
||||
--project=memo-2c4c4
|
||||
|
||||
# 4. Copy contents of cards-sa-key.json to GCP_SA_KEY_PROD secret
|
||||
cat cards-sa-key.json
|
||||
|
||||
# 5. Delete local key file (security best practice)
|
||||
rm cards-sa-key.json
|
||||
```
|
||||
|
||||
#### Create GitHub Personal Access Token
|
||||
|
||||
1. Go to https://github.com/settings/tokens
|
||||
2. Click "Generate new token (classic)"
|
||||
3. Name: `Cards CI/CD`
|
||||
4. Expiration: Choose appropriate timeframe
|
||||
5. Scopes: Select `repo` (Full control of private repositories)
|
||||
6. Click "Generate token"
|
||||
7. Copy token and add to `GH_PERSONAL_TOKEN` secret
|
||||
|
||||
### 2. GCP Artifact Registry
|
||||
|
||||
```bash
|
||||
# Create repository for Docker images
|
||||
gcloud artifacts repositories create cards-backend \
|
||||
--repository-format=docker \
|
||||
--location=europe-west3 \
|
||||
--project=memo-2c4c4 \
|
||||
--description="Docker images for Cards Backend"
|
||||
```
|
||||
|
||||
### 3. GCP Secrets (Required)
|
||||
|
||||
Run the interactive script:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./create-secrets.sh
|
||||
```
|
||||
|
||||
Or create manually:
|
||||
|
||||
```bash
|
||||
# All secrets go to mana-core-453821 project
|
||||
PROJECT_ID="mana-core-453821"
|
||||
|
||||
# Generate service key
|
||||
SERVICE_KEY=$(openssl rand -base64 32)
|
||||
|
||||
# Create secrets
|
||||
echo "your-app-id" | gcloud secrets create CARDS_APP_ID --data-file=- --project=$PROJECT_ID
|
||||
echo "$SERVICE_KEY" | gcloud secrets create CARDS_SERVICE_KEY --data-file=- --project=$PROJECT_ID
|
||||
echo "https://xxx.supabase.co" | gcloud secrets create CARDS_SUPABASE_URL --data-file=- --project=$PROJECT_ID
|
||||
echo "your-anon-key" | gcloud secrets create CARDS_SUPABASE_ANON_KEY --data-file=- --project=$PROJECT_ID
|
||||
echo "your-service-key" | gcloud secrets create CARDS_SUPABASE_SERVICE_KEY --data-file=- --project=$PROJECT_ID
|
||||
echo "https://app.com/welcome" | gcloud secrets create CARDS_SIGNUP_REDIRECT_URL --data-file=- --project=$PROJECT_ID
|
||||
|
||||
# Grant access to service account
|
||||
SA_EMAIL="cards-backend-sa@memo-2c4c4.iam.gserviceaccount.com"
|
||||
|
||||
for SECRET in MANA_SERVICE_URL CARDS_APP_ID CARDS_SERVICE_KEY CARDS_SUPABASE_URL CARDS_SUPABASE_ANON_KEY CARDS_SUPABASE_SERVICE_KEY CARDS_SIGNUP_REDIRECT_URL; do
|
||||
gcloud secrets add-iam-policy-binding $SECRET \
|
||||
--member="serviceAccount:${SA_EMAIL}" \
|
||||
--role="roles/secretmanager.secretAccessor" \
|
||||
--project=$PROJECT_ID
|
||||
done
|
||||
```
|
||||
|
||||
**IMPORTANT**: Add the generated `SERVICE_KEY` to mana-middleware's `APP_SERVICE_KEYS`:
|
||||
|
||||
```
|
||||
APP_SERVICE_KEYS=existing-apps,YOUR_APP_ID:YOUR_SERVICE_KEY
|
||||
```
|
||||
|
||||
## 🚀 Deployment Process
|
||||
|
||||
### Automatic Deployment (GitHub Actions)
|
||||
|
||||
1. Push to `main` branch:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: your changes"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
2. GitHub Actions will automatically:
|
||||
- ✅ Run tests and linting
|
||||
- ✅ Build Docker image
|
||||
- ✅ Push to Artifact Registry
|
||||
- ✅ Deploy to Cloud Run
|
||||
- ✅ Run health checks
|
||||
- ✅ Rollback on failure
|
||||
|
||||
3. Monitor deployment:
|
||||
- Go to https://github.com/Memo-2023/cards/actions
|
||||
- View workflow run progress
|
||||
|
||||
### Manual Deployment (Cloud Build)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Update version in cloudbuild.yaml (e.g., v1.0.0 → v1.0.1)
|
||||
|
||||
# Build and push
|
||||
gcloud builds submit --project=memo-2c4c4 --config=cloudbuild.yaml .
|
||||
|
||||
# Deploy
|
||||
gcloud run deploy cards-backend \
|
||||
--image=europe-west3-docker.pkg.dev/memo-2c4c4/cards-backend/cards-backend:v1.0.1 \
|
||||
--project=memo-2c4c4 \
|
||||
--region=europe-west3
|
||||
```
|
||||
|
||||
## 🔍 Verification
|
||||
|
||||
### Check Deployment Status
|
||||
|
||||
```bash
|
||||
# Get service URL
|
||||
gcloud run services describe cards-backend \
|
||||
--project=memo-2c4c4 \
|
||||
--region=europe-west3 \
|
||||
--format='value(status.url)'
|
||||
|
||||
# Test health endpoint
|
||||
curl https://cards-backend-xxx.run.app/health
|
||||
|
||||
# Test liveness
|
||||
curl https://cards-backend-xxx.run.app/health/live
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Recent logs
|
||||
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=cards-backend" \
|
||||
--project=memo-2c4c4 \
|
||||
--limit=50
|
||||
|
||||
# Error logs only
|
||||
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=cards-backend AND severity>=ERROR" \
|
||||
--project=memo-2c4c4 \
|
||||
--limit=20
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### GitHub Actions Fails with "npm ci" Error
|
||||
|
||||
**Problem**: Private package `@mana-core/nestjs-integration` can't be installed
|
||||
|
||||
**Solution**: Verify `GH_PERSONAL_TOKEN` secret is set with `repo` scope
|
||||
|
||||
### Deployment Fails with "Permission Denied" on Secrets
|
||||
|
||||
**Problem**: Service account can't access secrets in `mana-core-453821`
|
||||
|
||||
**Solution**: Grant cross-project secret access:
|
||||
|
||||
```bash
|
||||
SA_EMAIL="cards-backend-sa@memo-2c4c4.iam.gserviceaccount.com"
|
||||
|
||||
gcloud secrets add-iam-policy-binding CARDS_APP_ID \
|
||||
--member="serviceAccount:${SA_EMAIL}" \
|
||||
--role="roles/secretmanager.secretAccessor" \
|
||||
--project=mana-core-453821
|
||||
```
|
||||
|
||||
### Health Check Fails After Deployment
|
||||
|
||||
**Problem**: Service starts but health endpoint returns 500
|
||||
|
||||
**Possible causes**:
|
||||
|
||||
1. Missing environment variables/secrets
|
||||
2. Can't connect to Supabase
|
||||
3. Can't connect to Mana
|
||||
|
||||
**Debug**:
|
||||
|
||||
```bash
|
||||
# Check service logs
|
||||
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=cards-backend" \
|
||||
--project=memo-2c4c4 \
|
||||
--limit=20
|
||||
|
||||
# Check secret values (if you have permissions)
|
||||
gcloud secrets versions access latest --secret=CARDS_APP_ID --project=mana-core-453821
|
||||
```
|
||||
|
||||
### Peer Dependency Warning
|
||||
|
||||
**Problem**: `@mana-core/nestjs-integration` has peer dependency on `@nestjs/common@^10.0.0` but project uses `^11.0.0`
|
||||
|
||||
**Solution**: Already handled with `--legacy-peer-deps` flag in workflow. If you see this locally:
|
||||
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
## 📊 Project Structure
|
||||
|
||||
```
|
||||
cards/
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── deploy-backend.yml # GitHub Actions workflow
|
||||
├── backend/
|
||||
│ ├── src/ # Source code
|
||||
│ ├── Dockerfile # Container definition
|
||||
│ ├── cloudbuild.yaml # Manual deployment config
|
||||
│ ├── create-secrets.sh # Interactive secrets setup
|
||||
│ ├── verify-build.sh # Local build verification
|
||||
│ ├── DEPLOY_MANUAL.md # Detailed deployment docs
|
||||
│ └── package.json
|
||||
├── apps/
|
||||
│ ├── mobile/
|
||||
│ ├── web/
|
||||
│ └── landing/
|
||||
└── DEPLOYMENT_CHECKLIST.md # This file
|
||||
```
|
||||
|
||||
## 📝 Configuration Summary
|
||||
|
||||
| Component | Location | Value |
|
||||
| ---------------------- | ----------------- | ------------------------------------------------------ |
|
||||
| **Deployment Project** | GCP | `memo-2c4c4` |
|
||||
| **Secrets Project** | GCP | `mana-core-453821` |
|
||||
| **Region** | GCP | `europe-west3` |
|
||||
| **Service Name** | Cloud Run | `cards-backend` |
|
||||
| **Image Registry** | Artifact Registry | `europe-west3-docker.pkg.dev/memo-2c4c4/cards-backend` |
|
||||
| **Port** | Container | `8080` |
|
||||
| **Repository** | GitHub | `Memo-2023/cards` |
|
||||
|
||||
## 🎯 Quick Start
|
||||
|
||||
**First-time setup**:
|
||||
|
||||
```bash
|
||||
# 1. Create GCP resources
|
||||
./backend/create-secrets.sh
|
||||
|
||||
# 2. Add GitHub secrets (see "GitHub Secrets" section)
|
||||
|
||||
# 3. Push to trigger deployment
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**After setup**:
|
||||
|
||||
```bash
|
||||
# Just push to deploy
|
||||
git add .
|
||||
git commit -m "feat: your changes"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-09-30
|
||||
**Maintainer**: Development Team
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
# Edge Function Authentication Fix
|
||||
|
||||
## Problem
|
||||
|
||||
The Edge Function tries to validate Mana app tokens as Supabase JWTs using `supabase.auth.getUser()`, but Mana tokens are signed by a different Supabase project and need to be verified using the JWKS endpoint.
|
||||
|
||||
## Solution
|
||||
|
||||
Update the Edge Function to verify Mana tokens using the jose library and JWKS:
|
||||
|
||||
```typescript
|
||||
import 'jsr:@supabase/functions-js/edge-runtime.d.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
|
||||
import * as jose from 'https://deno.land/x/jose@v5.9.6/index.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the authorization header
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
throw new Error('No authorization header');
|
||||
}
|
||||
|
||||
// Extract the Mana app token
|
||||
const appToken = authHeader.replace('Bearer ', '');
|
||||
|
||||
// Get Mana Core JWKS URL from environment variable
|
||||
// This should be: https://your-mana-project.supabase.co/auth/v1/.well-known/jwks.json
|
||||
const manaJwksUrl = Deno.env.get('MANA_JWKS_URL');
|
||||
if (!manaJwksUrl) {
|
||||
throw new Error('MANA_JWKS_URL not configured');
|
||||
}
|
||||
|
||||
// Verify the Mana token using JWKS
|
||||
const JWKS = jose.createRemoteJWKSet(new URL(manaJwksUrl));
|
||||
const { payload } = await jose.jwtVerify(appToken, JWKS);
|
||||
|
||||
const userId = payload.sub as string;
|
||||
if (!userId) {
|
||||
throw new Error('Invalid token: no user ID');
|
||||
}
|
||||
|
||||
console.log(`Authenticated user: ${userId}`);
|
||||
|
||||
// Initialize Supabase client with service role
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Use the userId from the Mana token
|
||||
const user = { id: userId };
|
||||
|
||||
// Parse request body
|
||||
const requestData = await req.json();
|
||||
const {
|
||||
prompt: userPrompt,
|
||||
deckTitle,
|
||||
deckDescription = '',
|
||||
cardCount = 10,
|
||||
cardTypes = ['flashcard', 'quiz'],
|
||||
difficulty = 'intermediate',
|
||||
tags = [],
|
||||
} = requestData;
|
||||
|
||||
// Validate input
|
||||
if (!userPrompt || !deckTitle) {
|
||||
throw new Error('userPrompt and deckTitle are required');
|
||||
}
|
||||
if (cardCount < 1 || cardCount > 50) {
|
||||
throw new Error('cardCount must be between 1 and 50');
|
||||
}
|
||||
|
||||
// Continue with OpenAI call and rest of your existing function code...
|
||||
} catch (error) {
|
||||
console.error('Error in generate-deck function:', error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || 'Ein unerwarteter Fehler ist aufgetreten',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
status: error.message?.includes('authorization') ? 401 : 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Critical: Disable Gateway JWT Verification
|
||||
|
||||
The Supabase Edge Gateway tries to validate JWTs before passing requests to your function. Since you're using Mana Core JWTs (not Cards JWTs), you need to disable this.
|
||||
|
||||
Create `supabase/functions/generate-deck/config.toml`:
|
||||
|
||||
```toml
|
||||
# Disable automatic JWT verification by Supabase Edge Gateway
|
||||
# We'll manually verify the Mana Core JWT inside the function
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
Do the same for `generate-deck-from-image`:
|
||||
|
||||
```toml
|
||||
# supabase/functions/generate-deck-from-image/config.toml
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
## Environment Variables Needed
|
||||
|
||||
Add this to your Supabase Edge Function secrets:
|
||||
|
||||
```bash
|
||||
# Get your Mana Core project reference from the Supabase dashboard
|
||||
supabase secrets set MANA_JWKS_URL=https://zvwwwwtcmhnxcjiswrln.supabase.co/auth/v1/.well-known/jwks.json
|
||||
```
|
||||
|
||||
Replace `zvwwwwtcmhnxcjiswrln` with your actual Mana Core Supabase project reference.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. ✅ Receives Mana app token from Authorization header
|
||||
2. ✅ Verifies token signature using Mana Core JWKS endpoint
|
||||
3. ✅ Validates token expiration automatically (jose handles this)
|
||||
4. ✅ Extracts user ID from verified token payload
|
||||
5. ✅ Uses Supabase service role for database operations
|
||||
6. ✅ Associates created decks with the correct user ID
|
||||
|
||||
## Benefits
|
||||
|
||||
- Properly validates asymmetric ECC P-256 signed JWTs
|
||||
- Handles key rotation automatically via JWKS
|
||||
- More secure than manual JWT decoding
|
||||
- Works with Supabase's new JWT signing keys
|
||||
|
|
@ -4,8 +4,10 @@ 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. Markenname offen — bis dahin
|
||||
Arbeitstitel **Cards**.
|
||||
**Status:** Planungsphase, noch kein Code.
|
||||
**Name:** Cards.
|
||||
**Domain:** `cards.mana.how` (Subdomain unter `*.mana.how`, SSO über mana-auth).
|
||||
**Zugang:** offen für jeden eingeloggten Mana-User (`requiredTier: 'public'`, kein Beta-Gate).
|
||||
|
||||
## 1. Mission in einem Satz
|
||||
|
||||
|
|
@ -48,18 +50,32 @@ 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
|
||||
- Statistik-Dashboards (nur eine Streak-Zahl)
|
||||
- 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
|
||||
- Cloze, Bilder in Karten, Tags, erweiterte Suche
|
||||
- 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.
|
||||
|
||||
|
|
@ -68,10 +84,11 @@ Jede dieser Features ist legitim — aber nur, wenn der Loop steht.
|
|||
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 Abschnitt 5.
|
||||
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. **Keine eigene Subdomain-Logik in Phase 1.** Cards läuft als Subroute oder dedizierte Subdomain unter `*.mana.how`, damit SSO ohne Sonderwege funktioniert.
|
||||
6. **`cards.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)
|
||||
|
||||
|
|
@ -87,19 +104,21 @@ Alles bereits im Verein verwendet, alles OSI-Open-Source.
|
|||
| 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` | — |
|
||||
| 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 (Free Spaced Repetition Scheduler) v6 | BSD-3 |
|
||||
| Implementierung | `ts-fsrs` (offizielle TS-Portierung) | MIT |
|
||||
| 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 |
|
||||
|
|
@ -136,9 +155,9 @@ Verein-Services).
|
|||
| `mana-auth` | 3001 | SSO, JWT, Sessions, Tier-Claims. Cards-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 (Session gestartet, Karte bewertet …). |
|
||||
| `mana-events` | 3115 | Domain-Events falls für Streak-Logik nötig. |
|
||||
| `mana-notify` | 3040 | "Du hast X Karten fällig"-Push (später, Phase 1.5). |
|
||||
| `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.** |
|
||||
|
|
@ -149,8 +168,8 @@ Verein-Services).
|
|||
|---|---|
|
||||
| `@mana/shared-auth` | Client-seitiger Auth-Hook (SSO-Flow, JWT-Handling). |
|
||||
| `@mana/shared-auth-ui` | Login/Logout-Komponenten. |
|
||||
| `@mana/shared-hono` | (nur sobald cards-server existiert) Auth-/Health-/Error-Middleware. |
|
||||
| `@mana/shared-branding` | App-Registry-Eintrag, Tier-Konfiguration. |
|
||||
| `@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. |
|
||||
|
|
@ -158,13 +177,14 @@ Verein-Services).
|
|||
| `@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 (auch wenn Sharing erst Phase 2). |
|
||||
| `@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 (Sentry-Adapter o.ä., siehe Paket). |
|
||||
| `@mana/shared-logger` | Strukturiertes Logging (Server-Seite). |
|
||||
| `@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. |
|
||||
|
|
@ -185,15 +205,70 @@ User-Aktion → Store → encryptRecord → Dexie → Hooks (_pendingChanges)
|
|||
→ mana-sync → Postgres (mana_platform.cards.*) → andere Clients
|
||||
```
|
||||
|
||||
Die Tabellen heißen genau wie heute im mana-Modul (`cardDecks`, `cards`),
|
||||
plus neue Tabellen für FSRS-State (`cardReviews`, `cardStudyBlocks`).
|
||||
appId = `cards`.
|
||||
appId = `cards`. Tabellen: `cardDecks`, `cards`, `cardReviews`,
|
||||
`cardStudyBlocks`, `deckTags`.
|
||||
|
||||
## 6. Daten-Contract mit dem mana-Modul
|
||||
## 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** (siehe Spinoff-Skizze). Cards-Standalone und mana-Modul
|
||||
schreiben in dieselben Postgres-Tabellen.
|
||||
erhalten**. Cards-Standalone und mana-Modul schreiben in dieselben
|
||||
Postgres-Tabellen.
|
||||
|
||||
Daher gilt:
|
||||
- Schema-Änderungen werden **gemeinsam** im mana-Modul und im
|
||||
|
|
@ -202,25 +277,34 @@ Daher gilt:
|
|||
sein (Field-Allowlist).
|
||||
- Migrationen über `docs/DATABASE_MIGRATIONS.md`.
|
||||
|
||||
## 7. Definition of Done für Phase 1
|
||||
**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 der Cards-Web-App
|
||||
1. Ein eingeloggter Mana-User kann auf `cards.mana.how`
|
||||
- mindestens ein Deck anlegen,
|
||||
- Karten manuell hinzufügen (Front/Back, reiner Text),
|
||||
- eine Lernsession starten und Karten mit FSRS-Bewertung durchspielen,
|
||||
- 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. Die App ist als PWA installierbar und offline-bedienbar (Karten lernen ohne Netz).
|
||||
3. Auth läuft komplett über mana-auth (kein Eigen-Login).
|
||||
4. Daten landen in Postgres und sind im bestehenden mana-Modul sichtbar (gleiche Datenquelle).
|
||||
5. `pnpm validate:all` grün.
|
||||
6. Mindestens ein Smoke-E2E-Test (Playwright): „Login → Deck anlegen → Karte anlegen → Lernsession starten → Karte bewerten".
|
||||
7. Container baut & läuft auf dem Mac mini hinter Cloudflare Tunnel.
|
||||
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 (`cards.mana.how`).
|
||||
|
||||
Alles andere ist Phase 2.
|
||||
|
||||
## 8. Repo-Struktur (Phase 1)
|
||||
## 9. Repo-Struktur (Phase 1)
|
||||
|
||||
```
|
||||
apps/cards/
|
||||
|
|
@ -229,14 +313,15 @@ apps/cards/
|
|||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── data/ # Dexie + Sync-Anbindung
|
||||
│ │ │ ├── fsrs/ # ts-fsrs-Wrapper
|
||||
│ │ │ ├── stores/ # Decks, Cards, Reviews
|
||||
│ │ │ └── ui/ # Komponenten (Card, DeckList, Session)
|
||||
│ │ │ ├── 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
|
||||
│ │ └── learn/[deckId]/+page.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
|
||||
|
|
@ -247,15 +332,35 @@ apps/cards/
|
|||
`apps/cards/apps/mobile/` und `apps/cards/apps/landing/` sind erst
|
||||
Phase 2/3.
|
||||
|
||||
## 9. Was bei jedem Pull-Request gefragt wird
|
||||
## 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"?
|
||||
|
||||
## 10. Offene Fragen — siehe unten / im Chat
|
||||
## 11. Analytics-Events (Mindestumfang Phase 1)
|
||||
|
||||
Sammelpunkt für noch zu klärende Entscheidungen, bevor Code entsteht.
|
||||
Liste wird in den ersten Tagen aktiv abgearbeitet.
|
||||
Ü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 `cards.mana.how` live ist, bekommt das mana-Modul einen
|
||||
**dezenten** Hinweis (z.B. ein Banner oder Badge über der ListView):
|
||||
"Cards gibt es jetzt auch als eigenständige App". Kein Pop-up, kein
|
||||
forcierter Redirect — User entscheiden selbst.
|
||||
|
|
|
|||
|
|
@ -1,591 +0,0 @@
|
|||
# Mana Architecture in Storyteller
|
||||
|
||||
This document explains the architecture and data flow of the Mana integration in the Storyteller project.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend Layer │
|
||||
│ (React Native + Expo) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Auth Service │ │ API Client │ │Token Manager │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ • Sign In │ │ • fetchWith │ │ • getValid │ │
|
||||
│ │ • Sign Up │ │ Auth() │ │ Token() │ │
|
||||
│ │ • Sign Out │ │ • Auto │ │ • refresh │ │
|
||||
│ │ • Device │ │ Refresh │ │ Token() │ │
|
||||
│ │ Info │ │ • Error │ │ • Token │ │
|
||||
│ │ │ │ Handling │ │ Storage │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼──────────────────┼──────────────────┼────────────────┘
|
||||
│ │ │
|
||||
│ HTTP/HTTPS │ Bearer Token │ Refresh
|
||||
│ Requests │ in Headers │ Flow
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend Layer │
|
||||
│ (NestJS + TypeScript) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ Mana NestJS Integration │ │
|
||||
│ ├───────────────────────────────────────────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ AuthGuard │ │@CurrentUser()│ │ Credit │ │ │
|
||||
│ │ │ │ │ │ │ Client │ │ │
|
||||
│ │ │ • Validate │ │ • Extract │ │ Service │ │ │
|
||||
│ │ │ JWT │ │ User ID │ │ │ │ │
|
||||
│ │ │ • Check │ │ • Extract │ │ • validate │ │ │
|
||||
│ │ │ Expiry │ │ Email │ │ Credits() │ │ │
|
||||
│ │ │ • Inject │ │ • Extract │ │ • consume │ │ │
|
||||
│ │ │ User │ │ Role │ │ Credits() │ │ │
|
||||
│ │ │ │ │ │ │ • get │ │ │
|
||||
│ │ │ │ │ │ │ Balance() │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────┼──────────────────────────────────┐ │
|
||||
│ │ Application Controllers & Services │ │
|
||||
│ ├────────────────────────┼──────────────────────────────────┤ │
|
||||
│ │ │ │ │
|
||||
│ │ CharacterController │ StoryController │ │
|
||||
│ │ • generateCharacter │ • createStory │ │
|
||||
│ │ • getCharacters │ • getStories │ │
|
||||
│ │ • updateCharacter │ • updateStory │ │
|
||||
│ │ • deleteCharacter │ • deleteStory │ │
|
||||
│ │ │ │ │
|
||||
│ │ SettingsController │ CreatorsController │ │
|
||||
│ │ • getUserSettings │ • getCreators │ │
|
||||
│ │ • updateSettings │ • getLanguages │ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└───────────────────────────┼─────────────────────────────────────┘
|
||||
│
|
||||
│ API Requests
|
||||
│ (Auth Validation,
|
||||
│ Credit Operations)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Mana Service │
|
||||
│ (Authentication & Credits) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Authentication │ │ Credit Management │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • User Management │ │ • Balance Tracking │ │
|
||||
│ │ • Token Generation │ │ • Transaction Log │ │
|
||||
│ │ • Token Validation │ │ • Operation Types │ │
|
||||
│ │ • Token Refresh │ │ • App-Level Track │ │
|
||||
│ │ • Device Management │ │ • Space Credits │ │
|
||||
│ │ • OAuth Providers │ │ • Billing History │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────────┘ └──────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Sign-In Flow
|
||||
|
||||
```
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Mobile │ │Backend │ │ Mana │
|
||||
│ App │ │ (Nest) │ │ Core │
|
||||
└───┬────┘ └───┬────┘ └───┬────┘
|
||||
│ │ │
|
||||
│ 1. Sign In Request │ │
|
||||
│ POST /auth/signin │ │
|
||||
│ { email, password, │ │
|
||||
│ deviceInfo } │ │
|
||||
├────────────────────────────►│ │
|
||||
│ │ │
|
||||
│ │ 2. Forward to Mana │
|
||||
│ │ POST /auth/signin │
|
||||
│ ├────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ │ 3. Validate
|
||||
│ │ │ Credentials
|
||||
│ │ │
|
||||
│ │ 4. Return Tokens │
|
||||
│ │ { appToken, refreshToken, │
|
||||
│ │ user, device } │
|
||||
│ │◄────────────────────────────┤
|
||||
│ │ │
|
||||
│ 5. Return to Client │ │
|
||||
│ { appToken, refreshToken } │ │
|
||||
│◄────────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 6. Store Tokens │ │
|
||||
│ in SecureStorage │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### Token Refresh Flow
|
||||
|
||||
```
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Mobile │ │Backend │ │ Mana │
|
||||
│ App │ │ (Nest) │ │ Core │
|
||||
└───┬────┘ └───┬────┘ └───┬────┘
|
||||
│ │ │
|
||||
│ 1. API Request │ │
|
||||
│ with Expired Token │ │
|
||||
├────────────────────────────►│ │
|
||||
│ │ │
|
||||
│ │ 2. Validate Token │
|
||||
│ │ (Expired!) │
|
||||
│ │ │
|
||||
│ 3. 401 Unauthorized │ │
|
||||
│◄────────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 4. Token Refresh Request │ │
|
||||
│ POST /auth/refresh │ │
|
||||
│ { refreshToken, deviceInfo }│ │
|
||||
├────────────────────────────►│ │
|
||||
│ │ │
|
||||
│ │ 5. Forward to Mana │
|
||||
│ ├────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ │ 6. Validate
|
||||
│ │ │ Refresh
|
||||
│ │ │ Token
|
||||
│ │ │
|
||||
│ │ 7. New Tokens │
|
||||
│ │◄────────────────────────────┤
|
||||
│ │ │
|
||||
│ 8. Return New Tokens │ │
|
||||
│◄────────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 9. Store New Tokens │ │
|
||||
│ │ │
|
||||
│ 10. Retry Original Request │ │
|
||||
│ with New Token │ │
|
||||
├────────────────────────────►│ │
|
||||
│ │ │
|
||||
│ 11. Success Response │ │
|
||||
│◄────────────────────────────┤ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protected Route Flow
|
||||
|
||||
### With AuthGuard
|
||||
|
||||
```
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Mobile │ │Backend │ │ Mana │
|
||||
│ App │ │ (Nest) │ │ Core │
|
||||
└───┬────┘ └───┬────┘ └───┬────┘
|
||||
│ │ │
|
||||
│ 1. GET /character │ │
|
||||
│ Authorization: Bearer │ │
|
||||
│ eyJhbGc... │ │
|
||||
├────────────────────────────►│ │
|
||||
│ │ │
|
||||
│ │ 2. AuthGuard │
|
||||
│ │ intercepts │
|
||||
│ │ │
|
||||
│ │ 3. Validate Token │
|
||||
│ ├────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ 4. Token Valid │
|
||||
│ │ + User Payload │
|
||||
│ │◄────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ 5. Inject user into │
|
||||
│ │ request object │
|
||||
│ │ │
|
||||
│ │ 6. Execute Controller │
|
||||
│ │ Method │
|
||||
│ │ @CurrentUser() │
|
||||
│ │ extracts user │
|
||||
│ │ │
|
||||
│ 7. Response with Data │ │
|
||||
│◄────────────────────────────┤ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credit Management Flow
|
||||
|
||||
### Character Creation (20 Credits)
|
||||
|
||||
```
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Mobile │ │Backend │ │ Mana │
|
||||
│ App │ │ (Nest) │ │ Core │
|
||||
└───┬────┘ └───┬────┘ └───┬────┘
|
||||
│ │ │
|
||||
│ 1. Create Character Request │ │
|
||||
│ POST /character/generate │ │
|
||||
│ { name, description } │ │
|
||||
├────────────────────────────►│ │
|
||||
│ │ │
|
||||
│ │ 2. Pre-flight Check │
|
||||
│ │ validateCredits() │
|
||||
│ │ userId, "character_ │
|
||||
│ │ creation", 20 │
|
||||
│ ├────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ │ 3. Check
|
||||
│ │ │ Balance
|
||||
│ │ │
|
||||
│ │ 4. Validation Result │
|
||||
│ │ { hasCredits: true, │
|
||||
│ │ availableCredits: 100 } │
|
||||
│ │◄────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ 5. If hasCredits = false │
|
||||
│ │ Return error │
|
||||
│◄────────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 6. Show "Buy Credits" Modal │ │
|
||||
│ │ │
|
||||
│ │ 7. If hasCredits = true │
|
||||
│ │ Proceed with creation │
|
||||
│ │ • Generate images │
|
||||
│ │ • Store in database │
|
||||
│ │ │
|
||||
│ │ 8. Consume Credits │
|
||||
│ │ consumeCredits() │
|
||||
│ │ userId, "character_ │
|
||||
│ │ creation", 20, │
|
||||
│ │ "Created: Cat" │
|
||||
│ ├────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ │ 9. Deduct
|
||||
│ │ │ Credits
|
||||
│ │ │
|
||||
│ │ 10. Transaction Receipt │
|
||||
│ │ { transactionId, │
|
||||
│ │ remainingBalance: 80 } │
|
||||
│ │◄────────────────────────────┤
|
||||
│ │ │
|
||||
│ 11. Success Response │ │
|
||||
│ { data: { characterId, │ │
|
||||
│ images, ... } } │ │
|
||||
│◄────────────────────────────┤ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### Story Creation (100 Credits)
|
||||
|
||||
```
|
||||
Same flow as character creation, but with:
|
||||
- operationType: "story_creation"
|
||||
- amount: 100
|
||||
- More expensive due to:
|
||||
* Story text generation
|
||||
* Multiple image generations (10 pages)
|
||||
* Translation to German
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credit Operation Types in Storyteller
|
||||
|
||||
```typescript
|
||||
// Defined in Storyteller
|
||||
type StorytellerOperations =
|
||||
| 'character_creation' // 20 credits
|
||||
| 'story_creation'; // 100 credits
|
||||
|
||||
// Credit Costs
|
||||
const CREDIT_COSTS = {
|
||||
character_creation: 20, // 3 image variants
|
||||
story_creation: 100, // 10-page story with images + translation
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: Complete Example
|
||||
|
||||
### Creating a Story with Credits
|
||||
|
||||
1. **Frontend**: User fills story form
|
||||
```typescript
|
||||
const createStory = async () => {
|
||||
const response = await fetchWithAuth('/story', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
characters: [characterId],
|
||||
storyDescription: 'A magical forest adventure',
|
||||
authorId: 'author-1',
|
||||
illustratorId: 'illustrator-1',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error === 'insufficient_credits') {
|
||||
navigation.navigate('PurchaseCredits', {
|
||||
required: 100,
|
||||
available: data.availableCredits,
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **Backend Controller**: Story creation endpoint
|
||||
```typescript
|
||||
@Post()
|
||||
@UseGuards(AuthGuard)
|
||||
async createStory(
|
||||
@Body() dto: CreateStoryDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
// Pre-flight check
|
||||
const validation = await this.creditClient.validateCredits(
|
||||
user.sub,
|
||||
'story_creation',
|
||||
100,
|
||||
);
|
||||
|
||||
if (!validation.hasCredits) {
|
||||
throw new BadRequestException({
|
||||
error: 'insufficient_credits',
|
||||
requiredCredits: 100,
|
||||
availableCredits: validation.availableCredits,
|
||||
});
|
||||
}
|
||||
|
||||
// Create story
|
||||
const result = await this.storyService.createStory({
|
||||
userId: user.sub,
|
||||
characterId: dto.characters[0],
|
||||
storyDescription: dto.storyDescription,
|
||||
});
|
||||
|
||||
// Consume credits
|
||||
await this.creditClient.consumeCredits(
|
||||
user.sub,
|
||||
'story_creation',
|
||||
100,
|
||||
`Created story: ${result.storyData.title}`,
|
||||
{ storyId: result.storyData.id },
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Mana**: Processes credit operations
|
||||
- Validates user has sufficient credits
|
||||
- Deducts credits from user's balance
|
||||
- Records transaction in ledger
|
||||
- Returns transaction ID and new balance
|
||||
- Tracks operation by app ID
|
||||
|
||||
---
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Token Security
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Token Structure │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Access Token (appToken): │
|
||||
│ • JWT format │
|
||||
│ • Short-lived (15 minutes) │
|
||||
│ • Contains: { sub, email, role, exp, iat } │
|
||||
│ • Used for API authentication │
|
||||
│ • Stored in SecureStorage (mobile) │
|
||||
│ │
|
||||
│ Refresh Token: │
|
||||
│ • Long-lived (30 days) │
|
||||
│ • Used to get new access token │
|
||||
│ • Single-use (rotated on refresh) │
|
||||
│ • Device-specific │
|
||||
│ • Stored in SecureStorage (mobile) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### AuthGuard Protection
|
||||
|
||||
```typescript
|
||||
// All routes under @UseGuards(AuthGuard) are protected:
|
||||
|
||||
✅ Validates JWT signature
|
||||
✅ Checks token expiration
|
||||
✅ Extracts user payload
|
||||
✅ Injects user into request
|
||||
✅ Returns 401 if invalid
|
||||
|
||||
// Usage:
|
||||
@Controller('character')
|
||||
@UseGuards(AuthGuard) // ← Protects all routes in controller
|
||||
export class CharacterController {
|
||||
@Get()
|
||||
getCharacters(@CurrentUser() user: JwtPayload) {
|
||||
// user is automatically available here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Integration (Supabase RLS)
|
||||
|
||||
### Row Level Security with JWT
|
||||
|
||||
Storyteller uses Supabase with Row Level Security (RLS). The `@UserToken()` decorator extracts the raw JWT for RLS:
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async getCharacters(
|
||||
@CurrentUser() user: JwtPayload, // Validated user
|
||||
@UserToken() token: string, // Raw JWT for RLS
|
||||
) {
|
||||
// Pass token to Supabase client
|
||||
const characters = await this.supabase
|
||||
.from('characters')
|
||||
.select('*')
|
||||
.eq('user_id', user.sub)
|
||||
.setAuth(token); // RLS enforces user_id match
|
||||
|
||||
return { data: characters };
|
||||
}
|
||||
```
|
||||
|
||||
**Why Both Decorators?**
|
||||
- `@CurrentUser()`: Validated user data for business logic
|
||||
- `@UserToken()`: Raw JWT for database RLS enforcement
|
||||
|
||||
---
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Backend `.env`
|
||||
|
||||
```env
|
||||
# Mana (Required)
|
||||
MANA_SERVICE_URL=https://mana-middleware-111768794939.europe-west3.run.app
|
||||
APP_ID=8d2f5ddb-e251-4b3b-8802-84022a7ac77f
|
||||
MANA_SUPABASE_SECRET_KEY=your-service-key
|
||||
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=3002
|
||||
|
||||
# Database (Supabase)
|
||||
MAERCHENZAUBER_SUPABASE_URL=your-supabase-url
|
||||
MAERCHENZAUBER_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
### Frontend `.env`
|
||||
|
||||
```env
|
||||
# Backend URL
|
||||
EXPO_PUBLIC_STORYTELLER_BACKEND_URL=http://localhost:3002
|
||||
|
||||
# For production:
|
||||
# EXPO_PUBLIC_STORYTELLER_BACKEND_URL=https://your-api.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
### What Mana Provides
|
||||
|
||||
1. **Authentication System**
|
||||
- ✅ Email/password sign-in/sign-up
|
||||
- ✅ Google OAuth integration
|
||||
- ✅ Apple Sign-in integration
|
||||
- ✅ JWT token generation and validation
|
||||
- ✅ Token refresh mechanism
|
||||
- ✅ Multi-device management
|
||||
|
||||
2. **Credit Management**
|
||||
- ✅ Balance tracking per user
|
||||
- ✅ Pre-flight validation
|
||||
- ✅ Transaction recording
|
||||
- ✅ Operation type tracking
|
||||
- ✅ App-level tracking
|
||||
- ✅ Metadata support
|
||||
|
||||
3. **Developer Experience**
|
||||
- ✅ Simple module configuration
|
||||
- ✅ Guards for route protection
|
||||
- ✅ Decorators for data extraction
|
||||
- ✅ TypeScript support
|
||||
- ✅ Error handling utilities
|
||||
- ✅ Debug logging
|
||||
|
||||
### What You Need to Implement
|
||||
|
||||
1. **Application Logic**
|
||||
- Define operation types
|
||||
- Set credit costs
|
||||
- Implement business logic
|
||||
- Handle errors
|
||||
|
||||
2. **Frontend Integration**
|
||||
- Token storage
|
||||
- API client with auto-refresh
|
||||
- Error handling UI
|
||||
- Purchase flow (if needed)
|
||||
|
||||
3. **Testing**
|
||||
- Unit tests with mocked services
|
||||
- Integration tests
|
||||
- End-to-end flows
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Token Caching
|
||||
- Frontend caches tokens in SecureStorage
|
||||
- Token validation is fast (local check + remote if needed)
|
||||
- Refresh only when needed (5-minute buffer)
|
||||
|
||||
### Credit Operations
|
||||
- Pre-flight validation is lightweight
|
||||
- Consumption happens after success
|
||||
- No blocking during operations
|
||||
|
||||
### Best Practices
|
||||
1. Always validate credits BEFORE expensive operations
|
||||
2. Consume credits AFTER successful operations
|
||||
3. Handle `InsufficientCreditsException` gracefully
|
||||
4. Log all credit transactions for audit
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After understanding the architecture:
|
||||
|
||||
1. **Review the Integration Guide**: See `MANA_CORE_INTEGRATION_GUIDE.md`
|
||||
2. **Follow the Checklist**: Use `MANA_CORE_INTEGRATION_CHECKLIST.md`
|
||||
3. **Study the Code**: Examine Storyteller's implementation
|
||||
4. **Test Thoroughly**: Ensure all flows work correctly
|
||||
5. **Monitor in Production**: Track usage and errors
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
- **Full Guide**: `MANA_CORE_INTEGRATION_GUIDE.md`
|
||||
- **Checklist**: `MANA_CORE_INTEGRATION_CHECKLIST.md`
|
||||
- **Mana Docs**: https://docs.mana-core.com
|
||||
- **GitHub**: https://github.com/Memo-2023/mana-core-nestjs-package
|
||||
|
|
@ -1,463 +0,0 @@
|
|||
# Mana Integration Checklist
|
||||
|
||||
Use this checklist when integrating `@mana-core/nestjs-integration` into a new NestJS project.
|
||||
|
||||
## Prerequisites ✓
|
||||
|
||||
- [ ] NestJS v10+ application set up
|
||||
- [ ] `@nestjs/config` installed
|
||||
- [ ] Node.js v18+ and npm/yarn
|
||||
- [ ] Mana credentials obtained:
|
||||
- [ ] `MANA_SERVICE_URL`
|
||||
- [ ] `APP_ID`
|
||||
- [ ] `MANA_SUPABASE_SECRET_KEY` (service key)
|
||||
|
||||
---
|
||||
|
||||
## Backend Integration Steps
|
||||
|
||||
### 1. Installation
|
||||
|
||||
- [ ] Install the package:
|
||||
|
||||
```bash
|
||||
npm install git+https://github.com/Memo-2023/mana-core-nestjs-package.git
|
||||
```
|
||||
|
||||
- [ ] Verify in `package.json`:
|
||||
```json
|
||||
"@mana-core/nestjs-integration": "git+https://github.com/..."
|
||||
```
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
- [ ] Create/update `.env` file:
|
||||
|
||||
```env
|
||||
MANA_SERVICE_URL=https://your-mana-instance.com
|
||||
APP_ID=your-app-id
|
||||
MANA_SUPABASE_SECRET_KEY=your-service-key
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
- [ ] Add to `.env.example` (for team reference)
|
||||
|
||||
- [ ] Add `.env` to `.gitignore`
|
||||
|
||||
### 3. Module Configuration
|
||||
|
||||
- [ ] Import `ManaModule` in `app.module.ts`
|
||||
|
||||
- [ ] Configure with `forRootAsync()`:
|
||||
|
||||
```typescript
|
||||
ManaModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
manaServiceUrl: 'your-mana-url',
|
||||
appId: 'your-app-id',
|
||||
serviceKey: configService.get('MANA_SUPABASE_SECRET_KEY'),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Test backend starts without errors
|
||||
|
||||
### 4. Protect Routes with AuthGuard
|
||||
|
||||
- [ ] Import `AuthGuard` in controller:
|
||||
|
||||
```typescript
|
||||
import { AuthGuard } from '@mana-core/nestjs-integration';
|
||||
```
|
||||
|
||||
- [ ] Apply to controller or route:
|
||||
|
||||
```typescript
|
||||
@Controller('protected')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ProtectedController {}
|
||||
```
|
||||
|
||||
- [ ] Test: Verify 401 without token
|
||||
|
||||
### 5. Extract User Information
|
||||
|
||||
- [ ] Import `@CurrentUser()` decorator:
|
||||
|
||||
```typescript
|
||||
import { CurrentUser } from '@mana-core/nestjs-integration';
|
||||
```
|
||||
|
||||
- [ ] Use in route handlers:
|
||||
|
||||
```typescript
|
||||
@Get('profile')
|
||||
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||
return { userId: user.sub, email: user.email };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Test: Verify user data is extracted correctly
|
||||
|
||||
### 6. Integrate Credit System
|
||||
|
||||
- [ ] Inject `CreditClientService`:
|
||||
|
||||
```typescript
|
||||
constructor(private creditClient: CreditClientService) {}
|
||||
```
|
||||
|
||||
- [ ] Add pre-flight credit validation:
|
||||
|
||||
```typescript
|
||||
const validation = await this.creditClient.validateCredits(userId, 'operation_type', creditCost);
|
||||
```
|
||||
|
||||
- [ ] Add credit consumption after success:
|
||||
|
||||
```typescript
|
||||
await this.creditClient.consumeCredits(
|
||||
userId,
|
||||
'operation_type',
|
||||
creditCost,
|
||||
'Description',
|
||||
metadata
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] Handle `InsufficientCreditsException`:
|
||||
|
||||
```typescript
|
||||
import { InsufficientCreditsException } from '@mana-core/nestjs-integration';
|
||||
```
|
||||
|
||||
- [ ] Test: Verify credits are deducted
|
||||
|
||||
### 7. (Optional) Custom Token Decorator
|
||||
|
||||
- [ ] Create `@UserToken()` decorator for RLS:
|
||||
|
||||
```typescript
|
||||
// decorators/user.decorator.ts
|
||||
export const UserToken = createParamDecorator((_data: unknown, ctx: ExecutionContext): string => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return request.token;
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Use for database RLS:
|
||||
```typescript
|
||||
@Get()
|
||||
async getData(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@UserToken() token: string,
|
||||
) {
|
||||
return await this.db.query(userId, token);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Integration Steps
|
||||
|
||||
### 1. Configure API Base URL
|
||||
|
||||
- [ ] Create `.env` file:
|
||||
|
||||
```env
|
||||
EXPO_PUBLIC_STORYTELLER_BACKEND_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
- [ ] Create API utility (`utils/api.ts`):
|
||||
```typescript
|
||||
export const API_BASE_URL = process.env.EXPO_PUBLIC_BACKEND_URL;
|
||||
```
|
||||
|
||||
### 2. Create Auth Service
|
||||
|
||||
- [ ] Create `services/authService.ts`
|
||||
|
||||
- [ ] Implement sign-in:
|
||||
|
||||
```typescript
|
||||
signIn: async (email: string, password: string) => {
|
||||
const deviceInfo = await getDeviceInfo();
|
||||
const response = await fetch(`${BACKEND_URL}/auth/signin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
const data = await response.json();
|
||||
await storeTokens(data.appToken, data.refreshToken);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] Implement sign-up
|
||||
|
||||
- [ ] Implement sign-out
|
||||
|
||||
- [ ] Test: Verify tokens are stored securely
|
||||
|
||||
### 3. Create Token Manager
|
||||
|
||||
- [ ] Create `services/tokenManager.ts`
|
||||
|
||||
- [ ] Implement `getValidToken()`:
|
||||
|
||||
```typescript
|
||||
getValidToken: async () => {
|
||||
let token = await storage.getItem('appToken');
|
||||
if (isExpiringSoon(token)) {
|
||||
await this.refreshToken();
|
||||
token = await storage.getItem('appToken');
|
||||
}
|
||||
return token;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] Implement `refreshToken()`:
|
||||
|
||||
```typescript
|
||||
refreshToken: async () => {
|
||||
const refreshToken = await storage.getItem('refreshToken');
|
||||
const deviceInfo = await getDeviceInfo();
|
||||
const response = await fetch(`${BACKEND_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken, deviceInfo }),
|
||||
});
|
||||
const data = await response.json();
|
||||
await storeTokens(data.appToken, data.refreshToken);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] Test: Verify automatic refresh works
|
||||
|
||||
### 4. Create Authenticated API Client
|
||||
|
||||
- [ ] Create `fetchWithAuth()` function:
|
||||
|
||||
```typescript
|
||||
export async function fetchWithAuth(endpoint: string, options = {}) {
|
||||
const token = await tokenManager.getValidToken();
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
await tokenManager.refreshToken();
|
||||
// Retry request
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Test: Verify authenticated requests work
|
||||
|
||||
### 5. Handle Credit Errors
|
||||
|
||||
- [ ] Create error handling utility:
|
||||
|
||||
```typescript
|
||||
export function isInsufficientCreditsError(error: any) {
|
||||
return error?.error === 'insufficient_credits';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Handle in UI:
|
||||
|
||||
```typescript
|
||||
if (data.error === 'insufficient_credits') {
|
||||
showPurchaseCreditsModal({
|
||||
required: data.requiredCredits,
|
||||
available: data.availableCredits,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Test: Verify error is displayed correctly
|
||||
|
||||
### 6. Device Management
|
||||
|
||||
- [ ] Create `utils/deviceManager.ts`:
|
||||
|
||||
```typescript
|
||||
export class DeviceManager {
|
||||
static async getDeviceInfo() {
|
||||
return {
|
||||
deviceId: await getOrCreateDeviceId(),
|
||||
deviceName: Platform.OS,
|
||||
deviceType: Platform.OS as 'ios' | 'android' | 'web',
|
||||
userAgent: getUserAgent(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Include in auth requests
|
||||
|
||||
- [ ] Test: Verify device info is sent
|
||||
|
||||
---
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### Backend Tests
|
||||
|
||||
- [ ] Create unit tests with mocked services:
|
||||
|
||||
```typescript
|
||||
{
|
||||
provide: CreditClientService,
|
||||
useValue: {
|
||||
validateCredits: jest.fn().mockResolvedValue({
|
||||
hasCredits: true,
|
||||
}),
|
||||
consumeCredits: jest.fn(),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Create integration tests with real Mana module
|
||||
|
||||
- [ ] Test credit validation flow
|
||||
|
||||
- [ ] Test insufficient credits error
|
||||
|
||||
- [ ] Run tests: `npm run test`
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
- [ ] Test authentication flow
|
||||
|
||||
- [ ] Test token refresh
|
||||
|
||||
- [ ] Test authenticated API calls
|
||||
|
||||
- [ ] Test credit error handling
|
||||
|
||||
- [ ] Run tests: `npm run test`
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Set production environment variables:
|
||||
|
||||
```env
|
||||
MANA_SERVICE_URL=https://production-mana.com
|
||||
APP_ID=production-app-id
|
||||
MANA_SUPABASE_SECRET_KEY=production-key
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
- [ ] Disable debug logging (`debug: false`)
|
||||
|
||||
- [ ] Test health endpoint
|
||||
|
||||
- [ ] Deploy and monitor logs
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Update `.env` for production:
|
||||
|
||||
```env
|
||||
EXPO_PUBLIC_BACKEND_URL=https://your-api.com
|
||||
```
|
||||
|
||||
- [ ] Build production bundle
|
||||
|
||||
- [ ] Test authentication flow
|
||||
|
||||
- [ ] Test API calls
|
||||
|
||||
- [ ] Deploy to stores (iOS/Android) or web
|
||||
|
||||
---
|
||||
|
||||
## Post-Integration Verification
|
||||
|
||||
- [ ] Sign-up flow works end-to-end
|
||||
|
||||
- [ ] Sign-in flow works end-to-end
|
||||
|
||||
- [ ] Token refresh works automatically
|
||||
|
||||
- [ ] Protected routes require authentication
|
||||
|
||||
- [ ] Credit validation prevents operations
|
||||
|
||||
- [ ] Credit consumption records transactions
|
||||
|
||||
- [ ] Insufficient credits error handled gracefully
|
||||
|
||||
- [ ] Sign-out clears tokens
|
||||
|
||||
- [ ] Multi-device support works
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Update README with Mana setup instructions
|
||||
|
||||
- [ ] Document custom operation types and credit costs
|
||||
|
||||
- [ ] Add environment variable documentation
|
||||
|
||||
- [ ] Create troubleshooting guide
|
||||
|
||||
- [ ] Document API endpoints
|
||||
|
||||
---
|
||||
|
||||
## Common Issues Checklist
|
||||
|
||||
If something doesn't work, check:
|
||||
|
||||
- [ ] Environment variables are set correctly
|
||||
- [ ] Backend is running and accessible
|
||||
- [ ] Service key is configured (for credit operations)
|
||||
- [ ] Tokens are being stored and retrieved correctly
|
||||
- [ ] Token expiration is being checked
|
||||
- [ ] Device info is being sent with auth requests
|
||||
- [ ] CORS is configured (if using web frontend)
|
||||
- [ ] Network requests are not being blocked
|
||||
- [ ] Debug logging is enabled for troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## Support Resources
|
||||
|
||||
- **Full Integration Guide**: See `MANA_CORE_INTEGRATION_GUIDE.md`
|
||||
- **Mana Docs**: https://docs.mana-core.com
|
||||
- **GitHub Issues**: https://github.com/Memo-2023/mana-core-nestjs-package/issues
|
||||
- **Example Code**: Check Storyteller project for working implementation
|
||||
|
||||
---
|
||||
|
||||
## Integration Complete! 🎉
|
||||
|
||||
Once all items are checked, your application is fully integrated with Mana.
|
||||
|
||||
**Estimated Time**: 2-4 hours for basic integration, 1-2 days for complete implementation with testing.
|
||||
|
||||
**Next Steps**:
|
||||
|
||||
1. Define your operation types and credit costs
|
||||
2. Implement purchase flow for credits
|
||||
3. Add analytics and monitoring
|
||||
4. Set up role-based access control (if needed)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,590 +0,0 @@
|
|||
# Mana Integration Documentation
|
||||
|
||||
Complete documentation for integrating the `@mana-core/nestjs-integration` package into your NestJS application, based on the Storyteller project implementation.
|
||||
|
||||
## 📚 Documentation Overview
|
||||
|
||||
This documentation suite includes four comprehensive guides:
|
||||
|
||||
### 1. **This File** - Quick Start & Overview
|
||||
Start here for a high-level understanding and quick reference.
|
||||
|
||||
### 2. **[Integration Guide](./MANA_CORE_INTEGRATION_GUIDE.md)** - Complete Implementation Guide
|
||||
📖 **70+ pages** of detailed step-by-step instructions covering:
|
||||
- Installation and setup
|
||||
- Backend integration with code examples
|
||||
- Frontend integration (React Native/Expo)
|
||||
- Credit management implementation
|
||||
- Error handling patterns
|
||||
- Testing strategies
|
||||
- Troubleshooting common issues
|
||||
|
||||
**Use this when**: You're implementing Mana for the first time or need detailed explanations.
|
||||
|
||||
### 3. **[Integration Checklist](./MANA_CORE_INTEGRATION_CHECKLIST.md)** - Step-by-Step Checklist
|
||||
✅ **Actionable checklist** with checkboxes covering:
|
||||
- Prerequisites verification
|
||||
- Backend integration steps
|
||||
- Frontend integration steps
|
||||
- Testing checklist
|
||||
- Production deployment
|
||||
- Post-integration verification
|
||||
|
||||
**Use this when**: You want a quick reference while implementing or to verify nothing was missed.
|
||||
|
||||
### 4. **[Architecture Guide](./MANA_CORE_ARCHITECTURE.md)** - Visual Architecture & Data Flow
|
||||
🎨 **Visual diagrams and architecture** explaining:
|
||||
- System architecture
|
||||
- Authentication flows
|
||||
- Credit management flows
|
||||
- Data flow examples
|
||||
- Security architecture
|
||||
- Database integration
|
||||
|
||||
**Use this when**: You need to understand how everything fits together or explain it to your team.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### What is Mana?
|
||||
|
||||
Mana is a centralized authentication and credit management system that provides:
|
||||
- **Authentication**: Email/password, Google OAuth, Apple Sign-in
|
||||
- **JWT Token Management**: Automatic validation, refresh, and multi-device support
|
||||
- **Credit System**: Pre-flight validation, consumption tracking, and billing
|
||||
- **Guards & Decorators**: Easy-to-use NestJS integration
|
||||
|
||||
### How Storyteller Uses It
|
||||
|
||||
| Feature | Implementation | Credits |
|
||||
|---------|---------------|---------|
|
||||
| **User Authentication** | Email/password + OAuth | Free |
|
||||
| **Character Creation** | AI image generation (3 variants) | 20 credits |
|
||||
| **Story Creation** | 10-page illustrated story + translation | 100 credits |
|
||||
| **Protected Routes** | All character/story endpoints | Via AuthGuard |
|
||||
|
||||
### Integration Time
|
||||
|
||||
- **Basic Setup**: 1-2 hours
|
||||
- **Full Integration**: 4-8 hours
|
||||
- **With Testing**: 1-2 days
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
npm install git+https://github.com/Memo-2023/mana-core-nestjs-package.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
**Backend `.env`**:
|
||||
```env
|
||||
MANA_SERVICE_URL=https://mana-middleware-111768794939.europe-west3.run.app
|
||||
APP_ID=your-app-id
|
||||
MANA_SUPABASE_SECRET_KEY=your-service-key
|
||||
NODE_ENV=development
|
||||
PORT=3002
|
||||
```
|
||||
|
||||
**Frontend `.env`**:
|
||||
```env
|
||||
EXPO_PUBLIC_STORYTELLER_BACKEND_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
### 2. Module Setup
|
||||
|
||||
**`backend/src/app.module.ts`**:
|
||||
```typescript
|
||||
import { ManaModule } from '@mana-core/nestjs-integration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ManaModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
manaServiceUrl: 'https://mana-middleware-111768794939.europe-west3.run.app',
|
||||
appId: '8d2f5ddb-e251-4b3b-8802-84022a7ac77f',
|
||||
serviceKey: configService.get('MANA_SUPABASE_SECRET_KEY'),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Authentication Example
|
||||
|
||||
### Backend Controller
|
||||
|
||||
```typescript
|
||||
import {
|
||||
AuthGuard,
|
||||
CurrentUser,
|
||||
CreditClientService,
|
||||
} from '@mana-core/nestjs-integration';
|
||||
|
||||
@Controller('character')
|
||||
@UseGuards(AuthGuard) // Protect all routes
|
||||
export class CharacterController {
|
||||
constructor(
|
||||
private readonly creditClient: CreditClientService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getCharacters(@CurrentUser() user: JwtPayload) {
|
||||
// user.sub = user ID
|
||||
// user.email = user email
|
||||
return { data: await this.getCharactersForUser(user.sub) };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend API Client
|
||||
|
||||
```typescript
|
||||
import { tokenManager } from './tokenManager';
|
||||
|
||||
export async function fetchWithAuth(endpoint: string, options = {}) {
|
||||
const token = await tokenManager.getValidToken();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
await tokenManager.refreshToken();
|
||||
// Retry...
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const response = await fetchWithAuth('/character');
|
||||
const data = await response.json();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💳 Credit Management Example
|
||||
|
||||
### Backend: Validate & Consume Credits
|
||||
|
||||
```typescript
|
||||
@Post('generate')
|
||||
async generateCharacter(
|
||||
@Body() dto: CreateCharacterDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
// 1. Pre-flight validation
|
||||
const validation = await this.creditClient.validateCredits(
|
||||
user.sub,
|
||||
'character_creation',
|
||||
20,
|
||||
);
|
||||
|
||||
if (!validation.hasCredits) {
|
||||
return {
|
||||
error: 'insufficient_credits',
|
||||
requiredCredits: 20,
|
||||
availableCredits: validation.availableCredits,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Perform expensive operation
|
||||
const character = await this.createCharacter(dto);
|
||||
|
||||
// 3. Consume credits after success
|
||||
await this.creditClient.consumeCredits(
|
||||
user.sub,
|
||||
'character_creation',
|
||||
20,
|
||||
`Created character: ${character.name}`,
|
||||
{ characterId: character.id },
|
||||
);
|
||||
|
||||
return { data: character };
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend: Handle Credit Errors
|
||||
|
||||
```typescript
|
||||
const response = await fetchWithAuth('/character/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error === 'insufficient_credits') {
|
||||
// Show purchase modal
|
||||
navigation.navigate('PurchaseCredits', {
|
||||
required: data.requiredCredits,
|
||||
available: data.availableCredits,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
console.log('Character created:', data.data);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Key Features
|
||||
|
||||
### 1. AuthGuard
|
||||
|
||||
Protects routes automatically:
|
||||
|
||||
```typescript
|
||||
@Controller('protected')
|
||||
@UseGuards(AuthGuard) // All routes require authentication
|
||||
export class ProtectedController {}
|
||||
```
|
||||
|
||||
### 2. @CurrentUser() Decorator
|
||||
|
||||
Extracts authenticated user:
|
||||
|
||||
```typescript
|
||||
// Get entire user
|
||||
@Get('profile')
|
||||
getProfile(@CurrentUser() user: JwtPayload) {
|
||||
return { id: user.sub, email: user.email };
|
||||
}
|
||||
|
||||
// Get specific field
|
||||
@Get('id')
|
||||
getUserId(@CurrentUser('sub') userId: string) {
|
||||
return { userId };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Credit Client Service
|
||||
|
||||
Manages credits:
|
||||
|
||||
```typescript
|
||||
// Validate before operation
|
||||
const validation = await this.creditClient.validateCredits(
|
||||
userId,
|
||||
'operation_type',
|
||||
amount,
|
||||
);
|
||||
|
||||
// Consume after success
|
||||
await this.creditClient.consumeCredits(
|
||||
userId,
|
||||
'operation_type',
|
||||
amount,
|
||||
'Description',
|
||||
metadata,
|
||||
);
|
||||
|
||||
// Check balance
|
||||
const balance = await this.creditClient.getCreditBalance(userId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Storyteller Credit Costs
|
||||
|
||||
| Operation | Cost | What's Included |
|
||||
|-----------|------|-----------------|
|
||||
| **Character Creation** | 20 credits | 3 AI-generated image variants |
|
||||
| **Story Creation** | 100 credits | 10-page illustrated story + German translation |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Authentication Flow
|
||||
|
||||
```
|
||||
Mobile App (React Native)
|
||||
│
|
||||
│ 1. Sign In (email, password, deviceInfo)
|
||||
▼
|
||||
Backend (NestJS)
|
||||
│
|
||||
│ 2. Forward to Mana
|
||||
▼
|
||||
Mana Service
|
||||
│
|
||||
│ 3. Validate & Generate Tokens
|
||||
▼
|
||||
Backend
|
||||
│
|
||||
│ 4. Return { appToken, refreshToken }
|
||||
▼
|
||||
Mobile App
|
||||
│
|
||||
│ 5. Store in SecureStorage
|
||||
│ 6. Use appToken for API calls
|
||||
│ 7. Auto-refresh when expired
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Features
|
||||
|
||||
- ✅ **JWT-based authentication**
|
||||
- ✅ **Token expiration** (15 minutes for access, 30 days for refresh)
|
||||
- ✅ **Automatic token refresh** (5-minute buffer)
|
||||
- ✅ **Multi-device support** with device tracking
|
||||
- ✅ **Secure token storage** (SecureStorage on mobile)
|
||||
- ✅ **Row Level Security** (RLS) support for Supabase
|
||||
|
||||
---
|
||||
|
||||
## 📝 Code Examples
|
||||
|
||||
### Sign In
|
||||
|
||||
**Backend** (Auto-provided by Mana):
|
||||
```
|
||||
POST /auth/signin
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123",
|
||||
"deviceInfo": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend**:
|
||||
```typescript
|
||||
const result = await authService.signIn(email, password);
|
||||
if (result.success) {
|
||||
navigation.navigate('Home');
|
||||
} else {
|
||||
showError(result.error);
|
||||
}
|
||||
```
|
||||
|
||||
### Protected Route
|
||||
|
||||
```typescript
|
||||
@Get('stories')
|
||||
@UseGuards(AuthGuard)
|
||||
async getStories(@CurrentUser() user: JwtPayload) {
|
||||
return await this.storyService.getStoriesForUser(user.sub);
|
||||
}
|
||||
```
|
||||
|
||||
### Credit Validation
|
||||
|
||||
```typescript
|
||||
const validation = await this.creditClient.validateCredits(
|
||||
user.sub,
|
||||
'story_creation',
|
||||
100,
|
||||
);
|
||||
|
||||
if (!validation.hasCredits) {
|
||||
throw new BadRequestException({
|
||||
error: 'insufficient_credits',
|
||||
requiredCredits: 100,
|
||||
availableCredits: validation.availableCredits,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Mock Credit Client
|
||||
|
||||
```typescript
|
||||
{
|
||||
provide: CreditClientService,
|
||||
useValue: {
|
||||
validateCredits: jest.fn().mockResolvedValue({
|
||||
hasCredits: true,
|
||||
availableCredits: 100,
|
||||
}),
|
||||
consumeCredits: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
}),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Test Protected Routes
|
||||
|
||||
```typescript
|
||||
it('should require authentication', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/character')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should return data with valid token', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/character')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| **401 Unauthorized** | Check token expiration, verify service key |
|
||||
| **Credit validation fails** | Verify service key, check user balance |
|
||||
| **Token refresh not working** | Verify device info is sent, check backend URL |
|
||||
| **Module not found** | Re-install package from GitHub |
|
||||
|
||||
See [Integration Guide](./MANA_CORE_INTEGRATION_GUIDE.md#troubleshooting) for detailed troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Structure
|
||||
|
||||
```
|
||||
MANA_CORE_README.md ← You are here (Quick start)
|
||||
│
|
||||
├── MANA_CORE_INTEGRATION_GUIDE.md ← Complete guide (read first)
|
||||
│ ├── Installation
|
||||
│ ├── Backend Integration
|
||||
│ ├── Frontend Integration
|
||||
│ ├── Credit Management
|
||||
│ ├── Error Handling
|
||||
│ ├── Testing
|
||||
│ └── Troubleshooting
|
||||
│
|
||||
├── MANA_CORE_INTEGRATION_CHECKLIST.md ← Step-by-step checklist
|
||||
│ ├── Prerequisites ✓
|
||||
│ ├── Backend Steps ✓
|
||||
│ ├── Frontend Steps ✓
|
||||
│ ├── Testing ✓
|
||||
│ └── Deployment ✓
|
||||
│
|
||||
└── MANA_CORE_ARCHITECTURE.md ← Visual architecture & flows
|
||||
├── System Architecture
|
||||
├── Authentication Flow
|
||||
├── Credit Management Flow
|
||||
├── Security Architecture
|
||||
└── Data Flow Examples
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Learning Path
|
||||
|
||||
### For New Integrations
|
||||
|
||||
1. **Read**: [Integration Guide](./MANA_CORE_INTEGRATION_GUIDE.md) (Overview + Installation)
|
||||
2. **Understand**: [Architecture Guide](./MANA_CORE_ARCHITECTURE.md) (System design)
|
||||
3. **Implement**: Follow [Checklist](./MANA_CORE_INTEGRATION_CHECKLIST.md)
|
||||
4. **Study**: Review Storyteller code examples
|
||||
5. **Test**: Verify all flows work
|
||||
6. **Deploy**: Production configuration
|
||||
|
||||
### For Understanding Existing Implementation
|
||||
|
||||
1. **Review**: [Architecture Guide](./MANA_CORE_ARCHITECTURE.md)
|
||||
2. **Trace**: Follow authentication and credit flows
|
||||
3. **Explore**: Study Storyteller controllers
|
||||
4. **Reference**: Use [Integration Guide](./MANA_CORE_INTEGRATION_GUIDE.md) for details
|
||||
|
||||
---
|
||||
|
||||
## 📊 Key Files in Storyteller
|
||||
|
||||
### Backend
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/src/app.module.ts` | Mana module configuration |
|
||||
| `backend/src/character/character.controller.ts` | AuthGuard + Credit usage example |
|
||||
| `backend/src/story/story.controller.ts` | Credit validation + consumption |
|
||||
| `backend/src/decorators/user.decorator.ts` | Custom @UserToken() for RLS |
|
||||
|
||||
### Frontend
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `mobile/src/utils/api.ts` | API client with auto-refresh |
|
||||
| `mobile/src/services/authService.ts` | Sign-in, sign-up, sign-out |
|
||||
| `mobile/src/services/tokenManager.ts` | Token management |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Resources
|
||||
|
||||
- **Mana Package**: https://github.com/Memo-2023/mana-core-nestjs-package
|
||||
- **Mana Docs**: https://docs.mana-core.com
|
||||
- **NestJS Docs**: https://docs.nestjs.com
|
||||
- **Storyteller Source**: This repository
|
||||
|
||||
---
|
||||
|
||||
## 💡 Best Practices
|
||||
|
||||
1. ✅ **Always validate credits BEFORE expensive operations**
|
||||
2. ✅ **Consume credits AFTER successful operations**
|
||||
3. ✅ **Handle insufficient credits gracefully in UI**
|
||||
4. ✅ **Use AuthGuard for all protected routes**
|
||||
5. ✅ **Enable debug logging during development**
|
||||
6. ✅ **Store tokens securely (SecureStorage)**
|
||||
7. ✅ **Implement automatic token refresh**
|
||||
8. ✅ **Test authentication and credit flows thoroughly**
|
||||
|
||||
---
|
||||
|
||||
## ❓ Support
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Documentation**: Check all four documentation files
|
||||
2. **Code Examples**: Study Storyteller implementation
|
||||
3. **Mana Docs**: https://docs.mana-core.com
|
||||
4. **GitHub Issues**: https://github.com/Memo-2023/mana-core-nestjs-package/issues
|
||||
|
||||
### Contributing
|
||||
|
||||
If you find issues or improvements in this documentation:
|
||||
1. Create a pull request
|
||||
2. Open an issue
|
||||
3. Contact the team
|
||||
|
||||
---
|
||||
|
||||
## ✨ Summary
|
||||
|
||||
This documentation provides everything you need to integrate Mana into your NestJS application:
|
||||
|
||||
- **Complete integration guide** with step-by-step instructions
|
||||
- **Actionable checklist** to track progress
|
||||
- **Visual architecture diagrams** to understand the system
|
||||
- **Real code examples** from Storyteller
|
||||
- **Troubleshooting guides** for common issues
|
||||
|
||||
**Start with**: [Integration Guide](./MANA_CORE_INTEGRATION_GUIDE.md)
|
||||
|
||||
**Use while implementing**: [Checklist](./MANA_CORE_INTEGRATION_CHECKLIST.md)
|
||||
|
||||
**Reference for architecture**: [Architecture Guide](./MANA_CORE_ARCHITECTURE.md)
|
||||
|
||||
Good luck with your integration! 🚀
|
||||
|
|
@ -1,442 +1,34 @@
|
|||
# Cards
|
||||
|
||||
A deck management system with Mana authentication and credit system integration.
|
||||
Spaced-repetition flashcards on **cards.mana.how**.
|
||||
|
||||
## Features
|
||||
Phase-1 standalone web app. The frontend lives here; data, auth, and
|
||||
sync are shared with the rest of the Mana stack:
|
||||
|
||||
- 🔐 **Mana Authentication** - Complete auth system with JWT tokens, device tracking, and automatic token refresh
|
||||
- ⚡ **Credit System** - Mana-based billing for operations (10 mana to create a deck, 5 for AI features, etc.)
|
||||
- 📱 **React Native/Expo** - Cross-platform mobile app (iOS, Android, Web)
|
||||
- 🚀 **NestJS Backend** - Type-safe API with AuthGuard protection
|
||||
- 💾 **Supabase** - Database and real-time features
|
||||
- 🎨 **NativeWind** - Tailwind CSS for React Native styling
|
||||
- **Auth:** mana-auth (SSO), `*.mana.how`
|
||||
- **Sync:** mana-sync, app-id `cards`
|
||||
- **Storage:** `mana_platform.cards.*` (Postgres, RLS)
|
||||
|
||||
## Quick Start
|
||||
The same `cards` data backs the **mana** built-in Cards module at
|
||||
`mana.how/cards`. Schema changes ship to both frontends together — see
|
||||
`apps/cards/GUIDELINES.md`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Expo CLI (`npm install -g expo-cli`)
|
||||
- Supabase account
|
||||
- Mana credentials (APP_ID, SERVICE_KEY)
|
||||
|
||||
### Backend Setup
|
||||
|
||||
1. **Navigate to backend directory**:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Configure environment variables**:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and add your credentials:
|
||||
```env
|
||||
# Mana
|
||||
MANA_SERVICE_URL=https://mana-middleware-111768794939.europe-west3.run.app
|
||||
APP_ID=your-app-id-from-mana-core
|
||||
SERVICE_KEY=your-service-key-from-mana-core # Required for credits
|
||||
|
||||
# Supabase
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=8080
|
||||
```
|
||||
|
||||
4. **Start the backend**:
|
||||
```bash
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
Backend will be available at `http://localhost:8080`
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
1. **Navigate to mobile app directory**:
|
||||
```bash
|
||||
cd apps/mobile
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Configure environment**:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
```env
|
||||
# Supabase
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
|
||||
# Backend API
|
||||
EXPO_PUBLIC_API_URL=http://localhost:8080 # For local development
|
||||
# EXPO_PUBLIC_API_URL=https://your-production-backend.com # For production
|
||||
```
|
||||
|
||||
4. **Start Expo**:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
5. **Run on platform**:
|
||||
- Press `i` for iOS simulator
|
||||
- Press `a` for Android emulator
|
||||
- Press `w` for web browser
|
||||
|
||||
## Architecture
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Mobile App │ React Native + Expo
|
||||
│ (Frontend) │ - Authentication UI
|
||||
│ │ - Credit balance display
|
||||
│ │ - Deck management
|
||||
└──────────┬──────────┘
|
||||
│ HTTPS/JSON
|
||||
│ Bearer Token Auth
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Backend API │ NestJS
|
||||
│ │ - AuthGuard protection
|
||||
│ │ - Credit validation
|
||||
│ │ - Business logic
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
├─────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ Mana │ │ Supabase │
|
||||
│ - Auth │ │ - Database │
|
||||
│ - Credits │ │ - Storage │
|
||||
│ - Transactions │ │ - Real-time │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Credit System
|
||||
|
||||
Cards uses **Mana** as its credit currency. Operations cost credits:
|
||||
|
||||
| Operation | Cost |
|
||||
|-----------|------|
|
||||
| Create Deck | 10 mana |
|
||||
| Create Card | 2 mana |
|
||||
| AI Card Generation | 5 mana |
|
||||
| Export Deck | 3 mana |
|
||||
|
||||
### How it Works
|
||||
|
||||
1. **Pre-flight Validation**: Backend checks if user has enough credits
|
||||
2. **Operation**: Performs the requested operation (create deck, etc.)
|
||||
3. **Consumption**: Deducts credits only if operation succeeds
|
||||
4. **Response**: Returns success + credits used
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
The frontend automatically handles insufficient credits with a modal:
|
||||
|
||||
```typescript
|
||||
import { useInsufficientCredits } from '../hooks/useInsufficientCredits';
|
||||
import { InsufficientCreditsModal } from '../components/InsufficientCreditsModal';
|
||||
|
||||
function MyScreen() {
|
||||
const insufficientCredits = useInsufficientCredits();
|
||||
|
||||
const handleAction = async () => {
|
||||
try {
|
||||
await post('/api/decks', data);
|
||||
} catch (error) {
|
||||
// Automatically shows modal if insufficient credits
|
||||
insufficientCredits.handleCreditError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={handleAction} />
|
||||
<InsufficientCreditsModal {...insufficientCredits} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Full documentation**: See [CREDIT_SYSTEM.md](./CREDIT_SYSTEM.md)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication (via Mana)
|
||||
|
||||
- `POST /v1/auth/signin` - Sign in
|
||||
- `POST /v1/auth/signup` - Sign up
|
||||
- `POST /v1/auth/refresh` - Refresh token
|
||||
- `POST /v1/auth/logout` - Sign out
|
||||
|
||||
### Protected Endpoints (require Bearer token)
|
||||
|
||||
- `GET /api/profile` - Get user profile + credit balance
|
||||
- `GET /api/credits/balance` - Get credit balance
|
||||
- `GET /api/decks` - List user's decks
|
||||
- `POST /api/decks` - Create deck (costs 10 mana)
|
||||
- `PUT /api/decks/:id` - Update deck
|
||||
- `DELETE /api/decks/:id` - Delete deck
|
||||
- `GET /api/cards` - List cards
|
||||
- `POST /api/cards` - Create card (costs 2 mana)
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
- `GET /public/health` - Health check
|
||||
- `GET /public/version` - API version
|
||||
- `GET /public/featured-decks` - Featured decks (optional auth)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cards/
|
||||
├── backend/ # NestJS backend
|
||||
│ ├── src/
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── credit-operations.ts # Credit costs & operation types
|
||||
│ │ │ └── validation.schema.ts # Environment validation
|
||||
│ │ ├── controllers/
|
||||
│ │ │ ├── api.controller.ts # Protected endpoints
|
||||
│ │ │ ├── public.controller.ts # Public endpoints
|
||||
│ │ │ └── health.controller.ts # Health checks
|
||||
│ │ ├── services/
|
||||
│ │ │ └── supabase.service.ts # Supabase integration
|
||||
│ │ ├── app.module.ts # Main module (Mana config)
|
||||
│ │ └── main.ts # Entry point
|
||||
│ ├── .env # Environment variables
|
||||
│ └── package.json
|
||||
│
|
||||
apps/cards/
|
||||
├── apps/
|
||||
│ ├── mobile/ # React Native/Expo app
|
||||
│ ├── web/ # Web app
|
||||
│ └── landing/ # Landing page
|
||||
│ ├── app/ # Expo Router screens
|
||||
│ │ ├── (tabs)/ # Tab navigation
|
||||
│ │ └── _layout.tsx # Root layout
|
||||
│ ├── components/
|
||||
│ │ └── InsufficientCreditsModal.tsx # Credit error modal
|
||||
│ ├── services/
|
||||
│ │ ├── authService.ts # Authentication
|
||||
│ │ ├── tokenManager.ts # Token refresh & management
|
||||
│ │ └── creditService.ts # Credit operations
|
||||
│ ├── hooks/
|
||||
│ │ └── useInsufficientCredits.ts # Credit error handling
|
||||
│ ├── types/
|
||||
│ │ ├── auth.ts # Auth types
|
||||
│ │ └── credits.ts # Credit types
|
||||
│ ├── utils/
|
||||
│ │ ├── apiClient.ts # Authenticated API client
|
||||
│ │ └── deviceManager.ts # Device info
|
||||
│ ├── examples/
|
||||
│ │ └── DeckCreationExample.tsx # Usage example
|
||||
│ ├── .env # Environment variables
|
||||
│ └── package.json
|
||||
│
|
||||
├── CREDIT_SYSTEM.md # Credit system documentation
|
||||
├── MANA_CORE_INTEGRATION_GUIDE.md
|
||||
├── MANA_CORE_INTEGRATION_CHECKLIST.md
|
||||
├── MANA_CORE_ARCHITECTURE.md
|
||||
├── MANA_CORE_README.md
|
||||
└── README.md # This file
|
||||
│ └── web/ # SvelteKit 2 + Svelte 5 — the Phase-1 surface
|
||||
├── GUIDELINES.md # Project rules (read first)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Development
|
||||
`apps/cards/apps/mobile/` and any production `apps/cards/apps/landing/`
|
||||
will land in Phase 2/3.
|
||||
|
||||
### Backend
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Development with hot reload
|
||||
npm run start:dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Run production build
|
||||
npm run start:prod
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
|
||||
# Tests
|
||||
npm run test
|
||||
pnpm install
|
||||
pnpm --filter @cards/web dev # cards.mana.how on http://localhost:5180
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
|
||||
# Start Expo dev server
|
||||
npm start
|
||||
|
||||
# Run on iOS
|
||||
npm run ios
|
||||
|
||||
# Run on Android
|
||||
npm run android
|
||||
|
||||
# Run on web
|
||||
npm run web
|
||||
|
||||
# Linting & formatting
|
||||
npm run lint
|
||||
npm run format
|
||||
|
||||
# Build with EAS
|
||||
npm run build:dev # Development build
|
||||
npm run build:preview # Preview build
|
||||
npm run build:prod # Production build
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `MANA_SERVICE_URL` | Mana service URL | `https://mana-middleware-*.run.app` |
|
||||
| `APP_ID` | Your app ID from Mana | `cea4bfc6-a4de-4e17-91e2-54275940156e` |
|
||||
| `SERVICE_KEY` | Service key for credit operations | Get from Mana |
|
||||
| `SUPABASE_URL` | Supabase project URL | `https://abc.supabase.co` |
|
||||
| `SUPABASE_ANON_KEY` | Supabase anonymous key | Your anon key |
|
||||
| `PORT` | Backend port | `8080` |
|
||||
|
||||
### Frontend Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `EXPO_PUBLIC_SUPABASE_URL` | Supabase project URL | `https://abc.supabase.co` |
|
||||
| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | Supabase anonymous key | Your anon key |
|
||||
| `EXPO_PUBLIC_API_URL` | Backend API URL | `http://localhost:8080` |
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Testing
|
||||
|
||||
The backend includes example tests in `backend/src/app.controller.spec.ts`. To add credit system tests:
|
||||
|
||||
```typescript
|
||||
import { CreditClientService } from '@mana-core/nestjs-integration/services';
|
||||
|
||||
// Mock in test setup
|
||||
{
|
||||
provide: CreditClientService,
|
||||
useValue: {
|
||||
validateCredits: jest.fn().mockResolvedValue({
|
||||
hasCredits: true,
|
||||
availableCredits: 100,
|
||||
}),
|
||||
consumeCredits: jest.fn(),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **Test authentication**:
|
||||
```bash
|
||||
# Sign up
|
||||
curl -X POST http://localhost:8080/v1/auth/signup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"password123","username":"testuser"}'
|
||||
|
||||
# Sign in
|
||||
curl -X POST http://localhost:8080/v1/auth/signin \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"password123"}'
|
||||
```
|
||||
|
||||
2. **Test protected endpoint**:
|
||||
```bash
|
||||
export TOKEN="your-jwt-token-from-signin"
|
||||
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/profile
|
||||
```
|
||||
|
||||
3. **Test credit system**:
|
||||
```bash
|
||||
# Check balance
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/credits/balance
|
||||
|
||||
# Create deck (costs 10 mana)
|
||||
curl -X POST http://localhost:8080/api/decks \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"My Deck","description":"Test deck"}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
|
||||
- Check all environment variables are set
|
||||
- Verify Mana credentials are correct
|
||||
- Check if port 8080 is available
|
||||
|
||||
### Credits not working
|
||||
|
||||
- Ensure `SERVICE_KEY` is set in backend `.env`
|
||||
- Check backend logs for credit validation errors
|
||||
- Verify user has credits in Mana dashboard
|
||||
|
||||
### Frontend can't connect to backend
|
||||
|
||||
- Check `EXPO_PUBLIC_API_URL` is correct
|
||||
- For Android emulator, use `http://10.0.2.2:8080` instead of `localhost`
|
||||
- Verify backend is running
|
||||
|
||||
### Token refresh fails
|
||||
|
||||
- Check device info is being sent with refresh requests
|
||||
- Verify refresh token is stored correctly
|
||||
- Check network connectivity
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Credit System](./CREDIT_SYSTEM.md)** - Complete credit system documentation
|
||||
- **[Mana Integration Guide](./MANA_CORE_INTEGRATION_GUIDE.md)** - Step-by-step integration
|
||||
- **[Integration Checklist](./MANA_CORE_INTEGRATION_CHECKLIST.md)** - Checkboxes for tracking
|
||||
- **[Architecture Guide](./MANA_CORE_ARCHITECTURE.md)** - System architecture and flows
|
||||
- **[Mana README](./MANA_CORE_README.md)** - Quick reference
|
||||
- **[Example Implementation](./apps/mobile/examples/DeckCreationExample.tsx)** - Working code example
|
||||
|
||||
## Resources
|
||||
|
||||
- [Mana Documentation](https://docs.mana-core.com)
|
||||
- [Mana NestJS Package](https://github.com/Memo-2023/mana-core-nestjs-package)
|
||||
- [Expo Documentation](https://docs.expo.dev)
|
||||
- [NestJS Documentation](https://docs.nestjs.com)
|
||||
- [Supabase Documentation](https://supabase.com/docs)
|
||||
|
||||
## License
|
||||
|
||||
Private project - All rights reserved
|
||||
|
||||
## Support
|
||||
|
||||
For issues related to:
|
||||
- **Mana**: https://github.com/Memo-2023/mana-core-nestjs-package/issues
|
||||
- **This project**: Contact the development team
|
||||
|
|
|
|||
|
|
@ -1,339 +0,0 @@
|
|||
# Cards Setup Guide
|
||||
|
||||
## What's Been Completed
|
||||
|
||||
Your Mana integration is now **100% complete** with a fully functional credit system! 🎉
|
||||
|
||||
### ✅ Backend (Complete)
|
||||
- [x] ManaModule configured with environment variables
|
||||
- [x] Service key configuration for credit operations
|
||||
- [x] Credit operation types defined (deck creation = 10 mana)
|
||||
- [x] Credit validation before deck creation
|
||||
- [x] Credit consumption after successful operations
|
||||
- [x] Credit balance endpoint (`GET /api/credits/balance`)
|
||||
- [x] AuthGuard protecting all API routes
|
||||
- [x] User profile endpoint with credit balance
|
||||
|
||||
### ✅ Frontend (Complete)
|
||||
- [x] Authentication service with sign in/up/out
|
||||
- [x] Token manager with automatic refresh
|
||||
- [x] API client with auth token injection
|
||||
- [x] Device manager for multi-device support
|
||||
- [x] InsufficientCreditsModal component
|
||||
- [x] useInsufficientCredits hook
|
||||
- [x] Credit service for balance checking
|
||||
- [x] Type definitions for credit errors
|
||||
- [x] Example deck creation implementation
|
||||
|
||||
### ✅ Documentation (Complete)
|
||||
- [x] README.md with quick start
|
||||
- [x] CREDIT_SYSTEM.md with full documentation
|
||||
- [x] Integration guides (4 files)
|
||||
- [x] Working code example
|
||||
|
||||
## Next Steps to Go Live
|
||||
|
||||
### 1. Add Your Service Key (5 minutes) ⚠️ REQUIRED
|
||||
|
||||
The backend needs a service key from Mana to validate and consume credits.
|
||||
|
||||
**backend/.env**:
|
||||
```env
|
||||
SERVICE_KEY=your-actual-service-key-here
|
||||
```
|
||||
|
||||
**Where to get it**: Contact Mana admin or check your Mana dashboard.
|
||||
|
||||
Without this key, credit operations will fail with authentication errors.
|
||||
|
||||
### 2. Test the Credit Flow (15 minutes)
|
||||
|
||||
#### A. Start the backend
|
||||
```bash
|
||||
cd backend
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
#### B. Test authentication
|
||||
```bash
|
||||
# Sign up a test user
|
||||
curl -X POST http://localhost:8080/v1/auth/signup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@cards.com","password":"test123","username":"testuser"}'
|
||||
|
||||
# Sign in to get token
|
||||
curl -X POST http://localhost:8080/v1/auth/signin \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@cards.com","password":"test123"}'
|
||||
|
||||
# Copy the appToken from response
|
||||
export TOKEN="paste-your-token-here"
|
||||
```
|
||||
|
||||
#### C. Test credit balance
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/credits/balance
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"userId": "...",
|
||||
"balance": 100,
|
||||
"currency": "mana",
|
||||
"timestamp": "2025-..."
|
||||
}
|
||||
```
|
||||
|
||||
#### D. Test deck creation (costs 10 mana)
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/decks \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Test Deck","description":"My first deck"}'
|
||||
```
|
||||
|
||||
Expected success response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"userId": "...",
|
||||
"deck": {
|
||||
"id": "deck_...",
|
||||
"name": "Test Deck",
|
||||
"description": "My first deck",
|
||||
"userId": "...",
|
||||
"createdAt": "2025-..."
|
||||
},
|
||||
"creditsUsed": 10,
|
||||
"message": "Deck created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### E. Check updated balance
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/api/credits/balance
|
||||
```
|
||||
|
||||
Should now show `90` instead of `100`.
|
||||
|
||||
#### F. Test insufficient credits error
|
||||
|
||||
Create decks until balance < 10, then try one more time:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/decks \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Another Deck"}'
|
||||
```
|
||||
|
||||
Expected insufficient credits response (400):
|
||||
```json
|
||||
{
|
||||
"error": "insufficient_credits",
|
||||
"message": "Insufficient mana. Required: 10, Available: 5",
|
||||
"requiredCredits": 10,
|
||||
"availableCredits": 5,
|
||||
"operation": "Create a new deck"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Frontend Integration (15 minutes)
|
||||
|
||||
#### A. Start the mobile app
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npm start
|
||||
```
|
||||
|
||||
Press `i` for iOS, `a` for Android, or `w` for web.
|
||||
|
||||
#### B. Test authentication
|
||||
1. Sign up with a new account
|
||||
2. Verify tokens are stored
|
||||
3. Sign out and sign in again
|
||||
|
||||
#### C. Test credit display
|
||||
1. Navigate to profile or deck creation screen
|
||||
2. Verify credit balance is displayed
|
||||
3. Create a deck and see balance update
|
||||
|
||||
#### D. Test insufficient credits modal
|
||||
1. Create decks until balance < 10
|
||||
2. Try creating another deck
|
||||
3. Verify modal appears with:
|
||||
- Required: 10 mana
|
||||
- Available: [your balance]
|
||||
- Needed: [shortfall]
|
||||
|
||||
### 4. Integrate into Your Actual Screens (1-2 hours)
|
||||
|
||||
Copy the pattern from `apps/mobile/examples/DeckCreationExample.tsx` into your real deck creation screens.
|
||||
|
||||
#### Quick integration checklist:
|
||||
- [ ] Import `useInsufficientCredits` hook
|
||||
- [ ] Import `InsufficientCreditsModal` component
|
||||
- [ ] Import `creditService`
|
||||
- [ ] Load and display credit balance on screen
|
||||
- [ ] Wrap API calls in try/catch
|
||||
- [ ] Call `insufficientCredits.handleCreditError(error)` in catch
|
||||
- [ ] Render `<InsufficientCreditsModal {...insufficientCredits} />`
|
||||
|
||||
### 5. Add Credit Purchase Flow (Optional)
|
||||
|
||||
If users can purchase credits:
|
||||
|
||||
1. Create a purchase screen in `apps/mobile/app/purchase-credits.tsx`
|
||||
2. Implement payment integration (Stripe, in-app purchases, etc.)
|
||||
3. Update `onPurchase` handler in InsufficientCreditsModal:
|
||||
```typescript
|
||||
onPurchase={() => {
|
||||
insufficientCredits.hideInsufficientCredits();
|
||||
navigation.navigate('PurchaseCredits');
|
||||
}}
|
||||
```
|
||||
|
||||
## Credit Costs Reference
|
||||
|
||||
These are defined in `backend/src/config/credit-operations.ts`:
|
||||
|
||||
| Operation | Cost | Location |
|
||||
|-----------|------|----------|
|
||||
| Deck Creation | 10 mana | `POST /api/decks` |
|
||||
| Card Creation | 2 mana | `POST /api/cards` |
|
||||
| AI Card Generation | 5 mana | `POST /api/cards/generate` |
|
||||
| Deck Export | 3 mana | `POST /api/decks/:id/export` |
|
||||
|
||||
To change costs, edit the `CREDIT_COSTS` object in that file.
|
||||
|
||||
## Adding More Billable Operations
|
||||
|
||||
Example: Make card updates cost 1 mana:
|
||||
|
||||
### 1. Add to credit-operations.ts
|
||||
```typescript
|
||||
export enum CreditOperationType {
|
||||
// ... existing
|
||||
CARD_UPDATE = 'card_update',
|
||||
}
|
||||
|
||||
export const CREDIT_COSTS = {
|
||||
// ... existing
|
||||
[CreditOperationType.CARD_UPDATE]: 1,
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Update the endpoint
|
||||
```typescript
|
||||
@Put('cards/:id')
|
||||
async updateCard(@CurrentUser() user: any, @Param('id') cardId: string, @Body() data: any) {
|
||||
const operationType = CreditOperationType.CARD_UPDATE;
|
||||
const cost = getCreditCost(operationType);
|
||||
|
||||
// Validate credits
|
||||
const validation = await this.creditClient.validateCredits(user.id, operationType, cost);
|
||||
if (!validation.hasCredits) {
|
||||
throw new BadRequestException({
|
||||
error: 'insufficient_credits',
|
||||
requiredCredits: cost,
|
||||
availableCredits: validation.availableCredits,
|
||||
});
|
||||
}
|
||||
|
||||
// Update card
|
||||
const updated = await this.updateCardInDatabase(cardId, data);
|
||||
|
||||
// Consume credits
|
||||
await this.creditClient.consumeCredits(
|
||||
user.id,
|
||||
operationType,
|
||||
cost,
|
||||
`Updated card ${cardId}`,
|
||||
{ cardId }
|
||||
);
|
||||
|
||||
return { success: true, card: updated, creditsUsed: cost };
|
||||
}
|
||||
```
|
||||
|
||||
That's it! The frontend automatically handles the error.
|
||||
|
||||
## Production Checklist
|
||||
|
||||
Before deploying to production:
|
||||
|
||||
### Backend
|
||||
- [ ] Set `NODE_ENV=production` in production environment
|
||||
- [ ] Add real `SERVICE_KEY` from Mana
|
||||
- [ ] Configure proper CORS for your frontend domain
|
||||
- [ ] Set up proper logging/monitoring
|
||||
- [ ] Add rate limiting
|
||||
- [ ] Set up database backups
|
||||
|
||||
### Frontend
|
||||
- [ ] Update `EXPO_PUBLIC_API_URL` to production backend URL
|
||||
- [ ] Test on real devices (iOS + Android)
|
||||
- [ ] Test insufficient credits flow
|
||||
- [ ] Test token refresh
|
||||
- [ ] Test offline behavior
|
||||
- [ ] Build production bundle
|
||||
- [ ] Submit to app stores
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### "Service key not configured"
|
||||
**Problem**: Credit operations fail.
|
||||
**Solution**: Add `SERVICE_KEY` to `backend/.env`
|
||||
|
||||
### Credits not deducting
|
||||
**Problem**: Balance doesn't change after operations.
|
||||
**Solution**: Check that `consumeCredits()` is called AFTER operation succeeds.
|
||||
|
||||
### Modal not showing
|
||||
**Problem**: Error occurs but modal doesn't appear.
|
||||
**Solution**: Make sure:
|
||||
1. `useInsufficientCredits` hook is used
|
||||
2. `handleCreditError()` is called in catch block
|
||||
3. `InsufficientCreditsModal` is rendered
|
||||
|
||||
### Android can't connect to backend
|
||||
**Problem**: Frontend can't reach localhost:8080.
|
||||
**Solution**: Use `http://10.0.2.2:8080` for Android emulator.
|
||||
|
||||
## Files You Can Customize
|
||||
|
||||
### Credit Costs
|
||||
`backend/src/config/credit-operations.ts` - Change costs here
|
||||
|
||||
### Modal Appearance
|
||||
`apps/mobile/components/InsufficientCreditsModal.tsx` - Customize UI
|
||||
|
||||
### Error Messages
|
||||
`backend/src/controllers/api.controller.ts` - Customize error responses
|
||||
|
||||
### Purchase Flow
|
||||
Add `onPurchase` handler to modal in your screens
|
||||
|
||||
## Need Help?
|
||||
|
||||
1. **Credit System**: See [CREDIT_SYSTEM.md](./CREDIT_SYSTEM.md)
|
||||
2. **Integration**: See [MANA_CORE_INTEGRATION_GUIDE.md](./MANA_CORE_INTEGRATION_GUIDE.md)
|
||||
3. **Architecture**: See [MANA_CORE_ARCHITECTURE.md](./MANA_CORE_ARCHITECTURE.md)
|
||||
4. **Example Code**: See [apps/mobile/examples/DeckCreationExample.tsx](./apps/mobile/examples/DeckCreationExample.tsx)
|
||||
5. **Mana**: https://github.com/Memo-2023/mana-core-nestjs-package
|
||||
|
||||
## Summary
|
||||
|
||||
You now have:
|
||||
- ✅ Complete authentication system
|
||||
- ✅ Credit validation before operations
|
||||
- ✅ Credit consumption after success
|
||||
- ✅ Insufficient credits modal
|
||||
- ✅ Balance checking
|
||||
- ✅ Full documentation
|
||||
- ✅ Working example
|
||||
|
||||
**Next action**: Add your `SERVICE_KEY` to `backend/.env` and run the test flow above!
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://cards.app',
|
||||
integrations: [tailwind(), sitemap()],
|
||||
vite: {
|
||||
ssr: {
|
||||
noExternal: ['@mana/shared-landing-ui'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"name": "@cards/landing",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@mana/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#7C3AED"/>
|
||||
<text x="50" y="70" font-size="60" text-anchor="middle" fill="white">🃏</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 211 B |
|
|
@ -1,4 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://cards.app/sitemap-index.xml
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
---
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '/pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' },
|
||||
],
|
||||
legal: [
|
||||
{ href: '/privacy', label: 'Datenschutz' },
|
||||
{ href: '/terms', label: 'AGB' },
|
||||
{ href: '/cookies', label: 'Cookies' },
|
||||
{ href: '/imprint', label: 'Impressum' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="bg-background-card border-t border-border">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🃏</span>
|
||||
<span class="font-bold text-xl text-text-primary">Cards</span>
|
||||
</a>
|
||||
<p class="text-text-secondary text-sm max-w-md">
|
||||
Dein KI-gestützter Lernpartner. Erstelle intelligente Karteikarten aus deinen Notizen und
|
||||
lerne effizienter mit Spaced Repetition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.product.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.legal.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div
|
||||
class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4"
|
||||
>
|
||||
<p class="text-text-muted text-sm">
|
||||
© {currentYear} Cards. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: "So funktioniert's" },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' },
|
||||
];
|
||||
---
|
||||
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">🃏</span>
|
||||
<span class="font-bold text-xl text-text-primary">Cards</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="#download" class="btn-primary text-sm px-4 py-2"> App herunterladen </a>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
|
||||
aria-label="Menu"
|
||||
id="mobile-menu-button"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div class="hidden md:hidden" id="mobile-menu">
|
||||
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close menu when clicking a link
|
||||
mobileMenu?.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
1
apps/cards/apps/landing/src/env.d.ts
vendored
1
apps/cards/apps/landing/src/env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="astro/client" />
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
import Analytics from '@mana/shared-landing-ui/atoms/Analytics.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Cards - Dein KI-gestützter Lernpartner für Karteikarten und effektives Lernen',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
{
|
||||
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
|
||||
<script
|
||||
defer
|
||||
src="https://stats.mana.how/script.js"
|
||||
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import LegalPageTemplate from '@mana/shared-landing-ui/templates/LegalPageTemplate.astro';
|
||||
---
|
||||
|
||||
<Layout title="Cookie-Richtlinie - Cards">
|
||||
<LegalPageTemplate
|
||||
title="Cookie-Richtlinie"
|
||||
backLink="/"
|
||||
backText="Zurück zur Startseite"
|
||||
lastUpdatedText="Zuletzt aktualisiert"
|
||||
>
|
||||
<h2>1. Was sind Cookies?</h2>
|
||||
<p>
|
||||
Cookies sind kleine Textdateien, die auf Ihrem Gerät gespeichert werden. Sie helfen uns, die
|
||||
Funktionalität unserer Website zu verbessern und Ihre Nutzererfahrung zu personalisieren.
|
||||
</p>
|
||||
|
||||
<h2>2. Welche Cookies verwenden wir?</h2>
|
||||
|
||||
<h3>Notwendige Cookies</h3>
|
||||
<p>Diese Cookies sind für den Betrieb der Website unerlässlich:</p>
|
||||
<ul>
|
||||
<li><strong>Session-Cookie:</strong> Hält Sie eingeloggt</li>
|
||||
<li><strong>Sprach-Cookie:</strong> Speichert Ihre Spracheinstellung</li>
|
||||
</ul>
|
||||
|
||||
<h3>Funktionale Cookies</h3>
|
||||
<p>Diese Cookies verbessern Ihre Nutzererfahrung:</p>
|
||||
<ul>
|
||||
<li><strong>Präferenz-Cookies:</strong> Speichern Ihre Einstellungen</li>
|
||||
<li><strong>Lernfortschritts-Cookies:</strong> Ermöglichen Offline-Lernen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Analyse-Cookies</h3>
|
||||
<p>
|
||||
Wir verwenden Umami Analytics, eine datenschutzfreundliche Alternative zu Google Analytics:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Anonymisierte Nutzungsstatistiken</li>
|
||||
<li>Keine personenbezogenen Daten</li>
|
||||
<li>Keine Tracking über Websites hinweg</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Cookies von Drittanbietern</h2>
|
||||
<p>
|
||||
Wir verwenden keine Tracking-Cookies von Drittanbietern wie Google Analytics oder Facebook.
|
||||
</p>
|
||||
|
||||
<h2>4. Ihre Cookie-Einstellungen</h2>
|
||||
<p>Sie können Cookies in Ihren Browser-Einstellungen verwalten:</p>
|
||||
<ul>
|
||||
<li>Alle Cookies blockieren</li>
|
||||
<li>Cookies beim Schließen des Browsers löschen</li>
|
||||
<li>Drittanbieter-Cookies separat verwalten</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Auswirkungen der Cookie-Deaktivierung</h2>
|
||||
<p>
|
||||
Wenn Sie notwendige Cookies deaktivieren, funktionieren einige Funktionen der App
|
||||
möglicherweise nicht mehr korrekt, z.B. das automatische Einloggen.
|
||||
</p>
|
||||
|
||||
<h2>6. Kontakt</h2>
|
||||
<p>Bei Fragen zu unserer Cookie-Richtlinie kontaktieren Sie uns unter: privacy@mana.how</p>
|
||||
</LegalPageTemplate>
|
||||
</Layout>
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import LegalPageTemplate from '@mana/shared-landing-ui/templates/LegalPageTemplate.astro';
|
||||
---
|
||||
|
||||
<Layout title="Impressum - Cards">
|
||||
<LegalPageTemplate
|
||||
title="Impressum"
|
||||
backLink="/"
|
||||
backText="Zurück zur Startseite"
|
||||
lastUpdatedText="Zuletzt aktualisiert"
|
||||
>
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
<p>
|
||||
Mana Technologies<br />
|
||||
Musterstraße 1<br />
|
||||
12345 Musterstadt<br />
|
||||
Deutschland
|
||||
</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>
|
||||
E-Mail: contact@mana.how<br />
|
||||
Telefon: +49 (0) 123 456789
|
||||
</p>
|
||||
|
||||
<h2>Vertreten durch</h2>
|
||||
<p>Geschäftsführer: Max Mustermann</p>
|
||||
|
||||
<h2>Handelsregister</h2>
|
||||
<p>
|
||||
Registergericht: Amtsgericht Musterstadt<br />
|
||||
Registernummer: HRB 12345
|
||||
</p>
|
||||
|
||||
<h2>Umsatzsteuer-ID</h2>
|
||||
<p>
|
||||
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:<br />
|
||||
DE123456789
|
||||
</p>
|
||||
|
||||
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
<p>
|
||||
Max Mustermann<br />
|
||||
Musterstraße 1<br />
|
||||
12345 Musterstadt
|
||||
</p>
|
||||
|
||||
<h2>Streitschlichtung</h2>
|
||||
<p>
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
|
||||
<a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener"
|
||||
>https://ec.europa.eu/consumers/odr</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
|
||||
Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
|
||||
<h2>Haftungsausschluss</h2>
|
||||
|
||||
<h3>Haftung für Inhalte</h3>
|
||||
<p>
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
|
||||
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch
|
||||
nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen.
|
||||
</p>
|
||||
|
||||
<h3>Haftung für Links</h3>
|
||||
<p>
|
||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen
|
||||
Einfluss haben. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter
|
||||
verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
|
||||
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung.
|
||||
</p>
|
||||
</LegalPageTemplate>
|
||||
</Layout>
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
// Shared components
|
||||
import HeroSection from '@mana/shared-landing-ui/sections/HeroSection.astro';
|
||||
import FeatureSection from '@mana/shared-landing-ui/sections/FeatureSection.astro';
|
||||
import StepsSection from '@mana/shared-landing-ui/sections/StepsSection.astro';
|
||||
import FAQSection from '@mana/shared-landing-ui/sections/FAQSection.astro';
|
||||
import CTASection from '@mana/shared-landing-ui/sections/CTASection.astro';
|
||||
import PricingSection from '@mana/shared-landing-ui/sections/PricingSection.astro';
|
||||
import Card from '@mana/shared-landing-ui/atoms/Card.astro';
|
||||
|
||||
// Feature data
|
||||
const features = [
|
||||
{
|
||||
icon: '🧠',
|
||||
title: 'KI-generierte Karteikarten',
|
||||
description:
|
||||
'Lade deine Notizen, PDFs oder Texte hoch und lass die KI automatisch perfekte Karteikarten erstellen.',
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
title: 'Spaced Repetition',
|
||||
description:
|
||||
'Unser intelligenter Algorithmus zeigt dir Karten genau dann, wenn du sie wiederholen solltest.',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Lernstatistiken',
|
||||
description:
|
||||
'Verfolge deinen Fortschritt mit detaillierten Statistiken und optimiere dein Lernen.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Überall lernen',
|
||||
description: 'Synchronisiere deine Decks auf allen Geräten und lerne, wo immer du bist.',
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Personalisiertes Lernen',
|
||||
description:
|
||||
'Die KI passt sich deinem Lernstil an und erstellt maßgeschneiderte Wiederholungspläne.',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Decks teilen',
|
||||
description:
|
||||
'Teile deine Karteikarten-Decks mit Freunden oder entdecke öffentliche Sammlungen.',
|
||||
},
|
||||
];
|
||||
|
||||
// Steps data
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Inhalte hochladen',
|
||||
description:
|
||||
'Lade deine Notizen, PDFs oder Texte hoch. Cards unterstützt verschiedene Formate.',
|
||||
image: '/screenshots/upload.png',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'KI erstellt Karten',
|
||||
description:
|
||||
'Unsere KI analysiert deine Inhalte und erstellt automatisch strukturierte Karteikarten.',
|
||||
image: '/screenshots/generate.png',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Effizient lernen',
|
||||
description: 'Lerne mit Spaced Repetition und behalte das Gelernte langfristig im Gedächtnis.',
|
||||
image: '/screenshots/learn.png',
|
||||
},
|
||||
];
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '50 Karteikarten', included: true },
|
||||
{ text: '3 Decks', included: true },
|
||||
{ text: 'Basis-KI-Generierung', included: true },
|
||||
{ text: 'Spaced Repetition', included: true },
|
||||
{ text: 'Unbegrenzte Karten', included: false },
|
||||
{ text: 'Deck-Sharing', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#download',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '9,99',
|
||||
period: '/Monat',
|
||||
description: 'Für ambitionierte Lerner',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Karteikarten', included: true },
|
||||
{ text: 'Unbegrenzte Decks', included: true },
|
||||
{ text: 'Erweiterte KI-Funktionen', included: true },
|
||||
{ text: 'Spaced Repetition', included: true },
|
||||
{ text: 'Deck-Sharing', included: true },
|
||||
{ text: 'Prioritäts-Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro werden',
|
||||
href: '#download',
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt',
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
price: '24,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Gruppen und Klassen',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Team-Verwaltung', included: true },
|
||||
{ text: 'Gemeinsame Decks', included: true },
|
||||
{ text: 'Fortschritts-Tracking', included: true },
|
||||
{ text: 'Admin-Dashboard', included: true },
|
||||
{ text: 'Dedizierter Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Team starten',
|
||||
href: '#download',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// FAQ data
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Wie funktioniert die KI-Karteikarten-Generierung?',
|
||||
answer:
|
||||
'Cards verwendet fortschrittliche KI-Modelle, um deine Texte zu analysieren und die wichtigsten Konzepte zu extrahieren. Daraus werden automatisch Frage-Antwort-Paare erstellt, die du als Karteikarten lernen kannst.',
|
||||
},
|
||||
{
|
||||
question: 'Was ist Spaced Repetition?',
|
||||
answer:
|
||||
'Spaced Repetition ist eine wissenschaftlich bewährte Lernmethode, bei der Karten in optimalen Zeitabständen wiederholt werden. Karten, die du gut kennst, siehst du seltener, während schwierige Karten häufiger erscheinen.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich meine eigenen Karteikarten erstellen?',
|
||||
answer:
|
||||
'Ja! Du kannst sowohl KI-generierte Karten nutzen als auch komplett eigene Karteikarten erstellen. Beide Varianten lassen sich beliebig kombinieren und bearbeiten.',
|
||||
},
|
||||
{
|
||||
question: 'Welche Dateiformate werden unterstützt?',
|
||||
answer:
|
||||
'Cards unterstützt PDF, Word-Dokumente (.docx), Textdateien (.txt) und Markdown (.md). Du kannst auch direkt Text in die App einfügen.',
|
||||
},
|
||||
{
|
||||
question: 'Sind meine Daten sicher?',
|
||||
answer:
|
||||
'Ja, alle Daten werden verschlüsselt übertragen und gespeichert. Wir verkaufen keine Nutzerdaten an Dritte und sind DSGVO-konform.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich offline lernen?',
|
||||
answer:
|
||||
'Ja! Du kannst deine Decks für den Offline-Modus herunterladen und überall lernen, auch ohne Internetverbindung.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title="Cards - KI-gestützte Karteikarten für effektives Lernen">
|
||||
<Navigation />
|
||||
|
||||
<main class="pt-16">
|
||||
<HeroSection
|
||||
title="Lerne smarter, nicht härter"
|
||||
subtitle="Cards verwandelt deine Notizen in intelligente Karteikarten. Mit KI-Generierung und Spaced Repetition lernst du effizienter als je zuvor."
|
||||
variant="default"
|
||||
primaryCta={{
|
||||
text: 'Jetzt kostenlos starten',
|
||||
href: '#download',
|
||||
}}
|
||||
secondaryCta={{
|
||||
text: 'Features entdecken',
|
||||
href: '#features',
|
||||
variant: 'secondary',
|
||||
}}
|
||||
trustBadges={[
|
||||
{ icon: '✓', text: 'Kostenlos testen' },
|
||||
{ icon: '🔒', text: 'DSGVO-konform' },
|
||||
{ icon: '📱', text: 'iOS & Android' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles was du zum Lernen brauchst"
|
||||
subtitle="Cards kombiniert KI-Technologie mit bewährten Lernmethoden für maximalen Lernerfolg."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
class="bg-[var(--color-background-card)]"
|
||||
>
|
||||
<!-- Mana System Highlight -->
|
||||
<div class="mt-12 md:mt-16 px-4" slot="highlight">
|
||||
<Card variant="glow" class="max-w-4xl mx-auto" padding="lg">
|
||||
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-8">
|
||||
<div class="text-5xl sm:text-6xl">⚡</div>
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<h3
|
||||
class="font-bold text-xl sm:text-2xl text-[var(--color-text-primary)] mb-2 sm:mb-3"
|
||||
>
|
||||
Mana-System
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm sm:text-base leading-relaxed">
|
||||
Nutze Mana-Punkte für KI-Generierungen. Starte kostenlos mit 100 Mana und verdiene
|
||||
mehr durch regelmäßiges Lernen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-10 h-10 sm:w-12 sm:h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white font-bold text-sm sm:text-base">M</span>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-[var(--color-text-primary)] font-semibold text-sm sm:text-base">
|
||||
100 Mana
|
||||
</div>
|
||||
<div class="text-[var(--color-text-secondary)] text-xs sm:text-sm">zum Start</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="So einfach geht's"
|
||||
subtitle="In nur drei Schritten zum effektiven Lernen"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Wähle deinen Plan"
|
||||
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
|
||||
plans={pricingPlans}
|
||||
class="bg-[var(--color-background-card)]"
|
||||
/>
|
||||
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über Cards wissen musst"
|
||||
faqs={faqs}
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
id="download"
|
||||
title="Bereit, smarter zu lernen?"
|
||||
subtitle="Lade Cards jetzt herunter und erstelle deine ersten KI-generierten Karteikarten. Kostenlos und ohne Kreditkarte."
|
||||
primaryCta={{ text: 'App herunterladen', href: '#' }}
|
||||
variant="highlighted"
|
||||
>
|
||||
<!-- App Store Buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
|
||||
</a>
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Trust Indicators -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
|
||||
</div>
|
||||
</div>
|
||||
</CTASection>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import ManaPricingSection from '@mana/shared-landing-ui/sections/ManaPricingSection.astro';
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Preise - Cards"
|
||||
description="Transparente Preise für Cards - Wähle den passenden Mana-Plan für deine Karteikarten und Lerndecks."
|
||||
>
|
||||
<Navigation />
|
||||
|
||||
<main class="pt-20">
|
||||
<ManaPricingSection
|
||||
showOneTime={true}
|
||||
showTrustIndicators={true}
|
||||
ctaBaseUrl="https://app.mana.io/register"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import LegalPageTemplate from '@mana/shared-landing-ui/templates/LegalPageTemplate.astro';
|
||||
---
|
||||
|
||||
<Layout title="Datenschutzerklärung - Cards">
|
||||
<LegalPageTemplate
|
||||
title="Datenschutzerklärung"
|
||||
backLink="/"
|
||||
backText="Zurück zur Startseite"
|
||||
lastUpdatedText="Zuletzt aktualisiert"
|
||||
>
|
||||
<h2>1. Einleitung</h2>
|
||||
<p>
|
||||
Diese Datenschutzerklärung informiert Sie über die Art, den Umfang und den Zweck der
|
||||
Verarbeitung personenbezogener Daten innerhalb unserer Cards-Anwendung.
|
||||
</p>
|
||||
|
||||
<h2>2. Verantwortlicher</h2>
|
||||
<p>
|
||||
Verantwortlich für die Datenverarbeitung ist:<br />
|
||||
Mana Technologies<br />
|
||||
Musterstraße 1<br />
|
||||
12345 Musterstadt<br />
|
||||
Deutschland<br />
|
||||
E-Mail: privacy@mana.how
|
||||
</p>
|
||||
|
||||
<h2>3. Erhobene Daten</h2>
|
||||
<p>Wir erheben folgende Daten:</p>
|
||||
<ul>
|
||||
<li>E-Mail-Adresse bei Registrierung</li>
|
||||
<li>Karteikarten und Lernfortschritte</li>
|
||||
<li>Nutzungsstatistiken zur Verbesserung der App</li>
|
||||
<li>Geräteinformationen für die Synchronisierung</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Zweck der Datenverarbeitung</h2>
|
||||
<p>Ihre Daten werden verwendet für:</p>
|
||||
<ul>
|
||||
<li>Bereitstellung des Karteikarten-Dienstes</li>
|
||||
<li>Synchronisierung Ihrer Lernfortschritte</li>
|
||||
<li>Verbesserung der KI-generierten Karteikarten</li>
|
||||
<li>Kommunikation bezüglich Ihres Kontos</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Datenspeicherung</h2>
|
||||
<p>
|
||||
Ihre Daten werden auf sicheren Servern in der Europäischen Union gespeichert. Wir verwenden
|
||||
Verschlüsselung für alle übertragenen und gespeicherten Daten.
|
||||
</p>
|
||||
|
||||
<h2>6. Ihre Rechte</h2>
|
||||
<p>Sie haben folgende Rechte:</p>
|
||||
<ul>
|
||||
<li>Auskunft über Ihre gespeicherten Daten</li>
|
||||
<li>Berichtigung unrichtiger Daten</li>
|
||||
<li>Löschung Ihrer Daten</li>
|
||||
<li>Export Ihrer Daten</li>
|
||||
<li>Widerruf der Einwilligung</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. KI-Datenverarbeitung</h2>
|
||||
<p>
|
||||
Bei der KI-gestützten Karteikarten-Generierung werden Ihre Texte an KI-Dienste übermittelt.
|
||||
Diese Daten werden nur zur Verarbeitung verwendet und nicht dauerhaft gespeichert.
|
||||
</p>
|
||||
|
||||
<h2>8. Kontakt</h2>
|
||||
<p>Bei Fragen zum Datenschutz kontaktieren Sie uns unter: privacy@mana.how</p>
|
||||
</LegalPageTemplate>
|
||||
</Layout>
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import LegalPageTemplate from '@mana/shared-landing-ui/templates/LegalPageTemplate.astro';
|
||||
---
|
||||
|
||||
<Layout title="Nutzungsbedingungen - Cards">
|
||||
<LegalPageTemplate
|
||||
title="Nutzungsbedingungen"
|
||||
backLink="/"
|
||||
backText="Zurück zur Startseite"
|
||||
lastUpdatedText="Zuletzt aktualisiert"
|
||||
>
|
||||
<h2>1. Geltungsbereich</h2>
|
||||
<p>
|
||||
Diese Nutzungsbedingungen gelten für die Nutzung der Cards-Anwendung, einschließlich der
|
||||
mobilen Apps und der Web-Version.
|
||||
</p>
|
||||
|
||||
<h2>2. Leistungsbeschreibung</h2>
|
||||
<p>Cards bietet:</p>
|
||||
<ul>
|
||||
<li>KI-gestützte Erstellung von Karteikarten</li>
|
||||
<li>Spaced-Repetition-Lernsystem</li>
|
||||
<li>Synchronisierung über mehrere Geräte</li>
|
||||
<li>Import von Texten und Dokumenten</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Registrierung</h2>
|
||||
<p>
|
||||
Zur Nutzung ist eine Registrierung erforderlich. Sie sind für die Geheimhaltung Ihrer
|
||||
Zugangsdaten verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>4. Kostenfreie und kostenpflichtige Funktionen</h2>
|
||||
<p>Cards bietet sowohl kostenfreie als auch Premium-Funktionen:</p>
|
||||
<ul>
|
||||
<li><strong>Free:</strong> Grundfunktionen mit begrenzten Karten</li>
|
||||
<li><strong>Pro:</strong> Unbegrenzte Karten und erweiterte KI-Funktionen</li>
|
||||
<li><strong>Team:</strong> Zusätzliche Teamfunktionen</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Nutzerverhalten</h2>
|
||||
<p>Sie verpflichten sich:</p>
|
||||
<ul>
|
||||
<li>Keine urheberrechtlich geschützten Inhalte ohne Berechtigung hochzuladen</li>
|
||||
<li>Den Dienst nicht missbräuchlich zu nutzen</li>
|
||||
<li>Keine automatisierten Zugriffe ohne Genehmigung durchzuführen</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Geistiges Eigentum</h2>
|
||||
<p>
|
||||
Die von Ihnen erstellten oder generierten Karteikarten gehören Ihnen. Cards behält die Rechte
|
||||
an der Software und dem Design.
|
||||
</p>
|
||||
|
||||
<h2>7. Verfügbarkeit</h2>
|
||||
<p>
|
||||
Wir bemühen uns um eine hohe Verfügbarkeit, können jedoch keine 100%ige Verfügbarkeit
|
||||
garantieren. Wartungsarbeiten werden nach Möglichkeit vorab angekündigt.
|
||||
</p>
|
||||
|
||||
<h2>8. Kündigung</h2>
|
||||
<p>
|
||||
Sie können Ihr Konto jederzeit kündigen. Bei Premium-Abonnements gilt die Kündigung zum Ende
|
||||
des Abrechnungszeitraums.
|
||||
</p>
|
||||
|
||||
<h2>9. Haftung</h2>
|
||||
<p>
|
||||
Die Haftung ist auf Vorsatz und grobe Fahrlässigkeit beschränkt. Dies gilt nicht für Schäden
|
||||
aus der Verletzung von Leben, Körper oder Gesundheit.
|
||||
</p>
|
||||
|
||||
<h2>10. Änderungen</h2>
|
||||
<p>
|
||||
Wir behalten uns vor, diese Nutzungsbedingungen zu ändern. Wesentliche Änderungen werden Ihnen
|
||||
rechtzeitig mitgeteilt.
|
||||
</p>
|
||||
|
||||
<h2>11. Anwendbares Recht</h2>
|
||||
<p>Es gilt deutsches Recht. Gerichtsstand ist, soweit zulässig, der Sitz des Anbieters.</p>
|
||||
</LegalPageTemplate>
|
||||
</Layout>
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Cards Theme CSS Variables */
|
||||
:root {
|
||||
/* Primary colors - Cards Purple */
|
||||
--color-primary: #7C3AED;
|
||||
--color-primary-hover: #8B5CF6;
|
||||
--color-primary-glow: rgba(124, 58, 237, 0.3);
|
||||
|
||||
/* Text colors */
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Background colors */
|
||||
--color-background-page: #0f0a1a;
|
||||
--color-background-card: #1a1625;
|
||||
--color-background-card-hover: #2d2640;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #3d3555;
|
||||
--color-border-hover: #4d4570;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: var(--color-background-page);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-background-card);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-hover);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 bg-primary text-white font-semibold rounded-lg transition-all duration-200;
|
||||
@apply hover:bg-primary-hover hover:shadow-lg hover:shadow-primary-glow;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-border text-text-primary font-semibold rounded-lg transition-all duration-200;
|
||||
@apply hover:border-border-hover hover:bg-background-card;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
||||
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Cards Purple Theme
|
||||
primary: {
|
||||
DEFAULT: '#7C3AED',
|
||||
hover: '#8B5CF6',
|
||||
glow: 'rgba(124, 58, 237, 0.3)',
|
||||
},
|
||||
background: {
|
||||
page: '#0f0a1a',
|
||||
card: '#1a1625',
|
||||
'card-hover': '#2d2640',
|
||||
},
|
||||
text: {
|
||||
primary: '#f9fafb',
|
||||
secondary: '#d1d5db',
|
||||
muted: '#6b7280',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#3d3555',
|
||||
hover: '#4d4570',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# Cloudflare Pages configuration for Cards Landing
|
||||
# Deployed via GitHub Actions (Direct Upload)
|
||||
|
||||
name = "cards-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
7
apps/cards/apps/web/.gitignore
vendored
Normal file
7
apps/cards/apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
43
apps/cards/apps/web/Dockerfile
Normal file
43
apps/cards/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Cards Standalone — cards.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"]
|
||||
43
apps/cards/apps/web/package.json
Normal file
43
apps/cards/apps/web/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"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",
|
||||
"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-crypto": "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-types": "workspace:*",
|
||||
"@mana/shared-utils": "workspace:*",
|
||||
"dexie": "^4.4.1"
|
||||
}
|
||||
}
|
||||
64
apps/cards/apps/web/src/app.css
Normal file
64
apps/cards/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
/* Phase-1 placeholder palette. Will swap for @mana/shared-theme tokens
|
||||
once the theming pass lands in Etappe 3c. */
|
||||
@theme {
|
||||
--color-cards-bg: #0a0a0a;
|
||||
--color-cards-surface: #161616;
|
||||
--color-cards-border: #2a2a2a;
|
||||
--color-cards-fg: #f5f5f5;
|
||||
--color-cards-muted: #a3a3a3;
|
||||
--color-cards-accent: #6366f1;
|
||||
}
|
||||
|
||||
/* Cloze rendering classes — produced by @mana/cards-core/render. */
|
||||
.cloze-blank {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
color: #a5b4fc;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
mark.cloze-active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Minimal styling for HTML produced by marked() — Tailwind 4 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: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.card-content :where(pre) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.card-content :where(a) {
|
||||
color: #818cf8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.card-content :where(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.card-content :where(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
14
apps/cards/apps/web/src/app.html
Normal file
14
apps/cards/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="de" class="dark">
|
||||
<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-neutral-950 text-neutral-100 antialiased">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
89
apps/cards/apps/web/src/lib/components/CardFace.svelte
Normal file
89
apps/cards/apps/web/src/lib/components/CardFace.svelte
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<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.
|
||||
*/
|
||||
|
||||
import { renderCloze, renderMarkdown, type Card } from '@mana/cards-core';
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
subIndex: number;
|
||||
showBack: boolean;
|
||||
typedAnswer?: string;
|
||||
onTypedAnswer?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { card, subIndex, showBack, typedAnswer = '', onTypedAnswer }: 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-neutral-400">${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()
|
||||
);
|
||||
</script>
|
||||
|
||||
<article class="space-y-4">
|
||||
<div
|
||||
class="card-content rounded-xl border border-neutral-800 bg-neutral-900 p-6 text-lg leading-relaxed"
|
||||
>
|
||||
{@html view.prompt}
|
||||
</div>
|
||||
|
||||
{#if isTypeIn}
|
||||
<input
|
||||
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-base outline-none focus:border-indigo-400"
|
||||
type="text"
|
||||
placeholder="Antwort eingeben…"
|
||||
value={typedAnswer}
|
||||
oninput={(e) => onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)}
|
||||
disabled={showBack}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showBack}
|
||||
<div
|
||||
class="card-content rounded-xl border-2 p-6 text-lg leading-relaxed
|
||||
{isTypeIn
|
||||
? matched
|
||||
? 'border-green-500 bg-green-500/5'
|
||||
: 'border-red-500 bg-red-500/5'
|
||||
: 'border-indigo-500 bg-indigo-500/5'}"
|
||||
>
|
||||
{@html view.answer}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
13
apps/cards/apps/web/src/lib/components/CardsLogo.svelte
Normal file
13
apps/cards/apps/web/src/lib/components/CardsLogo.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
let { size = 64, color = '#6366f1' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 64 64">
|
||||
<rect x="6" y="10" width="42" height="50" rx="6" fill={color} opacity="0.85" />
|
||||
<rect x="16" y="4" width="42" height="50" rx="6" fill={color} />
|
||||
</svg>
|
||||
56
apps/cards/apps/web/src/lib/data/crypto.ts
Normal file
56
apps/cards/apps/web/src/lib/data/crypto.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Encryption wrapper — Phase-1 stub.
|
||||
*
|
||||
* The full Mana crypto stack (vault server roundtrip, KEK-wrapped
|
||||
* master key, recovery codes, zero-knowledge mode) lives in the mana
|
||||
* web app under `apps/mana/.../data/crypto/`. Lifting it intact into
|
||||
* the standalone Cards app is a Phase-2 task — it requires a vault
|
||||
* client, key provider, and boot-race handling that aren't worth
|
||||
* dragging in until we have the deployment story for them.
|
||||
*
|
||||
* For Phase 1 these helpers are intentionally identity functions:
|
||||
* data lands in IndexedDB and on `mana-sync` as plaintext. Everything
|
||||
* is wired up at the right call sites (stores → write, queries → read,
|
||||
* sync.applyServerChanges → apply) so flipping to real encryption is a
|
||||
* single-file change here, not a sweep through every store.
|
||||
*
|
||||
* Allowlist is the contract with the future vault. It mirrors the
|
||||
* mana-modul registry exactly so when sync converges, the same fields
|
||||
* are protected on both ends.
|
||||
*/
|
||||
|
||||
const ENCRYPTED_FIELDS: Record<string, readonly string[]> = {
|
||||
cards: ['front', 'back', 'fields'],
|
||||
cardDecks: ['name', 'description'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase-1 identity. Phase-2 swap-in: import `wrapValue` from
|
||||
* `@mana/shared-crypto`, fetch master key from the vault, encrypt
|
||||
* each allowlisted field in place.
|
||||
*/
|
||||
export async function encryptRecord<T extends object>(tableName: string, record: T): Promise<T> {
|
||||
void ENCRYPTED_FIELDS[tableName];
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function decryptRecord<T extends object>(_tableName: string, record: T): Promise<T> {
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function decryptRecords<T extends object>(
|
||||
tableName: string,
|
||||
records: T[]
|
||||
): Promise<T[]> {
|
||||
if (records.length === 0) return records;
|
||||
return Promise.all(records.map((r) => decryptRecord(tableName, r)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the fields that *will* be encrypted once the vault is on.
|
||||
* Stays exported so the GUIDELINES audit script can prove parity with
|
||||
* the mana-modul registry.
|
||||
*/
|
||||
export function encryptedFieldsFor(tableName: string): readonly string[] {
|
||||
return ENCRYPTED_FIELDS[tableName] ?? [];
|
||||
}
|
||||
150
apps/cards/apps/web/src/lib/data/database.ts
Normal file
150
apps/cards/apps/web/src/lib/data/database.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new CardsDatabase();
|
||||
|
||||
export const cardDeckTable = db.cardDecks;
|
||||
export const cardTable = db.cards;
|
||||
export const cardReviewTable = db.cardReviews;
|
||||
export const cardStudyBlockTable = db.cardStudyBlocks;
|
||||
export const pendingChangesTable = db._pendingChanges;
|
||||
|
||||
// ─── Server-apply suppression ──────────────────────────────
|
||||
|
||||
const applying = new Set<string>();
|
||||
|
||||
/** Mark a table as "currently applying server changes" — hooks skip
|
||||
* queueing for the duration. Caller must always pair with `endApplying`. */
|
||||
export function beginApplying(tableName: string) {
|
||||
applying.add(tableName);
|
||||
}
|
||||
export function endApplying(tableName: string) {
|
||||
applying.delete(tableName);
|
||||
}
|
||||
|
||||
// ─── Field-meta diff ───────────────────────────────────────
|
||||
|
||||
function diffToFields(
|
||||
previous: Record<string, unknown>,
|
||||
next: Record<string, unknown>
|
||||
): Record<string, FieldChange> {
|
||||
const at = new Date().toISOString();
|
||||
const out: Record<string, FieldChange> = {};
|
||||
for (const key of Object.keys(next)) {
|
||||
if (key.startsWith('_') || key === 'updatedAt') continue;
|
||||
if (previous[key] === next[key]) continue;
|
||||
out[key] = { value: next[key], at };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function snapshotForInsert(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key.startsWith('_')) continue;
|
||||
out[key] = row[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Hook installation ─────────────────────────────────────
|
||||
|
||||
function installSyncHooks(table: Table<any, any>, name: string) {
|
||||
table.hook('creating', (_pk, row) => {
|
||||
if (applying.has(name)) return;
|
||||
void db._pendingChanges.add({
|
||||
table: name,
|
||||
id: row.id,
|
||||
op: 'insert',
|
||||
data: snapshotForInsert(row),
|
||||
queuedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
table.hook('updating', (mods, _pk, prev) => {
|
||||
if (applying.has(name)) return;
|
||||
const next = { ...prev, ...mods };
|
||||
const fields = diffToFields(prev, next);
|
||||
if (Object.keys(fields).length === 0 && !('deletedAt' in mods)) return;
|
||||
const isDelete = (mods as { deletedAt?: string }).deletedAt;
|
||||
void db._pendingChanges.add({
|
||||
table: name,
|
||||
id: prev.id,
|
||||
op: isDelete ? 'delete' : 'update',
|
||||
fields: Object.keys(fields).length > 0 ? fields : undefined,
|
||||
deletedAt: isDelete ?? undefined,
|
||||
queuedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const name of SYNC_TABLES) {
|
||||
installSyncHooks(db.table(name), name);
|
||||
}
|
||||
290
apps/cards/apps/web/src/lib/data/sync.ts
Normal file
290
apps/cards/apps/web/src/lib/data/sync.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* Cards sync engine — talks to mana-sync (POST /sync/cards, GET /sync/cards/pull).
|
||||
*
|
||||
* Two loops, both polling-based for the Phase-1 MVP. WebSocket
|
||||
* notifications can replace the pull poll later without changing
|
||||
* anything outside this file.
|
||||
*
|
||||
* Push: drain `_pendingChanges` every 1s when there's anything queued.
|
||||
* On success, delete drained rows and apply any server-changes
|
||||
* the response carried back. Failures keep the rows queued —
|
||||
* the next tick retries.
|
||||
*
|
||||
* Pull: every 5s, ask each sync table for changes since its cursor.
|
||||
* Apply with suppression so the apply doesn't re-enqueue a push.
|
||||
* Cursor lives in localStorage per table.
|
||||
*
|
||||
* Cursor format: ISO timestamp string. The server returns
|
||||
* `syncedUntil` on push and we store that as a global push cursor; pull
|
||||
* uses one cursor per collection.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
beginApplying,
|
||||
endApplying,
|
||||
db,
|
||||
pendingChangesTable,
|
||||
type PendingChange,
|
||||
} from './database';
|
||||
import { encryptRecord } from './crypto';
|
||||
|
||||
const APP_ID = 'cards';
|
||||
const PUSH_INTERVAL_MS = 1_000;
|
||||
const PULL_INTERVAL_MS = 5_000;
|
||||
const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags'];
|
||||
|
||||
// ─── URL + Auth wiring ─────────────────────────────────────
|
||||
|
||||
function getSyncUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injected = (window as unknown as { __PUBLIC_MANA_SYNC_URL__?: string })
|
||||
.__PUBLIC_MANA_SYNC_URL__;
|
||||
if (injected) return injected;
|
||||
}
|
||||
return import.meta.env.DEV ? 'http://localhost:3050' : '';
|
||||
}
|
||||
|
||||
interface AuthLike {
|
||||
getValidToken?: () => Promise<string | null>;
|
||||
readonly isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
let authProvider: AuthLike | null = null;
|
||||
|
||||
// ─── Client ID ─────────────────────────────────────────────
|
||||
|
||||
const CLIENT_ID_KEY = 'mana.cards.clientId';
|
||||
|
||||
function getClientId(): string {
|
||||
if (!browser) return 'ssr';
|
||||
let id = localStorage.getItem(CLIENT_ID_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(CLIENT_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ─── Cursors ───────────────────────────────────────────────
|
||||
|
||||
const PUSH_CURSOR_KEY = 'mana.cards.pushCursor';
|
||||
const PULL_CURSOR_KEY = (table: string) => `mana.cards.pullCursor.${table}`;
|
||||
|
||||
function getPushCursor(): string {
|
||||
if (!browser) return '';
|
||||
return localStorage.getItem(PUSH_CURSOR_KEY) || '1970-01-01T00:00:00.000Z';
|
||||
}
|
||||
function setPushCursor(at: string) {
|
||||
if (browser) localStorage.setItem(PUSH_CURSOR_KEY, at);
|
||||
}
|
||||
function getPullCursor(table: string): string {
|
||||
if (!browser) return '';
|
||||
return localStorage.getItem(PULL_CURSOR_KEY(table)) || '1970-01-01T00:00:00.000Z';
|
||||
}
|
||||
function setPullCursor(table: string, at: string) {
|
||||
if (browser) localStorage.setItem(PULL_CURSOR_KEY(table), at);
|
||||
}
|
||||
|
||||
// ─── Server-Change shape ───────────────────────────────────
|
||||
|
||||
interface ServerChange {
|
||||
eventId?: string;
|
||||
schemaVersion?: number;
|
||||
table: string;
|
||||
id: string;
|
||||
op: 'insert' | 'update' | 'delete';
|
||||
fields?: Record<string, { value: unknown; at: string }>;
|
||||
data?: Record<string, unknown>;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
interface SyncResponse {
|
||||
serverChanges: ServerChange[];
|
||||
conflicts: unknown[];
|
||||
syncedUntil: string;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
// ─── Apply server changes ──────────────────────────────────
|
||||
|
||||
async function applyServerChanges(changes: ServerChange[]) {
|
||||
if (changes.length === 0) return;
|
||||
const byTable = new Map<string, ServerChange[]>();
|
||||
for (const c of changes) {
|
||||
const arr = byTable.get(c.table) ?? [];
|
||||
arr.push(c);
|
||||
byTable.set(c.table, arr);
|
||||
}
|
||||
|
||||
for (const [table, list] of byTable) {
|
||||
if (!SYNC_TABLES.includes(table)) continue;
|
||||
const t = db.table(table);
|
||||
beginApplying(table);
|
||||
try {
|
||||
for (const c of list) {
|
||||
try {
|
||||
if (c.op === 'delete') {
|
||||
await t.update(c.id, { deletedAt: c.deletedAt ?? new Date().toISOString() });
|
||||
continue;
|
||||
}
|
||||
if (c.op === 'insert' && c.data) {
|
||||
const row = { ...c.data, id: c.id };
|
||||
// Server data may already be ciphertext-on-the-wire when
|
||||
// encryption flips on. Re-running encryptRecord on it is a
|
||||
// safe no-op today (Phase-1 stub) and the right hook in
|
||||
// Phase-2 because existing-ciphertext values are detected
|
||||
// upstream via `isEncrypted(...)`.
|
||||
await encryptRecord(table, row);
|
||||
await t.put(row);
|
||||
continue;
|
||||
}
|
||||
// update — merge fields
|
||||
if (c.fields) {
|
||||
const existing = (await t.get(c.id)) ?? { id: c.id };
|
||||
const merged: Record<string, unknown> = { ...existing };
|
||||
for (const [k, v] of Object.entries(c.fields)) {
|
||||
merged[k] = v.value;
|
||||
}
|
||||
await encryptRecord(table, merged);
|
||||
await t.put(merged);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[cards-sync] apply failed', { table, id: c.id, op: c.op, err });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
endApplying(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Push ──────────────────────────────────────────────────
|
||||
|
||||
async function flushPush(): Promise<void> {
|
||||
if (!authProvider?.isAuthenticated) return;
|
||||
|
||||
const queued = await pendingChangesTable.orderBy('queuedAt').limit(500).toArray();
|
||||
if (queued.length === 0) return;
|
||||
|
||||
const token = (await authProvider.getValidToken?.()) ?? null;
|
||||
if (!token) return;
|
||||
|
||||
const since = getPushCursor();
|
||||
const body = {
|
||||
clientId: getClientId(),
|
||||
appId: APP_ID,
|
||||
since,
|
||||
schemaVersion: 1,
|
||||
changes: queued.map(toWireChange),
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${getSyncUrl()}/sync/${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-Id': getClientId(),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[cards-sync] push network error', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn('[cards-sync] push HTTP', res.status, await res.text().catch(() => ''));
|
||||
return;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as SyncResponse;
|
||||
await pendingChangesTable.bulkDelete(queued.map((q) => q.pk!).filter((pk) => pk !== undefined));
|
||||
setPushCursor(json.syncedUntil);
|
||||
await applyServerChanges(json.serverChanges ?? []);
|
||||
}
|
||||
|
||||
function toWireChange(p: PendingChange): ServerChange {
|
||||
const out: ServerChange = { table: p.table, id: p.id, op: p.op };
|
||||
if (p.fields) out.fields = p.fields;
|
||||
if (p.data) out.data = p.data;
|
||||
if (p.deletedAt) out.deletedAt = p.deletedAt;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Pull ──────────────────────────────────────────────────
|
||||
|
||||
async function pollPull(): Promise<void> {
|
||||
if (!authProvider?.isAuthenticated) return;
|
||||
const token = (await authProvider.getValidToken?.()) ?? null;
|
||||
if (!token) return;
|
||||
|
||||
for (const table of SYNC_TABLES) {
|
||||
const since = getPullCursor(table);
|
||||
const url =
|
||||
`${getSyncUrl()}/sync/${APP_ID}/pull?collection=${encodeURIComponent(table)}` +
|
||||
`&since=${encodeURIComponent(since)}`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: {
|
||||
'X-Client-Id': getClientId(),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[cards-sync] pull network error', err);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn('[cards-sync] pull HTTP', res.status, table);
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as SyncResponse;
|
||||
await applyServerChanges(json.serverChanges ?? []);
|
||||
if (json.syncedUntil) setPullCursor(table, json.syncedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────────
|
||||
|
||||
let pushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pullTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pushBusy = false;
|
||||
let pullBusy = false;
|
||||
|
||||
export function startSync(authStore: AuthLike) {
|
||||
authProvider = authStore;
|
||||
if (!browser) return;
|
||||
stopSync();
|
||||
pushTimer = setInterval(async () => {
|
||||
if (pushBusy) return;
|
||||
pushBusy = true;
|
||||
try {
|
||||
await flushPush();
|
||||
} finally {
|
||||
pushBusy = false;
|
||||
}
|
||||
}, PUSH_INTERVAL_MS);
|
||||
pullTimer = setInterval(async () => {
|
||||
if (pullBusy) return;
|
||||
pullBusy = true;
|
||||
try {
|
||||
await pollPull();
|
||||
} finally {
|
||||
pullBusy = false;
|
||||
}
|
||||
}, PULL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopSync() {
|
||||
if (pushTimer) clearInterval(pushTimer);
|
||||
if (pullTimer) clearInterval(pullTimer);
|
||||
pushTimer = null;
|
||||
pullTimer = null;
|
||||
}
|
||||
2
apps/cards/apps/web/src/lib/index.ts
Normal file
2
apps/cards/apps/web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
export {};
|
||||
154
apps/cards/apps/web/src/lib/queries.ts
Normal file
154
apps/cards/apps/web/src/lib/queries.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* 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 } 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(),
|
||||
};
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
12
apps/cards/apps/web/src/lib/stores/auth.svelte.ts
Normal file
12
apps/cards/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Auth Store — uses the shared Mana auth factory.
|
||||
*
|
||||
* SSO: tokens land in the shared `*.mana.how` storage so a user already
|
||||
* signed into mana.how / cards.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();
|
||||
165
apps/cards/apps/web/src/lib/stores/cards.svelte.ts
Normal file
165
apps/cards/apps/web/src/lib/stores/cards.svelte.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Card Store — standalone.
|
||||
*
|
||||
* Writes the {type, fields} shape directly. Legacy mirror (front/back
|
||||
* columns) kept on for cross-compat with the mana-modul data model
|
||||
* once sync flips on. No encryption, no domain events — that's the
|
||||
* deliberate Phase-1 simplification.
|
||||
*/
|
||||
|
||||
import { cardTable, cardDeckTable } from '../data/database';
|
||||
import { encryptRecord, decryptRecord } from '../data/crypto';
|
||||
import { reviewStore } from './reviews.svelte';
|
||||
import {
|
||||
type CardFields,
|
||||
type CardType,
|
||||
type LocalCard,
|
||||
type CreateCardInput,
|
||||
type UpdateCardInput,
|
||||
} from '@mana/cards-core';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function resolveTypeAndFields(input: CreateCardInput): {
|
||||
type: CardType;
|
||||
fields: CardFields;
|
||||
} {
|
||||
const type = input.type ?? 'basic';
|
||||
if (input.fields) return { type, fields: input.fields };
|
||||
if (type === 'cloze') return { type, fields: { text: input.front ?? '' } };
|
||||
return { type, fields: { front: input.front ?? '', back: input.back ?? '' } };
|
||||
}
|
||||
|
||||
function legacyMirror(type: CardType, fields: CardFields): { front?: string; back?: string } {
|
||||
if (type === 'basic' || type === 'basic-reverse' || type === 'type-in') {
|
||||
return { front: fields.front ?? '', back: fields.back ?? '' };
|
||||
}
|
||||
if (type === 'cloze') {
|
||||
return { front: fields.text ?? '', back: '' };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export const cardStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
async createCard(
|
||||
input: CreateCardInput,
|
||||
currentCardCount: number = 0
|
||||
): Promise<LocalCard | null> {
|
||||
error = null;
|
||||
try {
|
||||
const { type, fields } = resolveTypeAndFields(input);
|
||||
const legacy = legacyMirror(type, fields);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newLocal: LocalCard = {
|
||||
id: crypto.randomUUID(),
|
||||
deckId: input.deckId,
|
||||
type,
|
||||
fields,
|
||||
order: currentCardCount,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...legacy,
|
||||
};
|
||||
|
||||
await encryptRecord('cards', newLocal);
|
||||
await cardTable.add(newLocal);
|
||||
|
||||
const deck = await cardDeckTable.get(input.deckId);
|
||||
if (deck) {
|
||||
await cardDeckTable.update(input.deckId, {
|
||||
cardCount: (deck.cardCount || 0) + 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await reviewStore.ensureReviewsForCard({ id: newLocal.id, type, fields });
|
||||
return newLocal;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to create card';
|
||||
console.error('Create card error:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updateCard(id: string, updates: UpdateCardInput) {
|
||||
error = null;
|
||||
try {
|
||||
const existingRaw = await cardTable.get(id);
|
||||
if (!existingRaw) return;
|
||||
const existing = await decryptRecord('cards', { ...existingRaw });
|
||||
|
||||
const currentType: CardType = existing.type ?? 'basic';
|
||||
const currentFields: CardFields = existing.fields ?? {
|
||||
front: existing.front ?? '',
|
||||
back: existing.back ?? '',
|
||||
};
|
||||
|
||||
const nextType: CardType = updates.type ?? currentType;
|
||||
const nextFields: CardFields = updates.fields
|
||||
? updates.fields
|
||||
: updates.front !== undefined || updates.back !== undefined
|
||||
? nextType === 'cloze'
|
||||
? { ...currentFields, text: updates.front ?? currentFields.text ?? '' }
|
||||
: {
|
||||
...currentFields,
|
||||
front: updates.front ?? currentFields.front ?? '',
|
||||
back: updates.back ?? currentFields.back ?? '',
|
||||
}
|
||||
: currentFields;
|
||||
|
||||
const legacy = legacyMirror(nextType, nextFields);
|
||||
const diff: Partial<LocalCard> = {
|
||||
type: nextType,
|
||||
fields: nextFields,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...legacy,
|
||||
};
|
||||
if (updates.order !== undefined) diff.order = updates.order;
|
||||
|
||||
await encryptRecord('cards', diff as Record<string, unknown>);
|
||||
await cardTable.update(id, diff);
|
||||
|
||||
const structuralChange =
|
||||
updates.type !== undefined ||
|
||||
updates.fields !== undefined ||
|
||||
(nextType === 'cloze' && updates.front !== undefined);
|
||||
if (structuralChange) {
|
||||
await reviewStore.ensureReviewsForCard({ id, type: nextType, fields: nextFields });
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to update card';
|
||||
console.error('Update card error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCard(id: string, deckId?: string) {
|
||||
error = null;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await cardTable.update(id, { deletedAt: now });
|
||||
await reviewStore.softDeleteForCard(id);
|
||||
|
||||
if (deckId) {
|
||||
const deck = await cardDeckTable.get(deckId);
|
||||
if (deck) {
|
||||
await cardDeckTable.update(deckId, {
|
||||
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to delete card';
|
||||
console.error('Delete card error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
clearError() {
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
78
apps/cards/apps/web/src/lib/stores/decks.svelte.ts
Normal file
78
apps/cards/apps/web/src/lib/stores/decks.svelte.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Deck Store — standalone.
|
||||
*
|
||||
* Slim version of the mana-modul decks store: no time-blocks, no
|
||||
* domain-events, no Mana-wide visibility hooks. Just CRUD against the
|
||||
* standalone Dexie DB.
|
||||
*/
|
||||
|
||||
import { cardDeckTable, cardTable, db } from '../data/database';
|
||||
import { encryptRecord } from '../data/crypto';
|
||||
import type { CreateDeckInput, UpdateDeckInput, LocalDeck } from '@mana/cards-core';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const deckStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
async createDeck(input: CreateDeckInput): Promise<LocalDeck | null> {
|
||||
error = null;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const newLocal: LocalDeck = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.title,
|
||||
description: input.description ?? null,
|
||||
color: '#6366f1',
|
||||
cardCount: 0,
|
||||
visibility: 'private',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await encryptRecord('cardDecks', newLocal);
|
||||
await cardDeckTable.add(newLocal);
|
||||
return newLocal;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to create deck';
|
||||
console.error('Create deck error:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updateDeck(id: string, updates: UpdateDeckInput) {
|
||||
error = null;
|
||||
try {
|
||||
const diff: Partial<LocalDeck> = { updatedAt: new Date().toISOString() };
|
||||
if (updates.title !== undefined) diff.name = updates.title;
|
||||
if (updates.description !== undefined) diff.description = updates.description;
|
||||
await encryptRecord('cardDecks', diff as Record<string, unknown>);
|
||||
await cardDeckTable.update(id, diff);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to update deck';
|
||||
console.error('Update deck error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDeck(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await db.transaction('rw', cardDeckTable, cardTable, async () => {
|
||||
const cards = await cardTable.where('deckId').equals(id).toArray();
|
||||
for (const card of cards) {
|
||||
await cardTable.update(card.id, { deletedAt: now });
|
||||
}
|
||||
await cardDeckTable.update(id, { deletedAt: now });
|
||||
});
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to delete deck';
|
||||
console.error('Delete deck error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
clearError() {
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
90
apps/cards/apps/web/src/lib/stores/reviews.svelte.ts
Normal file
90
apps/cards/apps/web/src/lib/stores/reviews.svelte.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Card-Review Store — standalone.
|
||||
*
|
||||
* Plaintext, no encryption hook (Phase 1). Fan-out logic comes from
|
||||
* @mana/cards-core; the only standalone bit is which Dexie table to write to.
|
||||
*/
|
||||
|
||||
import { cardReviewTable } from '../data/database';
|
||||
import {
|
||||
newReview,
|
||||
gradeReview as fsrsGrade,
|
||||
subIndexesFor,
|
||||
type CardFields,
|
||||
type CardType,
|
||||
type LocalCardReview,
|
||||
type ReviewGrade,
|
||||
} from '@mana/cards-core';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const reviewStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
async ensureReviewsForCard(card: {
|
||||
id: string;
|
||||
type: CardType;
|
||||
fields: CardFields;
|
||||
}): Promise<LocalCardReview[]> {
|
||||
error = null;
|
||||
try {
|
||||
const existing = await cardReviewTable.where('cardId').equals(card.id).toArray();
|
||||
const live = existing.filter((r) => !r.deletedAt);
|
||||
const liveByIdx = new Map(live.map((r) => [r.subIndex, r]));
|
||||
|
||||
const wanted = subIndexesFor(card);
|
||||
const wantedSet = new Set(wanted);
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
for (const subIndex of wanted) {
|
||||
if (!liveByIdx.has(subIndex)) {
|
||||
const r = newReview({ cardId: card.id, subIndex });
|
||||
await cardReviewTable.add(r);
|
||||
liveByIdx.set(subIndex, r);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of live) {
|
||||
if (!wantedSet.has(r.subIndex)) {
|
||||
await cardReviewTable.update(r.id, { deletedAt: nowIso });
|
||||
liveByIdx.delete(r.subIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return [...liveByIdx.values()].sort((a, b) => a.subIndex - b.subIndex);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to ensure reviews';
|
||||
console.error('Ensure reviews error:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async grade(reviewId: string, grade: ReviewGrade): Promise<LocalCardReview | null> {
|
||||
error = null;
|
||||
try {
|
||||
const existing = await cardReviewTable.get(reviewId);
|
||||
if (!existing) return null;
|
||||
const next = fsrsGrade(existing, grade);
|
||||
await cardReviewTable.put(next);
|
||||
return next;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to grade review';
|
||||
console.error('Grade review error:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async softDeleteForCard(cardId: string): Promise<void> {
|
||||
const reviews = await cardReviewTable.where('cardId').equals(cardId).toArray();
|
||||
const now = new Date().toISOString();
|
||||
for (const r of reviews) {
|
||||
if (!r.deletedAt) await cardReviewTable.update(r.id, { deletedAt: now });
|
||||
}
|
||||
},
|
||||
|
||||
clearError() {
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
65
apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts
Normal file
65
apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Study-Block Store — standalone.
|
||||
*
|
||||
* Local daily-aggregate row for streak + per-day-stats.
|
||||
*/
|
||||
|
||||
import { cardStudyBlockTable } from '../data/database';
|
||||
import type { LocalCardStudyBlock } from '@mana/cards-core';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function localDateKey(d: Date = new Date()): string {
|
||||
const y = d.getFullYear();
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export const studyBlockStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
async recordReview(durationMs: number, count: number = 1): Promise<void> {
|
||||
error = null;
|
||||
try {
|
||||
const date = localDateKey();
|
||||
const existing = await cardStudyBlockTable.where('date').equals(date).first();
|
||||
if (existing && !existing.deletedAt) {
|
||||
await cardStudyBlockTable.update(existing.id, {
|
||||
cardsReviewed: existing.cardsReviewed + count,
|
||||
durationMs: existing.durationMs + durationMs,
|
||||
});
|
||||
} else {
|
||||
const row: LocalCardStudyBlock = {
|
||||
id: crypto.randomUUID(),
|
||||
date,
|
||||
cardsReviewed: count,
|
||||
durationMs,
|
||||
};
|
||||
await cardStudyBlockTable.add(row);
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to record review';
|
||||
console.error('Record review error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async getRecentStreak(): Promise<number> {
|
||||
const today = new Date();
|
||||
let streak = 0;
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const row = await cardStudyBlockTable.where('date').equals(localDateKey(d)).first();
|
||||
if (!row || row.deletedAt || row.cardsReviewed <= 0) break;
|
||||
streak++;
|
||||
}
|
||||
return streak;
|
||||
},
|
||||
|
||||
clearError() {
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
55
apps/cards/apps/web/src/routes/+layout.svelte
Normal file
55
apps/cards/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { AuthGate } from '@mana/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { startSync, stopSync } from '$lib/data/sync';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
// Auth/marketing pages render outside the gate so first-time visitors
|
||||
// can actually reach them. Everything else is gated.
|
||||
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];
|
||||
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);
|
||||
}
|
||||
|
||||
onDestroy(() => stopSync());
|
||||
</script>
|
||||
|
||||
{#if isPublic}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
|
||||
<header class="border-b border-neutral-900">
|
||||
<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>
|
||||
<div class="flex items-center gap-3 text-xs text-neutral-500">
|
||||
{#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-neutral-800 px-2 py-1 hover:border-neutral-700 hover:text-neutral-100"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
</AuthGate>
|
||||
{/if}
|
||||
132
apps/cards/apps/web/src/routes/+page.svelte
Normal file
132
apps/cards/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllDecks } from '$lib/queries';
|
||||
import { deckStore } from '$lib/stores/decks.svelte';
|
||||
import type { Deck } from '@mana/cards-core';
|
||||
|
||||
const decksQuery = $derived(useAllDecks());
|
||||
const decks = $derived(($decksQuery as Deck[] | undefined) ?? []);
|
||||
|
||||
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-neutral-400">
|
||||
{decks.length}
|
||||
{decks.length === 1 ? 'Deck' : 'Decks'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400"
|
||||
onclick={() => (showNew = true)}
|
||||
>
|
||||
Neues Deck
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showNew}
|
||||
<form
|
||||
class="mb-6 space-y-3 rounded-xl border border-neutral-800 bg-neutral-900 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-neutral-700 bg-neutral-950 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-neutral-700 bg-neutral-950 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-neutral-400 hover:text-neutral-100"
|
||||
onclick={() => {
|
||||
showNew = false;
|
||||
newTitle = '';
|
||||
newDesc = '';
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 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-neutral-800 bg-neutral-900 p-10 text-center">
|
||||
<div class="mb-3 text-4xl">🃏</div>
|
||||
<p class="text-neutral-400">Noch keine Decks. Leg dein erstes an.</p>
|
||||
<button
|
||||
class="mt-4 rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
|
||||
onclick={() => (showNew = true)}
|
||||
>
|
||||
Erstes Deck anlegen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each decks as deck (deck.id)}
|
||||
<li>
|
||||
<a
|
||||
href={`/decks/${deck.id}`}
|
||||
class="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 px-4 py-3 transition-colors hover:border-neutral-700 hover:bg-neutral-800"
|
||||
>
|
||||
<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-neutral-400">{deck.description}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-xs text-neutral-500">{deck.cardCount}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<p class="mt-12 text-center text-xs text-neutral-600">
|
||||
Phase 1 Vorschau · noch keine Sync zur Mana-App
|
||||
</p>
|
||||
</main>
|
||||
326
apps/cards/apps/web/src/routes/decks/[id]/+page.svelte
Normal file
326
apps/cards/apps/web/src/routes/decks/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
<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';
|
||||
|
||||
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 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-neutral-400 hover:text-neutral-100">← 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-neutral-400">{deck.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg border border-red-500/30 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10"
|
||||
onclick={() => (confirmDelete = true)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
onclick={() => goto(`/learn/${deckId}`)}
|
||||
disabled={dueCount === 0}
|
||||
>
|
||||
Lernen
|
||||
{#if dueCount > 0}
|
||||
<span class="ml-2 rounded-full bg-white/20 px-2 py-0.5 text-xs">{dueCount} fällig</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if dueCount === 0 && cards.length > 0}
|
||||
<span class="text-sm text-neutral-400">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-neutral-800 bg-neutral-900 p-4 text-center">
|
||||
<div class="text-2xl font-semibold">{cards.length}</div>
|
||||
<div class="text-xs text-neutral-400">Karten</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-center">
|
||||
<div class="text-2xl font-semibold text-amber-400">{dueCount}</div>
|
||||
<div class="text-xs text-neutral-400">Fällig</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
|
||||
onclick={() => (showNew = true)}
|
||||
>
|
||||
Neue Karte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showNew}
|
||||
<div class="mb-6 rounded-xl border border-indigo-500/30 bg-neutral-900 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-indigo-500/10 text-indigo-300'
|
||||
: 'border-neutral-700 hover:bg-neutral-800'}"
|
||||
>
|
||||
<div class="font-medium">{opt.label}</div>
|
||||
<div class="text-xs text-neutral-400">{opt.hint}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if newType === 'cloze'}
|
||||
<div>
|
||||
<label for="card-cloze" class="mb-1 block text-sm text-neutral-400">
|
||||
Text mit Lücken
|
||||
</label>
|
||||
<!-- 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-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
|
||||
autofocus
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-neutral-500">
|
||||
Markiere mit
|
||||
<code class="rounded bg-neutral-800 px-1">{{c1::Wort}}</code>
|
||||
— optional Hinweis: <code class="rounded bg-neutral-800 px-1">::Hinweis</code>.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label for="card-front" class="mb-1 block text-sm text-neutral-400">Vorderseite</label
|
||||
>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="card-front"
|
||||
type="text"
|
||||
bind:value={newFront}
|
||||
placeholder="Frage oder Begriff…"
|
||||
class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="card-back" class="mb-1 block text-sm text-neutral-400">Rückseite</label>
|
||||
<textarea
|
||||
id="card-back"
|
||||
bind:value={newBack}
|
||||
placeholder="Antwort oder Erklärung…"
|
||||
class="min-h-[80px] w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm outline-none focus:border-indigo-400"
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-100"
|
||||
onclick={() => {
|
||||
showNew = false;
|
||||
newFront = '';
|
||||
newBack = '';
|
||||
newCloze = '';
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
onclick={handleCreateCard}
|
||||
disabled={!canSubmit()}
|
||||
>
|
||||
Karte erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900">
|
||||
<h2 class="border-b border-neutral-800 p-4 text-lg font-semibold">
|
||||
Karten ({cards.length})
|
||||
</h2>
|
||||
{#if cards.length === 0}
|
||||
<div class="p-10 text-center text-neutral-400">
|
||||
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-neutral-500">{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-neutral-400">
|
||||
{@html renderMarkdown(p.secondary)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-400">
|
||||
{typeBadge(card.type)}
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-1 text-neutral-500 hover:text-red-400"
|
||||
onclick={() => handleDeleteCard(card.id)}
|
||||
aria-label="Karte löschen"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</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-neutral-800 bg-neutral-900 p-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 class="mb-2 text-xl font-semibold">Deck löschen?</h3>
|
||||
<p class="mb-6 text-neutral-400">
|
||||
"{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-neutral-400 hover:text-neutral-100"
|
||||
onclick={() => (confirmDelete = false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-4 py-2 text-sm text-white hover:bg-red-400"
|
||||
onclick={handleDeleteDeck}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="py-16 text-center text-neutral-400">
|
||||
Deck nicht gefunden.
|
||||
<a href="/" class="ml-2 text-indigo-400 hover:underline">zurück</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
180
apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte
Normal file
180
apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<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 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');
|
||||
|
||||
$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-neutral-400 hover:text-neutral-100"
|
||||
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-neutral-400">
|
||||
{Math.min(currentIndex + 1, queue.length)} / {queue.length}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if empty}
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-10 text-center">
|
||||
<div class="text-2xl">Alles gelernt</div>
|
||||
<p class="mt-2 text-sm text-neutral-400">
|
||||
Komm später wieder — fällige Karten erscheinen automatisch.
|
||||
</p>
|
||||
<button
|
||||
class="mt-4 rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
|
||||
onclick={() => goto(`/decks/${deckId}`)}
|
||||
>
|
||||
Zurück zum Deck
|
||||
</button>
|
||||
</div>
|
||||
{:else if finished}
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-10 text-center">
|
||||
<div class="text-2xl">Session abgeschlossen</div>
|
||||
<p class="mt-2 text-sm text-neutral-400">
|
||||
{sessionCount} Karten in {Math.round((Date.now() - sessionStartedAt) / 1000)} s.
|
||||
</p>
|
||||
<button
|
||||
class="mt-4 rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
|
||||
onclick={() => goto(`/decks/${deckId}`)}
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
{:else if current}
|
||||
<CardFace
|
||||
card={current.card}
|
||||
subIndex={current.review.subIndex}
|
||||
{showBack}
|
||||
{typedAnswer}
|
||||
onTypedAnswer={(v) => (typedAnswer = v)}
|
||||
/>
|
||||
|
||||
{#if !showBack}
|
||||
<button
|
||||
class="mt-6 w-full rounded-lg bg-indigo-500 py-3 text-base text-white hover:bg-indigo-400"
|
||||
onclick={reveal}
|
||||
>
|
||||
Aufdecken <span class="ml-2 text-xs opacity-70">(Leertaste)</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="mt-6 grid grid-cols-4 gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-red-500 py-3 text-sm text-white hover:bg-red-400"
|
||||
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-neutral-400">Lade…</div>
|
||||
{/if}
|
||||
</div>
|
||||
35
apps/cards/apps/web/src/routes/login/+page.svelte
Normal file
35
apps/cards/apps/web/src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { LoginPage } from '@mana/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import CardsLogo from '$lib/components/CardsLogo.svelte';
|
||||
|
||||
const verified = $derived(page.url.searchParams.get('verified') === 'true');
|
||||
const initialEmail = $derived(page.url.searchParams.get('email') || '');
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
return authStore.resendVerificationEmail(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="Cards"
|
||||
logo={CardsLogo}
|
||||
primaryColor="#6366f1"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#f5f5f5"
|
||||
darkBackground="#0a0a0a"
|
||||
isDark
|
||||
{verified}
|
||||
{initialEmail}
|
||||
/>
|
||||
27
apps/cards/apps/web/src/routes/register/+page.svelte
Normal file
27
apps/cards/apps/web/src/routes/register/+page.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { RegisterPage } from '@mana/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import CardsLogo from '$lib/components/CardsLogo.svelte';
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
return authStore.resendVerificationEmail(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<RegisterPage
|
||||
appName="Cards"
|
||||
logo={CardsLogo}
|
||||
primaryColor="#6366f1"
|
||||
onSignUp={handleSignUp}
|
||||
onResendVerification={handleResendVerification}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#f5f5f5"
|
||||
darkBackground="#0a0a0a"
|
||||
/>
|
||||
4
apps/cards/apps/web/static/favicon.svg
Normal file
4
apps/cards/apps/web/static/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect x="6" y="10" width="42" height="50" rx="6" fill="#6366f1"/>
|
||||
<rect x="16" y="4" width="42" height="50" rx="6" fill="#a855f7" opacity="0.85"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
12
apps/cards/apps/web/svelte.config.js
Normal file
12
apps/cards/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
apps/cards/apps/web/tsconfig.json
Normal file
15
apps/cards/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
7
apps/cards/apps/web/vite.config.ts
Normal file
7
apps/cards/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
});
|
||||
9
apps/cards/package.json
Normal file
9
apps/cards/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "cards",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Cards — Spaced-Repetition flashcards on cards.mana.how. Standalone Phase-1 frontend; data shared with the mana cards module via mana-sync.",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@cards/* --parallel dev"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- Remove foreign key constraint on study_sessions.user_id
|
||||
-- This allows storing Mana Core user IDs without requiring a local users table
|
||||
ALTER TABLE study_sessions DROP CONSTRAINT IF EXISTS study_sessions_user_id_fkey;
|
||||
|
||||
-- Remove foreign key constraint on card_progress.user_id
|
||||
ALTER TABLE card_progress DROP CONSTRAINT IF EXISTS card_progress_user_id_fkey;
|
||||
|
||||
-- Add comments to document that user_id references Mana Core users
|
||||
COMMENT ON COLUMN study_sessions.user_id IS 'User ID from Mana Core authentication system';
|
||||
COMMENT ON COLUMN card_progress.user_id IS 'User ID from Mana Core authentication system';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
-- Remove foreign key constraint on decks.user_id
|
||||
-- This allows storing Mana Core user IDs without requiring a local users table
|
||||
ALTER TABLE decks DROP CONSTRAINT IF EXISTS decks_user_id_fkey;
|
||||
|
||||
-- Optional: Add a comment to document that user_id references Mana Core users
|
||||
COMMENT ON COLUMN decks.user_id IS 'User ID from Mana Core authentication system';
|
||||
|
|
@ -58,15 +58,16 @@
|
|||
"@mana/qr-export": "workspace:*",
|
||||
"@mana/shared-ai": "workspace:*",
|
||||
"@mana/shared-auth": "workspace:*",
|
||||
"@mana/shared-crypto": "workspace:*",
|
||||
"@mana/shared-auth-ui": "workspace:*",
|
||||
"@mana/shared-branding": "workspace:*",
|
||||
"@mana/shared-crypto": "workspace:*",
|
||||
"@mana/shared-error-tracking": "workspace:*",
|
||||
"@mana/shared-i18n": "workspace:*",
|
||||
"@mana/shared-icons": "workspace:*",
|
||||
"@mana/shared-links": "workspace:*",
|
||||
"@mana/shared-llm": "workspace:*",
|
||||
"@mana/shared-privacy": "workspace:*",
|
||||
"@mana/cards-core": "workspace:*",
|
||||
"@mana/shared-stores": "workspace:*",
|
||||
"@mana/shared-tags": "workspace:*",
|
||||
"@mana/shared-tailwind": "workspace:*",
|
||||
|
|
@ -89,6 +90,7 @@
|
|||
"@types/suncalc": "^1.9.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.0.11",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"marked": "^17.0.5",
|
||||
"pako": "^2.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
|
|
@ -98,6 +100,7 @@
|
|||
"svelte-i18n": "^4.0.0",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"ts-fsrs": "^5.3.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"type": "module"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
|
|||
'budgets', // TODO: audit
|
||||
'calculations', // TODO: audit
|
||||
'calendars', // TODO: audit
|
||||
'cardReviews', // FSRS scheduling state (numbers + due timestamp). No user-typed text — query path scans by `due` to find what's fällig today.
|
||||
'cardStudyBlocks', // Daily activity aggregate (date + counters). No user-typed content.
|
||||
'ccFavorites', // TODO: audit
|
||||
'ccLocationTags', // TODO: audit
|
||||
'ccLocations', // TODO: audit
|
||||
|
|
|
|||
|
|
@ -224,10 +224,13 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
plants: { enabled: true, fields: ['name', 'careNotes', 'temperature', 'soilType'] },
|
||||
|
||||
// ─── Cards ───────────────────────────────────────────────
|
||||
// `cards` has no `notes` column on LocalCard — only front + back are
|
||||
// user content. cardDecks uses `name` (not `title`) on the schema
|
||||
// even though the public DTO translates it to `title`.
|
||||
cards: { enabled: true, fields: ['front', 'back'] },
|
||||
// User-typed content lives in three places on LocalCard:
|
||||
// - legacy `front`/`back` columns (pre-Phase-0 rows still use them)
|
||||
// - new `fields` map (Phase 0+, holds basic.{front,back} or cloze.{text,extra})
|
||||
// All three encrypt; wrapValue handles object payloads transparently.
|
||||
// cardDecks uses `name` (not `title`) on the schema even though the
|
||||
// public DTO translates it to `title`.
|
||||
cards: { enabled: true, fields: ['front', 'back', 'fields'] },
|
||||
cardDecks: { enabled: true, fields: ['name', 'description'] },
|
||||
|
||||
// ─── Presi ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1493,6 +1493,28 @@ db.version(60).stores({
|
|||
articleImportItems: 'id, jobId, [jobId+state], idx',
|
||||
});
|
||||
|
||||
// Schema version 61 — Cards Phase 0: FSRS scheduling.
|
||||
//
|
||||
// Two new tables back the new spaced-repetition pipeline:
|
||||
//
|
||||
// - `cardReviews`: FSRS state per learnable unit. A basic card has one
|
||||
// row (subIndex=0); basic-reverse has two; cloze has one per cluster.
|
||||
// Indexes: `cardId` for "all reviews of this card", `due` for the
|
||||
// hot "what's fällig now" query, `[cardId+subIndex]` for the
|
||||
// direct lookup the scheduler needs after a rating, `state` for
|
||||
// deck-stats panels.
|
||||
// - `cardStudyBlocks`: per-day aggregate (cardsReviewed + durationMs).
|
||||
// `date` is the only secondary index — the streak query scans the
|
||||
// last N days.
|
||||
//
|
||||
// `cards` itself doesn't change — `type` and `fields` are non-indexed
|
||||
// columns. Existing v1 indexes (`difficulty`, `nextReview`, `order`,
|
||||
// `[deckId+order]`) stay for backwards-compat with the legacy renderer.
|
||||
db.version(61).stores({
|
||||
cardReviews: 'id, cardId, due, [cardId+subIndex], state',
|
||||
cardStudyBlocks: 'id, date',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<!--
|
||||
Cards — Workbench ListView
|
||||
Deck list with card counts and study info.
|
||||
Deck list with card counts and due-now indicator.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { BaseListView } from '@mana/shared-ui';
|
||||
import type { LocalDeck, LocalCard } from './types';
|
||||
import type { LocalDeck, LocalCard, LocalCardReview } from './types';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
|
||||
let { navigate }: ViewProps = $props();
|
||||
|
||||
const decksQuery = useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table<LocalDeck>('decks').toArray();
|
||||
const all = await db.table<LocalDeck>('cardDecks').toArray();
|
||||
return all.filter((d) => !d.deletedAt);
|
||||
}, [] as LocalDeck[]);
|
||||
|
||||
|
|
@ -21,14 +21,34 @@
|
|||
return all.filter((c) => !c.deletedAt);
|
||||
}, [] as LocalCard[]);
|
||||
|
||||
const reviewsQuery = useLiveQueryWithDefault(async () => {
|
||||
const nowIso = new Date().toISOString();
|
||||
const due = await db
|
||||
.table<LocalCardReview>('cardReviews')
|
||||
.where('due')
|
||||
.belowOrEqual(nowIso)
|
||||
.toArray();
|
||||
return due.filter((r) => !r.deletedAt);
|
||||
}, [] as LocalCardReview[]);
|
||||
|
||||
const decks = $derived(decksQuery.value);
|
||||
const cards = $derived(cardsQuery.value);
|
||||
const dueReviews = $derived(reviewsQuery.value);
|
||||
|
||||
const dueForReview = $derived.by(() => {
|
||||
const now = new Date().toISOString();
|
||||
return cards.filter((c) => c.nextReview && c.nextReview <= now).length;
|
||||
const cardIdToDeckId = $derived(new Map(cards.map((c) => [c.id, c.deckId])));
|
||||
|
||||
const dueByDeck = $derived.by(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of dueReviews) {
|
||||
const deckId = cardIdToDeckId.get(r.cardId);
|
||||
if (!deckId) continue;
|
||||
counts.set(deckId, (counts.get(deckId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const totalDue = $derived(dueReviews.length);
|
||||
|
||||
function cardsInDeck(deckId: string): number {
|
||||
return cards.filter((c) => c.deckId === deckId).length;
|
||||
}
|
||||
|
|
@ -37,10 +57,11 @@
|
|||
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Decks">
|
||||
{#snippet header()}
|
||||
<span class="flex-1">{decks.length} Decks</span>
|
||||
<span class="text-warning/80">{dueForReview} fällig</span>
|
||||
<span class="text-warning/80">{totalDue} fällig</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet item(deck)}
|
||||
{@const due = dueByDeck.get(deck.id) ?? 0}
|
||||
<button
|
||||
onclick={() =>
|
||||
navigate('detail', {
|
||||
|
|
@ -53,6 +74,11 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div>
|
||||
<p class="flex-1 truncate text-sm font-medium text-foreground">{deck.name}</p>
|
||||
{#if due > 0}
|
||||
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs text-amber-600">
|
||||
{due} fällig
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-muted-foreground">{cardsInDeck(deck.id)}</span>
|
||||
</div>
|
||||
{#if deck.description}
|
||||
|
|
|
|||
6
apps/mana/apps/web/src/lib/modules/cards/card-reviews.ts
Normal file
6
apps/mana/apps/web/src/lib/modules/cards/card-reviews.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Cards module — review fan-out is now sourced from `@mana/cards-core`.
|
||||
* Thin re-export so existing local imports keep working.
|
||||
*/
|
||||
|
||||
export { subIndexesFor } from '@mana/cards-core';
|
||||
13
apps/mana/apps/web/src/lib/modules/cards/cloze.ts
Normal file
13
apps/mana/apps/web/src/lib/modules/cards/cloze.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Cards module — cloze parser is now sourced from `@mana/cards-core`.
|
||||
* Thin re-export so existing local imports keep working.
|
||||
*/
|
||||
|
||||
export {
|
||||
tokenize,
|
||||
clusterIndexes,
|
||||
clusters,
|
||||
renderCloze,
|
||||
type ClozeCluster,
|
||||
type RenderedCloze,
|
||||
} from '@mana/cards-core';
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
/**
|
||||
* Cards module — collection accessors and guest seed data.
|
||||
*
|
||||
* Uses table names from the unified DB: cardDecks, cards.
|
||||
* Tables in the unified DB: cardDecks, cards, cardReviews, cardStudyBlocks.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalDeck, LocalCard } from './types';
|
||||
import type { LocalDeck, LocalCard, LocalCardReview, LocalCardStudyBlock } from './types';
|
||||
|
||||
// ─── Collection Accessors ──────────────────────────────────
|
||||
|
||||
export const cardDeckTable = db.table<LocalDeck>('cardDecks');
|
||||
export const cardTable = db.table<LocalCard>('cards');
|
||||
export const cardReviewTable = db.table<LocalCardReview>('cardReviews');
|
||||
export const cardStudyBlockTable = db.table<LocalCardStudyBlock>('cardStudyBlocks');
|
||||
|
||||
// ─── Guest Seed ────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<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.
|
||||
*
|
||||
* - basic / basic-reverse subIndex 0: prompt = front, answer = back
|
||||
* - basic-reverse subIndex 1: prompt = back, answer = front
|
||||
* - cloze subIndex N: cloze.renderCloze(text, N)
|
||||
* - type-in: prompt = front, answer = back,
|
||||
* plus an input the user types into.
|
||||
*/
|
||||
|
||||
import type { Card } from '../types';
|
||||
import { renderCloze } from '../cloze';
|
||||
import { renderMarkdown } from '../render';
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
subIndex: number;
|
||||
showBack: boolean;
|
||||
typedAnswer?: string;
|
||||
onTypedAnswer?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { card, subIndex, showBack, typedAnswer = '', onTypedAnswer }: 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()
|
||||
);
|
||||
</script>
|
||||
|
||||
<article class="space-y-4">
|
||||
<div class="rounded-xl border border-border bg-card p-6 text-lg leading-relaxed">
|
||||
{@html view.prompt}
|
||||
</div>
|
||||
|
||||
{#if isTypeIn}
|
||||
<input
|
||||
class="w-full rounded-lg border border-border bg-card px-3 py-2 text-base"
|
||||
type="text"
|
||||
placeholder="Antwort eingeben…"
|
||||
value={typedAnswer}
|
||||
oninput={(e) => onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)}
|
||||
disabled={showBack}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showBack}
|
||||
<div
|
||||
class="rounded-xl border-2 p-6 text-lg leading-relaxed
|
||||
{isTypeIn
|
||||
? matched
|
||||
? 'border-green-500 bg-green-500/5'
|
||||
: 'border-red-500 bg-red-500/5'
|
||||
: 'border-primary bg-primary/5'}"
|
||||
>
|
||||
{@html view.answer}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
6
apps/mana/apps/web/src/lib/modules/cards/fsrs.ts
Normal file
6
apps/mana/apps/web/src/lib/modules/cards/fsrs.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Cards module — FSRS wrapper is now sourced from `@mana/cards-core`.
|
||||
* Thin re-export so existing local imports keep working.
|
||||
*/
|
||||
|
||||
export { newReview, gradeReview } from '@mana/cards-core';
|
||||
|
|
@ -2,5 +2,11 @@ import type { ModuleConfig } from '$lib/data/module-registry';
|
|||
|
||||
export const cardsModuleConfig: ModuleConfig = {
|
||||
appId: 'cards',
|
||||
tables: [{ name: 'cardDecks', syncName: 'decks' }, { name: 'cards' }, { name: 'deckTags' }],
|
||||
tables: [
|
||||
{ name: 'cardDecks', syncName: 'decks' },
|
||||
{ name: 'cards' },
|
||||
{ name: 'deckTags' },
|
||||
{ name: 'cardReviews' },
|
||||
{ name: 'cardStudyBlocks' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@ import { deriveUpdatedAt } from '$lib/data/sync';
|
|||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalDeck, LocalCard, Deck, Card } from './types';
|
||||
import type {
|
||||
CardFields,
|
||||
CardType,
|
||||
LocalDeck,
|
||||
LocalCard,
|
||||
LocalCardReview,
|
||||
Deck,
|
||||
Card,
|
||||
CardReview,
|
||||
} from './types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
|
|
@ -27,18 +36,49 @@ export function toDeck(local: LocalDeck): Deck {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote any LocalCard row — including legacy pre-Phase-0 ones — to
|
||||
* the canonical {type, fields} shape. Readers must go through this so
|
||||
* the rest of the app sees one schema.
|
||||
*
|
||||
* - Phase-0+ rows: returned as-is, with `front`/`back` derived from
|
||||
* fields for the convenience accessors on the DTO.
|
||||
* - Legacy rows (only `front`/`back` set): synthesised as
|
||||
* {type: 'basic', fields: {front, back}}.
|
||||
*/
|
||||
export function toLogicalCard(local: LocalCard): {
|
||||
type: CardType;
|
||||
fields: CardFields;
|
||||
front: string;
|
||||
back: string;
|
||||
} {
|
||||
const type: CardType = local.type ?? 'basic';
|
||||
const fields: CardFields = local.fields ?? {
|
||||
front: local.front ?? '',
|
||||
back: local.back ?? '',
|
||||
};
|
||||
const front = fields.front ?? local.front ?? '';
|
||||
const back = fields.back ?? local.back ?? '';
|
||||
return { type, fields, front, back };
|
||||
}
|
||||
|
||||
export function toCard(local: LocalCard): Card {
|
||||
const { type, fields, front, back } = toLogicalCard(local);
|
||||
return {
|
||||
id: local.id,
|
||||
deckId: local.deckId,
|
||||
front: local.front,
|
||||
back: local.back,
|
||||
difficulty: local.difficulty,
|
||||
nextReview: local.nextReview ?? undefined,
|
||||
reviewCount: local.reviewCount,
|
||||
type,
|
||||
fields,
|
||||
front,
|
||||
back,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
// Legacy fields surfaced for pre-Phase-0 UI. Populated only when the
|
||||
// underlying row carries them.
|
||||
difficulty: local.difficulty,
|
||||
nextReview: local.nextReview ?? undefined,
|
||||
reviewCount: local.reviewCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +116,71 @@ export function useCardsByDeck(deckId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* All reviews that are due now (or overdue), optionally filtered by
|
||||
* deck. Joined with the parent card so the UI can render the prompt
|
||||
* immediately without a second lookup.
|
||||
*
|
||||
* Sorted by `due` ascending so the oldest-due learnable unit comes
|
||||
* first — that's the natural session order.
|
||||
*/
|
||||
export function useDueReviews(deckId?: string) {
|
||||
return liveQuery(async () => {
|
||||
const nowIso = new Date().toISOString();
|
||||
const due = await db
|
||||
.table<LocalCardReview>('cardReviews')
|
||||
.where('due')
|
||||
.belowOrEqual(nowIso)
|
||||
.toArray();
|
||||
const live = due.filter((r) => !r.deletedAt);
|
||||
if (live.length === 0) return [] as { review: CardReview; card: Card }[];
|
||||
|
||||
const cardIds = [...new Set(live.map((r) => r.cardId))];
|
||||
const cardRows = await db.table<LocalCard>('cards').where('id').anyOf(cardIds).toArray();
|
||||
const decryptedCards = await decryptRecords(
|
||||
'cards',
|
||||
cardRows.filter((c) => !c.deletedAt)
|
||||
);
|
||||
const cardById = new Map(decryptedCards.map((c) => [c.id, toCard(c)] as const));
|
||||
|
||||
return live
|
||||
.filter((r) => {
|
||||
const c = cardById.get(r.cardId);
|
||||
if (!c) return false;
|
||||
if (deckId && c.deckId !== deckId) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0))
|
||||
.map((r) => ({ review: toCardReview(r), card: cardById.get(r.cardId)! }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Just the reviews row, no card join — useful in the session UI mid-grade. */
|
||||
export function useReview(reviewId: string) {
|
||||
return liveQuery(async () => {
|
||||
const r = await db.table<LocalCardReview>('cardReviews').get(reviewId);
|
||||
if (!r || r.deletedAt) return null;
|
||||
return toCardReview(r);
|
||||
});
|
||||
}
|
||||
|
||||
function toCardReview(r: LocalCardReview): CardReview {
|
||||
return {
|
||||
id: r.id,
|
||||
cardId: r.cardId,
|
||||
subIndex: r.subIndex,
|
||||
state: r.state,
|
||||
stability: r.stability,
|
||||
difficulty: r.difficulty,
|
||||
due: r.due,
|
||||
reps: r.reps,
|
||||
lapses: r.lapses,
|
||||
lastReview: r.lastReview,
|
||||
elapsedDays: r.elapsedDays,
|
||||
scheduledDays: r.scheduledDays,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Pure Helper Functions ─────────────────────────────────
|
||||
|
||||
export function getDeckById(decks: Deck[], id: string): Deck | undefined {
|
||||
|
|
|
|||
7
apps/mana/apps/web/src/lib/modules/cards/render.ts
Normal file
7
apps/mana/apps/web/src/lib/modules/cards/render.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Cards module — Markdown render helper is now sourced from
|
||||
* `@mana/cards-core`. Thin re-export so existing local imports keep
|
||||
* working.
|
||||
*/
|
||||
|
||||
export { renderMarkdown, type RenderOptions } from '@mana/cards-core';
|
||||
|
|
@ -3,17 +3,58 @@
|
|||
*
|
||||
* Reads come from liveQuery hooks in queries.ts.
|
||||
* This store only handles writes to IndexedDB via the unified database.
|
||||
*
|
||||
* Phase 0+: writes the new {type, fields} shape AND mirrors basic-card
|
||||
* content to the legacy front/back columns so older mana builds keep
|
||||
* rendering. Every create/update fans out to cardReviews via
|
||||
* reviewStore.ensureReviewsForCard().
|
||||
*/
|
||||
|
||||
import { CardsEvents } from '@mana/shared-utils/analytics';
|
||||
import { cardTable, cardDeckTable } from '../collections';
|
||||
import { toCard } from '../queries';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { toCard, toLogicalCard } from '../queries';
|
||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import type { LocalCard, Card, CreateCardInput, UpdateCardInput } from '../types';
|
||||
import type {
|
||||
CardFields,
|
||||
CardType,
|
||||
LocalCard,
|
||||
Card,
|
||||
CreateCardInput,
|
||||
UpdateCardInput,
|
||||
} from '../types';
|
||||
import { reviewStore } from './reviews.svelte';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Build the {type, fields} pair from a CreateCardInput. Accepts the
|
||||
* convenience `front`/`back` shortcut for basic cards and falls back
|
||||
* to an explicit `fields` map for cloze and friends.
|
||||
*/
|
||||
function resolveTypeAndFields(input: CreateCardInput): {
|
||||
type: CardType;
|
||||
fields: CardFields;
|
||||
} {
|
||||
const type = input.type ?? 'basic';
|
||||
if (input.fields) return { type, fields: input.fields };
|
||||
if (type === 'cloze') return { type, fields: { text: input.front ?? '' } };
|
||||
return { type, fields: { front: input.front ?? '', back: input.back ?? '' } };
|
||||
}
|
||||
|
||||
/** Mirror basic-card text into the legacy columns for older clients. */
|
||||
function legacyMirror(type: CardType, fields: CardFields): { front?: string; back?: string } {
|
||||
if (type === 'basic' || type === 'basic-reverse' || type === 'type-in') {
|
||||
return { front: fields.front ?? '', back: fields.back ?? '' };
|
||||
}
|
||||
if (type === 'cloze') {
|
||||
// Surface the cloze source on `front` so legacy list-views show
|
||||
// something meaningful rather than an empty row.
|
||||
return { front: fields.text ?? '', back: '' };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export const cardStore = {
|
||||
get error() {
|
||||
return error;
|
||||
|
|
@ -22,21 +63,22 @@ export const cardStore = {
|
|||
async createCard(input: CreateCardInput, currentCardCount: number = 0): Promise<Card | null> {
|
||||
error = null;
|
||||
try {
|
||||
const { type, fields } = resolveTypeAndFields(input);
|
||||
const legacy = legacyMirror(type, fields);
|
||||
|
||||
const newLocal: LocalCard = {
|
||||
id: crypto.randomUUID(),
|
||||
deckId: input.deckId,
|
||||
front: input.front,
|
||||
back: input.back,
|
||||
difficulty: 1,
|
||||
reviewCount: 0,
|
||||
type,
|
||||
fields,
|
||||
order: currentCardCount,
|
||||
...legacy,
|
||||
};
|
||||
|
||||
const plaintextSnapshot = toCard(newLocal);
|
||||
await encryptRecord('cards', newLocal);
|
||||
await cardTable.add(newLocal);
|
||||
|
||||
// Update deck card count
|
||||
const deck = await cardDeckTable.get(input.deckId);
|
||||
if (deck) {
|
||||
await cardDeckTable.update(input.deckId, {
|
||||
|
|
@ -44,6 +86,8 @@ export const cardStore = {
|
|||
});
|
||||
}
|
||||
|
||||
await reviewStore.ensureReviewsForCard({ id: newLocal.id, type, fields });
|
||||
|
||||
emitDomainEvent('CardCreated', 'cards', 'cards', newLocal.id, {
|
||||
cardId: newLocal.id,
|
||||
deckId: input.deckId,
|
||||
|
|
@ -60,17 +104,43 @@ export const cardStore = {
|
|||
async updateCard(id: string, updates: UpdateCardInput) {
|
||||
error = null;
|
||||
try {
|
||||
const localUpdates: Partial<LocalCard> = {};
|
||||
if (updates.front !== undefined) localUpdates.front = updates.front;
|
||||
if (updates.back !== undefined) localUpdates.back = updates.back;
|
||||
if (updates.difficulty !== undefined) localUpdates.difficulty = updates.difficulty;
|
||||
if (updates.order !== undefined) localUpdates.order = updates.order;
|
||||
const existing = await cardTable.get(id);
|
||||
if (!existing) return;
|
||||
|
||||
const decrypted = await decryptRecord('cards', { ...existing });
|
||||
const current = toLogicalCard(decrypted as LocalCard);
|
||||
const nextType: CardType = updates.type ?? current.type;
|
||||
const nextFields: CardFields = updates.fields
|
||||
? updates.fields
|
||||
: updates.front !== undefined || updates.back !== undefined
|
||||
? nextType === 'cloze'
|
||||
? { ...current.fields, text: updates.front ?? current.fields.text ?? '' }
|
||||
: {
|
||||
...current.fields,
|
||||
front: updates.front ?? current.fields.front ?? '',
|
||||
back: updates.back ?? current.fields.back ?? '',
|
||||
}
|
||||
: current.fields;
|
||||
|
||||
const legacy = legacyMirror(nextType, nextFields);
|
||||
const diff: Partial<LocalCard> = {
|
||||
...localUpdates,
|
||||
type: nextType,
|
||||
fields: nextFields,
|
||||
...legacy,
|
||||
};
|
||||
if (updates.order !== undefined) diff.order = updates.order;
|
||||
if (updates.difficulty !== undefined) diff.difficulty = updates.difficulty;
|
||||
|
||||
await encryptRecord('cards', diff);
|
||||
await cardTable.update(id, diff);
|
||||
|
||||
const structuralChange =
|
||||
updates.type !== undefined ||
|
||||
updates.fields !== undefined ||
|
||||
(nextType === 'cloze' && updates.front !== undefined);
|
||||
if (structuralChange) {
|
||||
await reviewStore.ensureReviewsForCard({ id, type: nextType, fields: nextFields });
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to update card';
|
||||
console.error('Update card error:', err);
|
||||
|
|
@ -82,9 +152,9 @@ export const cardStore = {
|
|||
try {
|
||||
const now = new Date().toISOString();
|
||||
await cardTable.update(id, { deletedAt: now });
|
||||
await reviewStore.softDeleteForCard(id);
|
||||
CardsEvents.cardDeleted();
|
||||
|
||||
// Update deck card count
|
||||
if (deckId) {
|
||||
const deck = await cardDeckTable.get(deckId);
|
||||
if (deck) {
|
||||
|
|
@ -102,7 +172,6 @@ export const cardStore = {
|
|||
async reorderCards(cardIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
for (let i = 0; i < cardIds.length; i++) {
|
||||
await cardTable.update(cardIds[i], { order: i });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Card-Review Store — FSRS scheduling state.
|
||||
*
|
||||
* Reviews are plaintext (no encryptRecord) — cardReviews is in
|
||||
* `plaintext-allowlist.ts` because the scheduler must query by `due`
|
||||
* to find what's fällig today.
|
||||
*
|
||||
* Three operations the rest of the module needs:
|
||||
* - ensureReviewsForCard: create the right number of subIndex rows
|
||||
* for a card, soft-delete obsolete ones (e.g. when a cloze cluster
|
||||
* gets removed). Idempotent — safe to call after every card edit.
|
||||
* - grade: apply a user rating, persist the next FSRS state.
|
||||
* - softDeleteForCard: cascade soft-delete when a card is deleted.
|
||||
*/
|
||||
|
||||
import { cardReviewTable } from '../collections';
|
||||
import { newReview, gradeReview as fsrsGrade } from '../fsrs';
|
||||
import { subIndexesFor } from '../card-reviews';
|
||||
import type { CardFields, CardType, LocalCardReview, ReviewGrade } from '../types';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const reviewStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reconcile the cardReviews rows for a card with what the card
|
||||
* structurally needs. New subIndexes get a fresh review; obsolete
|
||||
* ones get soft-deleted. Returns the live set of reviews.
|
||||
*/
|
||||
async ensureReviewsForCard(card: {
|
||||
id: string;
|
||||
type: CardType;
|
||||
fields: CardFields;
|
||||
}): Promise<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;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Study-Block Store — daily aggregate of learning activity.
|
||||
*
|
||||
* One row per local date with counters. The streak query walks back
|
||||
* from today; finding a gap (no row, or cardsReviewed=0) ends the
|
||||
* streak. Plaintext, no encryption.
|
||||
*
|
||||
* Why a daily aggregate row instead of just summing cardReviews?
|
||||
* Because the streak is a UI-hot read — we want it cheap (≤ 30 row
|
||||
* lookups) regardless of how many reviews exist in total.
|
||||
*/
|
||||
|
||||
import { cardStudyBlockTable } from '../collections';
|
||||
import type { LocalCardStudyBlock } from '../types';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function localDateKey(d: Date = new Date()): string {
|
||||
// YYYY-MM-DD in the user's local timezone — matches LocalCardStudyBlock.date.
|
||||
const y = d.getFullYear();
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export const studyBlockStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Record one review against today's block. Creates the row on the
|
||||
* first review of the day. Idempotent across concurrent calls only
|
||||
* within a Dexie transaction — for now we accept the small chance of
|
||||
* an off-by-one race; real users grade one card at a time.
|
||||
*/
|
||||
async recordReview(durationMs: number, count: number = 1): Promise<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);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Walk back from today; return how many consecutive days have at
|
||||
* least one reviewed card. Stops at the first gap. Caps at 365 days
|
||||
* to keep the worst case bounded.
|
||||
*/
|
||||
async getRecentStreak(): Promise<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,78 +1,24 @@
|
|||
/**
|
||||
* Cards module types for the unified app.
|
||||
* Cards module — types are now sourced from `@mana/cards-core` so the
|
||||
* standalone cards.mana.how app and this in-mana module stay in sync.
|
||||
*
|
||||
* This file is a thin re-export to keep existing
|
||||
* `from './types'` / `from '$lib/modules/cards/types'` imports working.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||
|
||||
export interface LocalDeck extends BaseRecord {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color: string;
|
||||
cardCount: number;
|
||||
lastStudied?: string | null;
|
||||
visibility?: VisibilityLevel;
|
||||
visibilityChangedAt?: string;
|
||||
visibilityChangedBy?: string;
|
||||
activeStudyBlockId?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalCard extends BaseRecord {
|
||||
deckId: string;
|
||||
front: string;
|
||||
back: string;
|
||||
difficulty: number; // 1-5
|
||||
nextReview?: string | null;
|
||||
reviewCount: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// ─── View Types (inline to avoid @cards/shared dependency) ──
|
||||
|
||||
export interface Deck {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
visibility: VisibilityLevel;
|
||||
tags: string[];
|
||||
cardCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
deckId: string;
|
||||
front: string;
|
||||
back: string;
|
||||
difficulty: number;
|
||||
nextReview?: string;
|
||||
reviewCount: number;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDeckInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDeckInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateCardInput {
|
||||
deckId: string;
|
||||
front: string;
|
||||
back: string;
|
||||
}
|
||||
|
||||
export interface UpdateCardInput {
|
||||
front?: string;
|
||||
back?: string;
|
||||
difficulty?: number;
|
||||
order?: number;
|
||||
}
|
||||
export type {
|
||||
CardType,
|
||||
CardFields,
|
||||
LocalDeck,
|
||||
LocalCard,
|
||||
LocalCardReview,
|
||||
LocalCardStudyBlock,
|
||||
Deck,
|
||||
Card,
|
||||
CardReview,
|
||||
CreateDeckInput,
|
||||
UpdateDeckInput,
|
||||
CreateCardInput,
|
||||
UpdateCardInput,
|
||||
ReviewGrade,
|
||||
} from '@mana/cards-core';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { deckStore } from '$lib/modules/cards/stores/decks.svelte';
|
||||
import { cardStore } from '$lib/modules/cards/stores/cards.svelte';
|
||||
import { useDeck, useCardsByDeck } from '$lib/modules/cards/queries';
|
||||
import type { Deck, Card } from '$lib/modules/cards/types';
|
||||
import { useDeck, useCardsByDeck, useDueReviews } from '$lib/modules/cards/queries';
|
||||
import type { Deck, Card, CardType } from '$lib/modules/cards/types';
|
||||
import { renderMarkdown } from '$lib/modules/cards/render';
|
||||
import { ArrowLeft, Trash, Plus, ShareNetwork } from '@mana/shared-icons';
|
||||
import { ShareModal } from '@mana/shared-uload';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
|
@ -20,18 +20,35 @@
|
|||
|
||||
// New card form
|
||||
let showNewCardForm = $state(false);
|
||||
let newCardType = $state<CardType>('basic');
|
||||
let newCardFront = $state('');
|
||||
let newCardBack = $state('');
|
||||
let newCardCloze = $state('');
|
||||
|
||||
// Live queries for this deck's data
|
||||
// svelte-ignore state_referenced_locally
|
||||
const currentDeck = useDeck(deckId);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const deckCards = useCardsByDeck(deckId);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const dueReviews = useDueReviews(deckId);
|
||||
|
||||
// Reactively read values
|
||||
let deck = $derived(($currentDeck as Deck | null | undefined) ?? null);
|
||||
let cards = $derived(($deckCards as Card[] | undefined) ?? []);
|
||||
let dueCount = $derived(
|
||||
($dueReviews as { review: unknown; card: unknown }[] | undefined)?.length ?? 0
|
||||
);
|
||||
|
||||
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 (newCardType === 'cloze') return newCardCloze.trim().length > 0;
|
||||
return newCardFront.trim().length > 0 && newCardBack.trim().length > 0;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deckId) return;
|
||||
|
|
@ -42,40 +59,74 @@
|
|||
}
|
||||
|
||||
async function handleCreateCard() {
|
||||
if (!newCardFront.trim() || !newCardBack.trim()) return;
|
||||
await cardStore.createCard(
|
||||
{
|
||||
deckId,
|
||||
front: newCardFront.trim(),
|
||||
back: newCardBack.trim(),
|
||||
},
|
||||
cards.length
|
||||
);
|
||||
if (!canSubmit()) return;
|
||||
if (newCardType === 'cloze') {
|
||||
await cardStore.createCard(
|
||||
{ deckId, type: 'cloze', fields: { text: newCardCloze.trim() } },
|
||||
cards.length
|
||||
);
|
||||
} else {
|
||||
await cardStore.createCard(
|
||||
{
|
||||
deckId,
|
||||
type: newCardType,
|
||||
front: newCardFront.trim(),
|
||||
back: newCardBack.trim(),
|
||||
},
|
||||
cards.length
|
||||
);
|
||||
}
|
||||
newCardFront = '';
|
||||
newCardBack = '';
|
||||
newCardCloze = '';
|
||||
showNewCardForm = false;
|
||||
}
|
||||
|
||||
async function handleDeleteCard(cardId: string) {
|
||||
if (!confirm('Karte wirklich loschen?')) return;
|
||||
if (!confirm('Karte wirklich löschen?')) return;
|
||||
await cardStore.deleteCard(cardId, deckId);
|
||||
}
|
||||
|
||||
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 previewSummary(card: Card): { primary: string; secondary: string } {
|
||||
if (card.type === 'cloze') {
|
||||
const text = card.fields.text ?? '';
|
||||
return { primary: 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 - Mana</title>
|
||||
<title>{deck?.title || 'Deck'} — Cards — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="cards" backHref="/cards/decks" title="Deck">
|
||||
{#if deck}
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<!-- Back Button -->
|
||||
<button
|
||||
onclick={() => goto('/cards/decks')}
|
||||
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Zuruck zu Decks
|
||||
Zurück zu Decks
|
||||
</button>
|
||||
|
||||
<!-- Deck Header -->
|
||||
|
|
@ -92,9 +143,8 @@
|
|||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if deck.visibility === 'public'}
|
||||
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs text-primary">
|
||||
Offentlich
|
||||
</span>
|
||||
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs text-primary">Öffentlich</span
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showShare = true)}
|
||||
|
|
@ -106,30 +156,41 @@
|
|||
<button
|
||||
class="rounded-lg border border-destructive/30 p-2 text-destructive transition-colors hover:bg-destructive/10"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
aria-label="Deck loschen"
|
||||
aria-label="Deck löschen"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action row: Lernen + Stats -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
onclick={() => goto(`/cards/learn/${deckId}`)}
|
||||
disabled={dueCount === 0}
|
||||
>
|
||||
Lernen
|
||||
{#if dueCount > 0}
|
||||
<span class="rounded-full bg-background/20 px-2 py-0.5 text-xs">{dueCount} fällig</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if dueCount === 0 && cards.length > 0}
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Heute alles gelernt — schau später wieder rein.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<div class="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-foreground">{cards.length}</div>
|
||||
<div class="text-sm text-muted-foreground">Karten gesamt</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-500">
|
||||
{cards.filter((c) => c.difficulty <= 2).length}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">Einfach</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-orange-500">
|
||||
{cards.filter((c) => c.difficulty >= 4).length}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">Schwierig</div>
|
||||
<div class="text-3xl font-bold text-amber-500">{dueCount}</div>
|
||||
<div class="text-sm text-muted-foreground">Fällig</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -148,32 +209,71 @@
|
|||
{#if showNewCardForm}
|
||||
<div class="rounded-xl border border-primary bg-card p-4">
|
||||
<h3 class="mb-3 font-medium text-foreground">Neue Karte</h3>
|
||||
|
||||
<!-- Type picker -->
|
||||
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{#each cardTypeOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (newCardType = opt.value)}
|
||||
class="rounded-lg border p-2 text-left text-sm transition-colors {newCardType ===
|
||||
opt.value
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-muted/50'}"
|
||||
>
|
||||
<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">
|
||||
<div>
|
||||
<label for="card-front" class="mb-1 block text-sm text-muted-foreground">
|
||||
Vorderseite
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="card-front"
|
||||
type="text"
|
||||
bind:value={newCardFront}
|
||||
placeholder="Frage oder Begriff..."
|
||||
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="card-back" class="mb-1 block text-sm text-muted-foreground">
|
||||
Ruckseite
|
||||
</label>
|
||||
<textarea
|
||||
id="card-back"
|
||||
bind:value={newCardBack}
|
||||
placeholder="Antwort oder Erklarung..."
|
||||
class="min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
||||
></textarea>
|
||||
</div>
|
||||
{#if newCardType === 'cloze'}
|
||||
<div>
|
||||
<label for="card-cloze" class="mb-1 block text-sm text-muted-foreground">
|
||||
Text mit Lücken
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
id="card-cloze"
|
||||
bind:value={newCardCloze}
|
||||
placeholder="Berlin ist die Hauptstadt von {{c1::Deutschland}}."
|
||||
class="min-h-[100px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
||||
autofocus
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
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>
|
||||
<label for="card-front" class="mb-1 block text-sm text-muted-foreground">
|
||||
Vorderseite
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="card-front"
|
||||
type="text"
|
||||
bind:value={newCardFront}
|
||||
placeholder="Frage oder Begriff…"
|
||||
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="card-back" class="mb-1 block text-sm text-muted-foreground">
|
||||
Rückseite
|
||||
</label>
|
||||
<textarea
|
||||
id="card-back"
|
||||
bind:value={newCardBack}
|
||||
placeholder="Antwort oder Erklärung…"
|
||||
class="min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
||||
></textarea>
|
||||
</div>
|
||||
{/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"
|
||||
|
|
@ -181,6 +281,7 @@
|
|||
showNewCardForm = false;
|
||||
newCardFront = '';
|
||||
newCardBack = '';
|
||||
newCardCloze = '';
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
|
|
@ -188,7 +289,7 @@
|
|||
<button
|
||||
class="rounded-lg bg-primary px-4 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
onclick={handleCreateCard}
|
||||
disabled={!newCardFront.trim() || !newCardBack.trim()}
|
||||
disabled={!canSubmit()}
|
||||
>
|
||||
Karte erstellen
|
||||
</button>
|
||||
|
|
@ -210,32 +311,33 @@
|
|||
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
|
||||
onclick={() => (showNewCardForm = true)}
|
||||
>
|
||||
Karte hinzufugen
|
||||
Karte hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each cards as card, i (card.id)}
|
||||
{@const preview = previewSummary(card)}
|
||||
<div class="flex items-start gap-4 p-4">
|
||||
<span class="mt-1 text-xs text-muted-foreground">{i + 1}.</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-foreground">{card.front}</div>
|
||||
<div class="mt-1 text-sm text-muted-foreground">{card.back}</div>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="prose prose-sm max-w-none text-foreground dark:prose-invert">
|
||||
{@html renderMarkdown(preview.primary)}
|
||||
</div>
|
||||
{#if preview.secondary}
|
||||
<div class="prose prose-sm max-w-none text-muted-foreground dark:prose-invert">
|
||||
{@html renderMarkdown(preview.secondary)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {card.difficulty < 3
|
||||
? 'bg-green-500/10 text-green-600'
|
||||
: card.difficulty === 3
|
||||
? 'bg-amber-500/10 text-amber-600'
|
||||
: 'bg-red-500/10 text-red-600'}"
|
||||
>
|
||||
{card.difficulty}/5
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{typeBadge(card.type)}
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
onclick={() => handleDeleteCard(card.id)}
|
||||
aria-label="Karte loschen"
|
||||
aria-label="Karte löschen"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
|
|
@ -261,10 +363,10 @@
|
|||
class="mx-4 w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 class="mb-2 text-xl font-semibold text-foreground">Deck loschen?</h3>
|
||||
<h3 class="mb-2 text-xl font-semibold text-foreground">Deck löschen?</h3>
|
||||
<p class="mb-6 text-muted-foreground">
|
||||
Mochtest du "{deck.title}" wirklich loschen? Diese Aktion kann nicht ruckgangig
|
||||
gemacht werden und loscht auch alle Karten in diesem Deck.
|
||||
Möchtest du "{deck.title}" wirklich löschen? Diese Aktion kann nicht rückgängig
|
||||
gemacht werden und löscht auch alle Karten in diesem Deck.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
|
|
@ -278,7 +380,7 @@
|
|||
disabled={deleting}
|
||||
onclick={handleDelete}
|
||||
>
|
||||
{deleting ? 'Losche...' : 'Deck loschen'}
|
||||
{deleting ? 'Lösche…' : 'Deck löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -292,12 +394,11 @@
|
|||
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
|
||||
onclick={() => goto('/cards/decks')}
|
||||
>
|
||||
Zuruck zu Decks
|
||||
Zurück zu Decks
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Share Modal (uLoad integration) -->
|
||||
<ShareModal
|
||||
visible={showShare}
|
||||
onClose={() => (showShare = false)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Learn session — the Phase-1 core gameloop.
|
||||
*
|
||||
* Shows the next due card from the deck, reveals on Space, takes a
|
||||
* 1-4 grade via key or button, persists FSRS state + a study-block
|
||||
* tick, and moves on. Session ends when the queue empties; the user
|
||||
* can leave any time, the next visit picks up where we left off
|
||||
* (state lives in cardReviews, not the page).
|
||||
*/
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { useDueReviews, useDeck } from '$lib/modules/cards/queries';
|
||||
import { reviewStore } from '$lib/modules/cards/stores/reviews.svelte';
|
||||
import { studyBlockStore } from '$lib/modules/cards/stores/study-blocks.svelte';
|
||||
import CardFace from '$lib/modules/cards/components/CardFace.svelte';
|
||||
import type { Card, CardReview, ReviewGrade } from '$lib/modules/cards/types';
|
||||
|
||||
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');
|
||||
|
||||
// Snapshot the queue once per visit so the user finishes what's in
|
||||
// front of them — otherwise a freshly-graded review getting its new
|
||||
// `due` tomorrow would vanish from the list mid-session and break
|
||||
// the "X of N" counter.
|
||||
$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; // sentinel — finished
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6 px-4 py-8">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<button
|
||||
class="text-sm text-muted-foreground hover:underline"
|
||||
onclick={() => goto(`/cards/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-8 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-primary px-4 py-2 text-sm text-white"
|
||||
onclick={() => goto(`/cards/decks/${deckId}`)}
|
||||
>
|
||||
Zurück zum Deck
|
||||
</button>
|
||||
</div>
|
||||
{:else if finished}
|
||||
<div class="rounded-xl border border-border bg-card p-8 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-primary px-4 py-2 text-sm text-white"
|
||||
onclick={() => goto(`/cards/decks/${deckId}`)}
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
{:else if current}
|
||||
<CardFace
|
||||
card={current.card}
|
||||
subIndex={current.review.subIndex}
|
||||
{showBack}
|
||||
{typedAnswer}
|
||||
onTypedAnswer={(v) => (typedAnswer = v)}
|
||||
/>
|
||||
|
||||
{#if !showBack}
|
||||
<button class="w-full rounded-lg bg-primary py-3 text-base text-white" onclick={reveal}>
|
||||
Aufdecken <span class="ml-2 text-xs opacity-70">(Leertaste)</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<button class="rounded-lg bg-red-500 py-3 text-sm text-white" 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" 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" 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" 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue