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:
Till JS 2026-05-07 01:20:43 +02:00
parent 950b822070
commit 0a544ac410
94 changed files with 4090 additions and 7466 deletions

View file

@ -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
View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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 (14), elapsedMs }`
- `cards_session_completed``{ deckId, cardCount, durationMs }`
- `cards_deck_created``{ deckId }`
- `cards_card_created``{ deckId, type }`
- `cards_fsrs_optimized``{ reviewCount, paramsHash }`
- `cards_pwa_installed` — Standard-PWA-Event
Reicht für die Core-Loop-Validierung. Mehr Events erst, wenn eine
konkrete Frage entsteht, die Daten beantworten sollen.
## 12. Hinweis im mana-Modul
Sobald `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.

View file

@ -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

View file

@ -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

View file

@ -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! 🚀

View file

@ -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

View file

@ -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!

View file

@ -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'],
},
},
});

View file

@ -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"
}
}

View file

@ -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

View file

@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://cards.app/sitemap-index.xml

View file

@ -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">
&copy; {currentYear} Cards. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
</div>
</div>
</footer>

View file

@ -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>

View file

@ -1 +0,0 @@
/// <reference types="astro/client" />

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;
}

View file

@ -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')],
};

View file

@ -1,9 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -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
View file

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

View file

@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
# 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"]

View 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"
}
}

View 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;
}

View 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>

View 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>

View file

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

View file

@ -0,0 +1,56 @@
/**
* Encryption wrapper Phase-1 stub.
*
* The full Mana crypto stack (vault server roundtrip, KEK-wrapped
* master key, recovery codes, zero-knowledge mode) lives in the mana
* web app under `apps/mana/.../data/crypto/`. Lifting it intact into
* the standalone Cards app is a Phase-2 task it requires a vault
* client, key provider, and boot-race handling that aren't worth
* dragging in until we have the deployment story for them.
*
* For Phase 1 these helpers are intentionally identity functions:
* data lands in IndexedDB and on `mana-sync` as plaintext. Everything
* is wired up at the right call sites (stores write, queries read,
* sync.applyServerChanges apply) so flipping to real encryption is a
* single-file change here, not a sweep through every store.
*
* Allowlist is the contract with the future vault. It mirrors the
* mana-modul registry exactly so when sync converges, the same fields
* are protected on both ends.
*/
const ENCRYPTED_FIELDS: Record<string, readonly string[]> = {
cards: ['front', 'back', 'fields'],
cardDecks: ['name', 'description'],
};
/**
* Phase-1 identity. Phase-2 swap-in: import `wrapValue` from
* `@mana/shared-crypto`, fetch master key from the vault, encrypt
* each allowlisted field in place.
*/
export async function encryptRecord<T extends object>(tableName: string, record: T): Promise<T> {
void ENCRYPTED_FIELDS[tableName];
return record;
}
export async function decryptRecord<T extends object>(_tableName: string, record: T): Promise<T> {
return record;
}
export async function decryptRecords<T extends object>(
tableName: string,
records: T[]
): Promise<T[]> {
if (records.length === 0) return records;
return Promise.all(records.map((r) => decryptRecord(tableName, r)));
}
/**
* Reports the fields that *will* be encrypted once the vault is on.
* Stays exported so the GUIDELINES audit script can prove parity with
* the mana-modul registry.
*/
export function encryptedFieldsFor(tableName: string): readonly string[] {
return ENCRYPTED_FIELDS[tableName] ?? [];
}

View file

@ -0,0 +1,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);
}

View file

@ -0,0 +1,290 @@
/**
* Cards sync engine talks to mana-sync (POST /sync/cards, GET /sync/cards/pull).
*
* Two loops, both polling-based for the Phase-1 MVP. WebSocket
* notifications can replace the pull poll later without changing
* anything outside this file.
*
* Push: drain `_pendingChanges` every 1s when there's anything queued.
* On success, delete drained rows and apply any server-changes
* the response carried back. Failures keep the rows queued
* the next tick retries.
*
* Pull: every 5s, ask each sync table for changes since its cursor.
* Apply with suppression so the apply doesn't re-enqueue a push.
* Cursor lives in localStorage per table.
*
* Cursor format: ISO timestamp string. The server returns
* `syncedUntil` on push and we store that as a global push cursor; pull
* uses one cursor per collection.
*/
import { browser } from '$app/environment';
import {
beginApplying,
endApplying,
db,
pendingChangesTable,
type PendingChange,
} from './database';
import { encryptRecord } from './crypto';
const APP_ID = 'cards';
const PUSH_INTERVAL_MS = 1_000;
const PULL_INTERVAL_MS = 5_000;
const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags'];
// ─── URL + Auth wiring ─────────────────────────────────────
function getSyncUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_SYNC_URL__?: string })
.__PUBLIC_MANA_SYNC_URL__;
if (injected) return injected;
}
return import.meta.env.DEV ? 'http://localhost:3050' : '';
}
interface AuthLike {
getValidToken?: () => Promise<string | null>;
readonly isAuthenticated: boolean;
}
let authProvider: AuthLike | null = null;
// ─── Client ID ─────────────────────────────────────────────
const CLIENT_ID_KEY = 'mana.cards.clientId';
function getClientId(): string {
if (!browser) return 'ssr';
let id = localStorage.getItem(CLIENT_ID_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(CLIENT_ID_KEY, id);
}
return id;
}
// ─── Cursors ───────────────────────────────────────────────
const PUSH_CURSOR_KEY = 'mana.cards.pushCursor';
const PULL_CURSOR_KEY = (table: string) => `mana.cards.pullCursor.${table}`;
function getPushCursor(): string {
if (!browser) return '';
return localStorage.getItem(PUSH_CURSOR_KEY) || '1970-01-01T00:00:00.000Z';
}
function setPushCursor(at: string) {
if (browser) localStorage.setItem(PUSH_CURSOR_KEY, at);
}
function getPullCursor(table: string): string {
if (!browser) return '';
return localStorage.getItem(PULL_CURSOR_KEY(table)) || '1970-01-01T00:00:00.000Z';
}
function setPullCursor(table: string, at: string) {
if (browser) localStorage.setItem(PULL_CURSOR_KEY(table), at);
}
// ─── Server-Change shape ───────────────────────────────────
interface ServerChange {
eventId?: string;
schemaVersion?: number;
table: string;
id: string;
op: 'insert' | 'update' | 'delete';
fields?: Record<string, { value: unknown; at: string }>;
data?: Record<string, unknown>;
deletedAt?: string;
}
interface SyncResponse {
serverChanges: ServerChange[];
conflicts: unknown[];
syncedUntil: string;
hasMore?: boolean;
}
// ─── Apply server changes ──────────────────────────────────
async function applyServerChanges(changes: ServerChange[]) {
if (changes.length === 0) return;
const byTable = new Map<string, ServerChange[]>();
for (const c of changes) {
const arr = byTable.get(c.table) ?? [];
arr.push(c);
byTable.set(c.table, arr);
}
for (const [table, list] of byTable) {
if (!SYNC_TABLES.includes(table)) continue;
const t = db.table(table);
beginApplying(table);
try {
for (const c of list) {
try {
if (c.op === 'delete') {
await t.update(c.id, { deletedAt: c.deletedAt ?? new Date().toISOString() });
continue;
}
if (c.op === 'insert' && c.data) {
const row = { ...c.data, id: c.id };
// Server data may already be ciphertext-on-the-wire when
// encryption flips on. Re-running encryptRecord on it is a
// safe no-op today (Phase-1 stub) and the right hook in
// Phase-2 because existing-ciphertext values are detected
// upstream via `isEncrypted(...)`.
await encryptRecord(table, row);
await t.put(row);
continue;
}
// update — merge fields
if (c.fields) {
const existing = (await t.get(c.id)) ?? { id: c.id };
const merged: Record<string, unknown> = { ...existing };
for (const [k, v] of Object.entries(c.fields)) {
merged[k] = v.value;
}
await encryptRecord(table, merged);
await t.put(merged);
}
} catch (err) {
console.error('[cards-sync] apply failed', { table, id: c.id, op: c.op, err });
}
}
} finally {
endApplying(table);
}
}
}
// ─── Push ──────────────────────────────────────────────────
async function flushPush(): Promise<void> {
if (!authProvider?.isAuthenticated) return;
const queued = await pendingChangesTable.orderBy('queuedAt').limit(500).toArray();
if (queued.length === 0) return;
const token = (await authProvider.getValidToken?.()) ?? null;
if (!token) return;
const since = getPushCursor();
const body = {
clientId: getClientId(),
appId: APP_ID,
since,
schemaVersion: 1,
changes: queued.map(toWireChange),
};
let res: Response;
try {
res = await fetch(`${getSyncUrl()}/sync/${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': getClientId(),
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
} catch (err) {
console.warn('[cards-sync] push network error', err);
return;
}
if (!res.ok) {
console.warn('[cards-sync] push HTTP', res.status, await res.text().catch(() => ''));
return;
}
const json = (await res.json()) as SyncResponse;
await pendingChangesTable.bulkDelete(queued.map((q) => q.pk!).filter((pk) => pk !== undefined));
setPushCursor(json.syncedUntil);
await applyServerChanges(json.serverChanges ?? []);
}
function toWireChange(p: PendingChange): ServerChange {
const out: ServerChange = { table: p.table, id: p.id, op: p.op };
if (p.fields) out.fields = p.fields;
if (p.data) out.data = p.data;
if (p.deletedAt) out.deletedAt = p.deletedAt;
return out;
}
// ─── Pull ──────────────────────────────────────────────────
async function pollPull(): Promise<void> {
if (!authProvider?.isAuthenticated) return;
const token = (await authProvider.getValidToken?.()) ?? null;
if (!token) return;
for (const table of SYNC_TABLES) {
const since = getPullCursor(table);
const url =
`${getSyncUrl()}/sync/${APP_ID}/pull?collection=${encodeURIComponent(table)}` +
`&since=${encodeURIComponent(since)}`;
let res: Response;
try {
res = await fetch(url, {
headers: {
'X-Client-Id': getClientId(),
Authorization: `Bearer ${token}`,
},
});
} catch (err) {
console.warn('[cards-sync] pull network error', err);
continue;
}
if (!res.ok) {
console.warn('[cards-sync] pull HTTP', res.status, table);
continue;
}
const json = (await res.json()) as SyncResponse;
await applyServerChanges(json.serverChanges ?? []);
if (json.syncedUntil) setPullCursor(table, json.syncedUntil);
}
}
// ─── Lifecycle ─────────────────────────────────────────────
let pushTimer: ReturnType<typeof setInterval> | null = null;
let pullTimer: ReturnType<typeof setInterval> | null = null;
let pushBusy = false;
let pullBusy = false;
export function startSync(authStore: AuthLike) {
authProvider = authStore;
if (!browser) return;
stopSync();
pushTimer = setInterval(async () => {
if (pushBusy) return;
pushBusy = true;
try {
await flushPush();
} finally {
pushBusy = false;
}
}, PUSH_INTERVAL_MS);
pullTimer = setInterval(async () => {
if (pullBusy) return;
pullBusy = true;
try {
await pollPull();
} finally {
pullBusy = false;
}
}, PULL_INTERVAL_MS);
}
export function stopSync() {
if (pushTimer) clearInterval(pushTimer);
if (pullTimer) clearInterval(pullTimer);
pushTimer = null;
pullTimer = null;
}

View file

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

View file

@ -0,0 +1,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);
});
}

View file

@ -0,0 +1,12 @@
/**
* Auth Store uses the shared Mana auth factory.
*
* SSO: tokens land in the shared `*.mana.how` storage so a user already
* signed into mana.how / 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();

View file

@ -0,0 +1,165 @@
/**
* Card Store standalone.
*
* Writes the {type, fields} shape directly. Legacy mirror (front/back
* columns) kept on for cross-compat with the mana-modul data model
* once sync flips on. No encryption, no domain events that's the
* deliberate Phase-1 simplification.
*/
import { cardTable, cardDeckTable } from '../data/database';
import { encryptRecord, decryptRecord } from '../data/crypto';
import { reviewStore } from './reviews.svelte';
import {
type CardFields,
type CardType,
type LocalCard,
type CreateCardInput,
type UpdateCardInput,
} from '@mana/cards-core';
let error = $state<string | null>(null);
function resolveTypeAndFields(input: CreateCardInput): {
type: CardType;
fields: CardFields;
} {
const type = input.type ?? 'basic';
if (input.fields) return { type, fields: input.fields };
if (type === 'cloze') return { type, fields: { text: input.front ?? '' } };
return { type, fields: { front: input.front ?? '', back: input.back ?? '' } };
}
function legacyMirror(type: CardType, fields: CardFields): { front?: string; back?: string } {
if (type === 'basic' || type === 'basic-reverse' || type === 'type-in') {
return { front: fields.front ?? '', back: fields.back ?? '' };
}
if (type === 'cloze') {
return { front: fields.text ?? '', back: '' };
}
return {};
}
export const cardStore = {
get error() {
return error;
},
async createCard(
input: CreateCardInput,
currentCardCount: number = 0
): Promise<LocalCard | null> {
error = null;
try {
const { type, fields } = resolveTypeAndFields(input);
const legacy = legacyMirror(type, fields);
const now = new Date().toISOString();
const newLocal: LocalCard = {
id: crypto.randomUUID(),
deckId: input.deckId,
type,
fields,
order: currentCardCount,
createdAt: now,
updatedAt: now,
...legacy,
};
await encryptRecord('cards', newLocal);
await cardTable.add(newLocal);
const deck = await cardDeckTable.get(input.deckId);
if (deck) {
await cardDeckTable.update(input.deckId, {
cardCount: (deck.cardCount || 0) + 1,
updatedAt: now,
});
}
await reviewStore.ensureReviewsForCard({ id: newLocal.id, type, fields });
return newLocal;
} catch (err: any) {
error = err.message || 'Failed to create card';
console.error('Create card error:', err);
return null;
}
},
async updateCard(id: string, updates: UpdateCardInput) {
error = null;
try {
const existingRaw = await cardTable.get(id);
if (!existingRaw) return;
const existing = await decryptRecord('cards', { ...existingRaw });
const currentType: CardType = existing.type ?? 'basic';
const currentFields: CardFields = existing.fields ?? {
front: existing.front ?? '',
back: existing.back ?? '',
};
const nextType: CardType = updates.type ?? currentType;
const nextFields: CardFields = updates.fields
? updates.fields
: updates.front !== undefined || updates.back !== undefined
? nextType === 'cloze'
? { ...currentFields, text: updates.front ?? currentFields.text ?? '' }
: {
...currentFields,
front: updates.front ?? currentFields.front ?? '',
back: updates.back ?? currentFields.back ?? '',
}
: currentFields;
const legacy = legacyMirror(nextType, nextFields);
const diff: Partial<LocalCard> = {
type: nextType,
fields: nextFields,
updatedAt: new Date().toISOString(),
...legacy,
};
if (updates.order !== undefined) diff.order = updates.order;
await encryptRecord('cards', diff as Record<string, unknown>);
await cardTable.update(id, diff);
const structuralChange =
updates.type !== undefined ||
updates.fields !== undefined ||
(nextType === 'cloze' && updates.front !== undefined);
if (structuralChange) {
await reviewStore.ensureReviewsForCard({ id, type: nextType, fields: nextFields });
}
} catch (err: any) {
error = err.message || 'Failed to update card';
console.error('Update card error:', err);
}
},
async deleteCard(id: string, deckId?: string) {
error = null;
try {
const now = new Date().toISOString();
await cardTable.update(id, { deletedAt: now });
await reviewStore.softDeleteForCard(id);
if (deckId) {
const deck = await cardDeckTable.get(deckId);
if (deck) {
await cardDeckTable.update(deckId, {
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
updatedAt: now,
});
}
}
} catch (err: any) {
error = err.message || 'Failed to delete card';
console.error('Delete card error:', err);
}
},
clearError() {
error = null;
},
};

View file

@ -0,0 +1,78 @@
/**
* Deck Store standalone.
*
* Slim version of the mana-modul decks store: no time-blocks, no
* domain-events, no Mana-wide visibility hooks. Just CRUD against the
* standalone Dexie DB.
*/
import { cardDeckTable, cardTable, db } from '../data/database';
import { encryptRecord } from '../data/crypto';
import type { CreateDeckInput, UpdateDeckInput, LocalDeck } from '@mana/cards-core';
let error = $state<string | null>(null);
export const deckStore = {
get error() {
return error;
},
async createDeck(input: CreateDeckInput): Promise<LocalDeck | null> {
error = null;
try {
const now = new Date().toISOString();
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
name: input.title,
description: input.description ?? null,
color: '#6366f1',
cardCount: 0,
visibility: 'private',
createdAt: now,
updatedAt: now,
};
await encryptRecord('cardDecks', newLocal);
await cardDeckTable.add(newLocal);
return newLocal;
} catch (err: any) {
error = err.message || 'Failed to create deck';
console.error('Create deck error:', err);
return null;
}
},
async updateDeck(id: string, updates: UpdateDeckInput) {
error = null;
try {
const diff: Partial<LocalDeck> = { updatedAt: new Date().toISOString() };
if (updates.title !== undefined) diff.name = updates.title;
if (updates.description !== undefined) diff.description = updates.description;
await encryptRecord('cardDecks', diff as Record<string, unknown>);
await cardDeckTable.update(id, diff);
} catch (err: any) {
error = err.message || 'Failed to update deck';
console.error('Update deck error:', err);
}
},
async deleteDeck(id: string) {
error = null;
try {
const now = new Date().toISOString();
await db.transaction('rw', cardDeckTable, cardTable, async () => {
const cards = await cardTable.where('deckId').equals(id).toArray();
for (const card of cards) {
await cardTable.update(card.id, { deletedAt: now });
}
await cardDeckTable.update(id, { deletedAt: now });
});
} catch (err: any) {
error = err.message || 'Failed to delete deck';
console.error('Delete deck error:', err);
}
},
clearError() {
error = null;
},
};

View file

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

View file

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

View file

@ -0,0 +1,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}

View 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>

View 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 &#123;&#123;c1::Deutschland&#125;&#125;."
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">&#123;&#123;c1::Wort&#125;&#125;</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>

View 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>

View file

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

View file

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

View file

@ -0,0 +1,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

View file

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

View file

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

View file

@ -0,0 +1,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
View 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"
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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"

View file

@ -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

View file

@ -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 ───────────────────────────────────────────────

View file

@ -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

View file

@ -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}

View 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';

View 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';

View file

@ -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 ────────────────────────────────────────────

View file

@ -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>

View 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';

View file

@ -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' },
],
};

View file

@ -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 {

View 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';

View file

@ -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 });
}

View file

@ -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;
},
};

View file

@ -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;
},
};

View file

@ -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';

View file

@ -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 &#123;&#123;c1::Deutschland&#125;&#125;."
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">&#123;&#123;c1::Wort&#125;&#125;</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)}

View file

@ -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>

View file

@ -56,8 +56,11 @@ ingress:
service: http://localhost:5000
- hostname: plants.mana.how
service: http://localhost:5000
# cards.mana.how → standalone Cards SvelteKit container (apps/cards/apps/web).
# Was pointed at :5000 (the unified mana-web) until the standalone spinoff
# landed. mana.how/cards still serves the in-mana cards module.
- hostname: cards.mana.how
service: http://localhost:5000
service: http://localhost:5180
- hostname: storage.mana.how
service: http://localhost:5000
- hostname: presi.mana.how

View file

@ -296,7 +296,7 @@ services:
# Enforced by services/mana-auth/src/auth/sso-config.spec.ts.
# All productivity modules now live under mana.how (path-based) —
# no per-module subdomain entries required here.
CORS_ORIGINS: https://mana.how,https://auth.mana.how,https://whopxl.mana.how
CORS_ORIGINS: https://mana.how,https://auth.mana.how,https://whopxl.mana.how,https://cards.mana.how
ports:
- "3001:3001"
healthcheck:
@ -1057,6 +1057,36 @@ services:
retries: 3
start_period: 45s
cards-web:
# Standalone Cards frontend on cards.mana.how — separate SvelteKit
# container that consumes the same mana-sync 'cards' app-id as the
# in-mana cards module. See apps/cards/GUIDELINES.md.
build:
context: .
dockerfile: apps/cards/apps/web/Dockerfile
image: cards-web:local
container_name: mana-app-cards-web
restart: always
mem_limit: 128m
depends_on:
mana-auth:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 5180
PUBLIC_MANA_AUTH_URL: http://mana-auth:3001
PUBLIC_MANA_AUTH_URL_CLIENT: https://auth.mana.how
PUBLIC_MANA_SYNC_URL: http://mana-sync:3050
PUBLIC_MANA_SYNC_URL_CLIENT: https://sync.mana.how
ports:
- "5180:5180"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5180/"]
interval: 180s
timeout: 10s
retries: 3
start_period: 45s
uload-server:
build:
context: apps/uload/apps/server

View file

@ -0,0 +1,30 @@
{
"name": "@mana/cards-core",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"description": "Pure utilities for the Cards product: types, FSRS wrapper, Cloze parser, Markdown render. Consumed by both the mana cards module and the cards.mana.how standalone app.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist"
},
"dependencies": {
"@mana/local-store": "workspace:*",
"@mana/shared-privacy": "workspace:*",
"isomorphic-dompurify": "^3.7.1",
"marked": "^17.0.5",
"ts-fsrs": "^5.3.2"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.9.3",
"vitest": "^4.1.3"
}
}

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { subIndexesFor } from './card-reviews';
describe('subIndexesFor', () => {
it('basic → [0]', () => {
expect(subIndexesFor({ type: 'basic', fields: { front: 'a', back: 'b' } })).toEqual([0]);
});
it('type-in → [0]', () => {
expect(subIndexesFor({ type: 'type-in', fields: { front: 'a', back: 'b' } })).toEqual([0]);
});
it('basic-reverse → [0, 1]', () => {
expect(subIndexesFor({ type: 'basic-reverse', fields: { front: 'a', back: 'b' } })).toEqual([
0, 1,
]);
});
it('cloze → cluster indexes', () => {
expect(
subIndexesFor({
type: 'cloze',
fields: { text: '{{c1::Berlin}} ist Hauptstadt von {{c2::Deutschland}}.' },
})
).toEqual([1, 2]);
});
it('cloze with no clusters falls back to [1]', () => {
expect(subIndexesFor({ type: 'cloze', fields: { text: '' } })).toEqual([1]);
expect(subIndexesFor({ type: 'cloze', fields: { text: 'no clozes here' } })).toEqual([1]);
});
it('cloze deduplicates repeated clusters', () => {
expect(
subIndexesFor({
type: 'cloze',
fields: { text: '{{c1::a}} und {{c1::b}} und {{c2::c}}' },
})
).toEqual([1, 2]);
});
it('phase-2 types stub to [0] (no crash)', () => {
expect(subIndexesFor({ type: 'image-occlusion', fields: {} })).toEqual([0]);
expect(subIndexesFor({ type: 'audio', fields: {} })).toEqual([0]);
expect(subIndexesFor({ type: 'multiple-choice', fields: {} })).toEqual([0]);
});
});

View file

@ -0,0 +1,30 @@
/**
* Per-card-type review fan-out.
*
* Different card types produce different numbers of learnable units:
* - basic / type-in: one (subIndex 0)
* - basic-reverse: two (0=frontback, 1=backfront)
* - cloze: one per distinct cluster (subIndex = cluster idx)
*/
import { clusterIndexes } from './cloze';
import type { CardFields, CardType } from './types';
export function subIndexesFor(input: { type: CardType; fields: CardFields }): number[] {
switch (input.type) {
case 'basic':
case 'type-in':
return [0];
case 'basic-reverse':
return [0, 1];
case 'cloze': {
const text = input.fields.text ?? '';
const idx = clusterIndexes(text);
return idx.length > 0 ? idx : [1];
}
case 'image-occlusion':
case 'audio':
case 'multiple-choice':
return [0];
}
}

View file

@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { tokenize, clusterIndexes, clusters, renderCloze } from './cloze';
describe('tokenize', () => {
it('splits plain text and clusters', () => {
const tokens = tokenize('A {{c1::B}} C');
expect(tokens).toEqual([
{ kind: 'text', value: 'A ' },
{ kind: 'cluster', index: 1, answer: 'B', hint: undefined },
{ kind: 'text', value: ' C' },
]);
});
it('captures hints', () => {
const [t] = tokenize('{{c1::Berlin::Hauptstadt}}');
expect(t).toEqual({ kind: 'cluster', index: 1, answer: 'Berlin', hint: 'Hauptstadt' });
});
it('handles multiple clusters in one source', () => {
const tokens = tokenize('{{c1::Berlin}} ist die Hauptstadt von {{c2::Deutschland}}.');
const indexes = tokens.filter((t) => t.kind === 'cluster').map((t) => (t as any).index);
expect(indexes).toEqual([1, 2]);
});
it('passes through unmatched braces', () => {
const tokens = tokenize('foo {bar} baz');
expect(tokens).toEqual([{ kind: 'text', value: 'foo {bar} baz' }]);
});
it('survives multi-line input', () => {
const tokens = tokenize('Line 1\n{{c1::x}}\nLine 3');
expect(tokens.length).toBe(3);
expect((tokens[1] as any).answer).toBe('x');
});
});
describe('clusterIndexes', () => {
it('returns ascending unique indexes', () => {
expect(clusterIndexes('{{c2::a}} {{c1::b}} {{c2::c}} {{c3::d}}')).toEqual([1, 2, 3]);
});
it('returns empty for plain text', () => {
expect(clusterIndexes('no clozes here')).toEqual([]);
});
});
describe('clusters', () => {
it('groups answers under their cluster', () => {
const result = clusters('{{c1::a}} {{c1::b}} {{c2::c}}');
expect(result).toEqual([
{ index: 1, answers: ['a', 'b'] },
{ index: 2, answers: ['c'] },
]);
});
});
describe('renderCloze', () => {
it('blanks the hidden cluster on front, reveals on back', () => {
const r = renderCloze('{{c1::Berlin}} ist die Hauptstadt von {{c2::Deutschland}}.', 1);
expect(r.front).toContain('[…]');
expect(r.front).toContain('Deutschland');
expect(r.back).toContain('Berlin');
expect(r.back).toContain('cloze-active');
expect(r.answer).toBe('Berlin');
});
it('uses hint when present', () => {
const r = renderCloze('{{c1::Berlin::Hauptstadt}} ist eine Stadt.', 1);
expect(r.front).toContain('[Hauptstadt]');
});
it('blanks every occurrence of the hidden cluster', () => {
const r = renderCloze('{{c1::x}} und {{c1::x}}', 1);
const blanks = r.front.match(/cloze-blank/g) ?? [];
expect(blanks.length).toBe(2);
});
it('escapes HTML in user content', () => {
const r = renderCloze('{{c1::<script>}}', 1);
expect(r.back).not.toContain('<script>');
expect(r.back).toContain('&lt;script&gt;');
});
});

View file

@ -0,0 +1,140 @@
/**
* Cloze parser Anki-compatible {{cN::answer}} / {{cN::answer::hint}} syntax.
*
* A cloze card produces one *review* per distinct cluster index. The
* `subIndex` of a review row is the cluster number (1-based), so basic
* cards use subIndex 0 and cloze cards use subIndex 1, 2, .
*
* Why parse to a tree of `Token`s instead of regex-replacing during
* render? Because the same cluster can appear multiple times in one
* text (e.g. `{{c1::Berlin}} … {{c1::Berlin}}`) and the renderer must
* blank both occurrences. A token list keeps the renderer trivial and
* lets us reuse the parse for stats / extraction.
*/
export interface ClozeCluster {
/** 1-based cluster index, e.g. 1 for {{c1::…}}. */
index: number;
/** The answer text(s) belonging to this cluster, in source order. */
answers: string[];
}
type Token =
| { kind: 'text'; value: string }
| { kind: 'cluster'; index: number; answer: string; hint?: string };
const CLOZE_RE = /\{\{c(\d+)::((?:(?!\}\}).)+?)\}\}/gs;
/**
* Lex a cloze source string. Anything not matching {{cN::}} is a
* `text` token; matches become `cluster` tokens with their parsed
* answer/hint.
*/
export function tokenize(source: string): Token[] {
const tokens: Token[] = [];
let lastIndex = 0;
for (const match of source.matchAll(CLOZE_RE)) {
const start = match.index ?? 0;
if (start > lastIndex) {
tokens.push({ kind: 'text', value: source.slice(lastIndex, start) });
}
const idx = Number.parseInt(match[1], 10);
const inner = match[2];
const hintSplit = inner.indexOf('::');
const answer = hintSplit >= 0 ? inner.slice(0, hintSplit) : inner;
const hint = hintSplit >= 0 ? inner.slice(hintSplit + 2) : undefined;
tokens.push({ kind: 'cluster', index: idx, answer, hint });
lastIndex = start + match[0].length;
}
if (lastIndex < source.length) {
tokens.push({ kind: 'text', value: source.slice(lastIndex) });
}
return tokens;
}
/**
* Distinct cluster indexes in ascending order. Each one becomes one
* `cardReviews` row (subIndex = cluster index).
*/
export function clusterIndexes(source: string): number[] {
const set = new Set<number>();
for (const t of tokenize(source)) {
if (t.kind === 'cluster') set.add(t.index);
}
return [...set].sort((a, b) => a - b);
}
/**
* Group answers by cluster. Useful for the editor preview and for
* generating the prompt that lists hints.
*/
export function clusters(source: string): ClozeCluster[] {
const grouped = new Map<number, string[]>();
for (const t of tokenize(source)) {
if (t.kind !== 'cluster') continue;
const arr = grouped.get(t.index) ?? [];
arr.push(t.answer);
grouped.set(t.index, arr);
}
return [...grouped.entries()]
.sort(([a], [b]) => a - b)
.map(([index, answers]) => ({ index, answers }));
}
export interface RenderedCloze {
front: string;
back: string;
answer: string;
}
/**
* Render a cloze prompt for a specific cluster index. Plain HTML
* Markdown coupling happens one level up.
*
* Hidden cluster: `[…]` placeholder, with hint in parens if set
* Other clusters: rendered as plain answer text
*
* Back side reveals every cluster; the active one wears
* `<mark class="cloze-active">…</mark>` so the UI can highlight it.
*/
export function renderCloze(source: string, hideIndex: number): RenderedCloze {
const tokens = tokenize(source);
const front: string[] = [];
const back: string[] = [];
const hiddenAnswers: string[] = [];
for (const t of tokens) {
if (t.kind === 'text') {
front.push(escapeHtml(t.value));
back.push(escapeHtml(t.value));
continue;
}
const ans = escapeHtml(t.answer);
if (t.index === hideIndex) {
hiddenAnswers.push(t.answer);
const placeholder = t.hint
? `<span class="cloze-blank">[${escapeHtml(t.hint)}]</span>`
: `<span class="cloze-blank">[…]</span>`;
front.push(placeholder);
back.push(`<mark class="cloze-active">${ans}</mark>`);
} else {
front.push(ans);
back.push(ans);
}
}
return {
front: front.join(''),
back: back.join(''),
answer: hiddenAnswers.join(', '),
};
}
function escapeHtml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

View file

@ -0,0 +1,91 @@
/**
* FSRS wrapper Free Spaced Repetition Scheduler v6 via `ts-fsrs`.
*
* Translates between ts-fsrs's `Card` (Date objects, snake_case) and
* our `LocalCardReview` (ISO strings, camelCase). Stores never see
* ts-fsrs types directly. One place to swap params for per-user-tuning
* later.
*/
import { fsrs, createEmptyCard, State, type Card, type Grade } from 'ts-fsrs';
import type { LocalCardReview, ReviewGrade } from './types';
const STATE_TO_STRING: Record<State, LocalCardReview['state']> = {
[State.New]: 'new',
[State.Learning]: 'learning',
[State.Review]: 'review',
[State.Relearning]: 'relearning',
};
const STRING_TO_STATE: Record<LocalCardReview['state'], State> = {
new: State.New,
learning: State.Learning,
review: State.Review,
relearning: State.Relearning,
};
function toLocalReview(id: string, cardId: string, subIndex: number, card: Card): LocalCardReview {
return {
id,
cardId,
subIndex,
state: STATE_TO_STRING[card.state],
stability: card.stability,
difficulty: card.difficulty,
due: card.due.toISOString(),
reps: card.reps,
lapses: card.lapses,
lastReview: card.last_review ? card.last_review.toISOString() : undefined,
elapsedDays: card.elapsed_days,
scheduledDays: card.scheduled_days,
};
}
function toFsrsCard(review: LocalCardReview): Card {
return {
due: new Date(review.due),
stability: review.stability,
difficulty: review.difficulty,
elapsed_days: review.elapsedDays,
scheduled_days: review.scheduledDays,
learning_steps: 0,
reps: review.reps,
lapses: review.lapses,
state: STRING_TO_STATE[review.state],
last_review: review.lastReview ? new Date(review.lastReview) : undefined,
};
}
/**
* Build a fresh review row for a new learnable unit (basic card,
* one cloze cluster, one direction of basic-reverse).
*/
export function newReview(opts: { cardId: string; subIndex: number; now?: Date }): LocalCardReview {
const id = crypto.randomUUID();
const empty = createEmptyCard(opts.now ?? new Date());
return toLocalReview(id, opts.cardId, opts.subIndex, empty);
}
/**
* Apply a grade to a review and return the next-state row.
*/
export function gradeReview(
review: LocalCardReview,
grade: ReviewGrade,
now: Date = new Date()
): LocalCardReview {
const scheduler = getScheduler();
const fsrsCard = toFsrsCard(review);
const result = scheduler.next(fsrsCard, now, gradeToRating(grade));
return toLocalReview(review.id, review.cardId, review.subIndex, result.card);
}
function gradeToRating(grade: ReviewGrade): Grade {
return grade as unknown as Grade;
}
let cached: ReturnType<typeof fsrs> | null = null;
function getScheduler() {
if (!cached) cached = fsrs();
return cached;
}

View file

@ -0,0 +1,13 @@
/**
* Cards-Core pure utilities used by both the mana cards module
* (apps/mana/.../modules/cards/) and the cards.mana.how standalone app.
*
* Only DB-free code lives here. Anything that touches Dexie, mana-sync,
* or app-specific encryption stays in the consumer apps.
*/
export * from './types';
export * from './cloze';
export * from './card-reviews';
export * from './fsrs';
export * from './render';

View file

@ -0,0 +1,31 @@
/**
* Markdown render helper for cards.
*
* Pipeline: marked (GFM) DOMPurify. Used by the card face for basic /
* type-in / basic-reverse, and by the cloze post-processor.
*
* Cloze callers should pass `{ skipParagraph: true }` so a single-line
* fragment doesn't get wrapped in <p>.
*/
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
marked.setOptions({ gfm: true, breaks: true });
export interface RenderOptions {
skipParagraph?: boolean;
}
export function renderMarkdown(source: string, opts: RenderOptions = {}): string {
if (!source) return '';
const raw = marked.parse(source, { async: false }) as string;
let html = DOMPurify.sanitize(raw, {
ADD_TAGS: ['mark'],
ADD_ATTR: ['class'],
});
if (opts.skipParagraph) {
html = html.replace(/^\s*<p>/, '').replace(/<\/p>\s*$/, '');
}
return html;
}

View file

@ -0,0 +1,181 @@
/**
* Cards shared types.
*
* Used by both the mana cards module (apps/mana/.../modules/cards/) and
* the cards.mana.how standalone app. Pure type definitions, no runtime
* imports beyond `BaseRecord` and `VisibilityLevel` from the shared
* Mana packages.
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
// ─── Card Types ────────────────────────────────────────────
/**
* Discriminator for the card's render/learn behaviour. Phase 1 ships
* basic, basic-reverse, cloze, type-in. Future types are reserved here
* so storage already understands them UI can light up later without
* a schema change.
*/
export type CardType =
| 'basic'
| 'basic-reverse'
| 'cloze'
| 'type-in'
| 'image-occlusion'
| 'audio'
| 'multiple-choice';
/**
* Free-form key/value bag with the user-typed content. Schema by type:
* basic / basic-reverse / type-in: { front, back }
* cloze: { text, extra? }
*/
export type CardFields = Record<string, string>;
// ─── Local (IndexedDB) Records ─────────────────────────────
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;
type?: CardType;
fields?: CardFields;
order: number;
// Legacy columns (pre-Phase-0). Still written for basic cards so
// older mana-app builds keep rendering.
front?: string;
back?: string;
difficulty?: number;
nextReview?: string | null;
reviewCount?: number;
}
/**
* FSRS state for one *learnable unit*. A basic card has one review
* (subIndex=0). A basic-reverse card has two (0=frontback, 1=backfront).
* A cloze card has one per cluster (1=c1, 2=c2, ).
*
* Plaintext on purpose: the scheduler must query by `due` to find what's
* fällig today.
*/
export interface LocalCardReview extends BaseRecord {
cardId: string;
subIndex: number;
state: 'new' | 'learning' | 'review' | 'relearning';
stability: number;
difficulty: number;
due: string;
reps: number;
lapses: number;
lastReview?: string;
elapsedDays: number;
scheduledDays: number;
}
/**
* Daily aggregate of learning activity for streak + stats. One row per
* user per local date. All plaintext (numbers + dates).
*/
export interface LocalCardStudyBlock extends BaseRecord {
date: string;
cardsReviewed: number;
durationMs: number;
}
// ─── View Types (DTOs returned to UI) ──────────────────────
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;
type: CardType;
fields: CardFields;
order: number;
createdAt: string;
updatedAt: string;
front: string;
back: string;
/** @deprecated legacy DTO field — read from cardReviews going forward. */
difficulty?: number;
/** @deprecated legacy DTO field — read from cardReviews going forward. */
nextReview?: string;
/** @deprecated legacy DTO field — read from cardReviews going forward. */
reviewCount?: number;
}
export interface CardReview {
id: string;
cardId: string;
subIndex: number;
state: LocalCardReview['state'];
stability: number;
difficulty: number;
due: string;
reps: number;
lapses: number;
lastReview?: string;
elapsedDays: number;
scheduledDays: number;
}
// ─── Inputs ────────────────────────────────────────────────
export interface CreateDeckInput {
title: string;
description?: string;
}
export interface UpdateDeckInput {
title?: string;
description?: string;
}
export interface CreateCardInput {
deckId: string;
type?: CardType;
fields?: CardFields;
front?: string;
back?: string;
}
export interface UpdateCardInput {
type?: CardType;
fields?: CardFields;
front?: string;
back?: string;
order?: number;
/** @deprecated legacy field — use cardReviews going forward. */
difficulty?: number;
}
/**
* Self-grading scale used by the learner during a session. Values match
* FSRS's Rating enum (1=Again, 2=Hard, 3=Good, 4=Easy).
*/
export type ReviewGrade = 1 | 2 | 3 | 4;

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

157
pnpm-lock.yaml generated
View file

@ -183,36 +183,89 @@ importers:
specifier: ~5.9.2
version: 5.9.3
apps/cards/apps/landing:
apps/cards: {}
apps/cards/apps/web:
dependencies:
'@astrojs/check':
specifier: ^0.9.0
version: 0.9.8(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3)
'@astrojs/sitemap':
specifier: ^3.2.1
version: 3.7.2
'@mana/shared-landing-ui':
'@mana/cards-core':
specifier: workspace:*
version: link:../../../../packages/shared-landing-ui
astro:
specifier: ^5.16.0
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
astro-icon:
specifier: ^1.1.5
version: 1.1.5
typescript:
specifier: ^5.0.0
version: 5.9.3
version: link:../../../../packages/cards-core
'@mana/local-store':
specifier: workspace:*
version: link:../../../../packages/local-store
'@mana/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
'@mana/shared-auth-ui':
specifier: workspace:*
version: link:../../../../packages/shared-auth-ui
'@mana/shared-branding':
specifier: workspace:*
version: link:../../../../packages/shared-branding
'@mana/shared-crypto':
specifier: workspace:*
version: link:../../../../packages/shared-crypto
'@mana/shared-icons':
specifier: workspace:*
version: link:../../../../packages/shared-icons
'@mana/shared-privacy':
specifier: workspace:*
version: link:../../../../packages/shared-privacy
'@mana/shared-pwa':
specifier: workspace:*
version: link:../../../../packages/shared-pwa
'@mana/shared-stores':
specifier: workspace:*
version: link:../../../../packages/shared-stores
'@mana/shared-tailwind':
specifier: workspace:*
version: link:../../../../packages/shared-tailwind
'@mana/shared-theme':
specifier: workspace:*
version: link:../../../../packages/shared-theme
'@mana/shared-types':
specifier: workspace:*
version: link:../../../../packages/shared-types
'@mana/shared-utils':
specifier: workspace:*
version: link:../../../../packages/shared-utils
dexie:
specifier: ^4.4.1
version: 4.4.2
devDependencies:
'@astrojs/tailwind':
specifier: ^6.0.0
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@mana/shared-vite-config':
specifier: workspace:*
version: link:../../../../packages/shared-vite-config
'@sveltejs/adapter-node':
specifier: ^5.0.0
version: 5.5.4(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
'@sveltejs/kit':
specifier: ^2.47.1
version: 2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@sveltejs/vite-plugin-svelte':
specifier: ^5.0.4
version: 5.1.1(svelte@5.55.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.2.2(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@types/node':
specifier: ^22.10.5
version: 22.19.17
svelte:
specifier: ^5.41.0
version: 5.55.1
svelte-check:
specifier: ^4.3.3
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.9.3)
tailwindcss:
specifier: ^3.4.17
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
specifier: ^4.1.17
version: 4.2.2
typescript:
specifier: ^5.7.2
version: 5.9.3
vite:
specifier: ^6.0.7
version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
apps/chat: {}
@ -319,8 +372,6 @@ importers:
specifier: ^3.4.0
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
apps/context: {}
apps/docs:
dependencies:
'@astrojs/check':
@ -495,6 +546,9 @@ importers:
'@huggingface/transformers':
specifier: ^4.0.0
version: 4.0.1
'@mana/cards-core':
specifier: workspace:*
version: link:../../../../packages/cards-core
'@mana/credits':
specifier: workspace:^
version: link:../../../../packages/credits
@ -615,6 +669,9 @@ importers:
dexie:
specifier: ^4.0.11
version: 4.4.2
isomorphic-dompurify:
specifier: ^3.7.1
version: 3.7.1(@noble/hashes@2.0.1)
marked:
specifier: ^17.0.5
version: 17.0.6
@ -642,6 +699,9 @@ importers:
swissqrbill:
specifier: ^4.3.0
version: 4.3.0(typescript@5.9.3)
ts-fsrs:
specifier: ^5.3.2
version: 5.3.2
zod:
specifier: ^3.25.76
version: 3.25.76
@ -1670,6 +1730,34 @@ importers:
specifier: ^5.0.0
version: 5.9.3
packages/cards-core:
dependencies:
'@mana/local-store':
specifier: workspace:*
version: link:../local-store
'@mana/shared-privacy':
specifier: workspace:*
version: link:../shared-privacy
isomorphic-dompurify:
specifier: ^3.7.1
version: 3.7.1(@noble/hashes@2.0.1)
marked:
specifier: ^17.0.5
version: 17.0.6
ts-fsrs:
specifier: ^5.3.2
version: 5.3.2
devDependencies:
'@types/node':
specifier: ^24.10.1
version: 24.12.2
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/credits:
devDependencies:
svelte:
@ -1737,7 +1825,7 @@ importers:
version: 4.0.3
isomorphic-dompurify:
specifier: ^3.7.1
version: 3.7.1
version: 3.7.1(@noble/hashes@2.0.1)
marked:
specifier: ^17.0.0
version: 17.0.6
@ -8410,6 +8498,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@urql/core@5.2.0':
resolution: {integrity: sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==}
@ -8599,6 +8688,7 @@ packages:
'@xmldom/xmldom@0.8.12':
resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@ -15654,6 +15744,10 @@ packages:
peerDependencies:
typescript: '>=4.0.0'
ts-fsrs@5.3.2:
resolution: {integrity: sha512-moJJfYAeG9ynyyGCNaQPUloi0sspTMHFtgCvsx2wDchELu3O2c513/7fp+6PxsGth0ztxNjEtG8d85gX4ce0og==}
engines: {node: '>=20.0.0'}
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@ -16045,6 +16139,7 @@ packages:
uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-to-istanbul@9.3.0:
@ -28730,7 +28825,7 @@ snapshots:
iso-639-1@2.1.15: {}
isomorphic-dompurify@3.7.1:
isomorphic-dompurify@3.7.1(@noble/hashes@2.0.1):
dependencies:
dompurify: 3.3.3
jsdom: 29.0.2(@noble/hashes@2.0.1)
@ -33349,6 +33444,8 @@ snapshots:
picomatch: 4.0.4
typescript: 5.9.3
ts-fsrs@5.3.2: {}
ts-interface-checker@0.1.13: {}
tsconfck@3.1.6(typescript@5.9.3):

View file

@ -25,6 +25,7 @@ export const PRODUCTION_TRUSTED_ORIGINS = [
'https://auth.mana.how',
// Separate apps (not part of the unified app)
'https://whopxl.mana.how', // Games
'https://cards.mana.how', // Cards spaced-repetition spinoff (own SvelteKit container, not the unified app)
] as const;
/** Local dev origins — web dev server + the auth server itself. */